@sabaiway/agent-workflow-kit 1.42.0 → 1.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/README.md +2 -1
- package/SKILL.md +12 -8
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/autonomy-doctor.md +21 -0
- package/references/modes/doc-parity.md +2 -1
- package/references/modes/gates.md +1 -1
- package/tools/atomic-write.mjs +13 -0
- package/tools/autonomy-doctor.mjs +488 -0
- package/tools/commands.mjs +9 -2
- package/tools/doc-parity.mjs +23 -2
- package/tools/seed-gates.mjs +160 -55
- package/tools/velocity-profile.mjs +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,88 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
|
|
|
4
4
|
is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
|
|
5
5
|
every `migrations/<version>-<slug>.md` newer than it, in semver order.
|
|
6
6
|
|
|
7
|
+
## 1.44.0 — Autonomy provisioner: the consent-gated sandbox doctor (AD-044 Plan 2)
|
|
8
|
+
|
|
9
|
+
A **feature** release (kit-only; memory/engine/bridges unchanged, lineage head stays `2.0.0`). New
|
|
10
|
+
routable GUARDED mode **`autonomy-doctor`** — the cross-platform system provisioner that makes the
|
|
11
|
+
AD-044 sandbox actually initializable on consumer hosts: detect → consent-gated install → verify →
|
|
12
|
+
loud degrade, over the locked matrix (macOS Seatbelt built-in / Linux + WSL2 `bwrap` + `socat` /
|
|
13
|
+
native Windows → WSL2 redirect). This is the kit's FIRST tool that can run a privileged command,
|
|
14
|
+
so the consent and honesty contract is the release:
|
|
15
|
+
|
|
16
|
+
- **Three explicit lanes.** Flagless = FS-only preview (ZERO subprocesses): the diagnosis, the
|
|
17
|
+
exact install command it WOULD run, and the exact `--apply <pm>:<pkg,…>` consent line — never a
|
|
18
|
+
"ready" claim (Linux flagless is `present-unverified`, exit 3, by design). `--verify` = the only
|
|
19
|
+
source of a Linux "ready (verified)": a pinned bwrap user-namespace smoke + `socat -V`.
|
|
20
|
+
`--apply <pm>:<pkgs>` = consent bound to the previewed tuple — any mismatch vs the re-derived
|
|
21
|
+
plan refuses (exit 2) and runs nothing; a successful install auto-verifies.
|
|
22
|
+
- **Privileged execution, closed world.** Every executed token — package manager, sudo, and the
|
|
23
|
+
binary `env` execs — resolves to an ABSOLUTE path inside the fixed trusted-dir allowlist
|
|
24
|
+
`/usr/bin:/bin:/usr/sbin:/sbin` (`/usr/local/bin` deliberately excluded); a PATH-shadowed
|
|
25
|
+
binary triggers a loud advisory, never execution. Frozen 4-family map: apt via the
|
|
26
|
+
env-trampoline (`sudo /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get install -y …`, so
|
|
27
|
+
non-interactivity survives sudo `env_reset`) · dnf `-y` · pacman `--needed --noconfirm` ·
|
|
28
|
+
apk `add`; package names come from a frozen internal map — no repo/config/user input ever
|
|
29
|
+
enters the command line. Unknown PM or untrusted location → stated degrade (exit 6), never a
|
|
30
|
+
guess.
|
|
31
|
+
- **Sudo boundary, honestly.** `sudo -n true` preflight; passwordless success is LOUDLY stated;
|
|
32
|
+
no TTY + a password required → the ENFORCED print-handoff (the doctor runs NOTHING and prints
|
|
33
|
+
the exact command to run in your own terminal) — the designed primary path under an agent
|
|
34
|
+
harness. Root callers (`euid 0` / `SUDO_UID`) are refused a "verified" claim — a green smoke
|
|
35
|
+
under root can't prove unprivileged user namespaces (`root-unproven`, exit 5).
|
|
36
|
+
- **Frozen output contract.** Exported EXIT/status table (Linux exit 0 ONLY via the verify
|
|
37
|
+
oracle) + a machine-parseable summary LAST line in every diagnosis outcome (`--help` prints the
|
|
38
|
+
help text alone); `doc-parity` bindings pin the contract to the mode doc. The docs/ai
|
|
39
|
+
deployment gate runs after arg parsing, ahead of every diagnosis/verify/apply lane; the
|
|
40
|
+
`.workflow-version` stamp gate is a stated EXEMPTION (the doctor mutates the OS, never
|
|
41
|
+
lineage-bound repo content).
|
|
42
|
+
- **Host-proven where it counts.** The bwrap smoke fixture and the apt env-trampoline descriptor
|
|
43
|
+
are host-proven (the trampoline crossed a real sudo boundary with zero apt prompts); a fresh
|
|
44
|
+
Claude Code session on the newly-ready host shows the series payoff — an ad-hoc command's
|
|
45
|
+
prompt-delta drops 1 → 0 while the commit/push/publish red-lines still ask (content-scoped
|
|
46
|
+
`ask` rules pierce `autoAllowBashIfSandboxed`; the render's exact rule form is load-bearing)
|
|
47
|
+
and network egress still prompts. The sandbox is picked up at session START — the doctor's
|
|
48
|
+
success output always states the restart step.
|
|
49
|
+
- **Registration + guards.** The GUARDED legend now reads "consent-gated destructive/privileged
|
|
50
|
+
actions (dry-run-first)"; the velocity render's sandbox-unavailable degrade message points at
|
|
51
|
+
the doctor; the doctor stays OUTSIDE every velocity auto-approve tier; tarball sentinel
|
|
52
|
+
143 → 145 (`tools/autonomy-doctor.mjs` + `references/modes/autonomy-doctor.md`).
|
|
53
|
+
|
|
54
|
+
## 1.43.0 — Closed-world gate seeding: lifecycle hooks die by construction (AD-052)
|
|
55
|
+
|
|
56
|
+
A **feature** release (kit-only; memory/engine/bridges unchanged, lineage head stays `2.0.0`). The
|
|
57
|
+
consent-gated `gates.json` seeder (`seed-gates.mjs`) moves from BLOCKLIST screening to a
|
|
58
|
+
**closed-world** offer derivation — the structural fix for **Issue-011**, whose three residuals the
|
|
59
|
+
AD-042 council could only ever push one gap further, never close. Since a declared gate is
|
|
60
|
+
hook-auto-approvable, the offer is now conservative BY CONSTRUCTION: the worst case is a legit
|
|
61
|
+
command not offered (add it by hand), never a dangerous one offered.
|
|
62
|
+
|
|
63
|
+
- **Uniform hook-free exec form.** The seeded cmd is
|
|
64
|
+
`COREPACK_ENABLE_NETWORK=0 <pm> exec -- <allowlisted-body>` for the detected package manager.
|
|
65
|
+
`exec` runs a command, not a named script, so no `pre<name>`/`post<name>` lifecycle hook can fire
|
|
66
|
+
— structurally and uniformly across **npm, pnpm, and yarn** (classic + berry). Never `<pm> run
|
|
67
|
+
<name>`, which would re-expose hooks and let a later `package.json` edit change what a byte-exact
|
|
68
|
+
approved gate runs.
|
|
69
|
+
- **Body allowlist, not blocklist.** The script body must be a string member of a 9-entry literal
|
|
70
|
+
runner allowlist (`node --test`, `vitest run`, `jest`, `jest --ci`, `eslint .`,
|
|
71
|
+
`prettier --check .`, `tsc --noEmit`, `tsc -p . --noEmit`, `vite build`) after a pinned
|
|
72
|
+
ASCII-only normalization; anything else — an injected `curl … | sh`, a `release:npm` alias, an
|
|
73
|
+
env/path body — is not offered, by non-membership. Editing the allowlist ADDS a test-guarded
|
|
74
|
+
entry; it can never weaken a filter.
|
|
75
|
+
- **Per-PM fail-closed floor.** npm is pinned `--offline --script-shell /bin/sh` (no registry fetch
|
|
76
|
+
of a missing runner; a hostile `.npmrc script-shell` loses); the `COREPACK_ENABLE_NETWORK=0`
|
|
77
|
+
prefix blocks a Corepack-shimmed PM from fetching a hostile `packageManager` pin before exec;
|
|
78
|
+
pnpm/yarn fail closed natively; a package manager whose exec contract cannot be verified is
|
|
79
|
+
WITHHELD with a loud note. Screened-out gate-class scripts are counted and named — never silently
|
|
80
|
+
absent (preview and apply alike).
|
|
81
|
+
- **Preflight parent-chain guard.** `assertDocsAiDeployment` (`atomic-write.mjs`) now walks the
|
|
82
|
+
`docs` parent chain (refusing a symlinked `docs` parent or cwd root) before any read, closing the
|
|
83
|
+
seeder's preview-path escape; all four write consumers inherit it. ENOENT-safe — a brand-new
|
|
84
|
+
project still gets the normal no-deployment stop.
|
|
85
|
+
- **Scoped safety claim.** Safe-by-construction is the OFFER DERIVATION, not a runtime sandbox: a
|
|
86
|
+
script gate runs the project's own tooling (project-controlled code), the disclosed residual
|
|
87
|
+
bounded by the two-consent trust chain. The preview discloses it.
|
|
88
|
+
|
|
7
89
|
## 1.42.0 — Opt-in ADR-store migration mode + old-layout detection (AD-051)
|
|
8
90
|
|
|
9
91
|
A **feature** release, co-released with **memory 2.0.0 (MAJOR)** — the one-file-per-ADR store that
|
package/README.md
CHANGED
|
@@ -239,12 +239,13 @@ file), or run the guarded `/agent-workflow-kit uninstall`.
|
|
|
239
239
|
| `/agent-workflow-kit grounding` | any time | **grounded-review facts assembler** — mechanizes populating `agy-review --facts @f`: slices your entry-point's **Hard Constraints** section verbatim (exactly one match, else a loud stop) and/or a plan's decision-bearing sections (`## Approach` + `## Verification` required, `## Decisions (locked)` when present; duplicates stop), under the same byte budget the agy wrapper enforces (minus `--reserve-bytes` for the artifact share), with a loud tail-trim on overflow. `--ledger-summary` (AD-049) appends a COMPUTED review-ledger digest for the single in-flight segment (rounds · origins · classifications · verdicts · overrides — unrelated loops excluded), so the reviewer sees the loop's own history without hand-copying. Prints to stdout; `--out` writes **one scratch file only** (gitignored / outside the repo — a tracked or not-ignored in-repo path is refused). Never commits, never runs a subscription CLI. |
|
|
240
240
|
| `/agent-workflow-kit review-ledger` | any time | **review-round ledger** — turns the review-loop stop rule into a **computed** signal: record each plan-execution review round, its triage, and any recorded **overrides** (`oracle-change` lifts named tampered test files; `red-proof` waives the observed-red proof for exactly one `testId`; v4 adds **`size-cap`** — the exact sanctioned magnitude for a surface over the diff cap, segment-scoped — all loud, durable, auditable waivers, never silent) into a JSONL ledger inside the git dir (never committable), and read the stop decision (`converged > resolved-residual > triage-required > continue`) from the records — never from a remembered rule. **Since v4 every record carries `base` = the HEAD commit and the whole loop is SEGMENT-scoped (AD-048):** round numbering, the caps, and every tooth reset ONLY at a gated commit — a multiphase plan records fully while round 4 within one segment stays refused; `gate-run` records (minted by `gates --record`) make gates-before-review a computed precondition; the triage class **`refuted`** is the honest phantom-finding lane (grounds mandatory); `--telemetry` renders counts-only gate-efficacy data across all loops and both ledgers. `--status` replaces the hand-composed per-round tally; `record --from-receipts` (AD-049) DRAFTS the `backends[]` from the current-fingerprint receipts (verdict per backend, counts from the supplied findings) instead of hand-composing them — a recipe-named backend with no receipt is a loud stop; `--check` is a fail-closed gate exit code for `docs/ai/gates.json` (by hand or via the consent-gated seeder). A `fixable-bug` triage **requires its red→green `testId`** (v1..v3 records stay valid on read — old records never enter a segment). The writer refuses a round (past the hard-max, while a triage is pending, or lacking a grounded review receipt) and refuses an override outside its single in-flight loop. Honest residual: records are forgeable — self-discipline, not a security boundary. Never commits, never runs a subscription CLI. |
|
|
241
241
|
| `/agent-workflow-kit fold-completeness` | any time | **fold-completeness gate** — attests the review loop's folded fixes were proven the HONEST way (no fix theater): the runner executes your suite ONCE under `NODE_V8_COVERAGE`, checks every changed executable line is **executed**, probes every bound `testId` N times (`AW_FOLD_RERUNS`), records content hashes, and scans the test surface for tamper; the `--red` verb observes a test FAILING on the pre-fold tree — **BEFORE the fix** — and mints an **observed-red receipt**. The read-only checker (`--status` / `--check`) then requires, per bound test: the receipt, receipt-precedes-run order, N/N-green probes, and content **custody** (the green test is byte-identical to the one seen failing) — the receipt+custody half waivable for exactly one `testId` by a recorded `red-proof` override; mixed/timed-out probes are QUARANTINE (never converted, no override); **v3 records carry `base` and the proof is segment-scoped (AD-048)** — a committed phase's custody obligations close with its commit, and a receipt never crosses a commit boundary; tampered pre-existing test files fail closed unless covered by a recorded `oracle-change` override. Honest residuals: coverage proves **execution, not assertion**, and records are forgeable — self-discipline, not a security boundary. v1 scope is JS/V8 (pluggable via `AW_FOLD_BOUND_CMD`): changed TS/JSX fails closed, docs/config changes are listed but never block, and **no mutation testing ships**. Wire the gate by hand — the consent-gated seeder deliberately does not offer it yet (JS-only hold). Never commits, never runs a subscription CLI. |
|
|
242
|
-
| `/agent-workflow-kit doc-parity` | any time | **read-only doc-parity lint** (AD-049) — kills the doc-drift class where a mode-contract doc silently lags a code constant (a `--check` doc still reading `300` after the diff cap moved to `400`): a **closed, exported registry** binds each live constant (review caps, schema versions,
|
|
242
|
+
| `/agent-workflow-kit doc-parity` | any time | **read-only doc-parity lint** (AD-049) — kills the doc-drift class where a mode-contract doc silently lags a code constant (a `--check` doc still reading `300` after the diff cap moved to `400`): a **closed, exported registry** binds each live constant (review caps, schema versions, the ledger's own class/scope vocabulary, and the autonomy-doctor EXIT/status/trusted-dir contract) to the exact token its `references/modes/*.md` contract must carry, and asserts the CURRENT value renders into every bound file — a drifted doc, an unreadable file, or an absent token **fails closed**. The values are sourced from the live imports (never re-typed), so the lint can't itself go stale; adding a binding is adding a checked entry (closed-world, edit-safe). `--check` is a gate exit code for `docs/ai/gates.json`. Never writes, never commits, never runs a subscription CLI. |
|
|
243
243
|
| `/agent-workflow-kit uninstall` | opt-in, any time | **guarded teardown** — the inverse of `init` / `setup`. Removes only what's **provably ours** (managed skill dirs + bridge wrappers; in a project, the hidden-mode git-ignore block it added + the pre-commit hook it installed); **never deletes** your `docs/ai` / `AGENTS.md` (prints the exact `rm` to run by hand) or your `.claude/settings.json` (prints an **edit** — remove the attribution key, review any velocity `permissions.*` — never an `rm`). Always `--dry-run` first; preflight-then-mutate; never commits. |
|
|
244
244
|
| `/agent-workflow-kit velocity` | Claude Code · opt-in | **onboarding velocity profile** — seeds a fixed, audited **read-only** allowlist into `.claude/settings.json` so routine read-only commands stop idling on approval prompts while you're away; opt-in `acceptEdits`; plus a **read-only advisory** of likely project gate commands to add by hand. Writes **only** `.claude/settings.json` — **never** allowlists commit/push/publish, never writes `settings.local.json`, never commits. A seeded entry is a **trust posture, not a sandbox** (a runtime residual remains at the settings level — its guard ships as the opt-in `hook` command); a direct commit/push/publish still asks. `--dry-run` first. |
|
|
245
245
|
| `/agent-workflow-kit agents` | Claude Code · opt-in | **cheap-lane subagents** — places bundled subagent definitions (`.claude/agents/`) pinned to a **cheap model** (haiku, low effort, read-only tools) for mechanical work: extraction sweeps, changelog fact-skeletons, gate-failure triage. Judgment, review, and real code stay on your main lane — these vehicles only extract and draft, and their output is verified. Preview by default (`--apply` writes); an existing customized file is **preserved, never overwritten**; never touches `settings*.json`, never commits. |
|
|
246
246
|
| `/agent-workflow-kit hook` | Claude Code · opt-in | **gate-approval hook** — places a self-contained PreToolUse hook (`.claude/hooks/`) and wires it into `.claude/settings.json`: a Bash command **byte-identical** to a gate you declared in `docs/ai/gates.json` (run from the project root) is auto-approved — no prompt, no idle; a seeded read-only command carrying a runtime residual (output redirection, command substitution, `--output` writes) now **asks** even where an allow rule would have silently passed it (proven live — hook `ask` overrides an allow rule). Exact matches only, never patterns; never `deny`; a broken `gates.json` only disables auto-approval, never the guard. Preview by default; never writes `settings.local.json`; never commits. |
|
|
247
247
|
| `/agent-workflow-kit bridge-settings` | opt-in, any time | **host-level bridge settings** — read or change the bridges' `KEY=VALUE` config file (`${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf`), the **one place a knob survives kit upgrades** (it lives outside every kit tree; a refresh never touches it, and now states loudly if it ever overwrote a local edit). First knobs: the **codex Fast tier** (`CODEX_SERVICE_TIER=priority` — ~1.5× speed at a **2.5× credit rate**, quality-neutral, default off — a consented spend), the codex/agy hard timeouts, the review byte cap, the oversized-review add-dir toggle. Allowed keys + value rules come from the bundled bridge manifests; **model/effort are never settable** (the quality guard is untouched). **Previews by default**; refuses an unknown key, an out-of-range value, or a duplicate-carrying file; writes atomically (symlink/TOCTOU-safe). Never commits, never runs a subscription CLI. |
|
|
248
|
+
| `/agent-workflow-kit autonomy-doctor` | opt-in, any time | **guarded sandbox provisioner "doctor"** — can this machine run the Claude sandbox, and (only with your consent) fix it: macOS Seatbelt built-in / Linux+WSL2 needs `bwrap`+`socat` / native Windows → WSL2. Flagless = **FS-only preview** (the diagnosis, the exact absolute-path command it WOULD run, the exact `--apply <pm>:<pkgs>` consent tuple — runs nothing, never claims ready); `--verify` runs the unprivileged proof (a `bwrap` user-namespace smoke + `socat -V`) — the **only** source of a Linux "ready (verified)" claim; `--apply <pm>:<pkgs>` is the consent-gated privileged install (the tuple must equal the previewed plan; everything executed resolves to absolute paths inside `/usr/bin:/bin:/usr/sbin:/sbin`; the exact command is re-printed immediately before execution), then verifies. Honest loud degrades (unknown PM, untrusted location, nested-sandbox INDETERMINATE, root-unproven) and a stated restart step. Sits **outside every velocity auto-approve tier**; never auto-runs, never writes repo files, never commits. |
|
|
248
249
|
|
|
249
250
|
It **never auto-commits** and **never overwrites** an existing `AGENTS.md` without asking.
|
|
250
251
|
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: agent-workflow-kit
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '1.
|
|
6
|
+
version: '1.44.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
|
@@ -20,8 +20,7 @@ This kit is the **composition root** of the `agent-workflow` family. The memory
|
|
|
20
20
|
(`docs/ai/`, the entry-point doc, caps / archive / index, the setup contracts) is owned by
|
|
21
21
|
**`agent-workflow-memory`**. The kit **prefers to delegate** substrate deployment to that skill
|
|
22
22
|
when it is present and healthy, and otherwise uses its **own bundled copy** (`references/`,
|
|
23
|
-
`migrations/`) —
|
|
24
|
-
memory substrate**. (The methodology slot is a separate axis: its fragment is read **live from the
|
|
23
|
+
`migrations/`) — the one-command install stays **dependency-free**. (The methodology slot is a separate axis: its fragment is read **live from the
|
|
25
24
|
installed `agent-workflow-engine`**, which `npx @sabaiway/agent-workflow-kit@latest init` installs;
|
|
26
25
|
see `${CLAUDE_SKILL_DIR}/references/shared/composition-handoff.md`.)
|
|
27
26
|
`init` also **refreshes the installed memory substrate** (best-effort — a miss is a loud DEGRADED
|
|
@@ -50,7 +49,7 @@ made: a partial/broken memory install discovered mid-flow must not disable the w
|
|
|
50
49
|
> [`tools/delegation.mjs`](${CLAUDE_SKILL_DIR}/tools/delegation.mjs): `detectMemory(<memory-dir>)` runs the validator +
|
|
51
50
|
> the required-asset check and returns `delegate` / fallback with a reason; `handoffPlan(delegate)`
|
|
52
51
|
> returns who writes what, which stamps end up present, and that the commit gate is kit-only. Both
|
|
53
|
-
> are unit-tested
|
|
52
|
+
> are unit-tested — the contract below is pinned by code.
|
|
54
53
|
|
|
55
54
|
**Hand-off contract & bounded pointer reconciliation** — `${CLAUDE_SKILL_DIR}/references/shared/composition-handoff.md` (read at the bootstrap/upgrade point of use).
|
|
56
55
|
|
|
@@ -63,25 +62,26 @@ ever deleted.
|
|
|
63
62
|
|
|
64
63
|
## Modes
|
|
65
64
|
|
|
66
|
-
Pick the mode from the user's invocation — the mapping is pinned by `tools/commands.mjs` `routeInvocation` (
|
|
65
|
+
Pick the mode from the user's invocation — the mapping is pinned by `tools/commands.mjs` `routeInvocation` (safe-routing rule below). An existing `docs/ai/` guards against bootstrapping over a live system.
|
|
67
66
|
|
|
68
67
|
### Version status & the two axes — the internal routing check
|
|
69
68
|
|
|
70
|
-
**Safe-routing rule (which mode did the user invoke?).** Map the invocation token with `tools/commands.mjs` `routeInvocation`: a **known** subcommand → its mode; the **bare/empty** invocation → `bootstrap` — the one writer reachable without a token, and only on an undeployed project (if `docs/ai/` already exists, **ask upgrade-vs-bootstrap**, never overwrite); **any unrecognized/ambiguous** token → `help`, which is **read-only**. The invariant: **no unrecognized/garbage invocation ever triggers a write** (only an explicit known token or the acknowledged bare-bootstrap exception can).
|
|
69
|
+
**Safe-routing rule (which mode did the user invoke?).** Map the invocation token with `tools/commands.mjs` `routeInvocation`: a **known** subcommand → its mode; the **bare/empty** invocation → `bootstrap` — the one writer reachable without a token, and only on an undeployed project (if `docs/ai/` already exists, **ask upgrade-vs-bootstrap**, never overwrite); **any unrecognized/ambiguous** token → `help`, which is **read-only**. The invariant: **no unrecognized/garbage invocation ever triggers a write** (only an explicit known token or the acknowledged bare-bootstrap exception can).
|
|
71
70
|
|
|
72
|
-
Before acting, read `docs/ai/.workflow-version` (the project's stamp) to decide the route —
|
|
71
|
+
Before acting, read `docs/ai/.workflow-version` (the project's stamp) to decide the route — an **internal** routing decision, **not** a line you print (version display: *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`). Route:
|
|
73
72
|
|
|
74
73
|
- **absent** → bootstrap (a fresh deployment).
|
|
75
74
|
- **stamp < `2.0.0`** (the deployment-lineage head) → `upgrade`.
|
|
76
75
|
- **stamp == `2.0.0`** → already current; only the stamp-independent reconciles may run — the FULL set lives in `${CLAUDE_SKILL_DIR}/references/modes/upgrade.md` step 3; run step 3 (never enumerate the reconciles from memory).
|
|
77
76
|
- **stamp > head / unparseable** → STOP — never-downgrade gate (see `${CLAUDE_SKILL_DIR}/references/modes/upgrade.md` step 2).
|
|
77
|
+
- **Exempt:** `autonomy-doctor` provisions the OS, not the deployment — stamp-independent (stated in its mode file).
|
|
78
78
|
|
|
79
79
|
**Two independent version axes — never conflate them:**
|
|
80
80
|
|
|
81
81
|
1. **Project deployment** — `docs/ai/.workflow-version` vs the lineage head (`2.0.0`). This is the **only** axis this skill compares.
|
|
82
82
|
2. **Kit freshness** — this skill's own files vs the published npm package. That is the **npx installer's** job: `npx @sabaiway/agent-workflow-kit@latest init` (it refuses a stale-cache downgrade by comparing the version on disk — **no network**). This skill never checks npm, and the package version is **not** the lineage head.
|
|
83
83
|
|
|
84
|
-
**Refreshed the kit but nothing changed?** After
|
|
84
|
+
**Refreshed the kit but nothing changed?** After the npx installer updates `~/.claude/skills/agent-workflow-kit/`, **restart the session** so the agent reloads the new skill files.
|
|
85
85
|
|
|
86
86
|
### Mode: help
|
|
87
87
|
|
|
@@ -127,6 +127,10 @@ writer — read `${CLAUDE_SKILL_DIR}/references/modes/set-recipe.md` before acti
|
|
|
127
127
|
|
|
128
128
|
writer — read `${CLAUDE_SKILL_DIR}/references/modes/set-autonomy.md` before acting.
|
|
129
129
|
|
|
130
|
+
### Mode: autonomy-doctor
|
|
131
|
+
|
|
132
|
+
guarded — read `${CLAUDE_SKILL_DIR}/references/modes/autonomy-doctor.md` before acting.
|
|
133
|
+
|
|
130
134
|
### Mode: uninstall
|
|
131
135
|
|
|
132
136
|
guarded — read `${CLAUDE_SKILL_DIR}/references/modes/uninstall.md` before acting.
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.44.0",
|
|
4
4
|
"description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
### Mode: autonomy-doctor
|
|
2
|
+
|
|
3
|
+
The **sandbox provisioner "doctor"** — the answer to *"can this machine run the Claude sandbox, and can you fix it?"* (AD-044: macOS Seatbelt built-in / Linux+WSL2 `bwrap`+`socat` / native Windows → WSL2). **Division of labor:** YOU narrate the diagnosis and relay the consent question; the KIT does the deterministic detect → consent-gated install → verify. It is **guarded**: the privileged lane runs ONLY with the per-run consent tuple, always faces the harness permission prompt (the mode sits outside every velocity auto-approve tier), **never auto-runs, never writes repo files, and never commits**.
|
|
4
|
+
|
|
5
|
+
Run **`node ${CLAUDE_SKILL_DIR}/tools/autonomy-doctor.mjs [--verify | --apply <pm>:<pkg[,pkg...]>]`**:
|
|
6
|
+
|
|
7
|
+
1. **Flagless = FS-only preview** (the default; runs NO subprocesses): the diagnosis, the exact absolute-path command the doctor WOULD run, and the exact `--apply <pm>:<pkgs>` consent line. It **never claims "ready"** — binaries present without proof render `present-unverified`, never exit `0`.
|
|
8
|
+
2. **`--verify`** — the unprivileged diagnostic: a `bwrap` user-namespace smoke plus `socat -V` (captured stdio). This is the ONLY source of a Linux **ready-verified** claim. Honesty split: a green verify proves the **OS primitives, not Claude-init** — the output always states the restart step. Under root/`SUDO_UID` the verified claim is REFUSED (`root-unproven`); a namespace-denied smoke with present binaries reports a loud `indeterminate` (nested sandboxing / already inside a container — re-run from an unsandboxed shell).
|
|
9
|
+
3. **`--apply <pm>:<pkgs>`** — the consent-gated privileged install: the tuple must EQUAL the re-derived plan (consent binds to the previewed plan; a mismatch or a bare `--apply` refuses with `2` usage and runs NOTHING), the exact resolved command is re-printed immediately before execution, then the verify lane runs automatically. Sudo boundary: root drops sudo; a passwordless sudo proceeds with a LOUD note; a password-needing sudo without a controlling terminal runs NOTHING and prints the exact command to run in the USER's terminal (`handoff-required`).
|
|
10
|
+
|
|
11
|
+
**Trust gate:** everything executed resolves to an ABSOLUTE path inside the fixed trusted dirs `/usr/bin:/bin:/usr/sbin:/sbin` (`/usr/local/bin` deliberately excluded); a binary or package manager found only outside is a loud `untrusted-path` / `unknown-pm` degrade, never executed. The privileged child gets a minimal scrubbed env (trusted-dir PATH + LANG), an argv array, no shell. Package names come from a frozen internal map (`bubblewrap`, `socat`); apt runs through the env trampoline (`env DEBIAN_FRONTEND=noninteractive …`) so non-interactivity survives sudo's `env_reset`.
|
|
12
|
+
|
|
13
|
+
**Exit codes (frozen):** `0` ready (`ready-verified` / `ready-assumed`; Linux only via the verify oracle) · `1` precondition STOP (`no-deployment`) · `2` usage (bare `--apply`, tuple mismatch, unknown flag) · `3` not-ready diagnosis (`missing-binaries` offer / `present-unverified` / `handoff-required`) · `4` install failed (`install-failed`) · `5` verify failed (`verify-failed` / `indeterminate` / `root-unproven`) · `6` unsupported / untrusted (`unsupported-platform` win32 / `unknown-pm` / `untrusted-path`). The machine summary line prints LAST in every diagnosis outcome (`--help` prints the help text alone): `[autonomy-doctor] status=<token> platform=<p> missing=<csv> pm=<name|none>`.
|
|
14
|
+
|
|
15
|
+
**Stamp EXEMPTION (stated, deliberate):** the doctor runs behind the docs/ai presence gate ahead of every lane (`--help` and parse-time usage errors return earlier) but has **NO `.workflow-version` stamp gate** — it provisions the OS, never lineage-bound repo content, so the global stale-deployment→upgrade routing does not apply to this mode.
|
|
16
|
+
|
|
17
|
+
**Residuals (disclosed in the preview):** a root-owned hostile binary inside the trusted dirs is out of the threat model; Claude itself resolves `bwrap`/`socat` via PATH at session init (the doctor warns on a PATH shadow, never fixes PATH); the harness permission prompt shows the `--apply` tuple, not the absolute-path command — bridged by the apply-time re-print.
|
|
18
|
+
|
|
19
|
+
Output is **English/structured** — **localize it to the user's conversational language** when you narrate.
|
|
20
|
+
|
|
21
|
+
**Invariants:** guarded (the MUTATING lane only via `--apply` with the matching tuple, exactly once) · preview-first · never auto-runs · outside every velocity auto-approve tier · never writes repo files · never commits · loud degrades, never silent.
|
|
@@ -9,7 +9,8 @@ The DOC-PARITY lint (BUGFREE-3 / AD-049, session-economics item (b)) — the det
|
|
|
9
9
|
|
|
10
10
|
**What it checks (the closed registry).** The numeric/version tokens are IMPORTED live from the tools — never re-typed in the lint — so the registry itself can never go stale:
|
|
11
11
|
- `references/modes/review-ledger.md` carries `SCHEMA_VERSION` (schema v4), `HARD_MAX` (the hard-max ceiling of 3), `DEFAULT_DIFF_CAP` (the default 400 diff cap), `REVIEW_CAP` (cap ≤2), and the ledger vocabulary sourced from the schema's own `V4_CLASSES` / `V4_OVERRIDE_SCOPES` Sets plus the `gate-run` kind (`size-cap`, `refuted`, `gate-run`, `red-proof`, `oracle-change`, `fixable-bug`, `inherent-layer-residual`, `escalate`);
|
|
12
|
-
- `references/modes/fold-completeness.md` carries `RESULT_SCHEMA_VERSION` (fold RESULT schema v4)
|
|
12
|
+
- `references/modes/fold-completeness.md` carries `RESULT_SCHEMA_VERSION` (fold RESULT schema v4);
|
|
13
|
+
- `references/modes/autonomy-doctor.md` carries the doctor's frozen D7 contract (AD-044 Plan 2): every live EXIT-table phrase (`` `0` ready `` … `` `6` unsupported / untrusted ``), every status token (sourced from the exported `STATUS`), and the trusted-dir allowlist (`TRUSTED_DIRS`).
|
|
13
14
|
|
|
14
15
|
**Why the modes/*.md docs and NOT the tool HELP strings.** Every tool's HELP INTERPOLATES the same constant (`the ${DEFAULT_DIFF_CAP}-line diff cap`), so it can never drift from the code — there is nothing to check there. The hand-authored contract prose in `references/modes/*.md` is the surface that DOES drift, so that is exactly what this lint pins. Change a constant and the current-value token stops appearing in the lagging doc → the gate fails, forcing the doc update **in the same edit as the code** (the §2.6 "contract docs change in the same edit as code" rule, mechanized).
|
|
15
16
|
|
|
@@ -16,6 +16,6 @@ Declared gates can also be **auto-approved** (no permission prompt on a byte-exa
|
|
|
16
16
|
|
|
17
17
|
**Candidate line — the review-receipt gate (opt-in, never auto-seeded; AD-021).** Projects that configure a reviewed/council `plan-execution.review` recipe can declare the AD-038 review-state check as one more gate — the exact candidate `{ id, title, cmd }` line and its contract live under `${CLAUDE_SKILL_DIR}/references/modes/review-state.md` (step 3). The template `gates.json` stays EMPTY; adding the line is the maintainer's explicit consent — by hand, or through the consent-gated seeder below (AD-042).
|
|
18
18
|
|
|
19
|
-
**Consent-gated seeding — a separate WRITER CLI, not part of the runner (AD-042).** Bootstrap recon already records the project's own daily commands; the seeder turns them into a preview of `{ id, title, cmd }` entries. Protocol: run `node ${CLAUDE_SKILL_DIR}/tools/seed-gates.mjs --cwd <project>` (dry-run by default — prints the derived entries and **writes NOTHING**; declining leaves the file byte-identical), show the user the EXACT entries (`AskUserQuestion` preview where supported, prose otherwise), and only on an explicit yes run it again with `--apply [--only <id>]…` — it **appends exactly the consented entries** (append-only: existing entries are never modified or removed; an id collision is refused loudly; a malformed declaration is never written over).
|
|
19
|
+
**Consent-gated seeding — a separate WRITER CLI, not part of the runner (AD-042).** Bootstrap recon already records the project's own daily commands; the seeder turns them into a preview of `{ id, title, cmd }` entries. Protocol: run `node ${CLAUDE_SKILL_DIR}/tools/seed-gates.mjs --cwd <project>` (dry-run by default — prints the derived entries and **writes NOTHING**; declining leaves the file byte-identical), show the user the EXACT entries (`AskUserQuestion` preview where supported, prose otherwise), and only on an explicit yes run it again with `--apply [--only <id>]…` — it **appends exactly the consented entries** (append-only: existing entries are never modified or removed; an id collision is refused loudly; a malformed declaration is never written over). The offer is **closed-world** (AD-052): only a terminating-class script NAME (test / lint / type-check / build — never dev/watch/serve, never a write-mode or release/publish/deploy variant) whose BODY is a member of the seeder's literal runner allowlist is offered — membership, never blocklist screening: the worst case is a legit command not offered, never a dangerous one offered. The seeded cmd is the uniform hook-free **`COREPACK_ENABLE_NETWORK=0 <pm> exec -- <allowlisted-body>`** — `exec` runs a command, not a named script, so no pre/post hook can fire (npm/pnpm/yarn alike; never `<pm> run <name>`, which re-exposes hooks), and the Corepack env prefix blocks a hostile `packageManager` pin from fetching the PM binary before exec. npm is pinned `--offline --script-shell /bin/sh` (no fetch of a missing runner; a hostile `.npmrc script-shell` loses); pnpm/yarn refuse an absent runner without network (a user-installed cache/global/PATH runner executing is user machine state — part of the disclosed residual); a family without a verified fail-closed exec contract is WITHHELD loudly. A gate-class script screened out on its BODY is counted and named in a note (add it by hand if you trust it); the review-state candidate above joins automatically on a reviewed/council `plan-execution.review`. **Disclose before the yes** (the preview prints it): the wired hook auto-approves byte-exact declared commands — two separate consents — and a script gate runs project-controlled tooling the seeder does not sandbox (safe-by-construction = the OFFER DERIVATION).
|
|
20
20
|
|
|
21
21
|
**Invariants:** the runner writes nothing by default (`--record` delegates its ONE ledger write to the review-ledger sole writer, `recordGateRun` — the runner itself never opens the ledger) · never commits · never runs a subscription CLI · executes only the project's OWN declared commands (never a kit-invented one) · the bash contract fails loud, never reinterprets · the seeder is a separate consent-per-run CLI — preview-first, append-only, never pre-approved by any velocity tier.
|
package/tools/atomic-write.mjs
CHANGED
|
@@ -53,6 +53,19 @@ export const assertDocsAiDeployment = (cwd, deps = {}, opts = {}) => {
|
|
|
53
53
|
const noun = opts.noun ?? 'a file';
|
|
54
54
|
const rel = opts.rel ?? 'under docs/ai';
|
|
55
55
|
const docsAi = join(cwd, 'docs', 'ai');
|
|
56
|
+
// Parent-chain preflight (AD-052, Issue-011 residual 3): refuse a symlinked cwd ROOT or a
|
|
57
|
+
// symlinked `docs` PARENT before any read — the walk the write path already enforces
|
|
58
|
+
// (writeContainedFileAtomic), aligned onto the gate every consumer runs first. The walk covers
|
|
59
|
+
// the PARENT chain only (the linkManaged precedent, fs-safe.mjs): the docs/ai LEAF keeps the
|
|
60
|
+
// dedicated checks below, whose exact STOP messages each consumer's tests pin. ENOENT-safe —
|
|
61
|
+
// an absent component is a no-op walk, so a brand-new project still reaches the normal
|
|
62
|
+
// "no deployment" STOP below. The walk throws a plain Error; re-throw as the CALLER's typed
|
|
63
|
+
// stop so every consumer's `.code` contract holds.
|
|
64
|
+
try {
|
|
65
|
+
assertContainedRealPath(cwd, join(cwd, 'docs'), { lstat });
|
|
66
|
+
} catch (err) {
|
|
67
|
+
throw stop(String(err?.message ?? err).replace(/^\[agent-workflow-kit\] /, ''));
|
|
68
|
+
}
|
|
56
69
|
const docsAiStat = lstatNoFollow(docsAi, lstat);
|
|
57
70
|
if (docsAiStat === null) {
|
|
58
71
|
throw stop(`no agent-workflow deployment here (docs/ai is absent) — run init/bootstrap before writing ${rel}`);
|
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// autonomy-doctor.mjs — the cross-platform sandbox provisioner "doctor" (AD-044 Plan 2; mode:
|
|
3
|
+
// autonomy-doctor, GUARDED). Detect → consent-gated install → verify → loud degrade, for the
|
|
4
|
+
// AD-044 sandbox matrix: macOS Seatbelt built-in / Linux+WSL2 bwrap+socat / native Windows → WSL2.
|
|
5
|
+
//
|
|
6
|
+
// THREE lanes:
|
|
7
|
+
// (flagless) FS-only preview: diagnosis + the exact command it WOULD run + the exact
|
|
8
|
+
// --apply consent tuple. ZERO subprocesses. Never claims "ready".
|
|
9
|
+
// --verify unprivileged diagnostic: bwrap userns smoke + `socat -V` (captured stdio,
|
|
10
|
+
// DIAGNOSTIC runner). The ONLY source of a Linux "ready (verified)" claim.
|
|
11
|
+
// --apply <pm>:<pkgs> consent-gated privileged install. The tuple must EQUAL the re-derived
|
|
12
|
+
// plan (consent binds to the previewed plan; mismatch → refuse, runs
|
|
13
|
+
// NOTHING), then the verify lane runs automatically.
|
|
14
|
+
//
|
|
15
|
+
// Trust gate (D2/D3): everything the doctor EXECUTES resolves to an ABSOLUTE path inside the
|
|
16
|
+
// FIXED trusted dirs /usr/bin:/bin:/usr/sbin:/sbin (isExecutableFile — statSync follows an
|
|
17
|
+
// in-dir symlink; /usr/local/bin deliberately excluded). A binary found only outside → loud
|
|
18
|
+
// degrade naming its location, never executed. The privileged child gets a minimal SCRUBBED env
|
|
19
|
+
// (trusted-dir PATH + LANG), argv array, no shell, stdio ['ignore','inherit','inherit'].
|
|
20
|
+
//
|
|
21
|
+
// Gating (D8): docs/ai deployment presence on EVERY run. NO .workflow-version stamp gate — the
|
|
22
|
+
// doctor mutates the OS, never lineage-bound repo content (the stated stamp EXEMPTION).
|
|
23
|
+
//
|
|
24
|
+
// Exit codes (D7, frozen): 0 ready (ready-verified / ready-assumed; Linux only via the verify
|
|
25
|
+
// oracle) · 1 precondition STOP (no-deployment) · 2 usage (bare --apply, tuple mismatch, unknown
|
|
26
|
+
// flag) · 3 not-ready diagnosis (missing-binaries offer / present-unverified / handoff-required)
|
|
27
|
+
// · 4 install failed · 5 verify failed / indeterminate / root-unproven · 6 unsupported/untrusted
|
|
28
|
+
// (win32 / unknown PM / found-only-outside-trusted-dirs). The machine summary line prints LAST in
|
|
29
|
+
// every diagnosis outcome (--help prints the help text alone):
|
|
30
|
+
// [autonomy-doctor] status=<token> platform=<p> missing=<csv> pm=<name|none>.
|
|
31
|
+
//
|
|
32
|
+
// Residual disclosure (D9, shipped in the preview): (i) a root-owned hostile binary inside the
|
|
33
|
+
// trusted dirs = the host is already lost (out of threat model); (ii) Claude itself resolves
|
|
34
|
+
// bwrap/socat via PATH at session init — the doctor verifies the trusted-dir copies and WARNS on
|
|
35
|
+
// a PATH shadow, never fixes it; (iii) the harness permission prompt shows the --apply tuple, not
|
|
36
|
+
// the absolute-path command — bridged by the apply-time re-print of the exact resolved command.
|
|
37
|
+
//
|
|
38
|
+
// Dependency-free, Node >= 18. No side effects on import (the isDirectRun idiom).
|
|
39
|
+
|
|
40
|
+
import { spawnSync } from 'node:child_process';
|
|
41
|
+
import { closeSync, lstatSync, openSync } from 'node:fs';
|
|
42
|
+
import { join } from 'node:path';
|
|
43
|
+
import { pathToFileURL } from 'node:url';
|
|
44
|
+
import { isExecutableFile, probeSandboxAvailability } from './velocity-profile.mjs';
|
|
45
|
+
import { assertDocsAiDeployment } from './atomic-write.mjs';
|
|
46
|
+
|
|
47
|
+
// ── the frozen contract tables (fixtures the tests copy) ────────────────────────────
|
|
48
|
+
|
|
49
|
+
export const TRUSTED_DIRS = Object.freeze(['/usr/bin', '/bin', '/usr/sbin', '/sbin']);
|
|
50
|
+
const TRUSTED_PATH = TRUSTED_DIRS.join(':');
|
|
51
|
+
|
|
52
|
+
// The closed world of sandbox binaries → package names. Keys double as the binary list (the
|
|
53
|
+
// probe's SANDBOX_LINUX_BINARIES mirror); no repo/config/user input ever enters a command line.
|
|
54
|
+
export const PACKAGE_FOR = Object.freeze({ bwrap: 'bubblewrap', socat: 'socat' });
|
|
55
|
+
|
|
56
|
+
export const EXIT = Object.freeze({
|
|
57
|
+
ready: 0,
|
|
58
|
+
stop: 1,
|
|
59
|
+
usage: 2,
|
|
60
|
+
notReady: 3,
|
|
61
|
+
installFailed: 4,
|
|
62
|
+
verifyFailed: 5,
|
|
63
|
+
unsupported: 6,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export const STATUS = Object.freeze({
|
|
67
|
+
readyVerified: 'ready-verified',
|
|
68
|
+
readyAssumed: 'ready-assumed',
|
|
69
|
+
noDeployment: 'no-deployment',
|
|
70
|
+
usage: 'usage',
|
|
71
|
+
missingBinaries: 'missing-binaries',
|
|
72
|
+
presentUnverified: 'present-unverified',
|
|
73
|
+
handoffRequired: 'handoff-required',
|
|
74
|
+
installFailed: 'install-failed',
|
|
75
|
+
verifyFailed: 'verify-failed',
|
|
76
|
+
indeterminate: 'indeterminate',
|
|
77
|
+
rootUnproven: 'root-unproven',
|
|
78
|
+
unsupportedPlatform: 'unsupported-platform',
|
|
79
|
+
unknownPm: 'unknown-pm',
|
|
80
|
+
untrustedPath: 'untrusted-path',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
export const EXIT_FOR_STATUS = Object.freeze({
|
|
84
|
+
[STATUS.readyVerified]: EXIT.ready,
|
|
85
|
+
[STATUS.readyAssumed]: EXIT.ready,
|
|
86
|
+
[STATUS.noDeployment]: EXIT.stop,
|
|
87
|
+
[STATUS.usage]: EXIT.usage,
|
|
88
|
+
[STATUS.missingBinaries]: EXIT.notReady,
|
|
89
|
+
[STATUS.presentUnverified]: EXIT.notReady,
|
|
90
|
+
[STATUS.handoffRequired]: EXIT.notReady,
|
|
91
|
+
[STATUS.installFailed]: EXIT.installFailed,
|
|
92
|
+
[STATUS.verifyFailed]: EXIT.verifyFailed,
|
|
93
|
+
[STATUS.indeterminate]: EXIT.verifyFailed,
|
|
94
|
+
[STATUS.rootUnproven]: EXIT.verifyFailed,
|
|
95
|
+
[STATUS.unsupportedPlatform]: EXIT.unsupported,
|
|
96
|
+
[STATUS.unknownPm]: EXIT.unsupported,
|
|
97
|
+
[STATUS.untrustedPath]: EXIT.unsupported,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// The MUTATING stdio contract: sudo prompts on /dev/tty (never stdin), no PM can block on stdin.
|
|
101
|
+
export const MUTATING_STDIO = Object.freeze(['ignore', 'inherit', 'inherit']);
|
|
102
|
+
|
|
103
|
+
// The frozen 4-family install map (D3, closed world). First-present in the trusted dirs wins.
|
|
104
|
+
// Every executable token in the built argv is a D2-resolved ABSOLUTE trusted-dir path — apt's
|
|
105
|
+
// non-interactivity rides the env TRAMPOLINE (env is the command sudo runs, so sudo env_reset
|
|
106
|
+
// cannot strip DEBIAN_FRONTEND; a bare `apt-get` after env would hand resolution back to PATH).
|
|
107
|
+
const PM_FAMILIES = Object.freeze([
|
|
108
|
+
Object.freeze({ name: 'apt-get', needsEnvTrampoline: true }),
|
|
109
|
+
Object.freeze({ name: 'dnf', needsEnvTrampoline: false }),
|
|
110
|
+
Object.freeze({ name: 'pacman', needsEnvTrampoline: false }),
|
|
111
|
+
Object.freeze({ name: 'apk', needsEnvTrampoline: false }),
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
const buildFamilyArgv = (family, pmPath, envPath, packages) => {
|
|
115
|
+
if (family.name === 'apt-get') return [envPath, 'DEBIAN_FRONTEND=noninteractive', pmPath, 'install', '-y', ...packages];
|
|
116
|
+
if (family.name === 'dnf') return [pmPath, 'install', '-y', ...packages];
|
|
117
|
+
if (family.name === 'pacman') return [pmPath, '-S', '--needed', '--noconfirm', ...packages];
|
|
118
|
+
return [pmPath, 'add', ...packages];
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// The bwrap userns smoke (D5) — HOST-PROVEN fixture 2026-07-10 (WSL2, bwrap 0.9.0): the payload is
|
|
122
|
+
// process.execPath (injectable; `--ro-bind / /` makes it reachable — no /bin/true assumption).
|
|
123
|
+
export const buildSmokeArgv = (bwrapPath, execPath) =>
|
|
124
|
+
Object.freeze([bwrapPath, '--unshare-all', '--die-with-parent', '--ro-bind', '/', '/', execPath, '--version']);
|
|
125
|
+
|
|
126
|
+
// A smoke stderr that matches this WITH present binaries is the namespace-denied signature —
|
|
127
|
+
// LOUD INDETERMINATE (nested sandboxing / already inside a container), never an install offer.
|
|
128
|
+
const NAMESPACE_DENIED_RE = /operation not permitted|permission denied|no permissions to creat/i;
|
|
129
|
+
|
|
130
|
+
const D9_DISCLOSURE = [
|
|
131
|
+
'Residual disclosure (D9):',
|
|
132
|
+
' (i) a root-owned hostile binary inside the trusted dirs means the host is already lost — out of this doctor\'s threat model.',
|
|
133
|
+
` (ii) Claude itself resolves bwrap/socat via PATH at session init — the doctor verifies the trusted-dir copies (${TRUSTED_PATH}) and WARNS on a PATH shadow; it never fixes PATH.`,
|
|
134
|
+
' (iii) the harness permission prompt shows the --apply <pm>:<pkgs> tuple, not the absolute-path command — bridged by the apply-time re-print of the exact resolved command.',
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
const RESTART_NOTE =
|
|
138
|
+
'Restart Claude Code now — the sandbox is detected only at session start; a fresh session is the behavioral proof.';
|
|
139
|
+
const VERIFY_HONESTY =
|
|
140
|
+
'Honesty: a green verify proves the OS sandbox primitives (bwrap user namespaces + socat), NOT that Claude initialized its sandbox.';
|
|
141
|
+
const WSL2_REDIRECT =
|
|
142
|
+
'native Windows is unsupported — run Claude Code inside WSL2 (Ubuntu) and re-run the doctor there.';
|
|
143
|
+
const SEATBELT_NOTE = 'macOS: Seatbelt assumed built-in — not smoke-tested by this doctor.';
|
|
144
|
+
|
|
145
|
+
const USAGE_HINT =
|
|
146
|
+
'usage: autonomy-doctor.mjs [--verify | --apply <pm>:<pkg[,pkg...]>] (flagless = FS-only preview)';
|
|
147
|
+
|
|
148
|
+
const HELP = `autonomy-doctor — sandbox provisioner doctor (detect → consent-gated install → verify).
|
|
149
|
+
|
|
150
|
+
Usage:
|
|
151
|
+
node autonomy-doctor.mjs FS-only preview (diagnosis + the exact plan; runs NOTHING)
|
|
152
|
+
node autonomy-doctor.mjs --verify unprivileged diagnostic: bwrap userns smoke + socat -V
|
|
153
|
+
node autonomy-doctor.mjs --apply <pm>:<pkgs> consent-gated install (tuple must equal the previewed plan),
|
|
154
|
+
then the verify lane runs automatically
|
|
155
|
+
--help, -h this help
|
|
156
|
+
|
|
157
|
+
Consent contract: --apply REQUIRES the <pm>:<pkg,...> tuple the preview printed — a mismatch vs the
|
|
158
|
+
re-derived plan refuses (exit 2) and runs NOTHING. Everything executed resolves to an absolute path
|
|
159
|
+
inside the trusted dirs ${TRUSTED_PATH}; the exact resolved command is re-printed immediately
|
|
160
|
+
before execution. The doctor never writes repo files and never commits.
|
|
161
|
+
|
|
162
|
+
Exit codes: 0 ready (ready-verified / ready-assumed) · 1 precondition STOP (no docs/ai deployment) ·
|
|
163
|
+
2 usage · 3 not-ready diagnosis · 4 install failed · 5 verify failed/indeterminate/root-unproven ·
|
|
164
|
+
6 unsupported/untrusted. The last line of every diagnosis outcome is the machine summary:
|
|
165
|
+
[autonomy-doctor] status=<token> platform=<p> missing=<csv> pm=<name|none>
|
|
166
|
+
(--help prints this help text alone — no summary line.)`;
|
|
167
|
+
|
|
168
|
+
// ── small pure helpers ───────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
const NO_MISSING = '-';
|
|
171
|
+
export const composeSummaryLine = ({ status, platform, missing = [], pm = null }) =>
|
|
172
|
+
`[autonomy-doctor] status=${status} platform=${platform} missing=${missing.length > 0 ? missing.join(',') : NO_MISSING} pm=${pm ?? 'none'}`;
|
|
173
|
+
|
|
174
|
+
const resolveTrusted = (name, isExec) => TRUSTED_DIRS.map((dir) => join(dir, name)).find((p) => isExec(p)) ?? null;
|
|
175
|
+
|
|
176
|
+
const findFirstPathHit = (name, env, isExec) =>
|
|
177
|
+
((env && env.PATH) || '').split(':').filter(Boolean).map((dir) => join(dir, name)).find((p) => isExec(p)) ?? null;
|
|
178
|
+
|
|
179
|
+
export const hasControllingTerminal = () => {
|
|
180
|
+
try {
|
|
181
|
+
closeSync(openSync('/dev/tty', 'r'));
|
|
182
|
+
return true;
|
|
183
|
+
} catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// ── the pure planner core (diagnosis + plan + tuple as DATA; no subprocesses) ─────────
|
|
189
|
+
|
|
190
|
+
export const deriveDoctorPlan = ({ probeResult, env, isExec }) => {
|
|
191
|
+
const missing = probeResult.missing ?? [];
|
|
192
|
+
const packages = missing.map((binary) => PACKAGE_FOR[binary]);
|
|
193
|
+
const resolutions = Object.keys(PACKAGE_FOR).map((name) => {
|
|
194
|
+
const trusted = resolveTrusted(name, isExec);
|
|
195
|
+
const pathHit = findFirstPathHit(name, env, isExec);
|
|
196
|
+
return { name, trusted, pathHit, missing: missing.includes(name) };
|
|
197
|
+
});
|
|
198
|
+
const shadows = resolutions.filter((r) => !r.missing && r.trusted !== null && r.pathHit !== null && r.pathHit !== r.trusted);
|
|
199
|
+
const untrusted = resolutions.filter((r) => !r.missing && r.trusted === null);
|
|
200
|
+
const family = PM_FAMILIES.map((f) => ({ family: f, path: resolveTrusted(f.name, isExec) })).find((f) => f.path !== null) ?? null;
|
|
201
|
+
const envPath = resolveTrusted('env', isExec);
|
|
202
|
+
const pm = family === null ? null : { name: family.family.name, path: family.path };
|
|
203
|
+
const envMissing = family !== null && family.family.needsEnvTrampoline && envPath === null;
|
|
204
|
+
const tuple = pm !== null && missing.length > 0 ? `${pm.name}:${packages.join(',')}` : null;
|
|
205
|
+
const rootArgv = pm === null || missing.length === 0 || envMissing
|
|
206
|
+
? null
|
|
207
|
+
: buildFamilyArgv(family.family, pm.path, envPath, packages);
|
|
208
|
+
return { missing, packages, resolutions, shadows, untrusted, pm, envMissing, tuple, rootArgv };
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// ── argv parsing (usage errors carry exitCode 2 via the shared idiom) ─────────────────
|
|
212
|
+
|
|
213
|
+
const usageFail = (message) => Object.assign(new Error(message), { exitCode: EXIT.usage });
|
|
214
|
+
|
|
215
|
+
export const parseDoctorArgs = (argv) => {
|
|
216
|
+
let lane = 'preview';
|
|
217
|
+
let tuple = null;
|
|
218
|
+
const setLane = (next) => {
|
|
219
|
+
if (lane !== 'preview') throw usageFail('pass at most ONE of --verify / --apply');
|
|
220
|
+
lane = next;
|
|
221
|
+
};
|
|
222
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
223
|
+
const arg = argv[i];
|
|
224
|
+
if (arg === '--help' || arg === '-h') return { lane: 'help', tuple: null };
|
|
225
|
+
if (arg === '--verify') setLane('verify');
|
|
226
|
+
else if (arg === '--apply' || arg.startsWith('--apply=')) {
|
|
227
|
+
const raw = arg === '--apply' ? argv[(i += 1)] : arg.slice('--apply='.length);
|
|
228
|
+
if (raw === undefined || raw === '' || raw.startsWith('--')) {
|
|
229
|
+
throw usageFail('--apply requires the consent tuple <pm>:<pkg[,pkg...]> the preview printed (a bare --apply is refused)');
|
|
230
|
+
}
|
|
231
|
+
if (!/^[^:\s]+:[^:\s]+$/.test(raw)) throw usageFail(`--apply tuple "${raw}" is not <pm>:<pkg[,pkg...]>`);
|
|
232
|
+
setLane('apply');
|
|
233
|
+
tuple = raw;
|
|
234
|
+
} else throw usageFail(`unknown flag: ${arg}`);
|
|
235
|
+
}
|
|
236
|
+
return { lane, tuple };
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// ── rendering ─────────────────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
const renderDiagnosis = ({ plan, probeResult, log }) => {
|
|
242
|
+
log('autonomy-doctor — Linux sandbox diagnosis (bwrap + socat)');
|
|
243
|
+
log(` probe: ${probeResult.reason}`);
|
|
244
|
+
for (const r of plan.resolutions) {
|
|
245
|
+
if (r.missing && r.trusted !== null) {
|
|
246
|
+
log(` ${r.name}: NOT on PATH (the probe follows Claude's own PATH resolution) but present at ${r.trusted} — fix your PATH rather than re-installing.`);
|
|
247
|
+
} else if (r.missing) log(` ${r.name}: MISSING`);
|
|
248
|
+
else if (r.trusted === null) {
|
|
249
|
+
log(` ${r.name}: found ONLY at ${r.pathHit} — NOT in the trusted dirs (${TRUSTED_PATH}); the doctor will never execute it`);
|
|
250
|
+
} else log(` ${r.name}: ${r.trusted}`);
|
|
251
|
+
}
|
|
252
|
+
for (const s of plan.shadows) {
|
|
253
|
+
log(` PATH-SHADOW WARNING: ${s.name} first resolves on PATH at ${s.pathHit}, not the trusted ${s.trusted} — Claude will resolve the PATH one at session init (the D9 residual); the doctor verifies only the trusted copy.`);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// The untrusted degrade every lane runs FIRST (codex R1): while any sandbox binary resolves only
|
|
258
|
+
// outside the trusted dirs, no offer, no consent invitation, and no privileged path may proceed —
|
|
259
|
+
// an install cannot make such a host verify-ready.
|
|
260
|
+
const refuseUntrusted = ({ plan, log }) => {
|
|
261
|
+
for (const r of plan.untrusted) {
|
|
262
|
+
log(`${r.name} found ONLY at ${r.pathHit} — NOT in the trusted dirs (${TRUSTED_PATH}); refusing every install/verify path until a trusted copy exists (loud degrade, D2).`);
|
|
263
|
+
}
|
|
264
|
+
return STATUS.untrustedPath;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// The install offer (flagless + --verify share it). Returns the status token for the missing case.
|
|
268
|
+
const renderOffer = ({ plan, euid, isExec, env, log }) => {
|
|
269
|
+
if (plan.untrusted.length > 0) return refuseUntrusted({ plan, log });
|
|
270
|
+
if (plan.pm === null) {
|
|
271
|
+
const packages = plan.packages.join(' + ');
|
|
272
|
+
const outside = PM_FAMILIES
|
|
273
|
+
.map((f) => ({ name: f.name, hit: findFirstPathHit(f.name, env, isExec) }))
|
|
274
|
+
.filter((f) => f.hit !== null);
|
|
275
|
+
if (outside.length > 0) {
|
|
276
|
+
log(`package manager found ONLY outside the trusted dirs (${outside.map((f) => `${f.name} at ${f.hit}`).join('; ')}) — refusing to execute it (loud degrade, D3); install ${packages} manually, then re-run --verify.`);
|
|
277
|
+
return STATUS.untrustedPath;
|
|
278
|
+
}
|
|
279
|
+
log(`no supported package manager found in the trusted dirs (supported: ${PM_FAMILIES.map((f) => f.name).join(', ')}) — install ${packages} manually with your PM, then re-run --verify. (zypper et al. are deliberate omissions — a stated degrade, never a guess.)`);
|
|
280
|
+
return STATUS.unknownPm;
|
|
281
|
+
}
|
|
282
|
+
if (plan.envMissing) {
|
|
283
|
+
log(`env not found in the trusted dirs (${TRUSTED_PATH}) — the apt non-interactivity trampoline cannot be built; install ${plan.packages.join(' + ')} manually, then re-run --verify.`);
|
|
284
|
+
return STATUS.untrustedPath;
|
|
285
|
+
}
|
|
286
|
+
log(` missing: ${plan.missing.join(', ')} → package(s): ${plan.packages.join(', ')} (via ${plan.pm.name})`);
|
|
287
|
+
const sudoTrusted = resolveTrusted('sudo', isExec);
|
|
288
|
+
const printedArgv = euid === 0 || sudoTrusted === null ? plan.rootArgv : [sudoTrusted, ...plan.rootArgv];
|
|
289
|
+
log(' with your consent the doctor WOULD run exactly:');
|
|
290
|
+
log(` ${printedArgv.join(' ')}`);
|
|
291
|
+
if (euid !== 0 && sudoTrusted === null) {
|
|
292
|
+
const sudoOnPath = findFirstPathHit('sudo', env, isExec);
|
|
293
|
+
log(sudoOnPath === null
|
|
294
|
+
? ' (no sudo in the trusted dirs — the command above is the ROOT form; run it as root)'
|
|
295
|
+
: ` (sudo found only at ${sudoOnPath} — NOT in the trusted dirs; the command above is the ROOT form)`);
|
|
296
|
+
}
|
|
297
|
+
log(` consent: re-run with --apply ${plan.tuple} (the tuple binds consent to exactly this plan)`);
|
|
298
|
+
return STATUS.missingBinaries;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// ── the verify lane (D5 — the ONE ready predicate; shared by --verify and post-apply) ──
|
|
302
|
+
|
|
303
|
+
const runVerifyLane = ({ plan, ctx }) => {
|
|
304
|
+
const { log, isExec, env, euid, execPath, diagnosticRunner, scrubbedEnv } = ctx;
|
|
305
|
+
if (plan.untrusted.length > 0) return refuseUntrusted({ plan, log });
|
|
306
|
+
if (plan.missing.length > 0) return renderOffer({ plan, euid, isExec, env, log });
|
|
307
|
+
if (euid === 0 || (env && env.SUDO_UID)) {
|
|
308
|
+
log('refusing the "verified" claim: running as root (euid 0 / SUDO_UID set) — a green smoke under root does NOT prove UNPRIVILEGED user namespaces. Re-run --verify as your normal user.');
|
|
309
|
+
return STATUS.rootUnproven;
|
|
310
|
+
}
|
|
311
|
+
const bwrapPath = plan.resolutions.find((r) => r.name === 'bwrap').trusted;
|
|
312
|
+
const socatPath = plan.resolutions.find((r) => r.name === 'socat').trusted;
|
|
313
|
+
const smoke = diagnosticRunner({ argv: [...buildSmokeArgv(bwrapPath, execPath)], env: scrubbedEnv, stdio: 'pipe' });
|
|
314
|
+
if (smoke.error) {
|
|
315
|
+
log(`bwrap smoke could not SPAWN (${smoke.error.code ?? smoke.error.message}) — the binary resolved but did not execute.`);
|
|
316
|
+
return STATUS.verifyFailed;
|
|
317
|
+
}
|
|
318
|
+
if (smoke.status !== 0) {
|
|
319
|
+
const stderr = smoke.stderr ?? '';
|
|
320
|
+
if (stderr) log(stderr.replace(/\n$/, ''));
|
|
321
|
+
if (NAMESPACE_DENIED_RE.test(stderr)) {
|
|
322
|
+
log('verify INDETERMINATE: the userns smoke was DENIED while the binaries are present — likely cause: nested sandboxing / already inside a container (a green probe cannot distinguish this). Re-run --verify from an unsandboxed shell.');
|
|
323
|
+
return STATUS.indeterminate;
|
|
324
|
+
}
|
|
325
|
+
log(`bwrap userns smoke FAILED (exit ${smoke.status}). The binaries are PRESENT — this is NOT an install problem, so re-installing will not fix it: check unprivileged user namespaces (userns sysctl / AppArmor restrictions) and that this host is WSL2, not WSL1.`);
|
|
326
|
+
return STATUS.verifyFailed;
|
|
327
|
+
}
|
|
328
|
+
const socat = diagnosticRunner({ argv: [socatPath, '-V'], env: scrubbedEnv, stdio: 'pipe' });
|
|
329
|
+
if (socat.error || socat.status !== 0) {
|
|
330
|
+
if (socat.stderr) log(socat.stderr.replace(/\n$/, ''));
|
|
331
|
+
log(socat.error
|
|
332
|
+
? `socat -V could not SPAWN (${socat.error.code ?? socat.error.message}).`
|
|
333
|
+
: `socat -V FAILED (exit ${socat.status}) — the binary is present but not functional; not an install-missing problem.`);
|
|
334
|
+
return STATUS.verifyFailed;
|
|
335
|
+
}
|
|
336
|
+
log('ready (verified): probe green + bwrap userns smoke green + socat -V green — nothing to install.');
|
|
337
|
+
log(VERIFY_HONESTY);
|
|
338
|
+
log(RESTART_NOTE);
|
|
339
|
+
return STATUS.readyVerified;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// ── the apply lane (the ONE place the MUTATING runner may be invoked, exactly once) ───
|
|
343
|
+
|
|
344
|
+
const runApplyLane = ({ plan, tuple, ctx }) => {
|
|
345
|
+
const { log, isExec, env, euid, diagnosticRunner, mutatingRunner, scrubbedEnv, isTTYOf } = ctx;
|
|
346
|
+
if (plan.untrusted.length > 0) return { status: refuseUntrusted({ plan, log }), finalPlan: plan };
|
|
347
|
+
if (plan.missing.length === 0) {
|
|
348
|
+
log(`consent REFUSED: the re-derived plan is EMPTY (nothing missing) — the tuple "${tuple}" matches no plan. Nothing was run or installed; run --verify for the ready proof.`);
|
|
349
|
+
return { status: STATUS.usage, finalPlan: plan };
|
|
350
|
+
}
|
|
351
|
+
if (plan.pm === null || plan.envMissing) return { status: renderOffer({ plan, euid, isExec, env, log }), finalPlan: plan };
|
|
352
|
+
if (tuple !== plan.tuple) {
|
|
353
|
+
log(`consent REFUSED: tuple mismatch — given "${tuple}", the current diagnosis derives "${plan.tuple}". Nothing was run; re-run flagless to preview, then --apply with the derived tuple.`);
|
|
354
|
+
return { status: STATUS.usage, finalPlan: plan };
|
|
355
|
+
}
|
|
356
|
+
let execArgv = plan.rootArgv;
|
|
357
|
+
if (euid !== 0) {
|
|
358
|
+
const sudoTrusted = resolveTrusted('sudo', isExec);
|
|
359
|
+
if (sudoTrusted === null) {
|
|
360
|
+
const sudoOnPath = findFirstPathHit('sudo', env, isExec);
|
|
361
|
+
if (sudoOnPath !== null) {
|
|
362
|
+
log(`sudo found ONLY at ${sudoOnPath} — NOT in the trusted dirs (${TRUSTED_PATH}); refusing to execute it.`);
|
|
363
|
+
return { status: STATUS.untrustedPath, finalPlan: plan };
|
|
364
|
+
}
|
|
365
|
+
log('sudo is not available — run this as ROOT in your terminal, then re-run --verify:');
|
|
366
|
+
log(` ${plan.rootArgv.join(' ')}`);
|
|
367
|
+
return { status: STATUS.handoffRequired, finalPlan: plan };
|
|
368
|
+
}
|
|
369
|
+
const preflight = diagnosticRunner({ argv: [sudoTrusted, '-n', 'true'], env: scrubbedEnv, stdio: 'pipe' });
|
|
370
|
+
const preflightGreen = !preflight.error && preflight.status === 0;
|
|
371
|
+
if (preflightGreen) {
|
|
372
|
+
log('sudo will run WITHOUT a password prompt (cached credentials or NOPASSWD) — the install proceeds non-interactively.');
|
|
373
|
+
} else if (!isTTYOf()) {
|
|
374
|
+
log('sudo needs a password and there is NO controlling terminal — running NOTHING. Run this in YOUR terminal, then re-run --verify:');
|
|
375
|
+
log(` ${[sudoTrusted, ...plan.rootArgv].join(' ')}`);
|
|
376
|
+
return { status: STATUS.handoffRequired, finalPlan: plan };
|
|
377
|
+
} else {
|
|
378
|
+
log('sudo will prompt for your password on your terminal.');
|
|
379
|
+
}
|
|
380
|
+
execArgv = [sudoTrusted, ...plan.rootArgv];
|
|
381
|
+
}
|
|
382
|
+
log('executing EXACTLY (every token trusted-dir absolute):');
|
|
383
|
+
log(` ${execArgv.join(' ')}`);
|
|
384
|
+
const res = mutatingRunner({ argv: execArgv, env: scrubbedEnv, stdio: [...MUTATING_STDIO] });
|
|
385
|
+
if (res.error || res.status !== 0) {
|
|
386
|
+
log(res.error
|
|
387
|
+
? `install could not SPAWN (${res.error.code ?? res.error.message}).`
|
|
388
|
+
: `install FAILED (exit ${res.status}) — see the package manager output above (stderr was inherited). A stale package index is one possible cause (the doctor never refreshes indexes — run your PM's update by hand if so).`);
|
|
389
|
+
return { status: STATUS.installFailed, finalPlan: plan };
|
|
390
|
+
}
|
|
391
|
+
log('install completed — running the verify lane...');
|
|
392
|
+
const freshProbe = ctx.probe();
|
|
393
|
+
const freshPlan = deriveDoctorPlan({ probeResult: freshProbe, env, isExec });
|
|
394
|
+
return { status: runVerifyLane({ plan: freshPlan, ctx }), finalPlan: freshPlan };
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
// ── default runners (the ONE real-process boundary; injectable for hermetic tests) ────
|
|
398
|
+
|
|
399
|
+
export const runDiagnosticSpawn = (descriptor) =>
|
|
400
|
+
spawnSync(descriptor.argv[0], descriptor.argv.slice(1), { env: descriptor.env, encoding: 'utf8', stdio: 'pipe' });
|
|
401
|
+
export const runMutatingSpawn = (descriptor) =>
|
|
402
|
+
spawnSync(descriptor.argv[0], descriptor.argv.slice(1), { env: descriptor.env, stdio: descriptor.stdio });
|
|
403
|
+
|
|
404
|
+
// ── main(argv, deps) → exit code ──────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
export const main = (argv, deps = {}) => {
|
|
407
|
+
const log = deps.log ?? console.log;
|
|
408
|
+
const platform = deps.platform ?? process.platform;
|
|
409
|
+
const env = deps.env ?? process.env;
|
|
410
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
411
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
412
|
+
const isExec = deps.isExecutable ?? isExecutableFile;
|
|
413
|
+
const execPath = deps.execPath ?? process.execPath;
|
|
414
|
+
const diagnosticRunner = deps.diagnosticRunner ?? runDiagnosticSpawn;
|
|
415
|
+
const mutatingRunner = deps.mutatingRunner ?? runMutatingSpawn;
|
|
416
|
+
const probe = deps.probe ?? (() => probeSandboxAvailability({ platform, env, isExecutable: isExec }));
|
|
417
|
+
const euidOf = deps.euid ?? (() => process.geteuid?.());
|
|
418
|
+
const isTTYOf = () => deps.isTTY ?? hasControllingTerminal();
|
|
419
|
+
|
|
420
|
+
const finish = (status, plan = null) => {
|
|
421
|
+
log(composeSummaryLine({ status, platform, missing: plan?.missing ?? [], pm: plan?.pm?.name ?? null }));
|
|
422
|
+
return EXIT_FOR_STATUS[status];
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
let parsed;
|
|
426
|
+
try {
|
|
427
|
+
parsed = parseDoctorArgs(argv);
|
|
428
|
+
} catch (err) {
|
|
429
|
+
log(`autonomy-doctor: ${err.message}`);
|
|
430
|
+
log(USAGE_HINT);
|
|
431
|
+
return finish(STATUS.usage);
|
|
432
|
+
}
|
|
433
|
+
if (parsed.lane === 'help') {
|
|
434
|
+
log(HELP);
|
|
435
|
+
return 0;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
try {
|
|
439
|
+
assertDocsAiDeployment(cwd, { lstat }, { noun: 'anything', rel: 'anything here' });
|
|
440
|
+
} catch (err) {
|
|
441
|
+
log(`precondition STOP — ${err.message}`);
|
|
442
|
+
return finish(STATUS.noDeployment);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (platform === 'win32') {
|
|
446
|
+
log(WSL2_REDIRECT);
|
|
447
|
+
return finish(STATUS.unsupportedPlatform);
|
|
448
|
+
}
|
|
449
|
+
if (platform === 'darwin') {
|
|
450
|
+
if (parsed.lane === 'apply') {
|
|
451
|
+
log(`consent REFUSED: nothing to install on macOS (Seatbelt is built-in) — the tuple "${parsed.tuple}" matches no plan. Nothing was run.`);
|
|
452
|
+
return finish(STATUS.usage);
|
|
453
|
+
}
|
|
454
|
+
log(SEATBELT_NOTE);
|
|
455
|
+
return finish(STATUS.readyAssumed);
|
|
456
|
+
}
|
|
457
|
+
if (platform !== 'linux') {
|
|
458
|
+
log(`native ${platform} sandbox unsupported — use WSL2 on Windows.`);
|
|
459
|
+
return finish(STATUS.unsupportedPlatform);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Linux from here; euid resolves LAZILY after the platform gate (process.geteuid is win32-absent).
|
|
463
|
+
const euid = euidOf();
|
|
464
|
+
const probeResult = probe();
|
|
465
|
+
const plan = deriveDoctorPlan({ probeResult, env, isExec });
|
|
466
|
+
const scrubbedEnv = { PATH: TRUSTED_PATH, ...(env && env.LANG ? { LANG: env.LANG } : {}) };
|
|
467
|
+
const ctx = { log, isExec, env, euid, execPath, diagnosticRunner, mutatingRunner, scrubbedEnv, isTTYOf, probe };
|
|
468
|
+
|
|
469
|
+
renderDiagnosis({ plan, probeResult, log });
|
|
470
|
+
|
|
471
|
+
if (parsed.lane === 'preview') {
|
|
472
|
+
let status;
|
|
473
|
+
if (plan.untrusted.length > 0) status = refuseUntrusted({ plan, log });
|
|
474
|
+
else if (plan.missing.length > 0) status = renderOffer({ plan, euid, isExec, env, log });
|
|
475
|
+
else {
|
|
476
|
+
log(' state: binaries present but UNVERIFIED — the flagless preview never claims ready; run --verify for the ready proof.');
|
|
477
|
+
status = STATUS.presentUnverified;
|
|
478
|
+
}
|
|
479
|
+
for (const line of D9_DISCLOSURE) log(line);
|
|
480
|
+
return finish(status, plan);
|
|
481
|
+
}
|
|
482
|
+
if (parsed.lane === 'verify') return finish(runVerifyLane({ plan, ctx }), plan);
|
|
483
|
+
const applied = runApplyLane({ plan, tuple: parsed.tuple, ctx });
|
|
484
|
+
return finish(applied.status, applied.finalPlan);
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
488
|
+
if (isDirectRun) process.exit(main(process.argv.slice(2)));
|
package/tools/commands.mjs
CHANGED
|
@@ -28,7 +28,7 @@ const invocationOf = (token) => `${BARE_INVOCATION}${token ? ` ${token}` : ''}`;
|
|
|
28
28
|
// ── kinds ────────────────────────────────────────────────────────────────────────
|
|
29
29
|
// read-only — never writes, never commits, never runs a subscription CLI.
|
|
30
30
|
// writer — writes files (a project deployment or a settings/skill placement).
|
|
31
|
-
// guarded —
|
|
31
|
+
// guarded — consent-gated destructive/privileged actions (dry-run-first + explicit consent).
|
|
32
32
|
// project-exec — the kit itself writes nothing, but the mode RUNS the project's own declared
|
|
33
33
|
// commands with the caller's privileges (the `gates` runner) — honest-tagged so a
|
|
34
34
|
// user never reads "read-only" on a surface that executes their gate matrix.
|
|
@@ -140,6 +140,13 @@ const CATALOG = [
|
|
|
140
140
|
kind: GUARDED,
|
|
141
141
|
oneLine: 'Read or change the host-level bridge settings (e.g. the codex Fast tier) — a KEY=VALUE file that survives kit upgrades; previews first, and the Fast tier carries its extra-cost caveat.',
|
|
142
142
|
},
|
|
143
|
+
{
|
|
144
|
+
key: 'autonomy-doctor',
|
|
145
|
+
invocation: invocationOf('autonomy-doctor'),
|
|
146
|
+
group: 'Configure',
|
|
147
|
+
kind: GUARDED,
|
|
148
|
+
oneLine: 'Check whether this machine can run the Claude sandbox and, only with your explicit consent, install what is missing — privileged, preview-first, consent-per-run; it never auto-runs and never commits.',
|
|
149
|
+
},
|
|
143
150
|
{
|
|
144
151
|
key: 'recipes',
|
|
145
152
|
invocation: invocationOf('recipes'),
|
|
@@ -274,7 +281,7 @@ export const formatHelp = () => {
|
|
|
274
281
|
const lines = [
|
|
275
282
|
`${SKILL_NAME} — command index (this list is read-only)`,
|
|
276
283
|
'',
|
|
277
|
-
'Each command is tagged read-only · writer (makes changes) · guarded (destructive, previews first) · runs project cmds (executes your own declared commands).',
|
|
284
|
+
'Each command is tagged read-only · writer (makes changes) · guarded (consent-gated destructive/privileged actions, previews first) · runs project cmds (executes your own declared commands).',
|
|
278
285
|
];
|
|
279
286
|
for (const group of GROUP_ORDER) {
|
|
280
287
|
const inGroup = COMMANDS.filter((c) => c.group === group);
|
package/tools/doc-parity.mjs
CHANGED
|
@@ -26,11 +26,13 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
26
26
|
import { SCHEMA_VERSION, REVIEW_CAP, V4_CLASSES, V4_OVERRIDE_SCOPES } from './review-ledger.mjs';
|
|
27
27
|
import { HARD_MAX, DEFAULT_DIFF_CAP } from './review-ledger-write.mjs';
|
|
28
28
|
import { RESULT_SCHEMA_VERSION } from './fold-completeness.mjs';
|
|
29
|
+
import { EXIT as DOCTOR_EXIT, STATUS as DOCTOR_STATUS, TRUSTED_DIRS as DOCTOR_TRUSTED_DIRS } from './autonomy-doctor.mjs';
|
|
29
30
|
|
|
30
31
|
const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
32
|
|
|
32
33
|
const REVIEW_LEDGER_DOC = 'references/modes/review-ledger.md';
|
|
33
34
|
const FOLD_DOC = 'references/modes/fold-completeness.md';
|
|
35
|
+
const AUTONOMY_DOCTOR_DOC = 'references/modes/autonomy-doctor.md';
|
|
34
36
|
|
|
35
37
|
// A typed usage failure (exit 2) for the CLI parser — the codebase's typed-error idiom (no classes).
|
|
36
38
|
const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 });
|
|
@@ -45,6 +47,19 @@ const valueBinding = (constant, value, phrase, files) => ({ constant, value, tok
|
|
|
45
47
|
// so the closed set can never disagree with the code. Every word must appear in the ledger contract.
|
|
46
48
|
const LEDGER_VOCAB = [...V4_CLASSES, ...V4_OVERRIDE_SCOPES, 'gate-run'];
|
|
47
49
|
|
|
50
|
+
// The autonomy-doctor D7 contract (AD-044 Plan 2): the live EXIT table + every status token must
|
|
51
|
+
// render into the mode's contract doc. `usage` is skipped as a bare-word token (trivially present
|
|
52
|
+
// everywhere) — its exit-code phrase below pins that outcome instead.
|
|
53
|
+
const DOCTOR_EXIT_PHRASES = [
|
|
54
|
+
['ready', `\`${DOCTOR_EXIT.ready}\` ready`],
|
|
55
|
+
['stop', `\`${DOCTOR_EXIT.stop}\` precondition STOP`],
|
|
56
|
+
['usage', `\`${DOCTOR_EXIT.usage}\` usage`],
|
|
57
|
+
['notReady', `\`${DOCTOR_EXIT.notReady}\` not-ready diagnosis`],
|
|
58
|
+
['installFailed', `\`${DOCTOR_EXIT.installFailed}\` install failed`],
|
|
59
|
+
['verifyFailed', `\`${DOCTOR_EXIT.verifyFailed}\` verify failed`],
|
|
60
|
+
['unsupported', `\`${DOCTOR_EXIT.unsupported}\` unsupported / untrusted`],
|
|
61
|
+
];
|
|
62
|
+
|
|
48
63
|
export const BINDINGS = Object.freeze([
|
|
49
64
|
valueBinding('SCHEMA_VERSION', SCHEMA_VERSION, `schema v${SCHEMA_VERSION}`, [REVIEW_LEDGER_DOC]),
|
|
50
65
|
valueBinding('HARD_MAX', HARD_MAX, `hard-max ceiling of ${HARD_MAX}`, [REVIEW_LEDGER_DOC]),
|
|
@@ -52,6 +67,11 @@ export const BINDINGS = Object.freeze([
|
|
|
52
67
|
valueBinding('REVIEW_CAP', REVIEW_CAP, `cap ≤${REVIEW_CAP}`, [REVIEW_LEDGER_DOC]),
|
|
53
68
|
valueBinding('RESULT_SCHEMA_VERSION', RESULT_SCHEMA_VERSION, `schema v${RESULT_SCHEMA_VERSION}`, [FOLD_DOC]),
|
|
54
69
|
...LEDGER_VOCAB.map((word) => valueBinding(`vocab:${word}`, word, word, [REVIEW_LEDGER_DOC])),
|
|
70
|
+
...DOCTOR_EXIT_PHRASES.map(([key, phrase]) => valueBinding(`doctor-exit:${key}`, DOCTOR_EXIT[key], phrase, [AUTONOMY_DOCTOR_DOC])),
|
|
71
|
+
...Object.values(DOCTOR_STATUS)
|
|
72
|
+
.filter((token) => token !== DOCTOR_STATUS.usage)
|
|
73
|
+
.map((token) => valueBinding(`doctor-status:${token}`, token, token, [AUTONOMY_DOCTOR_DOC])),
|
|
74
|
+
valueBinding('doctor-trusted-dirs', DOCTOR_TRUSTED_DIRS.join(':'), DOCTOR_TRUSTED_DIRS.join(':'), [AUTONOMY_DOCTOR_DOC]),
|
|
55
75
|
].map((b) => Object.freeze(b)));
|
|
56
76
|
|
|
57
77
|
// ── the pure checker (readText is injectable for hermetic tests) ────────────────────────
|
|
@@ -96,8 +116,9 @@ Usage:
|
|
|
96
116
|
node doc-parity.mjs [--check | --json]
|
|
97
117
|
|
|
98
118
|
A CLOSED, exported registry binds each live code constant (review-ledger SCHEMA_VERSION / REVIEW_CAP,
|
|
99
|
-
review-ledger-write HARD_MAX / DEFAULT_DIFF_CAP, fold-completeness RESULT_SCHEMA_VERSION)
|
|
100
|
-
ledger vocabulary (V4_CLASSES / V4_OVERRIDE_SCOPES + gate-run)
|
|
119
|
+
review-ledger-write HARD_MAX / DEFAULT_DIFF_CAP, fold-completeness RESULT_SCHEMA_VERSION), the
|
|
120
|
+
ledger vocabulary (V4_CLASSES / V4_OVERRIDE_SCOPES + gate-run), and the autonomy-doctor contract
|
|
121
|
+
(the EXIT table, the status tokens, the trusted-dir allowlist) to the exact token its
|
|
101
122
|
references/modes/*.md contract must carry, and asserts the CURRENT value renders into every bound
|
|
102
123
|
file. A drifted doc, an unreadable bound file, or an absent token FAILS CLOSED.
|
|
103
124
|
|
package/tools/seed-gates.mjs
CHANGED
|
@@ -6,14 +6,28 @@
|
|
|
6
6
|
// EMPTY (AD-021/AD-038): a populated declaration is per-entry maintainer consent recorded through
|
|
7
7
|
// this preview, never auto-seeding.
|
|
8
8
|
//
|
|
9
|
-
// What it offers (the derivation invariants, test-pinned):
|
|
9
|
+
// What it offers (the derivation invariants, test-pinned) — CLOSED-WORLD since AD-052: an entry
|
|
10
|
+
// is offered only when every axis is proven safe by MEMBERSHIP in a finite, test-guarded set,
|
|
11
|
+
// never by absence from a blocklist (Issue-011 killed the blocklist model — one more gap per
|
|
12
|
+
// review round, unprovable):
|
|
10
13
|
// • sources: discoverGateCandidates over package.json scripts (velocity-profile.mjs stays
|
|
11
14
|
// read-only; THIS module owns the offer mapping) + the conditional review-state candidate;
|
|
12
|
-
// • warn-flagged candidates (release/publish/
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// never
|
|
16
|
-
// •
|
|
15
|
+
// • NAME screens shape WHICH entries are offered: warn-flagged candidates (release/publish/
|
|
16
|
+
// deploy/push/version/commit/tag, pre*/post*) never enter; only terminating verification
|
|
17
|
+
// class NAMES (test / lint / type-check / build) pass, never dev/start/watch/serve/preview,
|
|
18
|
+
// never a mutating-variant name; a shell-unsafe name is screened first;
|
|
19
|
+
// • the BODY must be a string member of the literal BODY_ALLOWLIST after a pinned ASCII-only
|
|
20
|
+
// normalization — anything else is screened out with a LOUD note (never silently absent);
|
|
21
|
+
// • the cmd is the per-PM HOOK-FREE form `COREPACK_ENABLE_NETWORK=0 <pm> exec -- <allowlisted-body>`
|
|
22
|
+
// (packageManager field, else lockfile probe, else npm): `exec` runs a command, not a named
|
|
23
|
+
// script, so the pre/post lifecycle-hook class dies structurally on npm/pnpm/yarn alike, and
|
|
24
|
+
// the Corepack env prefix blocks a hostile packageManager pin from fetching the PM binary; a
|
|
25
|
+
// family with no verified fail-closed exec contract is WITHHELD with a loud note. NEVER
|
|
26
|
+
// `<pm> run <name>` — that re-exposes hooks and lets a later package.json edit change a
|
|
27
|
+
// byte-exact-approved gate;
|
|
28
|
+
// • the safety claim is scoped to the OFFER DERIVATION, not a runtime sandbox: a gate still
|
|
29
|
+
// executes project-controlled tooling (a node_modules/.bin PATH shim intercepts under every
|
|
30
|
+
// form) — the documented residual, disclosed in the preview, bounded by the two consents;
|
|
17
31
|
// • ids derive kebab-case from script names (build:prod → build-prod) and every offered entry
|
|
18
32
|
// passes the runner's validateDeclaration (the seeder imports the validator — NEVER the
|
|
19
33
|
// reverse: run-gates.mjs stays a runner that writes nothing);
|
|
@@ -62,33 +76,35 @@ const usageFail = (message) =>
|
|
|
62
76
|
export const TRUST_CHAIN_DISCLOSURE =
|
|
63
77
|
'Disclosure: once the optional approval hook is wired (/agent-workflow-kit hook), it ' +
|
|
64
78
|
'auto-approves byte-exact declared gate commands from the project root — seeding (this consent) ' +
|
|
65
|
-
'and hook wiring (its own consent) are two separate yeses.'
|
|
79
|
+
'and hook wiring (its own consent) are two separate yeses. A script gate runs the project\'s ' +
|
|
80
|
+
'own tooling, which executes project-controlled code (the seeder does not sandbox it; the two ' +
|
|
81
|
+
'consents bound it).';
|
|
66
82
|
|
|
67
83
|
const USAGE = `usage: seed-gates [--dry-run | --apply] [--only <id>]... [--cwd <dir>] [--help]
|
|
68
84
|
|
|
69
85
|
Consent-gated seeder for the project's own docs/ai/gates.json. Default is --dry-run: prints the
|
|
70
86
|
derived { id, title, cmd } entries and writes NOTHING. --apply APPENDS exactly the consented
|
|
71
87
|
entries (--only <id> selects a subset; append-only — existing entries are never modified or
|
|
72
|
-
removed, an id collision is refused).
|
|
73
|
-
(test / lint / type-check / build
|
|
74
|
-
|
|
88
|
+
removed, an id collision is refused). The offer is CLOSED-WORLD: only a terminating-class script
|
|
89
|
+
name (test / lint / type-check / build — never release/publish/deploy, never watch/serve, never a
|
|
90
|
+
write-mode variant) whose BODY is a member of the literal runner allowlist is offered, as the
|
|
91
|
+
hook-free \`COREPACK_ENABLE_NETWORK=0 <pm> exec -- <body>\` form for the detected package manager. A gate-class script whose
|
|
92
|
+
body is NOT in the allowlist is screened out with a note naming it (a command you trust can still
|
|
93
|
+
be declared by hand); non-gate-class names are excluded silently, as always.
|
|
94
|
+
${TRUST_CHAIN_DISCLOSURE}`;
|
|
75
95
|
|
|
76
|
-
// ── candidate classification (the LOCKED derivation invariants)
|
|
77
|
-
//
|
|
96
|
+
// ── candidate classification: the NAME screens (the LOCKED derivation invariants) ──────
|
|
97
|
+
// NAME screens shape WHICH safe entries are offered; the danger axis (the BODY) is closed-world
|
|
98
|
+
// below (AD-052). The derived cmd is bash-interpolated by the gate runner and can become
|
|
78
99
|
// hook-auto-approvable, so only shell-SAFE script names ever enter the offer: a name carrying
|
|
79
100
|
// whitespace or any shell metacharacter (`test:ci && echo pwn`) is screened out entirely.
|
|
80
101
|
const SAFE_SCRIPT_NAME_PATTERN = /^[A-Za-z0-9:_.-]+$/;
|
|
81
102
|
const TERMINATING_CLASS_PATTERN = /^(test|lint|type-?check|types|tsc|build)([:._-]|$)/i;
|
|
82
|
-
// Non-terminating screening is TOKEN-set based, position-independent (never an anchored
|
|
83
|
-
// the anchored form missed `build:preview`
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
// can still be declared by hand; a wrongly-included one would become hook-auto-approvable.
|
|
103
|
+
// Non-terminating NAME screening is TOKEN-set based, position-independent (never an anchored
|
|
104
|
+
// regex — the anchored form missed `build:preview`): a non-terminating token in ANY name segment
|
|
105
|
+
// disqualifies; `watch` disqualifies as a substring anywhere (watchAll). Conservative on purpose —
|
|
106
|
+
// a screened-out script can still be declared by hand.
|
|
87
107
|
const NON_TERMINATING_TOKENS = new Set(['dev', 'start', 'serve', 'watch', 'preview']);
|
|
88
|
-
// A terminating-looking NAME can still hide release work in its BODY (`"test": "npm publish"`):
|
|
89
|
-
// the same token classes the warn-name screen rejects are rejected as bare body words too — an
|
|
90
|
-
// offered cmd is hook-auto-approvable, so a dangerous body must never ride a clean name.
|
|
91
|
-
const DANGEROUS_BODY_TOKENS = new Set(['release', 'publish', 'deploy', 'push', 'version', 'commit', 'tag']);
|
|
92
108
|
const WATCH_ANYWHERE_PATTERN = /watch/i;
|
|
93
109
|
const wordOf = (raw) => raw.toLowerCase().replace(/^-+/, '').split('=')[0];
|
|
94
110
|
const hasTokenIn = (text, splitter, tokens) =>
|
|
@@ -97,16 +113,82 @@ const hasTokenIn = (text, splitter, tokens) =>
|
|
|
97
113
|
.some((part) => tokens.has(wordOf(part)));
|
|
98
114
|
const isNonTerminatingName = (name) =>
|
|
99
115
|
hasTokenIn(name, /[:._-]/, NON_TERMINATING_TOKENS) || WATCH_ANYWHERE_PATTERN.test(name);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// A MUTATING VARIANT of a terminating class never enters the offer — a hook-auto-approvable gate
|
|
104
|
-
// must never be a writer. Screened on BOTH axes: the script NAME (lint:fix, test:update,
|
|
105
|
-
// build:write, test:snapshot) and the script BODY's write-mode flags (eslint --fix,
|
|
106
|
-
// prettier --write / -w, jest -u / --updateSnapshot, tsc -w). Conservative by design: an excluded
|
|
107
|
-
// candidate can still be declared by hand — a wrongly-included one would be silently auto-approved.
|
|
116
|
+
// A MUTATING VARIANT NAME of a terminating class never enters the offer (lint:fix, test:update,
|
|
117
|
+
// build:write, test:snapshot) — a hook-auto-approvable gate must never be a writer. Write-mode
|
|
118
|
+
// BODIES need no screen: the closed-world allowlist simply has no write-mode member.
|
|
108
119
|
const MUTATING_VARIANT_NAME_PATTERN = /(^|[:._-])(fix|write|update|snapshot)([:._-]|$)/i;
|
|
109
|
-
|
|
120
|
+
|
|
121
|
+
// ── the CLOSED-WORLD body contract (AD-052 — the Issue-011 structural fix) ─────────────
|
|
122
|
+
// The body axis is ALLOWLIST MEMBERSHIP, never blocklist screening. The failure direction flips
|
|
123
|
+
// by construction: worst case = a legit command not offered (mild, add-by-hand) — never a
|
|
124
|
+
// dangerous one offered. Extension contract (edit-safety): adding an entry = adding the literal
|
|
125
|
+
// here + classifying its stem in the partition below + its own test case; the self-safety test
|
|
126
|
+
// pins every entry against shell metacharacters, write-mode flags, and unrecognized runner stems.
|
|
127
|
+
// Deliberately EXCLUDED: `tsc -p .` without --noEmit — it EMITS compiled .js into the tree by
|
|
128
|
+
// default, a write side-effect a verification gate must not have.
|
|
129
|
+
export const BODY_ALLOWLIST = Object.freeze([
|
|
130
|
+
'node --test',
|
|
131
|
+
'vitest run',
|
|
132
|
+
'jest',
|
|
133
|
+
'jest --ci',
|
|
134
|
+
'eslint .',
|
|
135
|
+
'prettier --check .',
|
|
136
|
+
'tsc --noEmit',
|
|
137
|
+
'tsc -p . --noEmit',
|
|
138
|
+
'vite build',
|
|
139
|
+
]);
|
|
140
|
+
// The FIXED executable-source partition of the allowlist stems (test-pinned, never a runtime
|
|
141
|
+
// probe): a host-runtime stem (`node`) is always on PATH and never a node_modules package — no
|
|
142
|
+
// local-bin resolution applies to it; a package-runner stem resolves from the project's own
|
|
143
|
+
// node_modules (or Berry .pnp.cjs) via `<pm> exec`, under the per-PM no-network-fetch rule below.
|
|
144
|
+
export const HOST_RUNTIME_STEMS = Object.freeze(['node']);
|
|
145
|
+
export const PACKAGE_RUNNER_STEMS = Object.freeze(['vitest', 'jest', 'eslint', 'prettier', 'tsc', 'vite']);
|
|
146
|
+
|
|
147
|
+
// Decision-3 order, test-pinned: (1) string-typed, else not offered; (2) reject ANY char outside
|
|
148
|
+
// printable ASCII + space/tab BEFORE trimming — String.trim() strips \n/NBSP/BOM at the EDGES and
|
|
149
|
+
// would mask a leading/trailing forbidden char; (3) trim + collapse ASCII space/tab runs to one
|
|
150
|
+
// space; (4) literal membership. No case folding, no arg reordering, no separate env/path reject
|
|
151
|
+
// axis — `FOO=bar node --test` and `./scripts/x.sh` are already rejected by NON-MEMBERSHIP.
|
|
152
|
+
const PRINTABLE_ASCII_BODY_PATTERN = /^[\x20-\x7E\t]*$/;
|
|
153
|
+
const allowlistedBodyOf = (body) => {
|
|
154
|
+
if (typeof body !== 'string') return null;
|
|
155
|
+
if (!PRINTABLE_ASCII_BODY_PATTERN.test(body)) return null;
|
|
156
|
+
const normalized = body.replace(/^[ \t]+|[ \t]+$/g, '').replace(/[ \t]+/g, ' ');
|
|
157
|
+
return BODY_ALLOWLIST.includes(normalized) ? normalized : null;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// The per-PM HOOK-FREE exec form (Decision 1, host-proven npm 12 / pnpm 10 / yarn 1 + berry 4):
|
|
161
|
+
// `exec` runs a COMMAND, not a named script — no pre/post lifecycle exists to fire, uniformly.
|
|
162
|
+
// The `--` keeps a body flag (`jest --ci`) from being absorbed by the PM. Per-PM hardening — the
|
|
163
|
+
// pinned invariant is NO NETWORK FETCH of an absent runner, not "no user-machine fallback":
|
|
164
|
+
// npm — `--script-shell /bin/sh` beats a hostile per-project `.npmrc script-shell` (proven the
|
|
165
|
+
// flag wins) and `--offline` refuses an absent runner as a cache miss, never a registry
|
|
166
|
+
// fetch; a hit on a previously-installed real package (the npm cache OR the host's
|
|
167
|
+
// global tree — both user machine state, not project content) is the documented runtime
|
|
168
|
+
// residual (Decision 2 ii / 6), not a separate guard.
|
|
169
|
+
// pnpm — no network fetch; an absent runner fails closed when no project-local or
|
|
170
|
+
// PATH-resolvable runner exists (a user-installed one executing is the same
|
|
171
|
+
// user-machine-state residual). Reads no npm `.npmrc script-shell`.
|
|
172
|
+
// yarn — same contract, uniform for classic + berry (berry resolves via .pnp.cjs; proven).
|
|
173
|
+
// The `COREPACK_ENABLE_NETWORK=0` prefix closes ONE more attacker-reachable fetch axis: when the
|
|
174
|
+
// PM binary is a Corepack shim, a hostile `packageManager: "<pm>@<uncached-version>"` field makes
|
|
175
|
+
// Corepack DOWNLOAD that PM release from the registry BEFORE exec even resolves the runner. The env
|
|
176
|
+
// var disables that provisioning (proven: it fails closed "Network access disabled" on an uncached
|
|
177
|
+
// pin, and is inert when the PM is a real install or already provisioned) — so the no-network
|
|
178
|
+
// contract holds for the PM-provision step too, not just the runner. Applies to all three (Corepack
|
|
179
|
+
// can shim npm/pnpm/yarn alike). An UNKNOWN family has no verified fail-closed exec contract → cmd
|
|
180
|
+
// null (withheld, loud note) — the mild worst case, never a hole. detectPackageManager can only
|
|
181
|
+
// yield npm/pnpm/yarn today (bun collapses to npm — characterized), so this is the builder floor.
|
|
182
|
+
const COREPACK_NO_NETWORK = 'COREPACK_ENABLE_NETWORK=0';
|
|
183
|
+
export const execCmdFor = (pm, body) => {
|
|
184
|
+
if (pm === 'npm') return { cmd: `${COREPACK_NO_NETWORK} npm exec --offline --script-shell /bin/sh -- ${body}`, note: null };
|
|
185
|
+
if (pm === 'pnpm') return { cmd: `${COREPACK_NO_NETWORK} pnpm exec -- ${body}`, note: null };
|
|
186
|
+
if (pm === 'yarn') return { cmd: `${COREPACK_NO_NETWORK} yarn exec -- ${body}`, note: null };
|
|
187
|
+
return {
|
|
188
|
+
cmd: null,
|
|
189
|
+
note: `no fail-closed exec form is verified for this package manager (${pm}) — add the gate by hand`,
|
|
190
|
+
};
|
|
191
|
+
};
|
|
110
192
|
|
|
111
193
|
export const kebabIdOf = (name) =>
|
|
112
194
|
String(name)
|
|
@@ -133,8 +215,11 @@ export const detectPackageManager = (cwd, deps = {}) => {
|
|
|
133
215
|
return 'npm';
|
|
134
216
|
};
|
|
135
217
|
|
|
136
|
-
// The script-derived offer entries
|
|
137
|
-
|
|
218
|
+
// The script-derived offer: entries + loud honesty notes (a gate-class script screened out on its
|
|
219
|
+
// body, or a whole family withheld, is COUNTED and NAMED — never silently absent). Order =
|
|
220
|
+
// package.json scripts order (the offer the user reads). `deps.packageManager` injects a family
|
|
221
|
+
// token past the detector for the builder-boundary fail-closed proof (T4b).
|
|
222
|
+
const deriveScripts = (cwd, deps = {}) => {
|
|
138
223
|
const read = deps.readFile ?? readFileSync;
|
|
139
224
|
const pkg = (() => {
|
|
140
225
|
try {
|
|
@@ -143,29 +228,48 @@ export const deriveScriptEntries = (cwd, deps = {}) => {
|
|
|
143
228
|
return null; // no/unreadable package.json → no script candidates (an honest empty offer)
|
|
144
229
|
}
|
|
145
230
|
})();
|
|
146
|
-
const pm = detectPackageManager(cwd, deps);
|
|
147
|
-
const
|
|
148
|
-
const bodyOf = (name) => String(pkg?.scripts?.[name] ?? '');
|
|
149
|
-
return discoverGateCandidates(pkg ?? {})
|
|
231
|
+
const pm = deps.packageManager ?? detectPackageManager(cwd, deps);
|
|
232
|
+
const named = discoverGateCandidates(pkg ?? {})
|
|
150
233
|
.filter((c) => !c.warn) // warn-flagged NEVER enter the offer
|
|
151
234
|
.filter((c) => SAFE_SCRIPT_NAME_PATTERN.test(c.scriptName)) // shell-safe names only, FIRST
|
|
152
235
|
.filter((c) => TERMINATING_CLASS_PATTERN.test(c.scriptName))
|
|
153
236
|
.filter((c) => !isNonTerminatingName(c.scriptName))
|
|
154
|
-
.filter((c) => !MUTATING_VARIANT_NAME_PATTERN.test(c.scriptName)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
237
|
+
.filter((c) => !MUTATING_VARIANT_NAME_PATTERN.test(c.scriptName));
|
|
238
|
+
const seen = new Set(); // first occurrence of a derived id wins, whatever its outcome (conservative)
|
|
239
|
+
const entries = [];
|
|
240
|
+
const screenedIds = [];
|
|
241
|
+
const withheld = [];
|
|
242
|
+
for (const c of named) {
|
|
243
|
+
const id = kebabIdOf(c.scriptName);
|
|
244
|
+
if (!id || seen.has(id)) continue; // an empty or duplicate derived id never enters the offer
|
|
245
|
+
seen.add(id);
|
|
246
|
+
const body = allowlistedBodyOf(pkg?.scripts?.[c.scriptName]);
|
|
247
|
+
if (body === null) {
|
|
248
|
+
screenedIds.push(id);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const { cmd, note } = execCmdFor(pm, body);
|
|
252
|
+
if (cmd === null) {
|
|
253
|
+
withheld.push({ id, note });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
entries.push({ id, title: `Project script ${c.scriptName}: ${body}`, cmd });
|
|
257
|
+
}
|
|
258
|
+
const notes = [];
|
|
259
|
+
if (screenedIds.length) {
|
|
260
|
+
notes.push(
|
|
261
|
+
`${screenedIds.length} gate-class script(s) screened out — body not in the closed-world ` +
|
|
262
|
+
`allowlist: ${screenedIds.join(', ')} — a command you trust can still be declared by hand in ${GATES_REL}`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
if (withheld.length) {
|
|
266
|
+
notes.push(`${withheld.length} script gate(s) withheld: ${withheld.map((w) => w.id).join(', ')} — ${withheld[0].note}`);
|
|
267
|
+
}
|
|
268
|
+
return { entries, notes };
|
|
167
269
|
};
|
|
168
270
|
|
|
271
|
+
export const deriveScriptEntries = (cwd, deps = {}) => deriveScripts(cwd, deps).entries;
|
|
272
|
+
|
|
169
273
|
// The conditional review-state candidate — keyed on the SLOT the checker enforces
|
|
170
274
|
// (plan-execution.review, tools/review-state.mjs), read via the shared config reader. Offered only
|
|
171
275
|
// when the config DECLARES reviewed/council there; solo configs and a council-on-plan-authoring-only
|
|
@@ -256,14 +360,13 @@ const assertOnlyIdsOffered = (offer, onlyIds = []) => {
|
|
|
256
360
|
// plus loud notes. Both review candidates key on the same slot (plan-execution.review reviewed/council)
|
|
257
361
|
// but gate distinct axes (receipt presence vs review-round convergence) — offered together.
|
|
258
362
|
export const buildOffer = (cwd, deps = {}) => {
|
|
259
|
-
const
|
|
363
|
+
const scripts = deriveScripts(cwd, deps);
|
|
260
364
|
const rs = reviewStateCandidate(cwd, deps);
|
|
261
365
|
const rl = reviewLedgerCandidate(cwd, deps);
|
|
262
366
|
const candidates = [rs.candidate, rl.candidate].filter(Boolean);
|
|
263
|
-
const notes = [rs.note, rl.note].filter(Boolean);
|
|
264
367
|
return {
|
|
265
|
-
entries: [...entries, ...candidates],
|
|
266
|
-
notes,
|
|
368
|
+
entries: [...scripts.entries, ...candidates],
|
|
369
|
+
notes: [...scripts.notes, rs.note, rl.note].filter(Boolean),
|
|
267
370
|
};
|
|
268
371
|
};
|
|
269
372
|
|
|
@@ -361,7 +464,7 @@ export const applySeed = ({ cwd, onlyIds = [] }, deps = {}) => {
|
|
|
361
464
|
}
|
|
362
465
|
const offer = buildOffer(cwd, deps);
|
|
363
466
|
assertOnlyIdsOffered(offer, onlyIds); // BEFORE the empty-offer return — a typo is never masked
|
|
364
|
-
if (!offer.entries.length) return { outcome: 'nothing' };
|
|
467
|
+
if (!offer.entries.length) return { outcome: 'nothing', notes: offer.notes };
|
|
365
468
|
const selected = onlyIds.length ? offer.entries.filter((e) => onlyIds.includes(e.id)) : offer.entries;
|
|
366
469
|
|
|
367
470
|
const existing = loadExistingDeclaration(cwd, deps);
|
|
@@ -382,7 +485,7 @@ export const applySeed = ({ cwd, onlyIds = [] }, deps = {}) => {
|
|
|
382
485
|
validateDeclaration(merged); // every written declaration passes the runner's validator, always
|
|
383
486
|
const body = `${JSON.stringify(merged, null, 2)}\n`;
|
|
384
487
|
const { writtenPath } = writeDocsAiFileAtomic(cwd, GATES_REL, body, deps, { stop, noun: 'a gate declaration' });
|
|
385
|
-
return { outcome: 'written', writtenPath, appended: selected.map((e) => e.id) };
|
|
488
|
+
return { outcome: 'written', writtenPath, appended: selected.map((e) => e.id), notes: offer.notes };
|
|
386
489
|
};
|
|
387
490
|
|
|
388
491
|
// ── CLI ────────────────────────────────────────────────────────────────────────────────
|
|
@@ -439,9 +542,11 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
|
439
542
|
const result = applySeed({ cwd, onlyIds: args.only }, deps);
|
|
440
543
|
if (result.outcome === 'nothing') {
|
|
441
544
|
log('[agent-workflow-kit] nothing to offer — no seedable terminating verification scripts were found; wrote nothing.');
|
|
545
|
+
for (const note of result.notes) log(` note: ${note}`); // the user learns WHY (same note as the preview)
|
|
442
546
|
return EXIT_OK;
|
|
443
547
|
}
|
|
444
548
|
log(`[agent-workflow-kit] appended ${result.appended.length} consented gate(s) to ${GATES_REL}: ${result.appended.join(', ')}`);
|
|
549
|
+
for (const note of result.notes) log(` note: ${note}`); // a mixed offer never silently omits what was screened
|
|
445
550
|
log(`[agent-workflow-kit] ${TRUST_CHAIN_DISCLOSURE}`);
|
|
446
551
|
return EXIT_OK;
|
|
447
552
|
} catch (err) {
|
|
@@ -838,7 +838,8 @@ const AUTONOMY_RESIDUAL_NOTICE =
|
|
|
838
838
|
// isExecutableFile — true iff `p` is a REGULAR file with an execute bit (statSync FOLLOWS a symlink, so
|
|
839
839
|
// a symlinked binary resolves to its target). A directory or a non-executable file NAMED bwrap/socat
|
|
840
840
|
// must NOT count as the binary (else the loud sandbox-unavailable degrade would be wrongly suppressed).
|
|
841
|
-
|
|
841
|
+
// Exported (AD-044 Plan 2): autonomy-doctor.mjs promotes this to its trusted-dir execution gate.
|
|
842
|
+
export const isExecutableFile = (p) => {
|
|
842
843
|
try {
|
|
843
844
|
const st = statSync(p);
|
|
844
845
|
return st.isFile() && (st.mode & 0o111) !== 0;
|
|
@@ -929,7 +930,7 @@ export const renderAutonomySettings = (resolved, probe) => {
|
|
|
929
930
|
// sandbox availability (Step 3.3 probe) — a LOUD degrade where the OS can't sandbox; the red-lines +
|
|
930
931
|
// defaultMode still land (they are permission rules, sandbox-independent).
|
|
931
932
|
if (!probe.available) {
|
|
932
|
-
degrades.push(`sandbox UNAVAILABLE on this host (${probe.reason}) — claude renders the sandbox block but WARNS and runs UNSANDBOXED: ad-hoc scripts will still PROMPT and network/fs confinement is NOT enforced until it is available (
|
|
933
|
+
degrades.push(`sandbox UNAVAILABLE on this host (${probe.reason}) — claude renders the sandbox block but WARNS and runs UNSANDBOXED: ad-hoc scripts will still PROMPT and network/fs confinement is NOT enforced until it is available (run /agent-workflow-kit autonomy-doctor to diagnose and, with your consent, install the missing dependency). The red-lines + defaultMode still apply. failIfUnavailable is left UNSET so the session is never bricked.`);
|
|
933
934
|
}
|
|
934
935
|
return { level, activities: resolved.activities, sandbox, defaultMode, ask, deny, notes, degrades };
|
|
935
936
|
};
|