Skip to content

feat(exec): warn value-free when a running service's env changed since start - #301

Merged
roodboi merged 71 commits into
nextfrom
wf/y5-staleenv-b6
Oct 12, 2026
Merged

roodboi merged 71 commits into
nextfrom
wf/y5-staleenv-b6

Conversation

@roodboi

@roodboi roodboi commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary (HACK-1140, unit Y5)

hack exec now warns when it targets a running Compose service whose resolved env has changed since the service started. The warning never prints a key or value.

  • At hack up and hack restart, Hack records one salted HMAC-SHA256 digest per Compose service. The digest covers the env set delivered to that service (preflightEnvByService, the same projection the env override renders). It is stored in Hack-managed runtime state at .hack/.internal/runtime-state.json, under the optional envFingerprint field. No key name or value is stored. A random 16-byte salt per record blocks precomputed and cross-project comparison.
  • hack exec resolves the env again through the W13 --env resolver it already uses (resolveRequestedEnvName → resolveComposeEnvOverrides) and compares the digests. When they differ, it writes one fixed line to stderr:
    WARNING: Env for service "<service>" changed since it started; this exec uses the env it started with. Run \hack restart ` to apply the current env.The service name is echoed only if it matches the Compose service-name grammar. Otherwise the line reads "this service" and the restart hint shows`. Stdout stays the target command's output, and exec still runs.
  • A service-scoped hack up <svc> and a targeted hack restart <svc> (which also covers hack env apply --service) refresh only the named services' digests. A failed targeted restart, or a run under a different env selection, drops those digests, so exec stays silent rather than guessing. State written before this change has no digests and never warns.
  • The check is advisory. It never refuses exec, and the existing env-mismatch refusal is unchanged. The native runtime backend and authored native-compose exec are out of scope.
  • Docs: the hack exec row in docs/env.md and a new "Stale env warning on hack exec" section. The CLI surface and agent guidance did not change, so the CLI reference and plugin renders are untouched.

Validation (local, macOS arm64; no Docker)

  • bun test tests/compose-env-staleness.test.ts: 11 pass. Covers:
    • the pure digest
    • strict record parsing
    • freshness
    • the closed warning
    • runtime-state update rules
    • a resolver-backed refresh through resolveRequestedEnvName and resolveModernComposeEnvOverrides, with the default overlay and an explicit --env qa
  • tests/project-exec-command.test.ts with the stubbed compose backend: exec emits exactly one stale WARNING: line, on stderr only, after the overlay value changes, and none before.
  • tests/project-up-startup-state.test.ts with the stubbed backend:
    • hack up records digests for every prepared service.
    • A targeted hack restart api refreshes only api and keeps the salt; migrate stays stale.
    • The stored state contains neither the key nor any value.
  • Focused combined run, 144 pass, 0 fail:
    • project-restart-command, project-run-command, project-lifecycle-env
    • integration-ownership, agent-plugin-generation
    • e2e-shard, e2e-scenario-selection
    • env-command-matrix, env-selection-parity (after mise exec -- bun run build:config-compiler)
  • bun run --cwd packages/cli typecheck: clean.
  • bun run --cwd packages/cli check: 0 errors. The 34 warnings are pre-existing. Changed tests and the e2e scenario were linted explicitly and are clean.
  • Mutation check: forcing the warning off makes 4 of the new tests fail.

Negative controls

  • Identical env produces no warning. This is covered by the pure, runtime-state, resolver-backed and stubbed exec tests, and by the e2e scenario's first exec.
  • The warning text contains no key or value. Tests assert this against the synthetic values before and after the change, and against the key name.
  • A missing entry, a pre-upgrade entry, or a malformed/tampered record means no warning.

Tier-2 Docker e2e

tests/e2e/scenarios/exec-stale-env.ts (tier: "docker") is registered in ALL_SCENARIOS, with an estimated 20 s shard hint in tests/e2e/shard.ts. It runs only in the hosted docker-e2e shard i/4 jobs. The flow:

  1. hack env add a synthetic global value, then hack up --detach.
  2. Exec printenv in web: start-time value, no warning.
  3. Edit the value. web and api both still print the old value and show the value-free warning.
  4. hack restart web: web prints the new value with no warning. api still warns.
  5. hack down.

Cleanup always runs. The scenario was not run locally (no Docker by instruction).

Remaining host-bound gaps

  • The Docker e2e scenario has no recorded pass yet. It needs the hosted docker-e2e shard run on this head. Its duration hint is an estimate.
  • Digests are recorded before Compose starts on full and service-scoped up, matching the existing env-selection persistence. If a full up fails, the entry is removed. If a container is not recreated for reasons outside Hack, exec can miss a warning. It never shows a false value.
  • Pre-existing on the base, not from this change: tests/skill-references.test.ts reports external-outside-further-reading for skills/hack-cli/SKILL.md. Separately, hack exec with --env omitted resolves to base/default rather than the stored overlay, so it refuses against a stack started with a named overlay.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

roodboi and others added 30 commits October 11, 2026 21:28
Add a data-only matrix (src/agents/client-capabilities.ts) that classifies
packaging, skill discovery, hooks, plugin MCP, standalone MCP, rules and UI
for Codex, Claude Code, Cursor and standalone consumers. Each cell records
status, evidence class, evidence ref, observed client version, source SHA
and host label. The rendered reference doc carries companion-package layout
and model-policy ownership decisions.

Tests enforce that every cell is classified, verified cells cite an existing
fixture-strength test, manifest or listing evidence never verifies loading,
and manifest-declared components agree with the matrix. The plugin
generation test gains a negative-controlled scan of plugins/hack and
examples/basic for model, permission and output-style settings and
non-primer hooks, plus a skill inventory check against the generator and
docs/integrations.md, which now names all four shipped skills.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hack.project.run built `run <flags> <service> <cmd...>`, so hack's own
parser consumed child flags: `node --version` printed hack's version and
`--branch other` retargeted the instance. Run now emits
`run <flags> -- <service> <cmd...>` and every option value is fused as
`--opt=value` across all tools.

- Validate service, services, branch, profiles, logs service and open
  target against a compose-identifier pattern (no leading '-', no empty
  value; branches may also contain '/').
- tail, maxEvents, maxMs and timeoutMs are positive integers with hard
  ceilings (provisional F27 settings); effective limits are echoed in
  data.limits.
- Every tool carries annotations (readOnly for list/status/logs/open,
  destructive for down/run/init, openWorld false).
- Refusals return error{code, message} with existing codes (E_USAGE,
  E_PROJECT_NOT_FOUND) and spawned:false. A typed tools/call dispatcher
  validates before any handler runs, so refused calls leave no argv
  entry and no audit line.
- Children spawn with HACK_NO_INTERACTIVE=1 on a copy of the frozen
  session env.

Refs HACK-1133, HACK-1134

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Legacy project env key reads (checkout-local, shared common-dir and
inherited primary keys) now use the same lstat, O_NOFOLLOW, size-bound and
stable-descriptor discipline as the native reader, extracted into
src/lib/private-file.ts. Key priority is unchanged. A symlinked, dangling,
oversized or non-regular key file is refused with a fixed message.

New key files are created with open(O_CREAT|O_EXCL|O_NOFOLLOW|O_WRONLY,
0600), fchmod 0600 and fsync, so an existing entry or dangling symlink is
never followed or replaced. A concurrent creator's key is adopted.

Encryption (env add --secret and the legacy migration) refuses a git-tracked
selected or inherited key with a fixed, value-free message before any key
copy. Decryption with a tracked key stays allowed for v4 compatibility.

Legacy v1 values and the encrypted_file store now require canonical base64,
a 12-byte IV, a 16-byte tag (authTagLength pinned) and a bounded ciphertext;
all parse, tamper and wrong-key failures use fixed redacted errors. The
envelope format, key derivation and v1 decryptability are unchanged.

Refs HACK-1127

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

Address verifier findings on the agent-client capability matrix:

- Move the matrix renderer into scripts/generate-agent-client-capabilities.ts
  and add `bun run docs:agent-client-capabilities` (with `--check`), which
  rewrites only the generated section. The test compares the committed
  section with the rendered one and covers marker failures with closed codes.
- Scope the model/permission policy scan to generated guidance surfaces
  (plugins/hack, .claude-plugin, .cursor-plugin, .agents through its skill
  symlinks, and the example agent configuration) instead of example compose
  files. Parse YAML and markdown frontmatter as YAML, fail closed on
  structured-config formats the scan cannot parse, and count a file as
  scanned only when it was parsed.
- Treat reasoning-effort keys (reasoning_effort, model_reasoning_effort,
  frontmatter `effort`, nested effort/reasoning keys) as forbidden model
  settings, with negative controls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every pre-spawn refusal now comes from a closed catalogue (MCP_REFUSALS):
error {code, reason, message} uses an existing code, a closed reason and a
fixed message, and stderr repeats that message. Project names, ids, paths
and registry values are no longer echoed. Schema refusals name only keys
of the tool's own input schema.

An unknown tool name returns the SDK's in-band isError text result again
instead of a JSON-RPC -32602 protocol error, with a fixed message that
omits the requested name.

A canary test drives every refusal reason and the unknown-tool path and
asserts the canary never appears in the result, with no spawn or audit line.

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

Seal authentic envelopes with 8- and 16-byte IVs and with ciphertext one
byte over each parser bound, so only the parser guards can refuse them, and
assert through a createDecipheriv spy that structural refusals happen before
any decipher and that successful opens pass authTagLength 16. Covers the
legacy project env, encrypted_file store and native env value parsers, with
at-bound 12-byte-IV controls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Interactive `hack init` now prints a non-mutating note that points at the
optional Hack plugin and `hack agent onboard`. Repository-local export of
Cursor rules, Claude hooks, Codex skills, AGENTS.md/CLAUDE.md sections and
project MCP config is a separate opt-in whose confirm defaults to no and
whose target list starts empty. The decision moves out of project.ts into
src/agents/init-agent-integrations.ts.

Doctor and the agent-prime freshness inventory now treat absent optional
integrations as current. Doctor flags a path only when it exists and lacks
the current revision marker (AGENTS.md/CLAUDE.md only when they carry a
Hack-managed section). integration-sync counts only stale or errored
checks; user-owned docs without a Hack section are not drift; retired
legacy artifacts still are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
config plan and config validate only treated the literal "base" as the
base selection, so `--env default` (and DEFAULT/none) reached the native
compiler as an overlay named "default" while env get, env list and the
runtime commands selected base layers.

Every CLI command that accepts --env now resolves it through one shared
resolver (src/cli/env-selection.ts): omitted keeps the configured default
overlay, base/default/none in any case select base layers, other names are
normalized, and a non-empty value with no usable name characters is refused
with the fixed "Invalid --env value." usage error instead of silently
selecting base. Authored native dispatch uses the same resolver, and
session workspace names stay env-base.

docs/env.md gains a command and delivery matrix with one row per CLI_SPEC
command that accepts --env; tests/env-command-matrix.test.ts keeps the rows
and selector flags in sync with CLI_SPEC. The canonical agent guidance gains
an env-composition section linking to the matrix; the content revision,
plugin bundle, repository examples and CLI reference are regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Address the W10 verifier findings so integration sync and doctor agree:

- checkAgentDocs marks a doc `unmanaged` only when it has neither the
  Hack start nor end marker. A doc with exactly one marker is a corrupted
  managed section and now reports a malformed-marker error, which sync
  counts as drift. Doctor treats the same doc as stale.
- Add readOptionalTextFile, which uses lstat before reading so callers
  can tell "absent" from "present but unreadable". The Cursor rules,
  Codex skill, shared skill, hack-init skill and agent-doc checks now
  report an unreadable existing file as `error` (drift) instead of
  `missing` (optional, current). Doctor uses the same helper.
- Add regression tests for start-only and end-only docs, plus mode-000
  Hack-owned files and docs in fixture HOME/project roots, for both
  sync and doctor.

Default `hack init` still writes no repository files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The env add --secret refusal tests asserted the refusal text on stderr, but
the default logger backend depends on the host: gum (stderr) when installed,
clack (stdout) otherwise. Hosted macOS runners have no gum, so the refusal
reached stdout and stderr was empty only in CI. Pin HACK_LOGGER=console in
the CLI helper, matching the other CLI-spawning tests, so the stderr
assertions hold on every host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The project-name boundary suite still expected the old value-echoing
"Ambiguous project name/ID" stderr from MCP. Assert the closed
MCP_REFUSALS contract instead: exact code, reason and fixed message,
spawned: false, no argv, and none of the duplicated names, registry ids
or fixture root anywhere in the serialized result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Temporary measurement only; reverted before the final commit. Writes closed
labels and durations per native command to a per-scenario JSONL file and
prints a summary in the docker-e2e job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A storage-bearing saved exec ran three full witness proofs (nine helper
carriers) and a warm up/restart seven. Within one command and one mutation
lease, an adjacent repeat with only read-only checks between it and the
previous proof now reuses that completed kernel proof instead of three new
helper carriers:

- saved exec: the pre-spawn check reuses the preparation proof; the proof
  after the process stays fresh (3 -> 2 proofs);
- up/restart without build, routing or file material: the first pre-spawn
  guard reuses the enrollment proof (warm 7 -> 6, cold seed path 6 -> 5).

Reuse requires the same lease authority, engine, generation and reference
and a byte-identical opening material binding to the one that opened and
closed the recorded proof. Every host check (anchors, expectation,
completion, binding, carrier artifact and lifetime) still runs; any
verification consumes the record first, so a failure or binding change
forces a fresh proof. Ordinary verification always proves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reverts the hosted measurement commit (closed-label cost trace, phase-trace
routing, per-scenario summary step and artifact) now that the per-phase and
per-call attribution is recorded in the pull request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The networks scenario took 3409 s on next with three proofs per storage exec
and ran 2275 s on the hosted engine with the coalesced proofs, so the 80 min
budget raise from the witness recovery change returns to 60 min. The budget
bounds a hung engine; no assertion or command deadline changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Storage witness proofs keep every fence; three exact costs are cut:

- Engine-ID fences: for an explicit DOCKER_HOST=unix:///... selection, the
  first fence of a command under one exact binding (selection, docker
  executable, client config and socket identities) still uses `docker info`
  and must equal one direct GET /info on that socket; only then do later
  fences under that unchanged binding each make one fresh direct request
  instead of a CLI process that lists every client plugin. A refused
  admission keeps the CLI, a moved binding re-admits through the CLI, and an
  admitted direct failure refuses without fallback. Two exact back-to-back
  ID reads with nothing between them are collapsed (holder observation into
  carrier inspect; provision fence into the target observation).
- Recorded command executables: the content digest is cached for one command
  by canonical path and complete stat identity (dev, ino, size, mtime and
  ctime in ns, mode, uid); every identity check still runs, any change
  re-reads, and racily fresh files are never cached.
- Target observations: the witness's closing re-observation of a
  deferred-finish verify invocation is answered by the carrier's own closing
  observation of the same query when the rechecked material binding is
  unchanged and only reads ran between (one observation per proof).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Temporary measurement only; reverted before the final commit. Re-applies the
X01 closed-label trace (reverting eb9a678) and adds point events for direct
engine-ID reads and admissions, cached executable digests, target observations
and reused closing observations, plus a shard 1 identity fence benchmark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An admitted direct engine-ID read that fails because the caller cancelled or
its probe deadline expired now reports the CLI probe's existing closed
cancel/timeout classification; that probe refuses before spawning anything.
Every other admitted direct failure still refuses without CLI fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Per command on the synthetic engine: 81 of 86 engine-ID fences and 20 of 21
  target observations per proof, with every proof kept; an admitted explicit
  Unix selection uses one CLI fence per command.
- A changed daemon ID between two admitted fences refuses before any helper,
  workload or state change; a refused admission keeps every fence on the CLI.
- A target change right after the verify carrier's closing observation is
  refused by the next remaining observation.
- Admission, re-admission, no-fallback and cancellation controls on a real
  Unix server; executable digest reuse, replacement, same-size tamper, mode,
  racy-fresh and mid-command replacement controls.
- Reference docs describe the three cuts and their limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measurement is complete; the hosted trace run was 38211327829.

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

Plugin skills can now ship references at skills/<skill>/references/*.md from
a typed registry (src/agents/skill-references.ts). The generator writes each
reference with a recomputable content revision, removes stale ones, and links
them from the plugin SKILL.md. Standalone hack-cli installs ship no
references, so their render (and ownership fingerprint) is unchanged.

The first reference is the hack-cli pattern catalogue, rendered from the typed
register of all twelve HACK-P cards with dispositions, owners, missing
contracts and value-free provenance. Tests check completeness, CLI_SPEC
command resolution, a banned-recipe scan outside negative-example blocks, and
an offline link contract over every packaged skill markdown file. External
URLs in hack-install and hack-remote move under "Further reading".

docs/agent-guidance.md records the reference contract and the promote/defer
decision for the lifecycle, cleanup, checks, sessions, hack-run and
hack-feedback skill candidates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seed an isolated HOME with sentinel Claude, Codex, Cursor and shared-skill
config (including stale hack-cli copies) and two fixtures with customized
AGENTS.md, CLAUDE.md, rules, skill copies, settings.local.json and .mcp.json.
Run init --auto, init --auto --with codex, agent prime, agent prime --check,
agent onboard, agent patterns, doctor --json, doctor --fix --no-interactive
and setup sync --check twice each with HOME and HACK_HOME overridden, and
require byte-identical hashes, no new client entries, git status changes only
under .hack/, and each command's expected exit code. An explicit
setup agents --agents-md is the positive control and must change exactly
AGENTS.md.

Docker is pointed at a missing socket and Mutagen at a stub so doctor --fix
stays free of Docker and network effects; the hackd it starts is stopped.
The scenario is registered in ALL_SCENARIOS and shard.ts places it in
exactly one docker-e2e shard via the default duration hint.

Refs: HACK-1124, HACK-1132, HACK-1155

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HACK-1211 needs a matched, bounded way to reproduce the intermittent Bun
crash and a repeatable record of crashes per hosted run. This adds the
instruments only; it changes no test, timeout, assertion or pin.

- bun-crash-experiment.yml runs only on workflow_dispatch or when the
  bun-crash-experiment label is added to a pull request. A labeled pull
  request runs the matched pair (1.3.9 baseline, 1.4.2 candidate) on its
  head; a dispatch runs one version at a chosen revision. It uses
  blacksmith-6vcpu-macos-15, contents: read, and timeout = budget + 15.
  The instrument comes from the workflow's commit, so older revisions
  can be measured.
- scripts/bun-crash-loop.ts alternates the native HTTPS owner test with
  the full bun test. It never starts an iteration whose estimate (its
  initial guess or longest observed run) ends past the budget, and
  records a run still going at the deadline as overrun, not a crash. It
  writes a closed summary and keeps logs plus bun*.ips reports for a
  7-day artifact. It never opens crash-report links.
- scripts/ci-bun-crash-census.ts reads every attempt's test job through
  the caller's gh, downloads only failed-job logs into an owned temp dir
  that it removes, and prints per-version counts and crashing ids only.
- Both use scripts/lib/bun-crash-classify.ts.

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

Storage-witness engine-ID admissions now resolve the Docker CLI's endpoint
selection the way the CLI does: a nonempty DOCKER_HOST, then DOCKER_CONTEXT,
then the client config's currentContext, then the default context at
unix:///var/run/docker.sock. A named context is read from its
contexts/meta/<sha256>/meta.json through a no-follow binding; only a plain
unix:/// docker endpoint with no TLS option, no unknown or duplicate field and
no entry at the context's TLS path is eligible. TLS material is checked for
absence by name and never opened. Missing, malformed, linked, TCP, SSH, TLS or
header-bearing contexts, and DOCKER_HOST with DOCKER_CONTEXT, keep the CLI.

Admission is unchanged: the first fence under a binding is the CLI read and
must equal one direct GET /info on the resolved socket. The binding now also
pins the context store entry and the absent TLS path, so a changed config,
context entry, socket or executable is admitted through the CLI again. The
ingress and VM-file observers, trusted without a CLI comparison, still accept
only an explicit Unix DOCKER_HOST.

Tests that run commands against a synthetic docker now pin an isolated client
config naming a missing context, so they never read the contributor's Docker
config or dial a real engine socket. A new opt-in test (HACK_TEST_DOCKER_CLI=1,
run on docker-e2e shard 4) runs the real Docker CLI against two fake Unix
engines for every selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Temporary measurement only; reverted before the final commit. Re-applies the
X03 closed-label trace (6a4e118) without its identity benchmark, labels each
engine-ID admission with its selection kind (host, default or context), counts
direct reads as trace leaves, and prints the runner's Docker client selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measurement is complete; the hosted trace run was 38225752202.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi and others added 15 commits October 12, 2026 07:30
A failed targeted restart must not record the newly resolved env as
applied. The test drives restartCommand with a failing compose up and
asserts the service's digest is dropped (freshness unknown, not
unchanged) while the salt and other services' digests are kept.

Refs HACK-1140

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/env.md said Hack refuses to read or write a symlinked env file, but
only the write and update paths (env set/unset, materialize, the daemon
env route) use the no-follow reader; code that only reads an env file
still follows a symlink. Narrow the sentence accordingly.

Add a test that calls replacePrivateFileAtomic directly with a live and a
dangling symlink, so its own target check is covered instead of being
masked by the earlier O_NOFOLLOW read in the env-file callers.

Refs HACK-1127

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge origin/train/queue-preview (77da332) into the env-file branch.

Resolutions:
- No textual conflicts; the branch only touches env/secret-store files.
- src/agents/integration-revision.ts: taken from train; matches the
  revision tests/agent-instruction-source.test.ts computes.
- src/agents/integration-ownership.ts: KNOWN_RENDER_FINGERPRINTS is the
  union (train's appended fingerprints, nothing removed);
  tests/integration-ownership.test.ts asks for no further entries.
- Checked-in renders (.codex/skills/hack-cli, .cursor/rules/hack.mdc,
  examples/basic AGENTS.md/CLAUDE.md, plugins/hack) come from train;
  generate:agent-plugins produced no diff. No CLI surface change.

Verified: skill-references, integration-ownership, agent-plugin-generation,
agent-instruction-source, hack-init-skill, legacy-agent-artifacts,
private-env-file, secret-store-file-hygiene, aes-gcm-v1-context-binding
tests pass; packages/cli typecheck passes; packages/cli check 0 errors.

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

Resolutions:
- No textual conflicts; git merged all files cleanly.
- src/agents/integration-revision.ts: took train value 51163ae32e56,
  which matches the revision tests/agent-instruction-source.test.ts
  recomputes from INSTRUCTION_SECTIONS on the merged tree.
- src/agents/integration-ownership.ts: KNOWN_RENDER_FINGERPRINTS is the
  union of both sides (train appended the offline env-matrix pointer
  fingerprints for hack-cli-skill and cursor rules); the ownership test
  requested no further fingerprints.
- Checked-in renders (.codex/skills/hack-cli, .cursor/rules/hack.mdc,
  examples/basic AGENTS.md/CLAUDE.md, hack-init skills) and the plugin
  bundle come from train and match their renderers byte-for-byte;
  generate:agent-plugins produced no diff. CLI surface unchanged.

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

Resolutions:
- No textual conflicts; train/queue-preview brought the offline env
  guide bullet change (instruction source, integration revision,
  render fingerprints and checked-in renders) unchanged.
- src/agents/integration-revision.ts: kept the train value; it matches
  the revision tests/agent-instruction-source.test.ts computes.
- KNOWN_RENDER_FINGERPRINTS: train's append-only list; no extra
  fingerprint requested by tests/integration-ownership.test.ts.
- generate:agent-plugins produced no further changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hosted CI failed "web should receive the env value resolved at up"
because `hack exec web -- printenv` stdout began with Clack warnings
(CoreDNS/Caddy not reachable) before the probe value. Every exec probe
now runs with HACK_LOGGER=console so Hack's logger writes to stderr,
and compares the last non-empty stdout line exactly. A wrong or missing
value still fails (missing output yields null).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the docker-tier compose-config-hash-drift scenario (HACK-1124, F03).
It starts app and dep, drifts only dep's Compose config hash, and requires
hack run and hack exec to keep every running container's full ID,
StartedAt, RestartCount and config-hash label, with no leftover one-off.
Positive controls: the run one-off sees the drifted value, and a final
hack up recreates dep.

Reproduce-only: the legacy Compose path already passes --no-deps when the
target is running and exec never converges, so no source change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`hack doctor` and `hack doctor --json` now run a read-only "env key" check
(HACK-1127). It audits the checkout-local, shared (git common dir) and
primary-checkout key files, or HACK_ENV_SECRET_KEY when no file exists, and
reports closed codes: env-key-ok, env-key-absent, env-key-weak-permissions,
env-key-wrong-owner, env-key-wrong-length, env-key-low-entropy,
env-key-symlink, env-key-not-regular, env-key-unreadable, env-key-tracked and
env-key-public-path. Findings carry project-relative subjects and the exact
command to run; key bytes and absolute paths are never emitted, symlinks are
never followed and --fix does not act on the check.

Also add scripts/dependabot-inventory.ts, a GET-only `gh api` inventory of
open Dependabot alerts (package, severity, manifest, fixed-in) that writes
nothing, documented in the docs contributor section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The gum-backed logger passed the log message as a bare argument, so a
message starting with "-" or "--" (for example "--value-stdin requires
...") was parsed by gum's kong CLI as an unknown flag. gum printed its
usage and "gum: error: unknown flag" to stderr before the logger fell
back to clack.

Every gum call site in src/ui/gum.ts now places free text (log message
and structured fields, style/join text, confirm prompt, choose/filter
options, file path, pager content, format template, spin command,
version-check constraint) after "--", and binds valued flags such as
--header, --placeholder, --prompt, --value, --title and --affirmative
in single-token --flag=value form so a dash-leading value cannot be
read as an option.

gum invocations now go through a small GumRunner boundary so tests can
assert argv without gum installed or any process being spawned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-parents onto next after the queued PRs squashed in; no content change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-parents onto next after the queued PRs squashed in; no content change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-parents onto next after the queued PRs squashed in; no content change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-parents onto next after the queued PRs squashed in. next's tree equals train/queue-preview 77da332, which this branch already contains, so the branch tree is kept unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-parents onto next after the queued PRs squashed in. next's tree equals train/queue-preview 77da332, which this branch already contains, so the branch tree is kept unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi changed the base branch from train/queue-preview to next October 12, 2026 14:10
roodboi and others added 6 commits October 12, 2026 10:10
Re-parents onto next after the queued PRs squashed in. next's tree equals train/queue-preview 77da332, which this branch already contains, so the branch tree is kept unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stack PR #302 on PR #311.

Conflicted files: none. The two change sets are disjoint (#302:
env-file and secret-store hygiene; #311: src/ui/gum.ts with its tests
and fixture), so git merged them cleanly. No generated files changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicted files: none. Git auto-merged the only shared file:
- docs/env.md: kept both sides' text (the env key doctor notes from this
  branch and the private env file and encrypted_file store notes from
  wf/y3-envfile-b6).

No generated files changed: neither branch alters CLI options or rendered
agent guidance, so plugins, docs/reference/cli.md, integration-revision.ts
and KNOWN_RENDER_FINGERPRINTS needed no update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicted files: none. The merge applied cleanly; the two change sets
touch disjoint files.

Regenerated plugins (generate:agent-plugins) and docs/reference/cli.md
(docs:cli-reference) after the merge; both produced no changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicted files: none. The merge was clean; the two change sets touch
disjoint files. Regenerated agent plugins and docs/reference/cli.md
produced no diff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stack PR #301 on PR #307. The merge had no textual conflicts.
Git auto-merged docs/env.md, tests/e2e/run.ts and tests/e2e/shard.ts;
each keeps both sides (stale env warning plus env key/private file docs;
both exec-stale-env and compose-config-hash-drift scenarios and hints).
Regenerated plugins and docs/reference/cli.md produced no drift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi changed the base branch from next to wf/c5-confighash-b7 October 12, 2026 14:27
Base automatically changed from wf/c5-confighash-b7 to next October 12, 2026 19:05
Re-parents onto next after the previous stacked PR squashed in; next's tree equals ef04f02, which this branch already contains, so the branch tree is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi marked this pull request as ready for review October 12, 2026 19:05
@roodboi
roodboi merged commit 5f062dd into next Oct 12, 2026
28 of 30 checks passed
@roodboi
roodboi deleted the wf/y5-staleenv-b6 branch October 12, 2026 19:06
roodboi added a commit that referenced this pull request Oct 14, 2026
…nv keys in native doctor (#323)

## Summary (HACK-1140 native follow-up, HACK-1127 follow-up; unit E3)

### HACK-1140: stale env warning for native exec

#301 added a value-free stale-env warning for Compose `hack exec`. Native image-project exec and authored native exec now run the same check.

- **Record.** The foreground `hack up` / `hack restart` records salted HMAC-SHA256 digests of
  the env selection it delivered, using the same digest code as #301 (`compose-env-
  fingerprint.ts`). The record is written once the run is ready and is bound to that run id. It
  lives in `.hack/.internal/native-env-fingerprints/<sha256(owner, branch)>.json`: one file per
  instance, mode 0600, written with a temp file and rename. It holds no key name or value.
  Authored records also store the public `--env` and `--profile` selectors.
  - Image projects digest the Compose-file env resolved for the run's env name. This is captured
  before adaptation, AWS and runtime metadata, so time-varying credentials cannot cause false
  warnings. The callback is `startNativeProject({ onEnvironmentReady })`.
  - Authored projects digest the compiler-selected managed values that `privateDelivery` sends.
  The new `serveNativeAuthoredProject({ onEnvironmentSelected })` observer is synchronous and
  receives digests only. `privateDelivery` now shares the projection with exec through
  `selectNativeAuthoredManagedEnvironment`. Its payload is unchanged.
- **Lifetime (same documented semantics as #301).** When the foreground ends, it removes the
  record only if the record is still its own (salt-bound). A restart drops the scope's record
  once the previous run is stopping. That is the image `down` hook and the authored `onStopped`
  hook. So a failed restart, or a run that never becomes ready, leaves no record, and exec stays
  silent rather than guessing.
- **Exec.** The check runs after the command is admitted and completes, so a refused request
  resolves no env. It still writes ahead of the captured output. It resolves the same selection
  again, compares only when the record's run equals the current run, and writes one closed line
  to stderr. Exec still runs, its exit code is unchanged, and stdout stays the command's output.
  - authored: `WARNING: Env for service "web" changed since it started; this exec uses the env
  it started with. Run \`hack restart\` to apply the current env.`
  - image: `... the running service still uses the env it started with. ...` Native image exec
  sends freshly resolved values to the exec itself, so the warning is about the running service.
  - The service name appears only if it matches the Compose service-name grammar. The hint is
  `hack restart` because native restart is whole-project.
- A missing record, a record from another run, a legacy mapping without an env name, a changed
  env name, and any resolution failure all produce no warning.
- **Bounded authored check.** The authored exec check gets the exec's cancellation signal,
  combined with a 5 s timeout (`NATIVE_AUTHORED_EXEC_STALE_ENV_TIMEOUT_MS`). The signal reaches
  input acquisition. The advisory step also settles as soon as that signal aborts, even if the
  resolution has not finished. Ctrl-C during the check returns 130 at once and writes no output.
  After the timeout, the command's output and exit code follow with no warning. A failed check
  also writes no warning.
- **Not covered by this PR.** Two exec paths still run no stale-env check; they are left as a
  HACK-1140 follow-up, and `docs/env.md` now says so:
  - an authored `.hack/hack.project.json` project on the Compose backend
  (`tryNativeComposeCommand` -> `savedCommand`)
  - a project switched with `hack config adopt` (`tryLegacyComposeAdoptedCommand`, exec in the
  retained containers)

### HACK-1127: env-key diagnostic in native Doctor and nested public copies

- `handleNativeDoctor` now runs the existing "env key" check when it runs inside a project.
  Outside a project, native Doctor output is unchanged, with no skip warning.
- `env-key-public-path` now also finds copies in nested `public/`, `static/`, `dist/`, `build/`,
  `out/`, `www/`, `_site/`, `.next/` and `.output/` directories, such as
  `apps/web/public/.hack.secret.key`.
  - The walk is breadth-first in name order and bounded: at most 6 levels deep, 4096 directories
  and 100,000 entries.
  - It skips `node_modules` and `.git`, never follows symlinked directories, and passes over
  unreadable directories.
  - Subjects are project-relative. A path that cannot be spelled portably is reported as
  `<unprintable-path>/.hack.secret.key` with a generic remedy.
  - Root-level findings are unchanged.
- `runCheck` now clears its timeout timer once a check settles. Before this change, a fast check
  with `timeoutMs` kept native Doctor alive until the deadline (about 5 s).

Docs: `docs/env.md` (the `hack exec` row, the stale-env section, and the env-key table and walk
bounds). The CLI surface and agent guidance did not change. `bun run docs:cli-reference`
produced no diff, and plugin renders are untouched.

## Validation (local, macOS arm64; no Docker, real HOME, keychain or sudo)

### Rework (head `7afb8048`)

Fixes for the verifier's findings on `a4497fe9`:

- Session ownership guard: the restart test now waits for the restart's queued write, asserts
  it, then asserts the record **still belongs to the restart after the previous session's late
  `release()`**, and is gone only after the restart's own release.
- Walk bounds: `findNestedPublicArtifactDirs` is exported with an optional `limits` test seam.
  Production uses the documented constants. New tests:
  - the directory bound stops breadth-first after N directories
  - the entry bound counts every entry read, including files
  - the real default stops at 4096 directories through `inspectProjectEnvKey`: with 4094
  fillers, `zz/public` is found; one more filler hides it
- Cancellation and latency: see **Bounded authored check** above. New `tryNativeAuthoredCommand`
  tests:
  - Ctrl-C during a check that ignores its signal returns 130, the seam's signal is aborted, and
  nothing is written
  - a check that never settles is cut at the bound: the output follows, the exit code is kept,
  there is no warning, and the seam's signal is aborted
  - negative control: a warning resolved after the bound, or a rejected check, writes nothing
- Docs: the `hack exec` row names the covered native families. The stale-env section describes
  the bound and lists the two uncovered exec paths.

### Tests

- `bun test tests/native-exec-stale-env.test.ts`: 24 pass (21 before). Covers:
  - the store: 0600, value-free, per-scope isolation, tampered/foreign records read as absent,
  salt-bound forget
  - the session: record, restart drop, late release of the previous run, never-ready, unwritable
  - the warning text
  - image and authored resolvers with stub run loaders and env resolvers
  - `runNativeProjectExec` with a stub exec: warning on stderr ahead of output, stdout
  unchanged, no check for a refused exec
  - `tryNativeAuthoredCommand` with stub `exec`, `serve` and `stop` seams: exec warning, up
  records while ready and removes on exit, never-ready writes nothing, a failed restart drops
  the record
- `tests/native-project-start.test.ts`: 70 pass. The digest is reported once, right after the
  mapping save and before ready. It matches the prepared (pre-adaptation) env and contains no
  value. A failing observer never fails startup.
- `tests/native-authored-project-start.test.ts`: 41 pass on `a4497fe9` (see the load note under
  remaining gaps). With the real managed-input fixture, the observer fires before ready, and its
  digest matches the delivered `TOKEN` value (unchanged) and differs for an edited one. A
  selection without managed keys digests an empty set, and a throwing observer never fails
  startup. The existing delivery-payload assertions still pass.
- `tests/doctor-env-key.test.ts`: 31 pass (28 before). Covers nested monorepo copies
  (`apps/web/public`, `packages/site/dist`, `apps/docs/.next`), skipping
  `node_modules`/`.git`/symlinks, the depth, directory and entry bounds, and unprintable paths.
- `tests/native-ca-doctor-cli.test.ts`: 4 pass, using `privateCliEnv`. Native Doctor in a
  fixture project reports the env key check with the nested public finding, and the output
  contains no key text or fixture path. The existing test now runs from an isolated cwd.
- Affected suites, combined: 613 of 616 pass across 28 files. Included: `compose-env-staleness`,
  `project-exec-command`, `project-up-startup-state`, `native-project-exec`, `native-project-
  restart`, `native-authored-command`/`-restart`/`-exec`/`-oneoff`/`-logs`, `native-exec-
  environment`, `native-up-runtime-metadata-selection`, `doctor-*`, `integration-ownership`,
  `cli-spawn-env-isolation`.
- `env-command-matrix` and `skill-references`: pass.
- `bun run --cwd packages/cli typecheck`: clean.
- `bun run --cwd packages/cli check`: 0 errors. The 34 warnings are pre-existing. Changed tests
  were linted explicitly and are clean.
- Rework run on `7afb8048` (load average 17 to 19):
  - `native-exec-stale-env`, `doctor-env-key`, `native-project-start`, `native-ca-doctor-cli`,
  `integration-ownership`, `cli-spawn-env-isolation`, `native-authored-project-exec` and
  `native-authored-project-oneoff`: 235 pass, 0 fail.
  - `native-authored-command`, `native-authored-command-dispatch`, `native-exec-stale-env` and
  `native-authored-project-exec`: 155 pass, 0 fail.
  - `compose-env-staleness`, the `doctor-*` suites, `project-exec-command` and `project-
  execution-routing`: pass.
  - `packages/cli typecheck`: exit 0. `packages/cli check`: exit 0 with 0 errors and the same 34
  pre-existing warnings. Biome on the changed files is clean.
  - `docs:cli-reference`: no diff.
- Rework mutation checks: all 11 mutations made at least one test fail (11/11 killed):
  - `release` forgets without the salt (the mutant that previously survived)
  - directory bound removed; entry bound removed (both previously survived)
  - default directory bound off by one
  - entries counted as directories only
  - `beforeOutput` gets a fresh signal instead of the exec's
  - resolver not given the signal
  - no settle-on-abort race
  - no timeout
  - `beforeOutput` unwired
  - a failed check surfaces a value
  - A post-abort write guard survived because the race already covers that case, so the guard
  was removed rather than left untested.
- Original mutation checks: all 17 mutations made at least one test fail (17/17 killed):
  - both resolvers forced silent
  - run binding removed
  - release keeps its own record
  - forget ignores the salt
  - restart drop removed
  - ready commit removed
  - authored and image exec warning writes removed
  - native start never reports, or reports adapted env
  - authored delivery observer removed
  - nested walk disabled, `node_modules` not skipped, depth unbounded
  - native Doctor skips the check, or always adds it

## Negative controls

- Identical env, no record, a record from another run, a legacy mapping, a changed env name, a
  different service and a resolution failure all produce no warning. Each is tested.
- Warnings, records and Doctor output were checked against the synthetic before/after values and
  key names. None appear.
- A run that never becomes ready writes nothing (`.hack/` stays empty). A refused image exec
  runs no env resolution.

## Remaining gaps (host-bound)

- No live native runtime run. The `handleNativeUp` wiring in `project.ts` (session creation,
  `onEnvironmentReady`, and the restart `down` drop) is three lines of plumbing. The functions
  it composes are tested, but the wiring itself has no direct test, because exercising it needs
  the native runtime. An end-to-end check (up, edit env, exec, restart) on a host with the
  native candidate is still open.
- The authored exec check acquires inputs again after a completed exec, which can add compiler
  and env-resolution latency. This happens only when a record for the current run exists, and it
  is capped at 5 s and cancellable. A real native host has not measured that latency.
- The image-project exec check does not receive a signal, and the exec it follows takes none
  either. It resolves only the Compose-file env, with no compiler run. The verifier did not flag
  it, and this PR leaves it unchanged.
- Under host load (load average 17 to 26), `tests/native-authored-project-start.test.ts` fails
  intermittently with "Startup fixture settlement unconfirmed" and 5 s timeouts. In the rework
  run it passed 18 to 24 of 41. The verifier saw the same suite fail in all 3 runs on base
  `8d9dc22e` under the same load. This rework does not touch that file or its code under test.
  The PR's two new tests in it pass when run alone.
- Pre-existing failures on the base, also failing in a clean export of `8d9dc22e`:
  - `tests/native-authored-project-recovery.test.ts`: 2 no-host recovery cases
  - `tests/mcp-repeat-install.test.ts`: 2 stdio rollback cases

🤖 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