@sabaiway/agent-workflow-kit 3.2.0 → 3.3.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 CHANGED
@@ -4,6 +4,44 @@ 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
+ ## 3.3.0 — version-pin honesty: the profile stops claiming a limit it never observed (AD-062)
8
+
9
+ `velocity --autonomy` told every user their credentials could not be protected by the sandbox —
10
+ naming a harness version frozen in the source, 30 patch releases stale, while a comment three lines
11
+ above recorded when the capability had actually arrived. A user who declared
12
+ `redlines.credentials=deny` got no `sandbox.credentials` key and a confident explanation that the
13
+ platform could not enforce it. Not a silent failure: a false claim about a security control.
14
+
15
+ Nobody can guarantee a vendor's version format, install layout or settings schema stays put, so the
16
+ fix is not "read the right version" — it is **never state what was not observed**. A pin goes stale
17
+ silently; a probe goes stale loudly, and that direction is the whole point.
18
+
19
+ - **`probeHarnessVersion`** reads the INSTALLED harness at render time — read-only PATH resolution,
20
+ never a spawn. Two layouts matched exactly (`claude/versions/<version>` and the
21
+ `@anthropic-ai/claude-code` package.json); a third-party wrapper whose name merely contains
22
+ "claude", a decorated version segment, and a prerelease all resolve to a STATED unknown. A
23
+ programming defect inside the walk is rethrown rather than reported as an unknown layout, and an
24
+ unreadable package.json surfaces instead of folding into "not found".
25
+ - **A supported build now gets the protection.** `sandbox.credentials` is rendered, MERGED into
26
+ settings (hand-declared `files` and foreign entries survive), drift-checked and local-mask-checked
27
+ — the render owns exactly the entries for the env vars it protects, nothing more.
28
+ - **Every honest limit is a loud degrade, not a note.** Coverage is stated PARTIAL (env vars only;
29
+ file credentials are NOT rendered, because that entry shape was never verified against an
30
+ installed build). `credentials=ask` degrades rather than being quietly upgraded to deny. An older
31
+ build, or one whose version cannot be RESOLVED from a recognised layout, degrades naming what was
32
+ observed or stating that nothing was — and a PASSING `--check` prints the degrades too, because
33
+ "in sync" only means the file matches the render. An UNREADABLE install (a permission error) or a
34
+ defect inside the probe is a different outcome on purpose: it throws loudly rather than degrading,
35
+ because "cannot confirm" must never read as "confirmed absent".
36
+ - **New `version-pin` rung in `release-scan`.** A harness version literal under `tools/` fails the
37
+ scan unless a runtime probe sits beside it in the same JS file. The harness series is discovered
38
+ from the scanned file, never hardcoded — a scanner carrying its own pin would be an instance of the
39
+ class it catches, and a self-refusal test enforces that. Prose earns nothing: a `#` comment in a
40
+ shell script or a line of Markdown cannot hold a probe, so those files stay scanned.
41
+ **Stated residual, argued in the source:** the rung proves a probe is PRESENT, not that its result
42
+ is compared with the literal. Proving the latter is JS lexing; an approximation of it was tried and
43
+ withdrawn, because each increment of precision opened a new hole without buying the guarantee.
44
+
7
45
  ## 3.2.0 — plain language + posture as code: the friction cluster shipped as mechanism (AD-061)
8
46
 
9
47
  Carrier release for a friction cluster (kit 3.2.0 bundles the bridges + shared contract + the
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: '3.2.0'
6
+ version: '3.3.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "3.2.0",
6
+ "version": "3.3.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "3.2.0",
3
+ "version": "3.3.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",
@@ -55,7 +55,11 @@ Honesty notes: tier entries get **NO PreToolUse-hook residual coverage** — the
55
55
 
56
56
  1. **Preview by default** (writes nothing); **`--apply`** merges the render-owned blocks into `.claude/settings.json` (merge-don't-clobber — foreign top-level keys, existing `permissions.allow`, and foreign ask/deny entries preserved; a policy flip MOVES a red-line rule between ask/deny rather than duplicating it). It **refuses an absent policy** loudly (seed one first with `set-autonomy`). It **never writes `settings.local.json`**; a `settings.local.json` `defaultMode` that would mask the render is reported (local > project).
57
57
  2. **`--autonomy --check`** is a **read-only drift gate**: it recomputes the render and compares against the live render-owned blocks — exit `0` in sync, exit `1` on drift (naming the exact key). A hand-edit OUTSIDE those blocks never flags.
58
- 3. **Version-sensitive (characterized against the real claude 2.1.185).** Sandbox keys are `sandbox.enabled` + `sandbox.autoAllowBashIfSandboxed`; `sandbox` level ⇒ auto-allow + `defaultMode: acceptEdits`, `prompt` level ⇒ auto-allow OFF + `defaultMode: default` (the sandbox stays enabled as a confine floor either way). Red-lines use the argument-matching `:*` wildcard (`Bash(git commit:*)` / `Bash(git push:*)` / `Bash(npm publish:*)`). Where 2.1.185 cannot express a red-line distinctly — a **network hard-block** (regular settings only prompt on egress), **credential denial** (`sandbox.credentials` is 2.1.187+ / mask 2.1.199+), or a **prompt-on-outside-write** — the render **DEGRADES LOUDLY** (never a silent allow, never pretend-security). A missing Linux dependency (`socat`/`bwrap`) or an unsupported platform degrades the WHOLE sandbox to unsandboxed — the render still lands the red-lines + `defaultMode` and caveats that ad-hoc scripts will still prompt.
58
+ 3. **Version-PROBED, never version-pinned.** The render reads the INSTALLED harness version at run time (`probeHarnessVersion` — a read-only PATH resolution, never a spawn) and names only what it observed. Sandbox keys are `sandbox.enabled` + `sandbox.autoAllowBashIfSandboxed`; `sandbox` level ⇒ auto-allow + `defaultMode: acceptEdits`, `prompt` level ⇒ auto-allow OFF + `defaultMode: default` (the sandbox stays enabled as a confine floor either way). Red-lines use the argument-matching `:*` wildcard (`Bash(git commit:*)` / `Bash(git push:*)` / `Bash(npm publish:*)`). Three branches, all loud:
59
+ - **Observed and supported** — `sandbox.credentials` IS rendered (and merged into settings.json, and drift-checked). Coverage is stated PARTIAL: env vars only. File credentials (`~/.ssh`) are **not** rendered, because that entry shape was never verified against an installed build — declare `sandbox.credentials.files` yourself if you need it. A `credentials=ask` policy degrades loudly too: the schema offers deny/mask and no ask mode, so `ask` is never quietly upgraded.
60
+ - **Observed but older than the capability** — no key rendered, degrade names **the observed version**.
61
+ - **Version unresolvable** — no key rendered, degrade STATES the unknown and its cause. Never a guess in either direction.
62
+ A **network hard-block** and a **prompt-on-outside-write** are not expressed by this render at all (stated as such, without claiming a platform limit it did not observe). A missing Linux dependency (`socat`/`bwrap`) or an unsupported platform degrades the WHOLE sandbox to unsandboxed — the render still lands the red-lines + `defaultMode` and caveats that ad-hoc scripts will still prompt.
59
63
  4. **`--autonomy` cannot combine** with `--accept-edits`, `--kit-tools`, or `--bridge-tier` (allowlist-mode flags) — a loud usage error.
60
64
 
61
- **Autonomy invariants:** policy-only (never seeds the allowlist, leaves `permissions.allow` untouched) · writes only `.claude/settings.json` (never `settings.local.json`) · refuses an absent policy · merge-don't-clobber · degrades loudly where 2.1.185 can't enforce · previews by default · never commits.
65
+ **Autonomy invariants:** policy-only (never seeds the allowlist, leaves `permissions.allow` untouched) · writes only `.claude/settings.json` (never `settings.local.json`) · refuses an absent policy · merge-don't-clobber · **names only the version it observed, and states the unknown when it observed none** · degrades loudly wherever the render cannot express a red-line · previews by default · never commits.
package/tools/recipes.mjs CHANGED
@@ -394,7 +394,10 @@ const projectTopOf = (cwd) => {
394
394
  }
395
395
  };
396
396
 
397
- export const composeAutonomyFacts = async (cwd) => {
397
+ // `deps` is threaded to the render check because that check now PROBES the installed harness: without
398
+ // a seam the render state would vary with the machine composing the facts, and a caller could not
399
+ // pin it. Production passes nothing and probes for real.
400
+ export const composeAutonomyFacts = async (cwd, deps = {}) => {
398
401
  try {
399
402
  const root = projectTopOf(cwd);
400
403
  const { loadAutonomy, resolveAutonomy, isSparseSeedConfig } = await import('./autonomy-config.mjs');
@@ -411,7 +414,7 @@ export const composeAutonomyFacts = async (cwd) => {
411
414
  let renderState;
412
415
  try {
413
416
  const { checkAutonomyProfile } = await import('./velocity-profile.mjs');
414
- renderState = checkAutonomyProfile({ cwd: root }).inSync
417
+ renderState = checkAutonomyProfile({ cwd: root }, deps).inSync
415
418
  ? 'in sync'
416
419
  : 'DRIFT — re-run the velocity --autonomy render';
417
420
  } catch (err) {
@@ -63,6 +63,81 @@ export const scanText = (text, { allowlist = ALLOWLIST } = {}) => {
63
63
  return findings;
64
64
  };
65
65
 
66
+ // ── the version-pin rung ─────────────────────────────────────────────────────────────────────────
67
+ // A hardcoded harness version under tools/ is a documentation claim frozen in code: it goes stale by
68
+ // default and nothing reports it. This rung refuses a BARE pin and demands a runtime probe beside
69
+ // it — the probe is what converts silent staleness into a loud degrade.
70
+ //
71
+ // A line is a claim when it names the harness AND carries a version literal. The rung reports
72
+ // exactly those lines — it deliberately does NOT spread the claimed series across the rest of the
73
+ // file, which was measured to flag an unrelated dependency constant that merely shared a major.minor
74
+ // with the claim two lines above it. Nothing is lost by the narrower rule: the exemption is
75
+ // FILE-scoped, so a single refusal already forces the probe that makes every literal in the file
76
+ // honest. (This comment names the harness without a literal on the same line ON PURPOSE — the rung
77
+ // scans its own source, and a worked example here would make the scanner refuse itself.)
78
+ //
79
+ // STATED RESIDUAL, not an oversight: a claim split across lines — the harness named on one line, the
80
+ // literal on another — is NOT detected. Closing it needs whole-file arming, which trades this narrow
81
+ // miss for a broad false-positive class. Deliberate line splitting is left to human review; this rung
82
+ // is a guard against staleness that accumulates by default, not against an author evading it.
83
+ const HARNESS_CLAIM_RE = /\bclaude\b/i;
84
+ const SEMVER_LITERAL_RE = /\b\d+\.\d+\.\d+\b/g;
85
+ const TOOLS_SEGMENT = 'tools';
86
+ const PATH_SEP_RE = /[/\\]/;
87
+ const VERSION_PIN_REMEDY =
88
+ 'pair it with a runtime probe (probeHarnessVersion) that compares the pin to the installed build '
89
+ + 'and degrades loudly, or move the literal into a test fixture or changelog entry, where it '
90
+ + 'records history instead of asserting a live platform fact';
91
+
92
+ // Three exemptions, each because the literal is NOT a live claim there. A test fixture and a
93
+ // changelog entry record what was true at a moment; a file that CALLS the probe already reports its
94
+ // own staleness, which is the entire behaviour the rung exists to demand.
95
+ //
96
+ // The probe exemption is a REFERENCE test, deliberately: it asks exactly what the decision asks —
97
+ // that a runtime probe sits beside the literal — and claims nothing more.
98
+ //
99
+ // It was briefly a CALL test. Separating a call from a declaration, a comment, a string and a regex
100
+ // literal is JS lexing, and hand-rolling that opened a new hole in three consecutive review rounds:
101
+ // a legitimate member call wrongly refused, then a per-character recursion that would crash this
102
+ // scanner inside the commit gate, then a regex literal read as a call. The repo does own a real
103
+ // lexical code mask — in root tooling a shipped kit tool cannot import — and extracting it is queued
104
+ // as its own work rather than approximated again here.
105
+ //
106
+ // STATED RESIDUAL, and why the precision was not worth its risk: even a perfect call detector proves
107
+ // only that the probe is REACHABLE in this file, never that its result is compared against the
108
+ // literal. That stronger guarantee was never on offer, so the rung stops where the decision stops
109
+ // and says so. The exemption stays scoped to files that could call anything at all — a `#` comment
110
+ // in a shell script and a line of Markdown prose earn nothing.
111
+ const PROBE_CALLABLE_EXT = new Set(['.mjs', '.js']);
112
+ const PROBE_SYMBOL = 'probeHarnessVersion';
113
+ const TEST_FILE_RE = /\.test\.mjs$/;
114
+ const CHANGELOG_FILE_RE = /^CHANGELOG\.md$/i;
115
+
116
+ const semverLiteralsIn = (line) => line.match(SEMVER_LITERAL_RE) ?? [];
117
+ const isUnderTools = (path) => String(path).split(PATH_SEP_RE).includes(TOOLS_SEGMENT);
118
+ const isRecordNotClaim = (path) => {
119
+ const name = basename(String(path));
120
+ return TEST_FILE_RE.test(name) || CHANGELOG_FILE_RE.test(name);
121
+ };
122
+
123
+ // PURE: classify one file's text. Path-aware — the same literal is a claim under tools/ and history
124
+ // in a changelog, so the verdict cannot be made from the bytes alone.
125
+ export const scanVersionPins = (text, path) => {
126
+ if (!isUnderTools(path) || isRecordNotClaim(path)) return [];
127
+ const probeCallable = PROBE_CALLABLE_EXT.has(extname(String(path)));
128
+ if (probeCallable && text.includes(PROBE_SYMBOL)) return [];
129
+ return text.split('\n').flatMap((line, idx) => {
130
+ const pinned = HARNESS_CLAIM_RE.test(line) ? semverLiteralsIn(line) : [];
131
+ return pinned.length === 0
132
+ ? []
133
+ : [{
134
+ line: idx + 1,
135
+ kind: 'version-pin',
136
+ detail: `harness version pin ${pinned.join(', ')} with no runtime probe in this file — a version literal frozen in code goes stale silently; ${VERSION_PIN_REMEDY}`,
137
+ }];
138
+ });
139
+ };
140
+
66
141
  const TEXT_EXT = new Set(['.md', '.mjs', '.js', '.json', '.sh', '.yml', '.yaml', '.txt', '']);
67
142
  const EXCLUDE_DIR_NAMES = new Set(['node_modules', '.git', 'plans', 'scan-fixtures']);
68
143
  // Only the TEST is self-excluded — it necessarily contains attribution fixtures. The scanner source
@@ -81,34 +156,90 @@ const walk = (path, acc = []) => {
81
156
  return acc;
82
157
  };
83
158
 
84
- export const scanPaths = (paths, opts = {}) => {
159
+ // Per-target outcomes. A caller-NAMED target that contributes no file must never fold into the
160
+ // clean verdict — an empty file list and a clean one print identically, so a mis-aimed scan would
161
+ // read as proof of cleanliness. `unmatchedGlob` is the one benign zero: the gate declaration lists
162
+ // shell globs, and bash passes an unmatched glob through literally, so a path that still carries a
163
+ // glob metacharacter is an optional target the shell found nothing for, not a mis-aim.
164
+ export const TARGET = Object.freeze({
165
+ scanned: 'scanned',
166
+ missing: 'missing',
167
+ unmatchedGlob: 'unmatched-glob',
168
+ excluded: 'excluded',
169
+ empty: 'empty',
170
+ });
171
+
172
+ const GLOB_META_RE = /[*?[\]]/;
173
+
174
+ // PURE-ish (one fs read): classify ONE caller-named target and collect the files it contributes.
175
+ // Every zero-contribution status names its own cause, so the refusal can state the remedy rather
176
+ // than a bare count (a count without a location is the defect class this family bans).
177
+ export const classifyTarget = (path) => {
178
+ if (!existsSync(path)) {
179
+ return GLOB_META_RE.test(path)
180
+ ? { path, status: TARGET.unmatchedGlob, files: [] }
181
+ : { path, status: TARGET.missing, files: [] };
182
+ }
183
+ const st = statSync(path);
184
+ const name = basename(path);
185
+ if (st.isDirectory() && EXCLUDE_DIR_NAMES.has(name)) return { path, status: TARGET.excluded, files: [] };
186
+ if (st.isFile() && (EXCLUDE_FILE_NAMES.has(name) || !TEXT_EXT.has(extname(path)) || path.endsWith('.tgz'))) {
187
+ return { path, status: TARGET.excluded, files: [] };
188
+ }
189
+ const files = walk(path);
190
+ return { path, status: files.length > 0 ? TARGET.scanned : TARGET.empty, files };
191
+ };
192
+
193
+ // Why each zero-contribution status refuses, and the one legal remedy for it.
194
+ const TARGET_REMEDY = Object.freeze({
195
+ [TARGET.missing]: 'the path does not exist — fix the target, or drop it from the target list',
196
+ [TARGET.excluded]: `the target itself is excluded (directory names: ${[...EXCLUDE_DIR_NAMES].join(', ')}; non-text or self-excluded files) — name the FILES to scan instead of the directory`,
197
+ [TARGET.empty]: 'the target exists but holds no scannable text file — fix the target, or drop it from the target list',
198
+ });
199
+
200
+ const findingsIn = (targets, opts) => {
85
201
  const report = [];
86
- for (const p of paths) {
87
- if (!existsSync(p)) continue;
88
- for (const file of walk(resolve(p))) {
89
- const findings = scanText(readFileSync(file, 'utf8'), opts);
90
- for (const f of findings) report.push({ file, ...f });
202
+ for (const target of targets) {
203
+ for (const file of target.files) {
204
+ const text = readFileSync(file, 'utf8');
205
+ for (const finding of scanText(text, opts)) report.push({ file, ...finding });
206
+ for (const finding of scanVersionPins(text, file)) report.push({ file, ...finding });
91
207
  }
92
208
  }
93
209
  return report;
94
210
  };
95
211
 
212
+ export const scanPaths = (paths, opts = {}) => findingsIn(paths.map((p) => classifyTarget(resolve(p))), opts);
213
+
96
214
  export const main = (argv) => {
97
215
  const reportOnly = argv.includes('--report');
98
216
  const paths = argv.filter((a) => a !== '--report');
99
217
  if (paths.length === 0) {
100
218
  console.error('usage: release-scan.mjs [--report] <path>...');
101
- process.exit(2);
219
+ return 2;
220
+ }
221
+ const targets = paths.map((p) => classifyTarget(resolve(p)));
222
+ for (const target of targets) {
223
+ if (target.status === TARGET.unmatchedGlob) {
224
+ console.log(`[release-scan] skipped ${target.path} — no file matched this glob (an optional target, stated not silent).`);
225
+ }
226
+ }
227
+ const misaimed = targets.filter((target) => TARGET_REMEDY[target.status] !== undefined);
228
+ for (const target of misaimed) {
229
+ console.log(`[release-scan] target ${target.path} contributed NO file (${target.status}) — ${TARGET_REMEDY[target.status]}`);
102
230
  }
103
- const report = scanPaths(paths);
231
+ const report = findingsIn(targets);
104
232
  for (const r of report) console.log(`[${r.kind}] ${r.file}:${r.line} — ${r.detail}`);
105
- if (report.length === 0) {
233
+ if (misaimed.length > 0) {
234
+ console.log(`\n[release-scan] ${misaimed.length} mis-aimed target(s) — this run proves nothing about them, so the clean verdict is refused.`);
235
+ }
236
+ if (report.length > 0) console.log(`\n[release-scan] ${report.length} finding(s).`);
237
+ if (report.length === 0 && misaimed.length === 0) {
106
238
  console.log('[release-scan] clean — no AI attribution or reviewer-round identity found.');
107
- return;
239
+ return 0;
108
240
  }
109
- console.log(`\n[release-scan] ${report.length} finding(s).`);
110
- if (!reportOnly) process.exit(1);
241
+ return reportOnly ? 0 : 1;
111
242
  };
112
243
 
113
244
  const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
114
- if (isDirectRun) main(process.argv.slice(2));
245
+ if (isDirectRun) process.exitCode = main(process.argv.slice(2));
@@ -77,6 +77,12 @@ const USAGE = [
77
77
  '',
78
78
  `Runs the gates declared in <cwd>/${GATES_REL} (one bash command line each, project root as cwd).`,
79
79
  'Prints a per-gate PASS/FAIL table + one machine-readable summary line; exit 0 iff all green.',
80
+ '',
81
+ 'Producer env: AW_GIT_DIR (inside a git tree) and AW_LCOV_FILE (--final only) are computed and',
82
+ 'exported to every gate child, and STRIPPED from the inherited environment first — a host-set',
83
+ 'value never stands in for a computed one. A selected gate referencing a producer variable this',
84
+ 'run will not set is refused BEFORE anything spawns (exit 1), naming the gate, the variable, and',
85
+ 'the remedy — never left to expand to empty and fail far from its cause.',
80
86
  'Sandbox-safe: the runner itself needs no network and writes only repo-local state — the D4 sandbox',
81
87
  'lane; each DECLARED gate command is the project\'s own, so ITS sandbox-safety is command-shape',
82
88
  'dependent (first try the sandbox-safe shape — cache under $TMPDIR, offline/notifier off).',
@@ -184,12 +190,37 @@ export const selectGates = (gates, onlyIds) => {
184
190
  // NODE_TEST_CONTEXT is stripped: a `node --test` gate spawned while run-gates is itself running
185
191
  // under a parent test context would otherwise inherit it, hit Node's recursive-run guard, silently
186
192
  // skip every file, and exit 0 — a vacuous false green.
193
+ // The variables this runner PRODUCES for gate children. They are stripped from the inherited
194
+ // environment before every spawn: composing the child env as {...process.env, ...injected} would
195
+ // let a stale or hostile HOST value stand in whenever this run injects nothing, so a gate would
196
+ // silently attest against the wrong git dir or lcov instead of the computed one.
197
+ export const RESERVED_PRODUCER_ENV = Object.freeze(['AW_GIT_DIR', 'AW_LCOV_FILE']);
198
+
187
199
  export const spawnGateViaBash = (cmd, cwd, extraEnv = {}) => {
188
- const env = { ...process.env, ...extraEnv };
200
+ const env = { ...process.env };
189
201
  delete env.NODE_TEST_CONTEXT;
190
- return spawnSync('bash', ['-c', cmd], { cwd, env, encoding: 'utf8', maxBuffer: MAX_GATE_OUTPUT_BYTES });
202
+ for (const name of RESERVED_PRODUCER_ENV) delete env[name];
203
+ return spawnSync('bash', ['-c', cmd], { cwd, env: { ...env, ...extraEnv }, encoding: 'utf8', maxBuffer: MAX_GATE_OUTPUT_BYTES });
191
204
  };
192
205
 
206
+ // A `$VAR` / `${VAR}` reference to a producer variable. A `${VAR:-default}` form is deliberately NOT
207
+ // matched — a cmd carrying its own fallback does not depend on the injection.
208
+ const referencesProducer = (cmd, name) => new RegExp(`\\$\\{${name}\\}|\\$${name}(?![A-Za-z0-9_])`).test(cmd);
209
+
210
+ const PRODUCER_RECOVERY = Object.freeze({
211
+ AW_GIT_DIR: 'run from inside a git work tree — the runner resolves the git dir there and exports it to every gate.',
212
+ AW_LCOV_FILE: 'AW_LCOV_FILE is produced only by --final — run with --final, or drop the reference from the gate cmd.',
213
+ });
214
+
215
+ // Which SELECTED gates reference a producer variable this run will not set. Such a reference
216
+ // expands to empty and fails the gate somewhere far from its cause, so it is refused before
217
+ // anything spawns, naming the gate, the variable, and the one remedy.
218
+ export const findUnmetProducerRefs = (gates, injected) =>
219
+ gates.flatMap((gate) =>
220
+ RESERVED_PRODUCER_ENV
221
+ .filter((name) => !injected.includes(name) && referencesProducer(gate.cmd, name))
222
+ .map((name) => ({ id: gate.id, name })));
223
+
193
224
  // The command the bash preflight runs before ANY gate: proves bash itself spawns on this host,
194
225
  // so "no bash" is one loud exit-6 error up front — never a per-gate spawn-failure cascade.
195
226
  export const BASH_PROBE_CMD = 'true';
@@ -378,6 +409,16 @@ export const runCli = (argv, deps = {}) => {
378
409
  // gate child env — a stray host AW_LCOV_FILE can never desync the checker from the receipt.
379
410
  gateSpawn = (cmd, cwd2) => spawn(cmd, cwd2, { AW_GIT_DIR: gitDir, AW_LCOV_FILE: join(gitDir, LCOV_BASENAME) });
380
411
  }
412
+ const injectedEnv = [...(gitDir === null ? [] : ['AW_GIT_DIR']), ...(opts.final ? ['AW_LCOV_FILE'] : [])];
413
+ const unmet = findUnmetProducerRefs(selected, injectedEnv);
414
+ if (unmet.length > 0) {
415
+ for (const { id, name } of unmet) {
416
+ logError(`[run-gates] gate "${id}" references $${name}, which this run will not set — it would expand to empty and fail far from its cause.`);
417
+ logError(` Recovery: ${PRODUCER_RECOVERY[name]}`);
418
+ }
419
+ log(composeSummaryLine({ status: 'fail' }));
420
+ return EXIT.fail;
421
+ }
381
422
  const probe = spawn(BASH_PROBE_CMD, projectDir);
382
423
  if (probe.error && probe.error.code === 'ENOENT') {
383
424
  logError(
@@ -1,5 +1,5 @@
1
1
  import { existsSync, lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
- import { join, relative, resolve } from 'node:path';
2
+ import { basename, dirname, join, relative, resolve } from 'node:path';
3
3
  import { fileURLToPath, pathToFileURL } from 'node:url';
4
4
  // The --autonomy render reads the per-project autonomy policy through the read-only autonomy core
5
5
  // (AD-044). This file is the family's one .claude/settings.json writer, so the policy render lives here;
@@ -9,6 +9,7 @@ import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, COMMAND_REDLINES } from '.
9
9
  // The bridge-wrappers tier's placement probe (AD-044 Plan 4, Decision 2): a tier entry derives ONLY
10
10
  // for a PLACED bridge wrapper — findOnPath is the same read-only PATH scan the backend detector uses.
11
11
  import { findOnPath } from './detect-backends.mjs';
12
+ import { compareSemver } from './semver-lite.mjs';
12
13
 
13
14
  // Velocity-profile core + writer: a fixed, audited read-only allowlist that an onboarding step seeds
14
15
  // into `.claude/settings.json` so routine read-only commands stop idling on approval prompts.
@@ -982,12 +983,12 @@ export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true,
982
983
  // ── the --autonomy render (AD-044) ───────────────────────────────────────────────────────────────
983
984
  // Render docs/ai/autonomy.json into the .claude/settings.json blocks the render OWNS — the `sandbox`
984
985
  // block + permissions.ask/deny red-lines + permissions.defaultMode — per the Decision-6 invariant,
985
- // wired to what the REAL claude 2.1.185 accepts (Step 3.1). POLICY-ONLY: never seeds the read-only
986
+ // wired to what the installed harness accepts, READ AT RUN TIME. POLICY-ONLY: never seeds the read-only
986
987
  // allowlist and never touches permissions.allow as a value; a sibling of the flagless allowlist merge
987
988
  // (mergeProjectSettings) that follows the identical merge-don't-clobber discipline. Preview-then-apply,
988
989
  // dry-run default. Refuses an absent policy (a writer renders only a declared policy).
989
990
 
990
- // The exact 2.1.185 sandbox settings keys (Step 3.1, characterized against the real CLI + official docs).
991
+ // The sandbox settings keys this render owns (characterized against the real CLI + official docs).
991
992
  const SANDBOX_KEY = 'sandbox';
992
993
  const SANDBOX_ENABLED_KEY = 'enabled';
993
994
  const SANDBOX_AUTOALLOW_KEY = 'autoAllowBashIfSandboxed';
@@ -1075,6 +1076,153 @@ export const probeSandboxAvailability = (deps = {}) => {
1075
1076
  return { platform, sandbox: 'none', available: false, missing: [], reason: `native ${platform} sandbox unsupported — use WSL2 on Windows` };
1076
1077
  };
1077
1078
 
1079
+ // ── the harness-version probe ────────────────────────────────────────────────────────────────────
1080
+ // A version literal frozen in code is a documentation claim: it goes stale by default and nothing
1081
+ // reports it. Nobody can guarantee another vendor's version format, install layout or settings
1082
+ // shape, so this probe does NOT promise a correct answer — it promises that being wrong is LOUD.
1083
+ // A pin goes stale silently; a probe goes stale with a stated unknown. That direction-of-failure IS
1084
+ // the whole value. Read-only (PATH scan + realpath via findOnPath), never a spawn — the same
1085
+ // doctrine probeSandboxAvailability follows.
1086
+ const HARNESS_BIN = 'claude';
1087
+ // Two install layouts, both read-only, both matched EXACTLY. The native installer puts the binary at
1088
+ // …/claude/versions/<semver>; an npm install carries the @anthropic-ai/claude-code package.json a few
1089
+ // levels up. Identity is checked, never guessed from a substring: a third-party wrapper called
1090
+ // my-claude-wrapper@9.0.0 must resolve to UNKNOWN, because a false "supported" renders a protection
1091
+ // that is not there — strictly worse than an honest unknown, which at least degrades loudly.
1092
+ const HARNESS_PKG_NAME = '@anthropic-ai/claude-code';
1093
+ const HARNESS_VERSIONS_DIR = 'versions';
1094
+ const HARNESS_PKG_SEARCH_DEPTH = 5;
1095
+ // The ONLY fs errors that mean "keep walking". EACCES is deliberately NOT here: an unreadable
1096
+ // package.json means we cannot confirm, which must surface rather than read as "not the harness".
1097
+ // A coded TypeError (Node attaches codes like ERR_INVALID_ARG_TYPE to programming defects) must not
1098
+ // pass as an expected walk failure — that is how a defect would masquerade as an unknown layout.
1099
+ const WALK_SKIP_FS_CODES = Object.freeze(['ENOENT', 'ENOTDIR', 'EISDIR', 'ELOOP', 'ENAMETOOLONG']);
1100
+ // Strict: the whole segment must be a version, so a decorated or trailing-garbage name is unknown
1101
+ // rather than a prefix-parsed guess.
1102
+ const STRICT_SEMVER_RE = /^\d+\.\d+\.\d+$/;
1103
+
1104
+ const unknownHarness = (reason) => ({ version: null, source: null, reason });
1105
+
1106
+ const ancestorDirs = (start, depth) => {
1107
+ const step = (acc) => {
1108
+ const last = acc[acc.length - 1];
1109
+ const parent = dirname(last);
1110
+ return acc.length >= depth || parent === last ? acc : step([...acc, parent]);
1111
+ };
1112
+ return step([start]);
1113
+ };
1114
+
1115
+ // The native layout, matched in FULL: …/claude/versions/<version>. Checking only the `versions/`
1116
+ // parent would accept /opt/my-wrapper/versions/9.0.0 as the official harness, so the grandparent is
1117
+ // checked too, and the version segment must match strictly — a prefix parse would read
1118
+ // `2.1.215-tampered` as a clean version.
1119
+ const harnessVersionFromNativeLayout = (installPath) => {
1120
+ const versionsDir = dirname(installPath);
1121
+ const isNativeLayout = basename(versionsDir) === HARNESS_VERSIONS_DIR && basename(dirname(versionsDir)) === HARNESS_BIN;
1122
+ const segment = basename(installPath);
1123
+ return isNativeLayout && STRICT_SEMVER_RE.test(segment) ? segment : null;
1124
+ };
1125
+
1126
+ // A walk failure is EXPECTED (no package.json at that ancestor, or a malformed one) and is skipped;
1127
+ // every other error class rethrows. A probe that swallowed a programming defect would report
1128
+ // "unrecognised install layout" — a wrong reason, which is the exact defect this feature exists to
1129
+ // stop, just relocated into the probe itself.
1130
+ const readPackageVersionAt = (dir, readFile) => {
1131
+ try {
1132
+ const pkg = JSON.parse(readFile(join(dir, 'package.json'), UTF8));
1133
+ // Strict, like the native layout: a prefix parse would read `2.1.187-beta.0` as the stable
1134
+ // `2.1.187` and switch a capability on for a build that may not have it. A prerelease is an
1135
+ // honest unknown, not a rounding-down opportunity.
1136
+ return pkg.name === HARNESS_PKG_NAME && STRICT_SEMVER_RE.test(String(pkg.version)) ? String(pkg.version) : null;
1137
+ } catch (err) {
1138
+ if (err instanceof SyntaxError) return null;
1139
+ if (err instanceof TypeError || err instanceof RangeError || err instanceof ReferenceError) throw err;
1140
+ if (err && WALK_SKIP_FS_CODES.includes(err.code)) return null;
1141
+ throw err;
1142
+ }
1143
+ };
1144
+
1145
+ // Short-circuits at the first confirmed package: an eager map would keep walking after the answer
1146
+ // was already found, so an unrelated ancestor's EACCES would abort a probe that had SUCCEEDED.
1147
+ // Fail-loud on an unreadable ancestor still applies for every dir visited BEFORE the confirmation.
1148
+ const harnessVersionFromPackageJson = (installPath, readFile) => {
1149
+ for (const dir of ancestorDirs(dirname(installPath), HARNESS_PKG_SEARCH_DEPTH)) {
1150
+ const version = readPackageVersionAt(dir, readFile);
1151
+ if (version !== null) return version;
1152
+ }
1153
+ return null;
1154
+ };
1155
+
1156
+ // probeHarnessVersion(deps) → { version, source, reason }. `version` is null whenever the installed
1157
+ // build could not be read; `reason` then STATES why, so a caller can say "unknown" instead of
1158
+ // inventing either direction.
1159
+ export const probeHarnessVersion = (deps = {}) => {
1160
+ const locate = deps.findOnPath ?? findOnPath;
1161
+ const readFile = deps.readFile ?? readFileSync;
1162
+ const found = locate(HARNESS_BIN, deps);
1163
+ if (!found.path) {
1164
+ return unknownHarness(`no ${HARNESS_BIN} binary resolved on PATH (${found.state}) — the installed harness version is unknown`);
1165
+ }
1166
+ const version = harnessVersionFromNativeLayout(found.path) ?? harnessVersionFromPackageJson(found.path, readFile);
1167
+ return version === null
1168
+ ? unknownHarness(`resolved ${HARNESS_BIN} to ${found.path}, but no version could be read from that install layout — the installed harness version is unknown`)
1169
+ : { version, source: found.path, reason: `read from the resolved ${HARNESS_BIN} install path ${found.path}` };
1170
+ };
1171
+
1172
+ // The capability threshold: sandbox credential denial arrived here. This literal is HISTORY (the
1173
+ // release where the key appeared), never a claim about the installed build — it is only ever
1174
+ // compared against a version the probe OBSERVED, which is what keeps it honest.
1175
+ const CREDENTIALS_DENY_SINCE = '2.1.187';
1176
+ const CREDENTIALS_KEY = 'credentials';
1177
+ const CREDENTIAL_DENY_MODE = 'deny';
1178
+ // The env vars the profile protects when the installed build supports it. File-based credentials
1179
+ // (~/.ssh) are deliberately NOT rendered: the entry shape for sandbox.credentials.files was not
1180
+ // verified against an installed build, and this profile never renders what it has not observed.
1181
+ const PROTECTED_ENV_VARS = Object.freeze(['NPM_TOKEN', 'GITHUB_TOKEN']);
1182
+ // A caller that did not probe gets the same treatment as a failed probe — a stated unknown. The
1183
+ // default must never be an assumed capability in either direction.
1184
+ const HARNESS_UNPROBED = Object.freeze({
1185
+ version: null,
1186
+ source: null,
1187
+ reason: 'the harness version was not probed by this caller',
1188
+ });
1189
+
1190
+ // Inside the credentials block this render owns EXACTLY the envVars entries for the names it
1191
+ // protects. Everything else — `files`, `allowPlaintextInject`, any other envVar — is the
1192
+ // maintainer's, and the degrade explicitly invites them to declare it. So the block follows the same
1193
+ // merge-don't-clobber contract as the rest of the sandbox key, and drift compares only the owned
1194
+ // slice: a maintainer who takes the tool's own advice must never be told their settings drifted.
1195
+ const isOwnedEnvVar = (entry) => isJsonObject(entry) && PROTECTED_ENV_VARS.includes(entry.name);
1196
+
1197
+ // Canonical projection: same keys, same order, sorted by name. Comparing raw entries would flag a
1198
+ // hand-written {mode, name} as drift against an identical {name, mode} — a warning about nothing,
1199
+ // which trains the reader to ignore the loud channel this whole feature depends on.
1200
+ const ownedEnvVarsIn = (block) => {
1201
+ const entries = isJsonObject(block) && Array.isArray(block.envVars) ? block.envVars : [];
1202
+ return entries
1203
+ .filter(isOwnedEnvVar)
1204
+ .map((entry) => ({ name: entry.name, mode: entry.mode }))
1205
+ .sort((a, b) => String(a.name).localeCompare(String(b.name)));
1206
+ };
1207
+
1208
+ const mergeCredentialsBlock = (existing, rendered) => {
1209
+ const base = isJsonObject(existing) ? existing : {};
1210
+ const foreignEnvVars = (Array.isArray(base.envVars) ? base.envVars : []).filter((e) => !isOwnedEnvVar(e));
1211
+ if (rendered !== undefined) return { ...base, envVars: [...foreignEnvVars, ...rendered.envVars] };
1212
+ const withoutOwned = { ...base };
1213
+ if (foreignEnvVars.length) withoutOwned.envVars = foreignEnvVars;
1214
+ else delete withoutOwned.envVars;
1215
+ return Object.keys(withoutOwned).length > 0 ? withoutOwned : undefined;
1216
+ };
1217
+
1218
+ const observedPhrase = (harness) =>
1219
+ harness.version !== null
1220
+ ? `observed ${HARNESS_BIN} ${harness.version}`
1221
+ : `the installed ${HARNESS_BIN} version could not be determined — ${harness.reason}`;
1222
+
1223
+ const supportsCredentialDenial = (harness) =>
1224
+ harness.version !== null && compareSemver(harness.version, CREDENTIALS_DENY_SINCE) >= 0;
1225
+
1078
1226
  // effectiveAutonomyLevel(resolved) → the ONE global level the (global, static) settings file renders.
1079
1227
  // The autonomy policy is per-activity (Decision 2), but .claude/settings.json is global and the
1080
1228
  // plan.end/exec.end checkpoints are BEHAVIORAL, not machine-enforced (Decision 1). Global auto-allow is
@@ -1086,15 +1234,22 @@ export const effectiveAutonomyLevel = (resolved) => {
1086
1234
  return levels.length > 0 && levels.every((l) => l === AUTONOMY_SANDBOX) ? AUTONOMY_SANDBOX : AUTONOMY_PROMPT;
1087
1235
  };
1088
1236
 
1089
- // renderAutonomySettings(resolved, probe) → the render-owned blocks + honesty notes/degrades. PURE. The
1237
+ // renderAutonomySettings(resolved, probe, harness) → the render-owned blocks + honesty notes/degrades. PURE. The
1090
1238
  // sandbox is ALWAYS enabled (the Decision-1 floor: BOTH levels confine); auto-allow + defaultMode follow
1091
1239
  // the collapsed level. Command red-lines land under ask/deny by value. The three non-command red-lines
1092
- // are the sandbox DEFAULTS (network prompt-on-egress; fs cwd+$TMPDIR confine) — where 2.1.185 cannot
1240
+ // are the sandbox DEFAULTS (network prompt-on-egress; fs cwd+$TMPDIR confine) — where this render cannot
1093
1241
  // express a value distinctly it DEGRADES LOUDLY (never a silent allow, never silent pretend-security).
1094
- export const renderAutonomySettings = (resolved, probe) => {
1242
+ export const renderAutonomySettings = (resolved, probe, harness = HARNESS_UNPROBED) => {
1095
1243
  const level = effectiveAutonomyLevel(resolved);
1096
1244
  const wantsSandbox = level === AUTONOMY_SANDBOX;
1097
- const sandbox = { [SANDBOX_ENABLED_KEY]: true, [SANDBOX_AUTOALLOW_KEY]: wantsSandbox };
1245
+ const credentialsSupported = supportsCredentialDenial(harness);
1246
+ const sandbox = {
1247
+ [SANDBOX_ENABLED_KEY]: true,
1248
+ [SANDBOX_AUTOALLOW_KEY]: wantsSandbox,
1249
+ ...(credentialsSupported
1250
+ ? { [CREDENTIALS_KEY]: { envVars: PROTECTED_ENV_VARS.map((name) => ({ name, mode: CREDENTIAL_DENY_MODE })) } }
1251
+ : {}),
1252
+ };
1098
1253
  const defaultMode = DEFAULT_MODE_FOR[level];
1099
1254
  const ask = [];
1100
1255
  const deny = [];
@@ -1103,20 +1258,34 @@ export const renderAutonomySettings = (resolved, probe) => {
1103
1258
  }
1104
1259
  const notes = [];
1105
1260
  const degrades = [];
1106
- // network — 2.1.185 regular settings cannot HARD-BLOCK egress; both values render as no allowedDomains
1107
- // (prompt-on-egress). deny DEGRADES LOUDLY (can't hard-block); ask is the faithful sandbox default.
1261
+ // network — this render owns no egress-blocking key, so both values land as prompt-on-egress. The
1262
+ // degrade states what THIS RENDER does not express; it never claims a platform limit it did not
1263
+ // observe, and it names the version it did observe (or states the unknown).
1108
1264
  if (resolved.redlines.network === 'deny') {
1109
- degrades.push('network=deny requested, but claude 2.1.185 regular settings cannot HARD-BLOCK egress — rendered as prompt-on-egress (the sandbox default: no domains pre-allowed, a new domain still prompts). A silent hard block needs managed settings (allowManagedDomainsOnly).');
1265
+ degrades.push(`network=deny requested, but this render expresses no HARD egress block (${observedPhrase(harness)}) — rendered as prompt-on-egress (the sandbox default: no domains pre-allowed, a new domain still prompts). A silent hard block needs managed settings (allowManagedDomainsOnly).`);
1110
1266
  } else {
1111
1267
  notes.push('network=ask → prompt on each new domain (the sandbox default; no domains pre-allowed).');
1112
1268
  }
1113
- // credentials — 2.1.185 has NO sandbox.credentials at all (deny added 2.1.187, mask 2.1.199). BOTH
1114
- // values degrade loudly never silent pretend-security.
1115
- degrades.push(`credentials=${resolved.redlines.credentials} requested, but claude 2.1.185 has NO sandbox credential denial (deny added in 2.1.187, mask in 2.1.199) — NPM_TOKEN/GITHUB_TOKEN and ~/.ssh are NOT hidden from sandboxed commands. Upgrade to 2.1.187+ for sandbox.credentials.`);
1116
- // fs_outside_repo — the sandbox default is a HARD confine to cwd+$TMPDIR; 2.1.185 has no
1117
- // prompt-on-outside-write, so `ask` DEGRADES LOUDLY to the deny form (hard confine).
1269
+ // credentials — rendered when the OBSERVED version reaches the threshold, degraded loudly when it
1270
+ // does not or when the version is unknown. Reporting a platform limit that does not exist is the
1271
+ // defect; so is pretending protection that was never confirmed.
1272
+ if (credentialsSupported) {
1273
+ // The installed schema offers deny|mask — there is NO ask mode, so `ask` cannot be expressed and
1274
+ // DEGRADES LOUDLY to the deny form rather than being quietly upgraded. And even on deny the
1275
+ // coverage is PARTIAL: env vars only, because the files entry shape was never verified against an
1276
+ // installed build. Partial protection reported as success is the same defect as the false claim
1277
+ // this render replaced — so it degrades too, never a note.
1278
+ if (resolved.redlines.credentials === 'ask') {
1279
+ degrades.push(`credentials=ask requested, but sandbox.${CREDENTIALS_KEY} offers no ask mode (${observedPhrase(harness)}) — rendered as the deny form: ${PROTECTED_ENV_VARS.join('/')} are unset for sandboxed commands with no prompt.`);
1280
+ }
1281
+ degrades.push(`credentials=${resolved.redlines.credentials} coverage is PARTIAL (${observedPhrase(harness)}) — sandbox.${CREDENTIALS_KEY} denies ${PROTECTED_ENV_VARS.join('/')} only. File-based credentials (~/.ssh and any other secret FILE) stay readable by sandboxed commands: this profile does not render ${CREDENTIALS_KEY}.files, whose entry shape it has not verified against an installed build. Declare them yourself if you need them.`);
1282
+ } else {
1283
+ degrades.push(`credentials=${resolved.redlines.credentials} requested, but ${observedPhrase(harness)}; sandbox credential denial arrived in ${CREDENTIALS_DENY_SINCE}, so ${PROTECTED_ENV_VARS.join('/')} and ~/.ssh are NOT hidden from sandboxed commands here. Upgrade to ${CREDENTIALS_DENY_SINCE}+ (or fix the install so the version can be read) for sandbox.${CREDENTIALS_KEY}.`);
1284
+ }
1285
+ // fs_outside_repo — the sandbox default is a HARD confine to cwd+$TMPDIR; this render expresses no
1286
+ // prompt-on-outside-write mode, so `ask` DEGRADES LOUDLY to the deny form (hard confine).
1118
1287
  if (resolved.redlines.fs_outside_repo === 'ask') {
1119
- degrades.push('fs_outside_repo=ask requested, but claude 2.1.185 has no prompt-on-outside-write — rendered as the deny form (writes hard-confined to cwd+$TMPDIR; an outside write is blocked, then auto-retried through the normal permission flow).');
1288
+ degrades.push(`fs_outside_repo=ask requested, but this render expresses no prompt-on-outside-write mode (${observedPhrase(harness)}) — rendered as the deny form (writes hard-confined to cwd+$TMPDIR; an outside write is blocked, then auto-retried through the normal permission flow).`);
1120
1289
  } else {
1121
1290
  notes.push('fs_outside_repo=deny → writes confined to cwd+$TMPDIR (the sandbox default).');
1122
1291
  }
@@ -1147,7 +1316,14 @@ export const mergeAutonomySettings = (projectData, render) => {
1147
1316
  else delete mergedPermissions.ask;
1148
1317
  if (mergedDeny.length) mergedPermissions.deny = mergedDeny;
1149
1318
  else delete mergedPermissions.deny;
1319
+ // The credentials block is render-OWNED like the two flags above: it must be written when the
1320
+ // render carries it and REMOVED when it does not, or a stale block would outlive the capability
1321
+ // that justified it. A render that carries no credentials key protects nothing, so leaving a
1322
+ // previous one in place would keep claiming a protection this run did not render.
1323
+ const mergedCredentials = mergeCredentialsBlock(existingSandbox[CREDENTIALS_KEY], render.sandbox[CREDENTIALS_KEY]);
1150
1324
  const mergedSandbox = { ...existingSandbox, [SANDBOX_ENABLED_KEY]: true, [SANDBOX_AUTOALLOW_KEY]: render.sandbox[SANDBOX_AUTOALLOW_KEY] };
1325
+ if (mergedCredentials === undefined) delete mergedSandbox[CREDENTIALS_KEY];
1326
+ else mergedSandbox[CREDENTIALS_KEY] = mergedCredentials;
1151
1327
  return { ...base, [SANDBOX_KEY]: mergedSandbox, permissions: mergedPermissions };
1152
1328
  };
1153
1329
 
@@ -1256,6 +1432,16 @@ const collectLocalMasks = (localData, render) => {
1256
1432
  masks.push({ key: `${SANDBOX_KEY}.${k}`, local: localSandbox[k], rendered: render.sandbox[k] });
1257
1433
  }
1258
1434
  }
1435
+ // credentials is render-owned too, and local > project applies to the WHOLE key: a local block
1436
+ // that drops or weakens a rendered entry silently removes a protection the preview just reported.
1437
+ // Compared on the owned slice only, so a local file that merely ADDS its own entries is not a mask.
1438
+ if (hasOwn(localSandbox, CREDENTIALS_KEY)) {
1439
+ const localOwned = ownedEnvVarsIn(localSandbox[CREDENTIALS_KEY]);
1440
+ const renderedOwned = ownedEnvVarsIn(render.sandbox[CREDENTIALS_KEY]);
1441
+ if (JSON.stringify(localOwned) !== JSON.stringify(renderedOwned)) {
1442
+ masks.push({ key: `${SANDBOX_KEY}.${CREDENTIALS_KEY}`, local: localOwned, rendered: renderedOwned });
1443
+ }
1444
+ }
1259
1445
  return masks;
1260
1446
  };
1261
1447
 
@@ -1310,7 +1496,7 @@ export const writeAutonomyProfile = ({ cwd, apply = false } = {}, deps = {}) =>
1310
1496
  }
1311
1497
  const resolved = resolveAutonomy(config);
1312
1498
  const probe = probeSandboxAvailability(deps);
1313
- const render = renderAutonomySettings(resolved, probe);
1499
+ const render = renderAutonomySettings(resolved, probe, probeHarnessVersion(deps));
1314
1500
  const preflight = preflightVelocityProfile({ cwd: projectDir }, deps);
1315
1501
  // local-mask honesty: a settings.local.json value for ANY render-owned key (defaultMode + the sandbox
1316
1502
  // enable/auto-allow keys) that differs from the render's MASKS it (local > project). Reported loudly;
@@ -1369,7 +1555,7 @@ export const checkAutonomyProfile = ({ cwd } = {}, deps = {}) => {
1369
1555
  if (source === 'none' || config === null) {
1370
1556
  throw makeVelocityProfileError(VELOCITY_NO_POLICY, `no ${AUTONOMY_REL} to check against — seed a policy first (set-autonomy previews, then --write)`);
1371
1557
  }
1372
- const render = renderAutonomySettings(resolveAutonomy(config), probeSandboxAvailability(deps));
1558
+ const render = renderAutonomySettings(resolveAutonomy(config), probeSandboxAvailability(deps), probeHarnessVersion(deps));
1373
1559
  const settings = readSettingsFile(join(projectDir, SETTINGS_FILE), { ...deps, cwd: projectDir });
1374
1560
  const data = settings.present ? settings.data : {};
1375
1561
  const drift = [];
@@ -1380,6 +1566,13 @@ export const checkAutonomyProfile = ({ cwd } = {}, deps = {}) => {
1380
1566
  if (liveSandbox[SANDBOX_AUTOALLOW_KEY] !== render.sandbox[SANDBOX_AUTOALLOW_KEY]) {
1381
1567
  drift.push(`${SANDBOX_KEY}.${SANDBOX_AUTOALLOW_KEY}: expected ${render.sandbox[SANDBOX_AUTOALLOW_KEY]}, found ${JSON.stringify(liveSandbox[SANDBOX_AUTOALLOW_KEY])}`);
1382
1568
  }
1569
+ // The credentials block is render-owned in BOTH directions: a removed one is a protection that
1570
+ // silently stopped applying, and a leftover one claims a protection this render did not make.
1571
+ const liveOwnedCredentials = ownedEnvVarsIn(liveSandbox[CREDENTIALS_KEY]);
1572
+ const wantOwnedCredentials = ownedEnvVarsIn(render.sandbox[CREDENTIALS_KEY]);
1573
+ if (JSON.stringify(liveOwnedCredentials) !== JSON.stringify(wantOwnedCredentials)) {
1574
+ drift.push(`${SANDBOX_KEY}.${CREDENTIALS_KEY}.envVars (render-owned entries): expected ${JSON.stringify(wantOwnedCredentials)}, found ${JSON.stringify(liveOwnedCredentials)}`);
1575
+ }
1383
1576
  const perms = getPermissions(data);
1384
1577
  if (perms[DEFAULT_MODE_KEY] !== render.defaultMode) {
1385
1578
  drift.push(`permissions.${DEFAULT_MODE_KEY}: expected ${render.defaultMode}, found ${JSON.stringify(perms[DEFAULT_MODE_KEY])}`);
@@ -1399,12 +1592,28 @@ export const checkAutonomyProfile = ({ cwd } = {}, deps = {}) => {
1399
1592
  if (inExpected !== 1) drift.push(`permissions.${want}: expected exactly one ${rule} (redlines.${rl}=${want}), found ${inExpected}`);
1400
1593
  if (inOther !== 0) drift.push(`permissions.${other}: ${rule} present ${inOther}× but redlines.${rl}=${want} (belongs under ${want})`);
1401
1594
  }
1402
- return { inSync: drift.length === 0, drift, source, level: render.level, settingsPresent: settings.present };
1595
+ // The degrades ride along even when the check PASSES. "In sync" only means the file matches the
1596
+ // render — it says nothing about what that render could not protect. Dropping them here would let
1597
+ // an unknown or too-old harness report a clean IN SYNC while the credential protection is simply
1598
+ // absent: exactly the silent-success shape this whole change exists to remove.
1599
+ // A local file that masks a render-owned key defeats the rendered value silently (local > project),
1600
+ // so the check must read it too. Without this a local block weakening sandbox.credentials still
1601
+ // produced IN SYNC while the report claimed the tokens were denied — a false all-clear about a
1602
+ // security control, which is the exact defect class this change exists to close.
1603
+ const localSettings = readSettingsFile(join(projectDir, SETTINGS_LOCAL_FILE), { ...deps, cwd: projectDir });
1604
+ const localMasks = collectLocalMasks(localSettings.present ? localSettings.data : undefined, render);
1605
+ for (const m of localMasks) {
1606
+ drift.push(`${SETTINGS_LOCAL_FILE} masks ${m.key}: local ${JSON.stringify(m.local)} overrides the rendered ${JSON.stringify(m.rendered)} (local > project)`);
1607
+ }
1608
+ return { inSync: drift.length === 0, drift, degrades: render.degrades, localMasks, source, level: render.level, settingsPresent: settings.present };
1403
1609
  };
1404
1610
 
1405
1611
  export const formatAutonomyCheck = (c) =>
1406
1612
  c.inSync
1407
- ? `autonomy --check: IN SYNC — ${SETTINGS_FILE} matches the ${c.source} render (level ${c.level}).`
1613
+ ? [
1614
+ `autonomy --check: IN SYNC — ${SETTINGS_FILE} matches the ${c.source} render (level ${c.level}).`,
1615
+ ...(c.degrades ?? []).map((d) => ` ⚠ DEGRADE: ${d}`),
1616
+ ].join('\n')
1408
1617
  : [
1409
1618
  `autonomy --check: DRIFT — ${SETTINGS_FILE} diverges from the ${c.source} render (level ${c.level}):`,
1410
1619
  ...c.drift.map((d) => ` ✗ ${d}`),