Skip to content

feat(native): warn value-free on stale env at native exec and audit env keys in native doctor - #323

Merged
roodboi merged 13 commits into
nextfrom
wf/e3-native-exec-staleenv-b14
Oct 14, 2026
Merged

roodboi merged 13 commits into
nextfrom
wf/e3-native-exec-staleenv-b14

Conversation

@roodboi

@roodboi roodboi commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

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 feat(exec): warn value-free when a running service's env changed since start #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 feat(exec): warn value-free when a running service's env changed since start #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


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

roodboi and others added 12 commits October 12, 2026 22:27
…mpt and add the env secrets threat model

The `hack global dns activate|deactivate` authorize closures move into
createNativeDnsAuthorizer (src/lib/native-domain-dns-authorization.ts) with
injectable interactivity, confirmation and administrator prompt. A failed
sudo -v now throws NativeDnsAdministratorAuthorizationError (closed
outcome "authorization-failed" and failure class; message unchanged).

Tests wire the authorizer into the real activation/deactivation flows and
host effect boundary and prove: sudo -v runs before every privileged and
file effect, a failed prompt is authorization-failed (not denied) and
changes nothing, and a decline or a non-interactive run never reaches sudo.

Adds docs/security/env-secrets-threat-model.md (HACK-1127), linked from
docs/env.md.

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

tests/global-dns-authorizer-wiring.test.ts checks, on the TypeScript
syntax tree of src/commands/global.ts, that handleGlobalDnsActivate and
handleGlobalDnsDeactivate each build exactly one authorizer with
createNativeDnsAuthorizer({ action: <own action>, domain }), pass it to
createNativeDnsHostDependencies, and run their flow with those
dependencies. The only other authorizer allowed is the decline-only
`async () => false` on the deactivate verify-only path.

tests/native-domain-dns-authorization.test.ts now also covers the
production defaults: the default interactivity check requires a stdin
TTY before confirming, and the default confirmation declines an
explicitly non-interactive run before sudo. The administrator prompt
stays a fake, so no test reaches real sudo.

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

`hack init --with`, `hack agent onboard`, `hack agent init` and the
`hack-init` MCP prompt now call one builder in
src/agents/onboarding-prompt.ts. It runs the corrected repo inventory
(bun.lock, nested workspace lockfiles, non-JS runtimes, runtime pins,
explicit discovery limits) and renders it before the phases. Each prompt
is exactly frame.before + core + frame.after; the core is byte-identical
for the same repo and only a fixed, value-free framing line differs.

Mode, project name and dev host now follow repo state, so the init
handoff always adopts the `.hack/` it runs after; its framing says
whether this run scaffolded it. The MCP prompt takes an optional
repoRoot; without it nothing is scanned and the core says no inventory
ran. MCP inventory failures return closed, value-free errors.

HACK-1137

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nv keys in native doctor

HACK-1140: native image-project and authored native exec now run the same
advisory, value-free stale-env check that #301 added for Compose exec. The
foreground up/restart records salted HMAC digests of the env selection it
delivered once the run is ready (bound to that run id, one 0600 file per
instance under .hack/.internal/native-env-fingerprints/), removes its own
record when the foreground ends, and a restart drops the record once the
previous run stops, so a failed restart leaves none. Exec resolves the same
selection again after the command is admitted and writes one closed warning
to stderr naming the service and suggesting `hack restart`.

HACK-1127: the env-key doctor diagnostic now also runs in native Doctor
inside a project, and public-artifact detection walks nested public, static,
dist and similar directories in monorepos (bounded depth/directories/entries,
skips node_modules and .git, never follows symlinks, value-free subjects).
Doctor check timers are cleared once a check settles.

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

Pass the exec's cancellation signal, bounded by a five-second timeout, into
the authored exec stale-env resolution, and settle the advisory step as soon
as that signal aborts, so Ctrl-C returns 130 without waiting for input
acquisition and a slow check never holds the command's output.

Make the nested public-artifact walk bounds injectable for tests and cover
the directory and entry bounds, including the documented 4096-directory
default. Assert that a late release of a previous foreground session keeps
the record a restart just wrote.

Document the two exec paths that do not run the stale-env check yet: an
authored project on the Compose backend and a project switched with
hack config adopt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Authored .hack/hack.project.json projects now receive the same HACK_*
runtime host metadata as Compose-file and native image projects, on the
authored native path (up/restart; exec and run inherit) and the authored
Compose renderer.

The config compiler accepts optional env_metadata.runtime values for
declared workloads (closed reserved key set, 64 KiB per value, no NUL)
and reports them as public `runtime` bindings below managed values and
every authored directive. Host entries never receive them. The values
come from the shared runtime-host-metadata builder, fed with the
compiler's effective routing hosts. runtime-core adds runtime bindings
to public workload environment, never to private delivery.

HACK-1263

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

Add a prepareCommand-level regression test: authored Compose `up` through the
source CLI renders the reserved runtime metadata keys, and HACK_COMPOSE_PROJECT
equals the generation store's hack-nc-... project for the default and a
--branch instance. A checkout replaced between metadata selection and store
open refuses before any Compose effect.

Cover the TS wire guards: runtime metadata with non-reserved keys, undeclared
workloads or non-text values refuses before the compiler spawns, and host
reports carrying runtime bindings are rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…en exec output assertions

- Add a command-level test for handleNativeUp with scoped module mocks for
  start, restart and down: up records the reported env under the image owner
  and effective branch and releases it (also on failure); restart drops the
  previous run's record at down (also when down fails), records the new run
  and releases it; a restart refused before down keeps the running record.
- Ctrl-C advisory test: capture output in an ordered log instead of asserting
  on a restored spy, whose recorded calls mockRestore() clears.
- Authored exec ordering test: capture stderr and Bun.write in one ordered log
  with non-empty command stdout/stderr, so a warning moved after the output
  fails.

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

- Pin the value-free hack-init MCP catch: an injected builder failure whose
  message carries a path and a credential value must surface only the closed
  `<code>: hack-init could not inventory the repo.` text.
- Pin the `<dev_host>` placeholder for an existing setup with no dev_host.
- Give interactive `hack init` a terminal seam (TTY check, prompts, service
  wizard, agent-step prompts) and test that its `--with` handoff frames a
  scaffolded run as `created` and a kept `.hack/` as `existing`.
- The /hack-init skill and canonical agent-setup guidance tell no-shell
  clients to pass `repoRoot` to the hack-init MCP prompt so it includes the
  repo inventory; renders, plugins, fingerprints and revision regenerated.
- Trim repoRoot once so the presence check and the resolved path agree.

HACK-1137

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

Conflicted files: none. The two change sets touch disjoint files, so
git merged them automatically.

Regenerated surfaces after the merge (no resulting diff):
- bun run generate:agent-plugins
- bun run docs:cli-reference
- checked-in renders (.codex/skills/hack-cli, .codex/skills/hack-init,
  .claude/skills/hack-init, .cursor/rules/hack.mdc) compared byte-equal
  with their render functions.

integration-revision.ts and KNOWN_RENDER_FINGERPRINTS keep the values
from wf/e1-authored-metadata-b14; agent-instruction-source and
integration-ownership tests pass unchanged.

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

Conflicted files and resolutions:
- src/agents/integration-revision.ts: set to bd15ee2d251b, the value
  tests/agent-instruction-source.test.ts computes for the merged
  instruction source.
- src/agents/integration-ownership.ts: KNOWN_RENDER_FINGERPRINTS is the
  append-only union of both sides, plus the merged renders requested by
  tests/integration-ownership.test.ts (hack-cli-skill 88900097f7eb18a752de31bb,
  cursor-rules 07e49d3c2b05b93b6c49adfd).
- plugins/hack/rules/hack.mdc, plugins/hack/skills/hack-branches/SKILL.md,
  plugins/hack/skills/hack-cli/SKILL.md,
  plugins/hack/skills/hack-diagnose/SKILL.md,
  plugins/hack/skills/hack-env/SKILL.md: regenerated with
  bun run generate:agent-plugins.
- .codex/skills/hack-cli/SKILL.md, .cursor/rules/hack.mdc: re-rendered
  through installCodexSkill/installCursorRules with an explicit project
  root; byte-equal to the renderers.
- examples/basic/AGENTS.md, examples/basic/CLAUDE.md: re-rendered through
  upsertAgentDocs with an explicit project root.

src/agents/instruction-source.ts merged cleanly. .claude/skills/hack-init
and .codex/skills/hack-init re-rendered as no-ops. docs/reference/cli.md
regenerated with no diff.

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

Conflicted files:
- src/lib/native-authored-command.ts: kept both sides. Imports keep the
  stale-env warning resolver (e3) and parseNativeHttpsSelection (e1 via e2).
  Kept e2's nativeHttpsPort helper and e3's envFingerprintSession helper.
  startupSelection keeps e3's signature (envFingerprints session, shared run
  id, onEnvironmentSelected capture, commit on ready) and adds e2's
  httpsPort: nativeHttpsPort(input.env).

Generated surfaces (plugins, docs/reference/cli.md) were regenerated and
produced no changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi changed the base branch from next to wf/e2-onboarding-builder-b14 October 13, 2026 04:50
Base automatically changed from wf/e2-onboarding-builder-b14 to next October 14, 2026 02:57
Re-parents onto next after the previous stacked PR squashed in; next's tree equals 43b4581, 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 14, 2026 02:58
@roodboi
roodboi merged commit 0fbba7a into next Oct 14, 2026
16 checks passed
@roodboi
roodboi deleted the wf/e3-native-exec-staleenv-b14 branch October 14, 2026 02:58
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