Skip to content

feat(agents): shared AX target and result contract with a read-only inspector - #346

Merged
roodboi merged 5 commits into
nextfrom
wf/f17-ax-contracts-b16
Oct 20, 2026
Merged

roodboi merged 5 commits into
nextfrom
wf/f17-ax-contracts-b16

Conversation

@roodboi

@roodboi roodboi commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Linear: HACK-1144 (WU31, shared AX target and result contracts).

This adds a provisional target/result decision record and a read-only target inspector that behaves the same through the CLI and MCP.

  • CLI: hack project target [--project=…|--path=…] [--branch=…] [--service=…] [--env=…] [--expect-generation=…] [--json] resolves the exact project, checkout (primary, linked or non-Git), runtime instance (explicit, worktree-derived or default, plus its Compose project name), optional service, env overlay selection and a Hack config generation. It reads local files only. It does not write the registry (not even the last-seen touch), call the engine or Docker, start or repair anything, or change Git. It reports runtime: { observed: false, reason: "not_probed" } and authority: "none".
  • MCP: hack.project.target (read-only) spawns the same command and passes projectName/path through without resolving them first. Every resolution refusal therefore comes from the CLI. data is the CLI envelope, re-validated against the contract: off-contract output becomes null and foreign fields are dropped. stdout is withheld because data carries the same envelope.
  • Refusals: a closed catalogue of 15 reasons. Each has a fixed, value-free message, a stage and a retry disposition, and reuses an existing HACK_ERROR_CODES code, so no new code or guidance render is needed. --expect-generation refuses stale config with observed_generation. If the config changes during the read, the inspector refuses instead of returning a mixed snapshot.
  • Malformed Compose file: a Compose file that is not YAML, whose top level is not a mapping, or whose services is not a mapping is refused as compose_parse_failed (E_CONFIG_PARSE, stage project, retry fix_project). It is no longer reported as a target with no services, and --service no longer turns it into service_not_found. An absent Compose file or an empty services: key still gives services: [].
  • Instance slug: an explicit --branch is slugged like hack up --branch, so Feature/X gives <base>--feature-x.
  • Generation: a fingerprint of .hack/hack.config.json, hack.toml and docker-compose.yml only. Ordinary source edits do not change it.
  • Equal names: registration ownership is matched by exact root or a recorded linked worktree, never by name. Selecting a name that two registrations share is refused as ambiguous_project, and so is a checkout that two registrations own.
  • The Compose project name resolver moved from src/commands/project.ts to src/lib/compose-project-name.ts, so the inspector and the commands share one implementation. Behavior is unchanged.
  • Docs: docs/reference/ax-target-result-contract.md (decision record, schema, refusal table, compatibility matrix, known gaps), a hack.project.target section and row in docs/reference/mcp-tools.md, and a regenerated docs/reference/cli.md.

All names, fields, reasons and retry values are marked provisional for the compatibility review.

Rework after verification (head bc49c11a)

  • Added the compose_parse_failed refusal and documented it, together with a "Compose validity" known gap: the inspector does not run docker compose config, so a file that parses but that Compose would reject is still reported.
  • Added paired CLI/MCP fixtures for the paths no test covered before: env selection (project default, explicit overlay, base), explicit instance slugging, two registrations owning one checkout, config_parse_failed, compose_parse_failed (with and without --service, and a list-valued services) and generation_input_too_large.
  • Added a stubbed-CLI MCP fixture: a second MCP server whose HACK_MCP_COMMAND prints a stored envelope. It proves the tool re-validates the payload instead of passing it on. Added direct validator tests for the code/message consistency check.
  • Moved the buildEnvExplainArgs JSDoc back above its function in src/mcp/server.ts.

Validation

  • bun test tests/project-target-inspect.test.ts: 18 pass. Each paired case runs the source CLI directly and through the MCP tool, whose child is the same source CLI via HACK_MCP_COMMAND. Both use privateCliEnv, a private HOME/HACK_HOME/registry and an isolated Docker client config. The envelopes must be equal apart from observed_at. Cases:
    • two projects, by session cwd and by name
    • a linked worktree's derived instance (alpha--feature-login), an explicit instance overriding it with the Git branch unchanged, and Feature/X slugged to alpha--feature-x
    • env selection: project default overlay, explicit overlay and base
    • conflicting selectors
    • stale generation, and a source edit that keeps the generation
    • ambiguous equal names, two registrations owning one checkout, a moved (deleted) registered root, an unknown project, a detached worktree, no project, a missing path and an unknown service
    • a malformed Hack config, a malformed or list-valued Compose file, and an oversized Compose file
    • malformed selectors: CLI invalid_selector, MCP invalid_arguments with no spawn
    • no-write checks: registry bytes and mtime, state dir contents, .hack contents and the Git worktree list are unchanged
    • MCP re-validation against a stubbed CLI: drifted data becomes null, foreign fields are dropped, and a mismatched message becomes null
    • domain: config changed during the read, selector validation, value-free messages, and validator drift rejection (including code/message consistency)
  • Required and related suites: tests/integration-ownership.test.ts, tests/cli-spawn-env-isolation.test.ts, tests/mcp-inspect-tools.test.ts, tests/agent-instruction-source.test.ts, tests/cli-help.test.ts and tests/experimental-gating.test.ts (78 pass together with the focused file). tests/mcp.test.ts: 11 pass.
  • bun run --cwd packages/cli typecheck: clean. bun run --cwd packages/cli check: 0 errors. The 34 warnings it reports are in existing files. ultracite check on the changed files reports nothing.
  • bun run docs:cli-reference: no drift (the CLI surface did not change in the rework). bun run privacy:check: ok. No Rust changed.

Negative controls (mutation proofs)

Each mutation was applied alone and then reverted. The focused suite failed every time.

First round:

Mutation Failing test
Drop the --project/--path conflict check conflicting selectors
MCP drops path when projectName is set (pre-resolution) conflicting selectors (parity)
Skip the expected-generation comparison stale generation
Skip the generation re-read config change during the read
Ignore an explicit instance explicit instance wins
Include a source file in the generation source edit keeps generation
Validator stops checking stage/retry drifted envelope rejection
Match registrations by name 7 paired cases
Accept undeclared services closed refusals

Rework (all 12 killed):

Mutation Failing test
resolveEnvSelection returns base for an explicit overlay env selection
resolveEnvSelection ignores the project default env selection
Delete the --env argv entry from buildTargetInspectArgs env selection
Use the raw explicit branch instead of sanitizeBranchSlug explicit instance slug
Disable the two-owner check (candidates.length > 1) two registrations own one checkout
Disable the config_parse_failed refusal malformed or oversized inputs
Raise the generation input bound to 64 MiB malformed or oversized inputs
Validator drops the code check validator drift rejection
Validator drops the message check validator drift rejection, MCP re-validation
MCP returns parseJson(stdout) without re-validation MCP re-validation
A Compose YAML error returns no services malformed or oversized inputs
A non-mapping services returns no services malformed or oversized inputs

Remaining gaps (host-bound or out of scope)

  • No runtime readback. The snapshot never observes containers, so it does not prove an instance exists or is healthy. Container and env readback for two projects and two branches needs a host with an engine and is not covered.
  • Not exercised: concurrent session isolation, slow or failed probes, unknown provenance and capability-limited clients. The inspector has no runtime probe yet.
  • Env: selection only. Overlay existence is not checked.
  • Compose validity: only YAML/mapping shape is checked; docker compose config is not run.
  • Generation scope: worktree-local overrides and env files are excluded. Native hack.project.json projects are refused as project_inputs_unsupported.
  • The CLI parser's shared --project/--env grammar refusals stay E_USAGE with no detail.reason, as for every command.
  • Hosted CI has not reported on the new head yet.
  • WU20, WU21 and WU28 runtime and AX gates remain open. This draft does not claim compatibility-review sign-off.

Rework 2

Fixes the three open verifier findings from round 1.

  • Registry no-write proof (should-fix). inspection writes nothing now compares projects.json against the bytes and mtime that beforeAll wrote, not a baseline read inside the test after earlier paired cases had already run the CLI. Mutant: inject touchProjectRegistration({ project: context }) after selectProject in resolveTarget. Round 1: survived. Now: killed (registry text differs).

  • Unreadable inputs leaked OS text and paths (blocking). A filesystem fault while reading inputs used to escape inspectTarget as E_UNEXPECTED with the raw message, for example EACCES: permission denied, open '<abs path>/.hack/docker-compose.yml'. It is now one of two closed, value-free refusals:

    • input_permission_denied (E_PERMISSION, project, fix_project) for EACCES/EPERM
    • input_unreadable (E_CONFIG_INVALID, project, fix_project) for any other errno error. New repro found in this round: when .hack/docker-compose.yml is a directory, the generation read treats it as absent, and the Compose read then threw EISDIR.

    The adapter validator now accepts a detail-less error only when its code is E_USAGE (the parser refusals). Any other detail-less error, such as an E_UNEXPECTED raised by a defect, becomes data: null. The contract doc and mcp-tools.md now say so.

  • Validator data checks untested (minor). New tests mutate a real inspector success envelope: authority set to something other than "none" or removed, runtime.observed: true, a wrong runtime.reason, and runtime: null. The authority case is also checked through the stubbed MCP CLI.

New paired CLI/MCP fixtures: dir-compose, which is deterministic and works as root, and locked-compose (mode 000; skipped when running as root, because root can read the file). Both also assert that the CLI envelope contains neither the fixture root nor the errno name.

Mutations, all killed by the named test:

Mutation Failing test
touchProjectRegistration after project selection inspection writes nothing
Drop the filesystem-fault mapping unreadable input, permission-denied input
Map EACCES to input_unreadable permission-denied input
Accept a detail-less error for any code parser E_USAGE only, drifted CLI output
Drop authority === "none" claims authority or readiness, drifted CLI output
Drop runtime.observed === false claims authority or readiness

Gates run locally on head 5352415d:

  • bun run typecheck and bun run check pass, as do bun run --cwd packages/cli typecheck and check.
  • gitleaks 8.30.1 over origin/next..HEAD finds no leaks.
  • The target-inspect, mcp and env-command-matrix suites pass: 37 pass, 0 fail.
  • Full bun run test (7369 tests, run on a host under load average 17–20) had 28 failures in 18 files. Serial reruns with --timeout 60000 passed in 10 of those files. The other 8 files are host-native tests (native-authored-*, native-compose-adoption-*, native-compose-proxy-probe, native-compose-retained-storage-command, native-compose-storage-activation-command), and they keep failing serially. The same native failures reproduce on the unmodified base a3eb34e9 (checked: proxy-probe, retained-storage-command, process-incarnation). They are pre-existing host-environment failures, not caused by this change.
  • There are no Rust changes.

Still open:

  • A non-filesystem defect inside the inspector still reaches the CLI as E_UNEXPECTED with free text. MCP returns data: null for it. Mapping every thrown value to a closed refusal would hide defects, so it was not done.
  • An errno error from a read-only git child, if one were thrown, would also be reported as input_unreadable.
  • The gaps listed under "Remaining gaps" above are unchanged.

Rework 2: independent re-check (head 5352415d, no new commits)

A second rework pass started from the current remote head, 5352415d. All three round-1 findings are already fixed there, so this pass pushes nothing.

  • bun test tests/project-target-inspect.test.ts: 22 pass, 0 fail. This includes the permission-denied case, which runs because the host user is not root.
  • Every mutant was re-run after the Biome reflow, and each was killed by the named test:
    • touchProjectRegistration after project selection: "inspection writes nothing"
    • filesystem-fault mapping removed: unreadable input, permission-denied input
    • EACCES mapped to input_unreadable: permission-denied input
    • detail-less error accepted for any code: parser E_USAGE only, drifted CLI output
    • authority === "none" removed: authority/readiness, drifted CLI output
    • runtime.observed === false removed: authority/readiness
    • runtime.reason === "not_probed" removed: authority/readiness
  • The argv-helper gap (the direct CLI run reuses buildTargetInspectArgs) does not hide a dropped flag. Removing any one of --project, --path, --branch, --service, --env or --expect-generation from the helper fails 1 to 8 paired tests through their per-field assertions.
  • Hosted CI on 5352415d: all 16 checks pass, including test, secret-scan and every docker-e2e shard.
  • The branch is behind next (099877a0). It was not updated, because force-pushing and merging next were both outside this pass.

Open (not fixed; each needs a product decision):

  • Silent registry degradation. When projects.json is present but unreadable (mode 000) or is not valid JSON, the shared lenient reader returns an empty registry. --path then reports registration: null, and --project refuses with project_not_found. Lifecycle commands read the registry the same way, so the answer matches what they would do. The caller just cannot tell this case from a project that is genuinely unregistered. The options are a closed refusal, an explicit registration state, or keeping parity with the commands.
  • Retry advice for transient errno. Transient errno errors (EMFILE, EAGAIN, EIO, EINTR) map to input_unreadable, whose retry is fix_project. For these errors, reinspect is the more accurate advice.

Rework 2: re-confirmation on merged head 8395b342 (no new commits)

The branch now includes next (b431f0e7, merged by the main session). That merge does not touch the inspector, its tests or its docs.

  • Focused and related suites (project-target-inspect, env-command-matrix, mcp, mcp-inspect-tools, cli-spawn-env-isolation, integration-ownership): 69 pass, 0 fail. packages/cli typecheck and check: 0 errors. docs:cli-reference: no drift. gitleaks 8.30.1 over the origin/next..HEAD patches: no leaks.
  • The six round-1 mutants were re-run on 5352415d, whose inspector files are identical on 8395b342 (registry touch, detail-less pass-through for any code, authority check removed, runtime.observed check removed, filesystem-fault mapping removed, EACCES mapped to input_unreadable). Each one was killed by the named test.
  • The two open items above (silent registry degradation and retry advice for transient errno) are unchanged. Both still need a product decision.

🤖 Generated with Claude Code

roodboi and others added 2 commits October 19, 2026 11:57
…nspector

Add a provisional target/result decision record and a read-only target
inspector with CLI and MCP parity (HACK-1144, WU31).

- `hack project target [--json]` resolves the exact project, checkout,
  runtime instance, optional service, env overlay selection and a Hack
  config generation from local files only. It performs no registry write,
  engine call or Git mutation, and reports runtime as not probed.
- `hack.project.target` (read-only MCP tool) spawns the same command and
  passes selection through unresolved, so every resolution refusal is the
  CLI's. Its data is the CLI envelope re-validated against the contract.
- Refusals are a closed, value-free catalogue (reason, stage, retry) that
  reuses existing error codes. `--expect-generation` refuses stale config;
  a config change during the read is refused instead of snapshotted.
- Extract the Compose project name resolver from commands/project.ts so
  the inspector and commands share one implementation.
- Paired CLI/MCP fixtures: two projects, a linked-worktree branch and an
  explicit instance, conflicting selectors, stale generation, ambiguous
  equal names, a moved checkout, detached worktree and no-write checks.

Names, fields and reasons are provisional pending compatibility review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n gaps

The read-only target inspector now refuses a Compose file that is not YAML,
whose top level is not a mapping, or whose services key is not a mapping, as
compose_parse_failed, instead of reporting a target with no services.

Paired CLI/MCP fixtures now cover env selection (project default, explicit
overlay, base), explicit instance slugging (Feature/X -> <base>--feature-x),
two registrations owning one checkout, config_parse_failed,
compose_parse_failed and generation_input_too_large. A stubbed CLI proves the
MCP tool re-validates the envelope, and the validator's code/message
consistency check has direct tests. Restores the buildEnvExplainArgs JSDoc.

Refs HACK-1144

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@blacksmith-sh

This comment has been minimized.

roodboi and others added 3 commits October 19, 2026 13:04
The new read-only target inspector accepts --env and --service, so the env
matrix source check requires a row for it. It reports only the overlay
selection and never reads env files or values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A filesystem fault while reading target inputs (a mode-000 or directory
Compose file) escaped as E_UNEXPECTED with the raw OS message and absolute
path. Map errno errors to the closed input_permission_denied and
input_unreadable refusals, and let the adapter validator pass a detail-less
error only for parser E_USAGE refusals.

Strengthen the no-registry-write proof by comparing against the registry
bytes written in beforeAll, and cover the validator's authority and
runtime.observed checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi marked this pull request as ready for review October 20, 2026 17:59
@roodboi
roodboi merged commit 8c79000 into next Oct 20, 2026
16 checks passed
@roodboi
roodboi deleted the wf/f17-ax-contracts-b16 branch October 20, 2026 17:59
roodboi added a commit that referenced this pull request Oct 21, 2026
Brings #355 (G4, HACK-1272: retire authored starts refused before journal
reservation), #352, #341, #342 and #346 into the G1 closed-refusal branch.
Both sides' behavior is kept.

Conflict resolutions:

- docs/reference/native-authored-commands.md: keep G1's explanation of
  `pending` (in-progress start or failed start with retained evidence; only
  the owner's cleanup proof may retire it) and append G4's pointer that
  `down --recover` retires an attempt that never reserved runtime state when
  no startup is running.

- src/lib/native-authored-command.ts: the `pending` status note keeps G1's
  wording and adds G4's `hack down --recover` hint. Auto-merged code keeps
  G4's canceled `unreserved` exit 130 ahead of G1's stopped-runtime
  classification. Semantic fix: G1's stoppedRuntimeStartup message map had
  no entry for G4's new `unreserved` outcome, so a runtime_not_running start
  that was retired as unreserved would have produced an undefined message.
  It now reports E_DOCKER_UNAVAILABLE with an `unreserved` message (intent
  retired, `hack ps` reports not_started). G4's `down --recover` pending-start
  path already refuses through commandFailure as a closed coded HackCliError
  (E_LIFECYCLE_FAILED, values omitted), and unreserved startup failures map
  to E_STARTUP_INCOMPLETE or E_DOCKER_UNAVAILABLE, so no stack or help is
  printed.

- tests/native-authored-project-start.test.ts: the fixture keeps G1's
  `stoppedDuringServe` and G4's `failBeforeReceiptCode` and
  `unreservedInspection` options. The stub serve emits G4's configurable
  failBeforeReceipt code and G1's wrapped stopped-runtime refusal. The stub
  inspect answers both failure modes, with `native_graph_unreserved` only
  when requested. The duplicate native-runtime-client import is merged into
  one. Semantic fix: G4's HACK-1272 call-log expectation now includes G1's
  read-only `runtime status --json` preflight between plan and serve.
  Both sides' tests are kept.

- tests/native-authored-command.test.ts (auto-merged): add an `unreserved`
  case to the stopped-runtime startup table.

Verification (Bun 1.4.2, macOS):
- packages/cli typecheck and check: 0 errors.
- native-authored-command, native-refusal-cli,
  native-authored-command-dispatch and cli-spawn-env-isolation: 124 pass.
- Other native-authored-* and native-runtime-* suites: 409 pass, 5 fail.
  2 are load-timing failures that pass when rerun alone. 3 are host
  live-session census failures in the recovery and incarnation suites,
  which fail identically on origin/next and ead8383 on this host.
- native-authored-project-start (--timeout 60000): 48 pass. The 3
  host-metadata cases are intermittent (HACK-1299). They also fail
  intermittently on origin/next and ead8383 and pass when run alone.
- Six scripts/check-native-*-plan-cli.ts: all pass after build and
  build:config-compiler.
- docs:cli-reference and generate:agent-plugins: no drift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi added a commit that referenced this pull request Oct 21, 2026
Brings in the squash-merged AX target contract (#346) so the PR no longer
conflicts with next. Conflicts were additive: the task catalog's command,
MCP tools and docs rows are kept beside next's content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi added a commit that referenced this pull request Oct 21, 2026
Linear: HACK-1157 (WU26-CATALOG). Stacked on #346 (`wf/f17-ax-contracts-b16`, WU31 target
contract); review that first.

## Final behavior

- New read-only commands `hack project task list` and `hack project task inspect <task>`, plus
  MCP tools `hack.project.task.list` and `hack.project.task.inspect`. Names and schemas are
  provisional; see `docs/reference/ax-task-catalog.md`.
- Source of truth is a checked-in `.hack/tasks.json` (`hack.tasks.v0`), kept separate from
  `hack.config.json` so the published config schema and the WU31 generation do not change. Each
  recipe names a Compose service and exact argv, workdir, env scope (overlay plus key names
  only), required capabilities, effects, prerequisites, checks, cleanup, and provenance pins:
  SHA-256 of whole files, of one JSON value (RFC 6901 pointer, for example one `package.json`
  script), and of the canonical Compose service definition.
- Every result embeds the full `hack.target.v0` target. Target refusals pass through unchanged,
  and a config generation change during the read is refused.
- Nothing is executed. Results say `authority: "none"` and `execution: "unavailable"`. There is
  no shell endpoint and no execution path. Refused recipes are reported by index, id and reason
  only; their argv, text and any smuggled env values are never echoed.
- Closed per-recipe refusals: `recipe_invalid`, `ambiguous_task`, `untrusted_provenance`,
  `inline_command`, `unapproved_host_profile`, `missing_capability`, `env_scope_mismatch`,
  `service_not_found`, `input_path_invalid`, `input_missing`, `stale_recipe` (with observed
  digests). Whole-catalog refusals: `invalid_task_id`, `task_not_found`, `catalog_parse_failed`,
  `catalog_invalid`, `catalog_too_large`, `catalog_changed_during_read`.
- **Inline-command detection (reworked, fail-closed).** Every argv position is checked, because
  an entrypoint may `exec` any word (this repo's toolchain `run.sh` does). An inline-code or
  stdin flag anywhere after a shell or interpreter refuses the recipe, even after an option
  value or a script path. So `bash -o pipefail -c`, `sh -O extglob -c`, `bash --rcfile x -c`,
  `python3 -W ignore -c`, `node -r ./x.js -e`, `perl -I lib -e` and the GitHub Actions form
  `exec bash --noprofile --norc -e -o pipefail -c …` are all `inline_command`. `env` (including
  `env -S`), `eval`, `xargs`, `su`, `ssh`, awk and sed are refused at any position; `watch
  <cmd>`, `Rscript -e`, `R -e`, `flock -c`, `bun repl`, `deno eval|repl`, `sh -s`, `-i`, `-` and
  a shell or interpreter with no script operand (code from stdin) are refused too. Reviewed
  script argv still passes, for example `bash -o pipefail scripts/check.sh`, `python3 -m pytest`
  and `bun run check`.
- **Pin paths (reworked).** Pins may not enter any `.git` segment, any `.hack` directory's
  machine-local entries (`.internal`, `.branch`, `.env`, `.env.state.json`,
  `hack.env.local.yaml`, `hack.env.*.local.yaml`, `tickets`), or name a secret or key file
  (`.hack.secret.key`, legacy Hack secrets files, `.env`/`.env.*`, `*.key`, `*.pem`, `*.p8`,
  `*.p12`, `*.pfx`). A stale refusal reports the observed digest, so these exclusions keep
  value-derived digests out of output. Names are compared NFC-normalized and lowercased for
  case-insensitive APFS (`.GIT/config`, `.hack/.Internal/x`).
- **Resolved-path check (reworked).** `readPinnedDigest` re-runs the same path rules on the
  checkout-relative path with directory symlinks resolved. This replaces the old prefix
  containment check: a symlinked directory can neither leave the checkout nor alias excluded
  state such as `.hack/tickets`.
- Capabilities a Compose-service runner may grant: `checkout_read`, `checkout_write`,
  `named_volumes`, `network_egress`, `managed_env`. Never granted: host ports/network, Docker
  socket, home mount, privileged, SSH agent, credential store. No host profile is approved, so
  host recipes are refused rather than run unconfined.
- This repository's catalog has three reviewed recipes for existing toolchain scripts: `models`,
  `check` and `rust-check`. They are unaffected by the stricter rules and are still `reviewed`.

## Validation (local, macOS, Bun 1.4.3)

Rework head `267f62be`:

- `bun test tests/project-task-catalog.test.ts tests/integration-ownership.test.ts tests/cli-
  spawn-env-isolation.test.ts tests/mcp.test.ts`: 44 pass, 0 fail. The catalog file alone: 17
  pass, including the repo-catalog check.
- `bun run --cwd packages/cli typecheck`: 0 errors. `bun run --cwd packages/cli check`: 0 errors
  (34 existing warnings). Biome on the changed files: clean.
- `bun run docs:cli-reference`: no diff (the CLI surface did not change). No agent guidance or
  plugin render changed.

From the original head `ee3704fd` (not rerun on the rework head): `tests/mcp-inspect-
tools.test.ts`, `tests/project-target-inspect.test.ts`, `tests/cli-reference-docs.test.ts`,
`tests/agent-instruction-source.test.ts`, `cli-help`, `doctor-generated-files`, `project-target-
argument-redaction-cli` and `pattern-examples` passed; `bun run typecheck` and `bun run check`
had 0 errors.

## Negative controls (each proved by a mutation that makes its test fail)

Rework mutations (all killed):

- Inline flags matched only in leading options (the old behavior): 2 tests fail (grammar table
  and the paired `log-actions` fixture).
- `env`/`eval`/`xargs`/awk/sed refused only at argv[0]; stdin flags ignored; missing script
  operand ignored; `-` operand ignored; REPL/eval subcommands ignored; `watch <cmd>` ignored:
  each fails the grammar test.
- Path segments compared case-sensitively; `.git` refused only as the first segment; secret
  basenames accepted; `.hack` local entries cut back to `.internal`/`.branch`: each fails the
  path test.
- Resolved-path guard in `readPinnedDigest` disabled: the discovery fixture fails (`escape-dir`
  through `linked -> ../outside`, and `alias-local` through `hack-link -> .hack` reaching
  `.hack/tickets/notes.md`, become `stale_recipe` instead of `input_path_invalid`). This closes
  the verifier's surviving mutation.

Original controls (unchanged):

- **Registration runs nothing:** under the spawn recorder, listing, inspecting a reviewed `touch
  <canary>` recipe and inspecting a refused `sh -c` recipe run only `git`; the canary file is
  never created.
- **Stale recipe:** an edited script or service definition is refused with its observed digest.
  Editing `.hack/toolchain/run.sh` fails the repo-catalog check.
- **Log-injected command:** `provenance.source: "log"` is `untrusted_provenance`; `sh -c …` and
  the Actions-style line with repository provenance are `inline_command`. No argv or canary text
  appears in CLI stdout or stderr.
- **Wrong env scope:** a `staging` recipe is refused under base and passes with `--env staging`;
  a base recipe is refused under `staging`.
- **Missing capability:** `docker_socket` plus an unknown token is refused with
  `missing_capabilities: ["docker_socket"]` and `unknown_capabilities: 1`.
- **Other closed refusals:** ambiguous ids, host runner, unknown service, missing input, file
  symlink escaping the checkout, invalid id, malformed/foreign/oversized/symlinked catalogs,
  catalog or config edits during the read, forged or drifted envelopes (dropped by MCP re-
  validation), and a malformed task id (refused by the CLI; the MCP tool refuses it without
  spawning).

## Remaining gaps (host-bound or owned elsewhere)

- No execution. Running a reviewed recipe, managed env injection, mount/network/resource
  enforcement, timeouts, cleanup and output policy remain WU26 gates. No container was started
  for this PR.
- Inline-code detection is a denylist of known shells, interpreters and string-to-shell
  wrappers. A program it does not know, or a reviewed script, can still evaluate an argument;
  the reviewer must check argv, and WU26 must not treat this check as a sandbox. Being fail-
  closed, it can also refuse a script argument that looks like an inline flag (`script.sh -c`).
- `reviewed` means the pins match. It does not prove who reviewed the recipe, so execution must
  not treat it as authorization.
- Pins cover only the named files and JSON values. Nested scripts, dependencies, lockfiles and
  base images are not covered. The service definition is pinned but not analyzed for policy.
- Env-scope checks compare only the overlay selection. Whether the overlay exists and supplies
  the scoped keys is not checked.
- No host profile is approved, so host-side scripts cannot be catalogued yet.
- Pinned inputs are read once. The catalog file and the config generation are re-read and
  compared.
- The full `bun run test` suite and hosted CI were not run locally.

## Rework 2

Addresses the independent verifier's round-1 findings. Commits on top of `267f62be`: `356f6e3b`
(fix), `aab02f85` (gitleaks allowlist for the existing synthetic canary), `2dd831e1`
(docs/env.md rows), and merges of the current base `wf/f17-ax-contracts-b16` (`a3eb34e9`, then
`5352415d`).

- **Legacy `.dev` project directory (blocking).** Machine-local entries are now excluded after
  either project directory name, `.hack` or the legacy `.dev` (from
  `HACK_PROJECT_DIR_PRIMARY`/`HACK_PROJECT_DIR_LEGACY`). `.dev/.internal/state.json`,
  `.dev/hack.env.local.yaml`, `.dev/hack.env.prod.local.yaml`, `.dev/tickets/x.md` and
  `apps/web/.dev/.internal/x` are `recipe_invalid` in any letter case; `.dev/docker-compose.yml`
  and `.dev/tasks.json` stay accepted.
- **Secret files the name rules cannot list (medium).** Every pin must now be a Git-tracked
  file. One read-only `git --literal-pathspecs -C <root> -c core.fsmonitor=false ls-files -z
  --cached -- <pins>` runs per pinned recipe, after the lexical and symlink checks and before
  any pinned byte is read. An untracked or gitignored file (`.npmrc`, `.netrc`, `secrets.json`,
  ...), a file removed from the index, or a checkout without Git gives `input_path_invalid` with
  no digest. The docs no longer claim the denylist alone keeps value-derived digests out of
  output. Known gap, documented: the digest is of the working-tree bytes, so an uncommitted
  local edit to a tracked file can still appear as a digest in a stale refusal.
- **Inline-command forms (non-blocking).** Added `npx -c`/`--call`, `npm exec -c`, `pnpm
  -c`/`--shell-mode`, `yarn exec`, `bun exec`, `nodemon --exec`, `tsx`/`ts-node` (`-e`, `-p`,
  `-i`, no operand), `sudo -s|-i`, `doas -s`, `git -c`/`--config-
  env`/`--exec`/`-x`/`--extcmd`/`submodule foreach`, `nix
  --expr`/`eval`/`repl`/`develop`/`shell`, `make --eval`/`-E`/`-f -`, `deno run -`,
  `elixir`/`mix -e`, `erl -eval`, `entr -s`, `parallel`/`sg` with a command, and `runuser`,
  `nix-shell`, `concurrently`, `watchexec`, `tmux`, `iex`, vi/Vim/Neovim and Emacs at any
  position. Every repro in the verifier's list is now `inline_command`. The docs now call the
  check a denylist of known programs that is fail-closed only for the programs it knows.
- **Vacuous registration test (minor).** The spawn-recorder test now requires at least one
  recorded `git` call and a recorded `ls-files`, so a recorder that fails to load cannot pass.
- **Not changed here:** the base branch `wf/f17-ax-contracts-b16` is still based on `3b530068`,
  behind current `next`; that belongs to #346.

### Rework 2 validation (local, macOS, Bun 1.4.3; package.json pins 1.4.2)

Head ``6400eec4``:

- `bun test tests/project-task-catalog.test.ts tests/project-target-inspect.test.ts`: 40 pass, 0
  fail.
- Full `bun run test`: 7253 pass, 126 skip, 8 fail, 7387 tests across 524 files (1h16m, host
  load average 12-18). Every failure is in code this stack does not touch. Rerun serially with
  `--timeout 60000`: `agent-onboard-command`, `init-assistant`, `native-compose-file-command`
  and `native-env-values` pass. Three native census tests still fail on deadline errors ("Native
  process census expired", "live-session observation refused (unknown)"): one in `native-
  authored-process-incarnation` and two in `native-authored-project-recovery`. I treat these as
  load-only and have not masked them; hosted CI is the clean signal.
- `bun run --cwd packages/cli typecheck`: 0 errors. `bun run --cwd packages/cli check`: 0 errors
  (34 existing warnings). Root `bun run typecheck` and `bun run check`: pass. Biome on the
  changed files: clean.
- `gitleaks git --config .gitleaks.toml --redact --log-opts='origin/next..HEAD'` (8.30.1): no
  leaks.

Mutations, each killed by the named test:

- Skip the tracked-file check: 3 tests fail (paired discovery fixture `untracked-secret`,
  registration `ls-files` assertion, domain tracking test).
- Exclude local entries only after `.hack`: the path grammar test fails.
- Remove `npx`, `git` `foreach`, `sudo`, `ts-node` or `runuser` from the tables: the inline
  grammar test fails for each.
- Do not load the spawn recorder: the registration test fails (it passed vacuously before).

Hosted CI: CI run 39441252597 (pull_request) was queued at 2026-10-20T14:39Z for head
`6400eec4`. Earlier runs on this branch took 3 to 4 hours, so the result was not available when
this section was written.

### Rework 2, round 2 (head `14676d5a`)

Re-checked the verifier's round-1 findings against the remote head `6400eec4`. All four in-scope
items were already fixed there by `356f6e3b`; each was re-proved by a mutation on the new head:

- **Legacy `.dev` local state (blocking): fixed.** `isSafeInputPath` excludes machine-local
  entries after `.hack` or `.dev`. Mutation: drop `HACK_PROJECT_DIR_LEGACY` from the project-dir
  set → the path grammar test fails.
- **Untracked secret files (medium): fixed.** Pins must be Git-tracked (one read-only `git ls-
  files` before any byte is read). Mutation: make the tracked check always true → 3 tests fail
  (paired `untracked-secret` fixture, registration `ls-files` assertion, domain tracking test).
- **Inline-command forms (non-blocking): fixed for every listed repro.** Re-ran the verifier's
  14 repros through the real classifier: all are `inline_command`.
- **Vacuous registration test (minor): fixed.** The test requires a recorded `git` call and `ls-
  files`.
- **Base behind `next` (informational): resolved.** #346 was squash-merged into `next`
  (`8c790004`) and this PR showed `CONFLICTING`. Merged `origin/next` (`c7336989`). Every
  conflict was additive (the task command, the MCP tools, and the docs, CLI reference and env-
  matrix rows); the merged tree differs from `next` only in the 14 F18 files.

New commits:

- `125e2127` (from an earlier unpushed rework pass, reviewed and kept): refuses `data:` URL
  modules for Node, Bun, Deno and tsx, `git bisect`/`filter-branch`, and `julia -e`/`--print`/no
  operand. Mutation of those rules → the grammar test fails.
- `14676d5a`: refuses `git fetch`/`clone`/`ls-remote --upload-pack` and `git push --receive-
  pack`, because git runs both values as shell commands. Also maps a dash version suffix
  (`node-22 -e`) to the same interpreter as `node22`. `git push -u origin main`, `git fetch
  origin` and `node-22 script.js` still pass. Each rule has its own mutation, and each mutation
  makes the grammar test fail.

Validation (local macOS, Bun 1.4.3; package.json pins 1.4.2):

- `bun test tests/project-task-catalog.test.ts`: 18 pass, 0 fail.
- `bun run --cwd packages/cli typecheck` / `check`: 0 errors (34 warnings that were already
  there). Root `bun run typecheck` / `bun run check`: pass.
- `gitleaks git --config .gitleaks.toml --redact --log-opts='origin/next..HEAD'` (8.30.1): 11
  commits, no leaks.
- Full `bun run test`: 7124 pass, 149 skip, 255 fail across 533 files (1h31m, load average
  14-28). Every failing file was rerun serially with `--timeout 60000`. These pass: `mcp`, `mcp-
  prompts`, `agent-plugin-generation`, `env-selection-parity`, `e2e-shard`, `mcp-publication-
  readiness`, `env-command-matrix`, the task catalog and target inspector suites, and 38 other
  files. Nine native files still fail: `native-authored-project-start`, `native-authored-
  process-incarnation`, `native-authored-project-recovery`, `native-compose-adoption-
  generation`, `native-compose-file-command`, `native-compose-proxy-probe`, `native-compose-
  retained-storage-command`, `native-compose-storage-activation-command` and `native-compose-
  storage-fence-cost`. Run serially on a plain export of `origin/next` on the same host, these
  same files also fail with the same errors: census expiry, `engine:false`, and "Completed-job
  worktree acceptance refused". `native-authored-project-start` passed once on `next` and failed
  on the next run. These failures come from the host and its load, not from F18. They are not
  masked. Hosted CI is the clean signal; `6400eec4` passed every hosted check.

Not fixed here (needs a product decision): SQL clients that take a statement string (`psql -c`,
`sqlite3 db "…"`) and `python -m code` still pass. `psql`'s `\!` and `sqlite3`'s `.shell` can
reach a shell. Refusing every `psql -c` would also refuse common reviewed database tasks.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
roodboi added a commit that referenced this pull request Oct 23, 2026
…#379)

Follow-up to #346 for HACK-1332 (target inspector contract, HACK-1144 / WU31).

## What changed

`hack project target` (and the `hack.project.target` MCP tool) could still return a detail-less
error envelope when a shared project input guard refused. The MCP adapter turned that into
`data: null`. This PR maps every input-guard fault to a closed, value-free refusal.

1. **Guard refusals (cherry-picked rework 077825fe).** The inspector maps the shared guards'
refusals wherever they are raised, including inside config reads:    - native or conflicting
inputs become `project_inputs_unsupported`    - an unparseable, interrupted or uncheckable
legacy adoption state (for example an unsearchable `.hack` or `.hack/.internal`) becomes the new
provisional `adoption_state_unsafe`    - a `.hack` file becomes `input_unreadable`

`LegacyComposeAdoptionSelectionError` now carries its `kind`. 2. **Uncheckable input presence
(new).** `inputPresent()` in `src/lib/project-input-selection.ts` treats only `ENOENT` as
absence. Any other fault threw a plain `Error`, so a `.dev` file, or a mode-000 `.dev`
directory, beside a valid `.hack` still produced `E_UNEXPECTED` with no detail. The guard now
throws a typed `ProjectInputAccessError`. It is value-free: no OS message, path or `cause`. It
classifies the fault as `permission` (`EACCES`/`EPERM`) or `unreadable`. The inspector maps
these to `input_permission_denied` and `input_unreadable`.

**Side effect on other commands:** the same fault now reports `E_CONFIG_INVALID` instead of
`E_UNEXPECTED`. The message text is unchanged except for a trailing "Values omitted.", which
matches the adoption guard's input-access refusal.

The contract doc (`docs/reference/ax-target-result-contract.md`) and its proof table are
updated.

## Validation

- Before the fix, the new paired CLI/MCP fixtures returned the off-contract envelope
  `{"code":"E_UNEXPECTED","message":"Cannot inspect Hack project inputs. …"}`. After the fix,
  they return the closed refusal, and the envelope contains neither the fixture path nor the
  errno.
- Each new test failed under a mutation:
  - always classifying as `unreadable` failed the permission domain test and the paired test
  - restoring the plain `Error` failed both domain tests
  - disabling the inspector mapping failed both paired tests
- Local checks: `packages/cli` typecheck passed. `packages/cli` check reported 0 errors (pre-
  existing warnings only). Biome on the changed files passed. `privacy:check` passed. gitleaks
  over `origin/next..HEAD` found no leaks. `tests/project-target-inspect.test.ts` and
  `tests/project-input-selection.test.ts` gave 52 pass, 0 fail.
- Full suite on the M3 at the exact head `85c8ec23`: 7579 pass, 126 skip, 2 fail, across 7707
  tests. The mode-000 cases are skipped when running as root.
  - Both failures are in `tests/native-authored-project-recovery.test.ts`: "new no-host death
  uses real read-only quiescence facts…" and "new no-host recovery retains original bindings on
  last-await".
  - Both also fail on the untouched `next` base `f2278c79` in the same M3 environment (16 pass,
  2 fail). The error is "Native process census expired". The real host process census keeps
  refusing its sessions phase while concurrent suites change the process table, so the 3 s
  budget expires.
  - These failures predate this PR and do not touch project inputs. They are reported
  separately, not fixed here.

## Not changed (noted)

`readProjectsRegistry` still treats an unreadable registry as empty. That is pending a product
decision (HACK-1332 item 1) and is intentionally left alone here.

Refs HACK-1332, HACK-1144.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant