Skip to content

Commit e668682

Browse files
Refuse an artifact.include path that leaves the checkout through a symlink
A tracked symlink above an include path (web/link -> /outside, include web/link/secrets) let the build package controller files outside the checkout. The include path itself may still be a symlink, archived as one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c3f0414 commit e668682

5 files changed

Lines changed: 36 additions & 3 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
### Added
22

3-
- `cloudfall-engine artifact build` runs a Component's declared `spec.build` steps on the controller before packaging: it checks every `spec.buildTools` entry is on `PATH` at its minimum version before cloning (`artifact_build_tool_missing`, `artifact_build_tool_unsupported`), runs each step in the clean checkout with only `PATH`, `HOME`, `LANG`, `TMPDIR` and `CI=true` in its environment and its output on stderr, fails without writing an archive on a non-zero exit or a step outrunning its own `timeoutSeconds` (`artifact_build_step_failed`), a missing include path (`artifact_include_missing`) or a tracked file a step deleted (`artifact_tracked_file_missing`), packages the tracked files plus every `spec.artifact.include` path (a gitignored `web/dist`, say), and records the steps and tool versions as the new optional `spec.build` of the Artifact metadata. The command's time limit is now the clone time (1800 seconds) plus every declared step timeout, on the CLI and the SDK/MCP path alike; an explicit `--timeout` replaces it, and `--timeout 0` still disables it. A Component without `build` is packaged as before (#115)
3+
- `cloudfall-engine artifact build` runs a Component's declared `spec.build` steps on the controller before packaging: it checks every `spec.buildTools` entry is on `PATH` at its minimum version before cloning (`artifact_build_tool_missing`, `artifact_build_tool_unsupported`), runs each step in the clean checkout with only `PATH`, `HOME`, `LANG`, `TMPDIR` and `CI=true` in its environment and its output on stderr, fails without writing an archive on a non-zero exit or a step outrunning its own `timeoutSeconds` (`artifact_build_step_failed`), a missing include path (`artifact_include_missing`), an include path that leads out of the checkout through a symlink (`artifact_include_outside_checkout`) or a tracked file a step deleted (`artifact_tracked_file_missing`), packages the tracked files plus every `spec.artifact.include` path (a gitignored `web/dist`, say), and records the steps and tool versions as the new optional `spec.build` of the Artifact metadata. The command's time limit is now the clone time (1800 seconds) plus every declared step timeout, on the CLI and the SDK/MCP path alike; an explicit `--timeout` replaces it, and `--timeout 0` still disables it. A Component without `build` is packaged as before (#115)

‎docs/specs/002-component-build-steps.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ validate`, and the `build_artifact` MCP tool.
147147
- S-002-5: `test_s002_5_a_missing_tool_fails_before_any_clone` exited 81 with `artifact_build_tool_missing` and `tool: node`; the logging `git` wrapper on `PATH` recorded no `clone`, and no file was written
148148
- S-002-6: `test_s002_6_an_old_tool_names_the_tool_and_both_versions` (`node` printing `v20.11.1` against `22.0.0`) and `test_s002_6_a_tool_that_prints_no_version_is_unsupported` exited 81 with `artifact_build_tool_unsupported`, naming the tool and both versions
149149
- S-002-7: `test_s002_7_a_failing_step_names_itself_and_writes_nothing` exited 81 with `artifact_build_step_failed`, `step: 1`, `argv: ["pnpm", "fail"]`, `exitCode: 3` and the step's stderr tail; `test_s002_7_a_step_past_its_timeout_fails_the_build` showed a step's own 2-second `timeoutSeconds` firing as `artifact_build_step_failed`; neither left a `.tar.gz` or `.json` in the output directory. The command's limit is the clone time (1800 s) plus every declared step timeout, on the CLI and on the SDK/MCP path, whose engine call carries no 3600 s cap of its own: `test_s002_7_the_run_limit_covers_every_declared_step` (three 3600 s steps), `test_s002_7_the_sdk_leaves_the_build_limit_to_the_engine`, `test_s002_7_an_explicit_timeout_still_wins` (`--timeout 3` ends in `TIMEOUT`), `test_s002_7_without_timeout_the_computed_limit_applies`, and `test_s002_7_timeout_zero_still_means_no_limit` (`--timeout 0` applies no limit, as before). The command takes `--timeout` as a field of its own, which treaty allows in place of its framework flag, so the build can tell an omitted flag apart from 0
150-
- S-002-8: `test_s002_8_a_missing_include_path_writes_no_artifact` exited 81 with `artifact_include_missing` naming `web/dist` and wrote nothing
150+
- S-002-8: `test_s002_8_a_missing_include_path_writes_no_artifact` exited 81 with `artifact_include_missing` naming `web/dist` and wrote nothing; `test_s002_8_an_include_through_a_symlink_out_of_the_checkout_is_refused` exited 81 with `artifact_include_outside_checkout` for an include path under a tracked symlink to a directory outside the checkout, and wrote nothing
151151
- S-002-9: `test_s002_9_step_stdout_stays_out_of_the_json_envelope` and `test_s002_9_stdout_is_exactly_one_envelope` parsed the command's whole stdout as one JSON envelope, on success and on a failed step; the steps' stdout lines were on stderr
152152
- S-002-10: `test_s002_10_a_step_sees_ci_and_none_of_the_controller_secrets` set `HCLOUD_TOKEN` and `SOPS_AGE_KEY` for the command; the environment the build step dumped into the archive had `CI=true`, the controller's `PATH`, `HOME` and `LANG`, and neither secret
153153
- S-002-11: the `test_s002_11_*` tests in `sdk/tests/test_component_build_validation.py` passed (undeclared tool, absolute and `..` paths, `buildTools` or `artifact` without `build`, malformed steps)

‎engine/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,8 @@ SOPS keys stay out of `package.json` scripts. Step output goes to stderr. The
179179
archive holds the files git tracks plus the `artifact.include` paths, such as
180180
a gitignored `web/dist`, and the metadata records the steps and the tool
181181
versions under `spec.build`. A failed or timed-out step, a missing include
182-
path, or a tracked file a step deleted writes no archive. The command may take
182+
path, an include path that leads out of the checkout through a symlink, or a
183+
tracked file a step deleted writes no archive. The command may take
183184
1800 seconds for the clone plus every step's `timeoutSeconds`; an explicit
184185
`--timeout` replaces that limit, and `--timeout 0` disables it.
185186

‎engine/src/cloudfall_engine/artifact.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ def __call__(
7272
_ERROR_TOOL_UNSUPPORTED = "artifact_build_tool_unsupported"
7373
_ERROR_STEP_FAILED = "artifact_build_step_failed"
7474
_ERROR_INCLUDE_MISSING = "artifact_include_missing"
75+
_ERROR_INCLUDE_OUTSIDE = "artifact_include_outside_checkout"
7576
_ERROR_TRACKED_FILE_MISSING = "artifact_tracked_file_missing"
7677

7778

@@ -493,6 +494,17 @@ def _require_includes(build: ComponentBuild, source_root: Path) -> None:
493494
raise ArtifactBuildError(
494495
_ERROR_INCLUDE_MISSING, detail, {"path": include.value}
495496
)
497+
# The path itself may be a symlink (archived as one, never followed),
498+
# but no directory above it may lead out of the checkout.
499+
parent = (source_root / include.value).parent.resolve()
500+
if not parent.is_relative_to(source_root.resolve()):
501+
detail = (
502+
f"artifact.include path {include.value} leads out of the "
503+
"checkout through a symlink"
504+
)
505+
raise ArtifactBuildError(
506+
_ERROR_INCLUDE_OUTSIDE, detail, {"path": include.value}
507+
)
496508

497509

498510
def _require_tracked(tracked: Sequence[str], source_root: Path) -> None:

‎engine/tests/test_artifact_build_steps.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,26 @@ def test_s002_8_a_missing_include_path_writes_no_artifact(tmp_path: Path) -> Non
490490
assert fixture.written() == []
491491

492492

493+
def test_s002_8_an_include_through_a_symlink_out_of_the_checkout_is_refused(
494+
tmp_path: Path,
495+
) -> None:
496+
"""A tracked symlink must not let an include path package outside files."""
497+
outside = tmp_path / "outside" / "secrets"
498+
outside.mkdir(parents=True)
499+
(outside / "id_rsa").write_text("PRIVATE KEY\n", encoding="utf-8")
500+
fixture = _fixture(tmp_path, include=["web/dist", "web/link/secrets"])
501+
(fixture.repository / "web" / "link").symlink_to(tmp_path / "outside")
502+
fixture.commit("<h1>linked</h1>\n")
503+
504+
exit_code, envelope, _ = fixture.build("main")
505+
506+
assert exit_code == ARTIFACT_BUILD_FAILED
507+
context = _failure(envelope)
508+
assert context["code"] == "artifact_include_outside_checkout"
509+
assert context["path"] == "web/link/secrets"
510+
assert fixture.written() == []
511+
512+
493513
def test_s002_9_step_stdout_stays_out_of_the_json_envelope(tmp_path: Path) -> None:
494514
fixture = _fixture(tmp_path)
495515

0 commit comments

Comments
 (0)