Repository navigation
[Bug]: Harden persisted workflow run artifact access against symlinks #4914
Description
Bug Description
Workflow run storage guards validate shared ancestors through .specify/workflows/runs, but RunState.load() and persisted snapshot readers still follow a symlinked per-run directory or artifact file. This is a cross-cutting workflow-run storage gap that predates workflow definition: workflow resume already reads runs/<run_id>/workflow.yml, and run-state loading already reads state.json beneath the same directory.
Steps to Reproduce
- Initialize a Specify project and run a workflow that pauses, creating
.specify/workflows/runs/<run_id>/state.jsonandworkflow.yml. - Move that run directory to an external location, then replace
runs/<run_id>with a symbolic link to that external directory. Alternatively, replace itsstate.jsonorworkflow.ymlwith a symbolic link to an external valid artifact. - Run
specify workflow resume <run_id>; on this PR, runspecify workflow definition <run_id> --jsonas well.
Expected Behavior
All workflow-run consumers reject persisted run directories and artifact files that violate one shared, documented symlink policy before reading or writing them. The failure should be consistent across RunState.load, resume, status, and definition.
Actual Behavior
The current shared-ancestor guard accepts the symlinked per-run path. The state and snapshot readers can follow it, so resume reads externally redirected artifacts. workflow definition additionally emits the externally redirected snapshot.
Specify CLI Version
1.1.3.dev0 (v0.1.10-1615-g57b41362 working tree)
AI Agent
Not applicable to the affected workflow behavior.
Operating System
Linux 6.17.0-1032-oem x86_64 GNU/Linux
Python Version
Python 3.14.4
Proposed Scope
Establish one persisted-run storage boundary rather than adding command-local checks. Cover all readers and writers of run artifacts, including RunState.load, persisted definition reads, resume, status, and workflow definition.
Decide and document whether the policy rejects all symlinks below runs/<run_id> or permits only links confined to the run root. Make checks and opens resistant to replacement races if the adopted threat model requires it.
Acceptance Criteria
- Per-run directories and persisted artifacts follow one documented symlink policy.
- All run-state consumers receive consistent failures.
- Negative tests cover symlinked run directories and persisted artifact files across shared behavior.
Additional Context
Reported by Copilot review on PR #4912: #4912 (comment)
AI Disclosure
Filed and updated by Markus Wondrak with OpenCode (github-copilot/gpt-5.6-terra), default reasoning, autonomous mode. The agent traced the existing run-storage paths, drafted the reproduction and issue, and updated it to match the repository bug-report template; no claim of human line-by-line review is made.
Activity
[-]Harden persisted workflow run artifact access against symlinks[/-][+][Bug]: Harden persisted workflow run artifact access against symlinks[/+]github-actions commentedon Oct 12, 2026
Bug assessment — persisted-run-symlinks: Valid · severity medium
Bug Assessment: Harden persisted workflow run artifact access against symlinks
Report (summarized)
The report states that workflow-run storage guards validate shared ancestors through
.specify/workflows/runs, but consumers still follow symlinked per-run directories or artifact files. It identifiesRunState.load()and persisted workflow snapshots used by resume (and the reportedworkflow definitioncommand on PR #4912) as affected, and requests one documented policy covering readers and writers. The report includes a reproduction involving a symlinked run directory orstate.json/workflow.ymland expects consistent rejection across resume, status, and definition.Additional context: the report links to the Copilot review discussion on github/spec-kit#4912. No external content was required to assess the issue.
Symptom
The current workflow storage guard rejects symlinked shared directories such as
.specify/workflows/runs, but it does not reject a symlink below that directory. As a result, persisted state and snapshot reads can follow a run-directory or artifact symlink to an external location, while writes can also target a redirected path; commands do not therefore enforce one consistent persisted-run boundary.Reproduction
.specify/workflows/runs/<run_id>/state.jsonandworkflow.ymlexist.state.jsonorworkflow.ymlwith a symlink to an external file.specify workflow resume <run_id>orspecify workflow status <run_id>.workflow definition <run_id> --jsoncommand from PR feat: addspecify workflow definitionfor run-scoped definitions #4912, invoke it as well.The exact command and option set for producing the paused run is not specified in the report: [NEEDS CLARIFICATION: confirm the minimal non-interactive workflow and the exact
workflow definitioncommand available in the target release].Suspected Code Paths
src/specify_cli/workflows/_commands.py:166-177—_reject_unsafe_workflow_storage()checks.specify,.specify/workflows, and.specify/workflows/runs, but stops at the sharedrunsdirectory and does not inspect<run_id>or its files.src/specify_cli/workflows/engine.py:734-778—RunState.runs_dir,save(), andappend_log()construct paths belowruns/<run_id>and usemkdir,open, andos.replacewithout rejecting a symlinked run directory or artifact leaf.src/specify_cli/workflows/engine.py:812-834—RunState.load()opensruns/<run_id>/state.jsondirectly. The validated run ID prevents lexical traversal, but the filesystem path can still resolve through a symlinked directory or file.src/specify_cli/workflows/engine.py:923-925— legacy input loading checksinputs.jsonand then opens it without a symlink-policy check.src/specify_cli/workflows/engine.py:1185-1190—WorkflowEngine.resume()checksrun_copy.exists()and then loadsruns/<run_id>/workflow.yml; both operations follow a symlinked run directory or snapshot file.src/specify_cli/workflows/engine.py:1484-1500—WorkflowEngine.list_runs()iterates run entries and opens eachstate.jsonwithout rejecting symlinked per-run directories or state files, affectingworkflow statuswhen listing runs.src/specify_cli/workflows/command_resume.py:48-74— the CLI preloads persisted state throughRunState.load(), so the shared gap is reached before the later ownership checks.src/specify_cli/workflows/command_status.py:31-48— single-run status also delegates directly toRunState.load()and therefore inherits the same behavior.Root Cause Hypothesis
Confidence: high. The existing protection is an ancestor-only guard: it verifies that the shared storage directories are not symlinks before workflow commands run, but it treats all descendants as trusted. The persistence layer subsequently uses ordinary path-based opens and atomic replacement, which follow descendant symlinks. Run ID validation closes path traversal through user input but does not establish an object-level boundary for the run directory or artifact files. The same pattern appears in both read paths (
state.json,inputs.json,workflow.yml) and write paths (state.json,inputs.json,log.jsonl, and the initial snapshot).Proposed Remediation
Preferred: introduce a shared persisted-run storage boundary helper in the workflow persistence layer. It should validate the lexical run ID, reject any symlink component below
.specify/workflows/runs, require the per-run directory to be a real directory, and reject symlinked artifact leaves before every read or write. RouteRunState.load(),save(),append_log(),WorkflowEngine.execute()snapshot creation, andlist_runs()through that helper so resume, status, and future persisted-artifact consumers receive the same failure behavior. Document the policy in the workflow architecture/README: the simplest defensible rule is to reject all symlinks belowruns/, rather than permitting links whose targets happen to remain inside the project.For race resistance, use descriptor-relative operations with
O_NOFOLLOW/dir_fdwhere supported, or otherwise verify the relevant directory and leaf immediately before opening/replacing and fail closed on replacement races. Preserve the existing user-facing error convention by translating unsafe storage into a clear CLI error rather than silently skipping or following the path.Alternatives:
runsroot. This is simpler but permits symlinks within the root and is vulnerable to replacement races unless paired with no-follow file-descriptor operations.state.json/inputs.jsonreads.Files likely to change:
src/specify_cli/workflows/engine.pysrc/specify_cli/workflows/_commands.py(shared command-level error integration, if needed)src/specify_cli/workflows/command_resume.pysrc/specify_cli/workflows/command_status.py(only if command-specific handling is required)workflows/ARCHITECTURE.mdand/orworkflows/README.mdfor the persisted-run symlink policytests/specify_cli/workflows/test_command_resume.pytests/specify_cli/workflows/test_command_status.pyTests to add or update:
<run_id>directory duringRunState.load().state.json,inputs.json,workflow.yml, andlog.jsonlfor both reads and writes.workflow resume, single-runworkflow status, and run listing fail consistently and do not read the external target.Risks & Considerations
os.replaceand append mode have different symlink behavior, so the implementation must cover each write path rather than only reads.O_NOFOLLOWand directory descriptors; retain a safe fallback that rejects symlinks even where race-resistant primitives are unavailable.Open Questions
runs/, including links whose targets remain within the project, or allow a constrained in-root form?]workflow definition <run_id> --jsonpart of the target branch/release, or only the PR context referenced by the report?]Fix persisted-run-symlinks: reject symlinked workflow artifacts
github-actions commentedon Oct 12, 2026
Pull request created: #4930
github-actions commentedon Oct 12, 2026
Draft PR created for
persisted-run-symlinks: review the proposed changes. The fix adds a shared persisted-run storage boundary that rejects symlinked run directories and state, input, snapshot, and log artifacts. Please review the draft and validate it in the configured stage-3bug-testworkflow.