@sabaiway/agent-workflow-kit 1.43.0 → 1.45.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +8 -4
  3. package/SKILL.md +20 -8
  4. package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +87 -16
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +3 -0
  7. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +4 -2
  8. package/bridges/antigravity-cli-bridge/capability.json +3 -2
  9. package/bridges/codex-cli-bridge/SKILL.md +1 -1
  10. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +3 -0
  11. package/bridges/codex-cli-bridge/bin/codex-review.sh +90 -19
  12. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +4 -0
  13. package/bridges/codex-cli-bridge/capability.json +3 -2
  14. package/bridges/codex-cli-bridge/references/driving-codex.md +3 -2
  15. package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +3 -2
  16. package/capability.json +1 -1
  17. package/package.json +1 -1
  18. package/references/modes/autonomy-doctor.md +21 -0
  19. package/references/modes/bootstrap.md +1 -1
  20. package/references/modes/doc-parity.md +2 -1
  21. package/references/modes/grounding.md +8 -7
  22. package/references/modes/recommendations.md +14 -0
  23. package/references/modes/review-state.md +1 -1
  24. package/references/modes/sandbox-masks.md +15 -0
  25. package/references/modes/upgrade.md +10 -7
  26. package/references/modes/velocity.md +13 -2
  27. package/references/shared/composition-handoff.md +21 -16
  28. package/references/shared/report-footer.md +5 -1
  29. package/references/templates/AGENTS.md +2 -1
  30. package/references/templates/autonomy.json +3 -0
  31. package/tools/autonomy-config.mjs +13 -3
  32. package/tools/autonomy-doctor.mjs +488 -0
  33. package/tools/commands.mjs +24 -3
  34. package/tools/delegation.mjs +9 -5
  35. package/tools/detect-backends.mjs +2 -2
  36. package/tools/doc-parity.mjs +31 -2
  37. package/tools/engine-source.mjs +6 -0
  38. package/tools/family-registry.mjs +21 -7
  39. package/tools/fold-completeness-run.mjs +5 -0
  40. package/tools/grounding.mjs +139 -22
  41. package/tools/inject-methodology.mjs +117 -28
  42. package/tools/manifest/schema.md +20 -0
  43. package/tools/manifest/validate.mjs +26 -0
  44. package/tools/procedures.mjs +61 -8
  45. package/tools/recipes.mjs +94 -15
  46. package/tools/recommendations.mjs +469 -0
  47. package/tools/review-ledger-write.mjs +14 -0
  48. package/tools/review-ledger.mjs +3 -2
  49. package/tools/review-state.mjs +101 -24
  50. package/tools/run-gates.mjs +3 -0
  51. package/tools/sandbox-masks.mjs +370 -0
  52. package/tools/set-recipe.mjs +13 -1
  53. package/tools/velocity-profile.mjs +231 -24
@@ -3,40 +3,45 @@
3
3
  **Hand-off contract (explicit; tested independent of agent interpretation).**
4
4
  - **Delegated** (memory valid): the kit passes the **target project dir** + the **three setup
5
5
  answers** (visibility / language / attribution) to `agent-workflow-memory`, which writes
6
- `docs/ai/` + `AGENTS.md` (carrying the **two empty pointer pairs** — `workflow:methodology` and
7
- `workflow:orchestration`) + **`.memory-version`**. The kit then **reconciles both bounded pointers**
8
- (below) and writes the kit-fallback **`.workflow-version`**. → **both stamps** present. In **hidden** mode the kit is the **single
6
+ `docs/ai/` + `AGENTS.md` (carrying the **three empty pointer pairs** — `workflow:methodology`,
7
+ `workflow:orchestration` and `workflow:autonomy`) + **`.memory-version`**. The kit then **reconciles
8
+ the bounded pointers** (below) and writes the kit-fallback **`.workflow-version`**. → **both stamps** present. In **hidden** mode the kit is the **single
9
9
  hide authority**: after the hand-off it runs `tools/hide-footprint.mjs` (bootstrap step 9 — `${CLAUDE_SKILL_DIR}/references/modes/bootstrap.md`), which **absorbs
10
10
  memory's project-local footprint lines** into the one canonical `.git/info/exclude` block and adds
11
11
  the external footprint — so there is **no machine-global write** at any step (a stale memory's
12
12
  residual global block is cleaned via the upgrade reconcile, below).
13
13
  - **Fallback** (memory absent/invalid): the kit runs the bootstrap procedure (`${CLAUDE_SKILL_DIR}/references/modes/bootstrap.md`) from its own
14
- bundled assets — whose entry-point template now ships **two empty pointer pairs** (`workflow:methodology`
15
- + `workflow:orchestration`) the kit reconciles + fills — and writes **`.workflow-version`** only.
14
+ bundled assets — whose entry-point template now ships **three empty pointer pairs** (`workflow:methodology`
15
+ + `workflow:orchestration` + `workflow:autonomy`) the kit reconciles + fills — and writes **`.workflow-version`** only.
16
16
  Softly suggest installing `agent-workflow-memory` — never a prerequisite.
17
17
 
18
18
  **Bounded pointer reconciliation (the kit is the ONLY writer of these slots).** After `AGENTS.md`
19
19
  exists, run ONE command — `node ${CLAUDE_SKILL_DIR}/tools/inject-methodology.mjs reconcile
20
- <project>/AGENTS.md` — which reconciles **two** bounded pointers in a single atomic write: the
21
- **workflow-methodology** pointer (the plan → execute → review summary) **and**, right below it, the
20
+ <project>/AGENTS.md` — which reconciles **three** bounded pointers in a single atomic write: the
21
+ **workflow-methodology** pointer (the plan → execute → review summary), the
22
22
  **orchestration-recipes** pointer (the Solo / Reviewed / Council / Delegated vocabulary, routing to
23
- `/agent-workflow-kit recipes`). Each is **one atomic operation per slot**: **ensure the slot exists**
23
+ `/agent-workflow-kit recipes`) right below it, and the **autonomy-policy** pointer (the
24
+ `docs/ai/autonomy.json` read contract) below that. Each is **one atomic operation per slot**:
25
+ **ensure the slot exists**
24
26
  (insert an empty marker pair at its anchor when a legacy entry point lacks one) → **inject the bounded
25
27
  fragment ONLY IF the slot is empty** (a filled / user-customized slot is preserved verbatim) →
26
- **cap-check** (the second pointer's check runs on the file *after* the first, so it guards the
27
- **combined** ≤100-line budget). Both fragments are short summary + pointer, read **live from the
28
- installed `agent-workflow-engine`** (`references/methodology-slot.md` + `references/orchestration-slot.md`,
28
+ **cap-check** (each chained pointer's check runs on the file *after* the previous one, so it guards the
29
+ **combined** ≤100-line budget). The fragments are short summary + pointer, read **live from the
30
+ installed `agent-workflow-engine`** (`references/methodology-slot.md` + `references/orchestration-slot.md`
31
+ + `references/autonomy-slot.md`,
29
32
  the family's one source of truth) — **not** a bundled mirror, and **not** the full references. The
30
33
  live read is **lazy + fail-loud**: the engine is consulted **only when a slot actually needs filling**,
31
- so a deployment whose pointers are already filled reconciles to a zero-diff no-op even on a host
32
- without the engine; but when a fill **is** needed and the engine is **absent/invalid**, reconcile
34
+ so a deployment with **all three** pointers filled reconciles to a zero-diff no-op even on a host
35
+ without the engine (one still missing the autonomy pair needs the engine to gain it); when a fill
36
+ **is** needed and the engine is **absent/invalid**, reconcile
33
37
  **STOPs** — report it in plain language with the one-line install command
34
38
  `npx @sabaiway/agent-workflow-engine@latest init` (`npx @sabaiway/agent-workflow-kit@latest init`
35
39
  installs the engine for you; translate, never leak tool internals). Contract per slot: exactly one
36
40
  ordered `start → end` pair; a malformed slot (single, reversed, nested, duplicate) or a missing /
37
- duplicate anchor → **STOP with an error**, never edit (the file is left byte-for-byte unchanged). The
38
- **orchestration pointer is soft-skipped** (reported, never silent) when — and only when — adding it
39
- would exceed the 100-line cap: the methodology pointer still lands and the upgrade continues.
41
+ duplicate anchor → **STOP with an error**, never edit (the file is left byte-for-byte unchanged). A
42
+ **chained pointer is soft-skipped** (reported, never silent) when — and only when — adding it would
43
+ exceed the 100-line cap, the engine is too old to ship its fragment, or (autonomy only) its anchor
44
+ (the orchestration pair) is absent: every prior pointer still lands and the upgrade continues.
40
45
 
41
46
  **Agent-rules lens refresh (runs in BOTH paths).** Its precondition is its own target file — run it
42
47
  only after `<project>/docs/ai/agent_rules.md` exists: after the substrate deploy in the
@@ -9,7 +9,11 @@ The tool runs the backend detector and appends the recipe recommendation itself
9
9
  recipe clause of its own, ever.
10
10
 
11
11
  - **Placeholder template (structure only — never copy this example; paste the tool's line):**
12
- `backends: <alias> <✓|✗> <readiness> · <alias> <✓|✗> <readiness> — run /agent-workflow-kit backends · recipes: <recommendation clause> — see /agent-workflow-kit recipes`
12
+ `backends: <alias> <✓|✗> <readiness> · <alias> <✓|✗> <readiness> — run /agent-workflow-kit backends · recipes: <recommendation clause> — see /agent-workflow-kit recipes · autonomy: <per-activity levels> (<policy state>)`
13
+ - The **`autonomy:` segment is appended by the composer itself** (`composeAutonomyFacts`, AD-044) —
14
+ per-activity levels + the policy state (computed defaults / declared / MALFORMED, loud) — the
15
+ agent never types it; the composer resolves the policy from the project root, so the line is
16
+ correct from any working directory inside the project.
13
17
  - The **`recipes:` clause is appended after** the `— run /agent-workflow-kit backends` pointer (never
14
18
  replacing it) and routes to the read-only `recipes` mode (`see /agent-workflow-kit recipes`). It is
15
19
  **never blank**: both backends ready → *"Council available, Reviewed the everyday default"*; one
@@ -57,6 +57,8 @@ Start-of-session, during-work, and task-completion procedures live in [`docs/ai/
57
57
  <!-- workflow:methodology:end -->
58
58
  <!-- workflow:orchestration:start -->
59
59
  <!-- workflow:orchestration:end -->
60
+ <!-- workflow:autonomy:start -->
61
+ <!-- workflow:autonomy:end -->
60
62
 
61
63
  ---
62
64
 
@@ -68,7 +70,6 @@ Start-of-session, during-work, and task-completion procedures live in [`docs/ai/
68
70
  | No `any` / no unsafe casts | Linter / type-checker |
69
71
  | Functional style (no mutation in app code) | Linter |
70
72
  | Interactive elements semantic (button/link, not div+onClick) | Linter / a11y |
71
- | No hardcoded colors — design tokens only | Code review |
72
73
  | No business logic in components → hooks/services | Architecture review |
73
74
  | No changes without tests (TDD) | Required |
74
75
  | Check page docs before changes; update them after | Process |
@@ -0,0 +1,3 @@
1
+ {
2
+ "_README": "Per-project autonomy policy: red-lines (always) + per-activity autonomy level. Hand-editable; or use the set-autonomy writer (previews, then writes valid JSON). Strict JSON — no comments."
3
+ }
@@ -236,6 +236,15 @@ export const resolveAutonomy = (config) => {
236
236
  return { redlines, activities };
237
237
  };
238
238
 
239
+ // isSparseSeedConfig(config) → true when the policy file is STRUCTURALLY the deploy seed: meta keys
240
+ // only (`_README` today) — no red-lines section, no activity entry. Seed detection must be
241
+ // structural, never resolved-equality: an EXPLICIT policy that declares exactly the default values
242
+ // is a real DECLARATION (its render — the red-line ask rules included — must not be suppressed as
243
+ // "just the seed"), while the seed declares nothing (codex, Segment B closing). Shared by every
244
+ // autonomy read surface (advisor / recipes / procedures / grounding) so the four can never diverge.
245
+ export const isSparseSeedConfig = (config) =>
246
+ isJsonObject(config) && Object.keys(config).every((k) => k.startsWith('_'));
247
+
239
248
  // ── pure merge + canonical serialization ────────────────────────────────────────────
240
249
 
241
250
  // A pure deep-equal over the JSON-ish policy shape (plain objects + string values). Used only for the
@@ -288,9 +297,10 @@ export const serializeAutonomy = (config) => {
288
297
 
289
298
  // ── the canonical seed (Decision 5) ──────────────────────────────────────────────────
290
299
  // AUTONOMY_README is the onboarding note; SEED_AUTONOMY is the Decision-5 fixture the set-autonomy
291
- // writer seeds and the config tests copy/validate verbatim. Plan 1 ships NO references/templates/
292
- // autonomy.json and NO template-parity test (both Plan 4) — SEED_AUTONOMY's Plan-1 consumers are the
293
- // autonomy-config tests and the render's "seed one first" guidance.
300
+ // WRITER seeds (opinionated sandbox levels) and the config tests copy/validate verbatim. DISTINCT
301
+ // from the Plan-4 deploy template `references/templates/autonomy.json` (bootstrap/upgrade ensure):
302
+ // that seed is SPARSE `_README` only, defaults-equivalent by the template-parity pin
303
+ // (resolveAutonomy(template) ≡ resolveAutonomy(null)) — so deploying it never changes behavior.
294
304
  export const AUTONOMY_README =
295
305
  'Per-project autonomy policy: red-lines (always) + per-activity autonomy level. Hand-editable; or ' +
296
306
  'use the set-autonomy writer (previews, then writes valid JSON). Strict JSON — no comments.';
@@ -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)));