@sabaiway/agent-workflow-kit 5.3.0 → 5.4.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.
@@ -1,5 +1,6 @@
1
1
  import { existsSync, lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { basename, dirname, join, relative, resolve } from 'node:path';
3
+ import { homedir, tmpdir } from 'node:os';
3
4
  import { fileURLToPath, pathToFileURL } from 'node:url';
4
5
  // The --autonomy render reads the per-project autonomy policy through the read-only autonomy core
5
6
  // (AD-044). This file is the family's one .claude/settings.json writer, so the policy render lives here;
@@ -10,6 +11,9 @@ import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, COMMAND_REDLINES } from '.
10
11
  // for a PLACED bridge wrapper — findOnPath is the same read-only PATH scan the backend detector uses.
11
12
  import { findOnPath } from './detect-backends.mjs';
12
13
  import { compareSemver } from './semver-lite.mjs';
14
+ // The declared-path resolution + segment containment the allowWrite degrade shares with the
15
+ // advisor's worktrees-dir convergence lane — ONE leaf, so the two readings cannot drift.
16
+ import { resolveDeclaredDir, dirCovers, isResolvableDeclaredEntry } from './declared-paths.mjs';
13
17
 
14
18
  // Velocity-profile core + writer: a fixed, audited read-only allowlist that an onboarding step seeds
15
19
  // into `.claude/settings.json` so routine read-only commands stop idling on approval prompts.
@@ -337,6 +341,17 @@ const MUTATING_SCRIPT_HOOK_PATTERN = /^(pre|post)/iu;
337
341
  // invariant (kept deliberately even though the read-only screen already rejects these, so the
338
342
  // refusal is named, tested, and produces a clear message).
339
343
  const MUTATING_ALLOW_COMMAND_PATTERN = /^(?:git\s+(?:commit|push)|npm\s+publish)(?:\s|$)/iu;
344
+ // Whether a host APPLIES a `sandbox.*` settings key is not knowable from here: a settings-native
345
+ // host honors it, while an IDE/session-imposed sandbox was observed ignoring the hand-applied
346
+ // security keys in BOTH scopes (the advisor records the same limit in
347
+ // references/modes/recommendations.md). So every claim about the RUNTIME EFFECT of a settings key —
348
+ // the tier's exclusion routing and every weakening detail alike — carries this qualifier. The
349
+ // CLASSIFICATION is untouched: which red-line a key would weaken, and that a proven tier exclusion
350
+ // is a note rather than a weakening, are properties of the DECLARATION, not of a host.
351
+ export const HOST_HONORS_QUALIFIER = 'where the host honors the settings sandbox keys';
352
+ export const HOST_HONORS_NOTICE =
353
+ `host-conditional: whether a host applies the sandbox.* settings keys is not knowable from here — a settings-native host honors them, a harness-managed session sandbox may ignore them in BOTH scopes, so each runtime effect above is stated "${HOST_HONORS_QUALIFIER}" rather than promised; what a key would weaken is a property of the declaration and is stated flat.`;
354
+
340
355
  const RESIDUAL_NOTICE =
341
356
  'residual: seeded read-only allow entries are a trust-posture convenience, NOT a sandbox; settings-level rules cannot inspect runtime redirection/command-substitution/--output writes; commit/push/publish are never allowlisted (a DIRECT invocation still ASKs, but the runtime residual is not closed here); the residual guard ships as the opt-in PreToolUse hook — Mode: hook (/agent-workflow-kit hook). floor (never auto-approved, with or without the tier): every writer --apply/--write/--yes still prompts; clobber-protection STOPs still stop; the three release asks (commit/push/publish) stay maintainer-owned.';
342
357
 
@@ -353,9 +368,9 @@ preview byte-strings. Never touches settings.local.json.
353
368
  --bridge-tier (own consent) seeds the bridge REVIEW wrappers' CODE mode for PLACED bridges
354
369
  (codex-review code, agy-review code - never the execution/probe wrappers, never plan/diff modes)
355
370
  + the quoted grounding pre-step rule, and the wrapper names into sandbox.excludedCommands (they
356
- need network - the harness runs them outside the sandbox). Consented posture: an auto-allowed
357
- review wrapper runs UNATTENDED and sends the assembled repo payload to its subscription backend
358
- (see the printed tier notice).
371
+ need network - ${HOST_HONORS_QUALIFIER} the harness runs them outside the sandbox). Consented
372
+ posture: an auto-allowed review wrapper runs UNATTENDED and sends the assembled repo payload to its
373
+ subscription backend (see the printed tier notice).
359
374
 
360
375
  --autonomy renders docs/ai/autonomy.json into the settings blocks it OWNS — the sandbox block +
361
376
  permissions.ask/deny red-lines + permissions.defaultMode. POLICY-ONLY: never seeds the allowlist and
@@ -663,7 +678,10 @@ const formatKitTier = (result) =>
663
678
  // The bridge tier's honest posture, printed on EVERY --bridge-tier run: the informed-consent
664
679
  // resolution states the exfiltration surface, never pretends it away.
665
680
  export const KIT_BRIDGE_TIER_NOTICE =
666
- 'bridge-wrappers tier: seeds the REVIEW wrappers only, and only their CODE mode (`codex-review code`, `agy-review code` — never codex-exec/agy-run: delegated execution keeps its human prompt; never the plan/diff modes: their file arguments can point outside the repo, so they keep their prompt), each derived ONLY when its bridge is PLACED on PATH, plus the grounding pre-step rule in its rendered quoted byte-form. POSTURE (what this consent covers): an auto-allowed review wrapper runs UNATTENDED — it reads any repo file it is pointed at and sends the assembled payload to its subscription backend, and prefix rules cannot inspect arguments, so a code-mode argument that names a readable file (agy\'s --facts/--decided) rides the same consent — the same documented residual class as the autonomy red-line rules; that is the tier\'s PURPOSE (unattended council review runs) and its residual — tier entries get NO PreToolUse-hook coverage. The grounding entry\'s writer surface is bounded by grounding.mjs\'s OWN scratch-destination guard (a tracked or in-repo-not-ignored --out is refused by the tool). The wrapper names are ALSO seeded into sandbox.excludedCommands IN THE PROJECT settings.json (an exclusion only in settings.local.json was live-observed NOT to route — the wrapper then runs sandboxed and dies on a read-only HOME): the harness runs an excluded command OUTSIDE the sandbox (the wrappers need network), so a plain allowlisted invocation triggers no sandbox-bypass approval. INVOCATION SHAPE: a prefix rule matches only a PLAIN invocation starting with the wrapper name — an env-var prefix or a compound chain never matches (redirects are fine).';
681
+ 'bridge-wrappers tier: seeds the REVIEW wrappers only, and only their CODE mode (`codex-review code`, `agy-review code` — never codex-exec/agy-run: delegated execution keeps its human prompt; never the plan/diff modes: their file arguments can point outside the repo, so they keep their prompt), each derived ONLY when its bridge is PLACED on PATH, plus the grounding pre-step rule in its rendered quoted byte-form. POSTURE (what this consent covers): an auto-allowed review wrapper runs UNATTENDED — it reads any repo file it is pointed at and sends the assembled payload to its subscription backend, and prefix rules cannot inspect arguments, so a code-mode argument that names a readable file (agy\'s --facts/--decided) rides the same consent — the same documented residual class as the autonomy red-line rules; that is the tier\'s PURPOSE (unattended council review runs) and its residual — tier entries get NO PreToolUse-hook coverage. The grounding entry\'s writer surface is bounded by grounding.mjs\'s OWN scratch-destination guard (a tracked or in-repo-not-ignored --out is refused by the tool). The wrapper names are ALSO seeded into sandbox.excludedCommands IN THE PROJECT settings.json (an exclusion only in settings.local.json was live-observed NOT to route — the wrapper then runs sandboxed and dies on a read-only HOME): ' +
682
+ // The one interpolated seam in this notice: the shared host-conditional qualifier, so the tier's
683
+ // routing promise and the render's degrade lines can never drift apart.
684
+ `${HOST_HONORS_QUALIFIER} the harness runs an excluded command OUTSIDE the sandbox (the wrappers need network), so a plain allowlisted invocation triggers no sandbox-bypass approval — whether a host honors them is not knowable from here, and where it does not the wrapper simply starts sandboxed (fail-safe, never a silent widening). INVOCATION SHAPE: a prefix rule matches only a PLAIN invocation starting with the wrapper name — an env-var prefix or a compound chain never matches (redirects are fine).`;
667
685
 
668
686
  const formatBridgeTier = (result) =>
669
687
  result.bridgeTier
@@ -1280,9 +1298,9 @@ export const renderAutonomySettings = (resolved, probe, harness = HARNESS_UNPROB
1280
1298
  // degrade states what THIS RENDER does not express; it never claims a platform limit it did not
1281
1299
  // observe, and it names the version it did observe (or states the unknown).
1282
1300
  if (resolved.redlines.network === 'deny') {
1283
- 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).`);
1301
+ 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 ${HOST_HONORS_QUALIFIER}). A silent hard block needs managed settings (allowManagedDomainsOnly).`);
1284
1302
  } else {
1285
- notes.push('network=ask → prompt on each new domain (the sandbox default; no domains pre-allowed).');
1303
+ notes.push(`network=ask → prompt on each new domain ${HOST_HONORS_QUALIFIER} (the sandbox default; no domains pre-allowed).`);
1286
1304
  }
1287
1305
  // credentials — rendered when the OBSERVED version reaches the threshold, degraded loudly when it
1288
1306
  // does not or when the version is unknown. Reporting a platform limit that does not exist is the
@@ -1294,23 +1312,23 @@ export const renderAutonomySettings = (resolved, probe, harness = HARNESS_UNPROB
1294
1312
  // installed build. Partial protection reported as success is the same defect as the false claim
1295
1313
  // this render replaced — so it degrades too, never a note.
1296
1314
  if (resolved.redlines.credentials === 'ask') {
1297
- 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.`);
1315
+ 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, ${HOST_HONORS_QUALIFIER}.`);
1298
1316
  }
1299
- 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.`);
1317
+ degrades.push(`credentials=${resolved.redlines.credentials} coverage is PARTIAL (${observedPhrase(harness)}) — ${HOST_HONORS_QUALIFIER}, 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.`);
1300
1318
  } else {
1301
- 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}.`);
1319
+ degrades.push(`credentials=${resolved.redlines.credentials} requested, but ${observedPhrase(harness)}; sandbox credential denial arrived in ${CREDENTIALS_DENY_SINCE}, so THIS RENDER hides nothing: ${PROTECTED_ENV_VARS.join('/')} and ~/.ssh are not hidden by any key it writes (a host sandbox of its own may still hide them — that is not knowable from here, the mirror image of ${HOST_HONORS_QUALIFIER}). Upgrade to ${CREDENTIALS_DENY_SINCE}+ (or fix the install so the version can be read) for sandbox.${CREDENTIALS_KEY}.`);
1302
1320
  }
1303
1321
  // fs_outside_repo — the sandbox default is a HARD confine to cwd+$TMPDIR; this render expresses no
1304
1322
  // prompt-on-outside-write mode, so `ask` DEGRADES LOUDLY to the deny form (hard confine).
1305
1323
  if (resolved.redlines.fs_outside_repo === 'ask') {
1306
- 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).`);
1324
+ degrades.push(`fs_outside_repo=ask requested, but this render expresses no prompt-on-outside-write mode (${observedPhrase(harness)}) — rendered as the deny form (${HOST_HONORS_QUALIFIER}, writes hard-confined to cwd+$TMPDIR; an outside write is blocked, then auto-retried through the normal permission flow).`);
1307
1325
  } else {
1308
- notes.push('fs_outside_repo=deny → writes confined to cwd+$TMPDIR (the sandbox default).');
1326
+ notes.push(`fs_outside_repo=deny → ${HOST_HONORS_QUALIFIER}, writes confined to cwd+$TMPDIR (the sandbox default).`);
1309
1327
  }
1310
1328
  // sandbox availability (Step 3.3 probe) — a LOUD degrade where the OS can't sandbox; the red-lines +
1311
1329
  // defaultMode still land (they are permission rules, sandbox-independent).
1312
1330
  if (!probe.available) {
1313
- degrades.push(`sandbox UNAVAILABLE on this host (${probe.reason}) — claude renders the sandbox block but WARNS and runs UNSANDBOXED: ad-hoc scripts will still PROMPT and network/fs confinement is NOT enforced until it is available (run /agent-workflow-kit autonomy-doctor to diagnose and, with your consent, install the missing dependency). The red-lines + defaultMode still apply. failIfUnavailable is left UNSET so the session is never bricked.`);
1331
+ degrades.push(`sandbox UNAVAILABLE on this host (${probe.reason}) — ${HOST_HONORS_QUALIFIER}, claude renders the sandbox block but WARNS and runs UNSANDBOXED: ad-hoc scripts will still PROMPT and the confinement THIS RENDER asks for is not enforced until it is available (run /agent-workflow-kit autonomy-doctor to diagnose and, with your consent, install the missing dependency). The red-lines + defaultMode still apply. failIfUnavailable is left UNSET so the session is never bricked.`);
1314
1332
  }
1315
1333
  return { level, activities: resolved.activities, sandbox, defaultMode, ask, deny, notes, degrades };
1316
1334
  };
@@ -1389,7 +1407,28 @@ const collectRedlineBypass = (sources) =>
1389
1407
  // escape the sandbox entirely. The render owns only enabled/autoAllow and preserves other sandbox
1390
1408
  // sub-keys (merge-don't-clobber, never a silent clobber of the user's sandbox tuning), so a pre-existing
1391
1409
  // weakening sub-key is REPORTED loudly (remove it by hand) — never silently carried as security.
1392
- const collectSandboxWeakenings = (sources) => {
1410
+ // The allowWrite degrade's own half: an entry is only an `fs_outside_repo` weakening once it
1411
+ // RESOLVES outside the boundaries a red-line already allows (the repo itself and $TMPDIR — the
1412
+ // autonomy policy's own scratch surface). Resolution comes FIRST, then the surviving entries are
1413
+ // NAMED by their resolved path: reporting a bare count declared every entry external and told the
1414
+ // maintainer nothing about which one to remove. A path is rendered JSON-quoted, so one carrying
1415
+ // spaces, shell metacharacters or a newline still renders on ONE line.
1416
+ // An entry that is not a resolvable string cannot be judged — and must never be DROPPED into
1417
+ // silence: it is counted as unresolvable, so a malformed declaration stays loud instead of quietly
1418
+ // emptying the degrade. A blank entry belongs to that class rather than resolving to the repo root
1419
+ // and disappearing as "contained".
1420
+ const externalWriteEntries = (entries, { root, home, tmp }) => {
1421
+ const boundaries = [resolve(root), ...(tmp ? [resolve(tmp)] : [])];
1422
+ const resolvable = entries.filter(isResolvableDeclaredEntry);
1423
+ return {
1424
+ external: resolvable
1425
+ .map((entry) => resolveDeclaredDir(entry, { home, root }))
1426
+ .filter((resolved) => !boundaries.some((boundary) => dirCovers(boundary, resolved))),
1427
+ unresolvable: entries.length - resolvable.length,
1428
+ };
1429
+ };
1430
+
1431
+ const collectSandboxWeakenings = (sources, { root, home, tmp }) => {
1393
1432
  // Tier-known PROOF: an excludedCommands entry is downgraded to a note ONLY when it is
1394
1433
  // demonstrably the consented tier's own output — it lives in the PROJECT settings.json (the file
1395
1434
  // the tier writes; a local-file exclusion is never tier output) AND the matching derived
@@ -1405,14 +1444,34 @@ const collectSandboxWeakenings = (sources) => {
1405
1444
  const out = [];
1406
1445
  const net = isJsonObject(sb.network) ? sb.network : {};
1407
1446
  if (Array.isArray(net.allowedDomains) && net.allowedDomains.length) {
1408
- out.push({ source, key: `${SANDBOX_KEY}.network.allowedDomains`, weakens: 'network', detail: `${net.allowedDomains.length} pre-allowed domain(s) — egress to them is not gated` });
1447
+ out.push({ source, key: `${SANDBOX_KEY}.network.allowedDomains`, weakens: 'network', detail: `${net.allowedDomains.length} pre-allowed domain(s) — ${HOST_HONORS_QUALIFIER}, egress to them is not gated` });
1409
1448
  }
1410
1449
  const fsb = isJsonObject(sb.filesystem) ? sb.filesystem : {};
1411
- if (Array.isArray(fsb.allowWrite) && fsb.allowWrite.length) {
1412
- out.push({ source, key: `${SANDBOX_KEY}.filesystem.allowWrite`, weakens: 'fs_outside_repo', detail: `${fsb.allowWrite.length} path(s) writable outside cwd+$TMPDIR` });
1450
+ // A PRESENT but unreadable declaration is reported, never assumed empty: what it would make
1451
+ // writable is unknown, and silence there is the same defect as an over-report.
1452
+ if (fsb.allowWrite !== undefined && !Array.isArray(fsb.allowWrite)) {
1453
+ // UNVERIFIABLE, not "weakening": an unreadable value cannot be claimed to widen anything (a
1454
+ // host may reject the whole key). Its own class, so the render states what it cannot verify
1455
+ // instead of asserting an effect it does not know.
1456
+ out.push({ source, key: `${SANDBOX_KEY}.filesystem.allowWrite`, weakens: 'fs_outside_repo', unverifiable: true, detail: `the declared value is not an array (${typeof fsb.allowWrite}) — it cannot be read, so what it would make writable is UNKNOWN` });
1457
+ } else if (Array.isArray(fsb.allowWrite) && fsb.allowWrite.length) {
1458
+ // Only the entries that RESOLVE outside the repo and $TMPDIR are a weakening at all — an entry
1459
+ // pointing INSIDE the repo grants nothing the red-line withholds, so reporting it would be an
1460
+ // over-report. All-contained ⇒ no line at all.
1461
+ // TWO records, never one: a resolved external path IS a weakening, an unresolvable entry is
1462
+ // UNVERIFIABLE, and a single line carrying both would assert an effect for entries it could
1463
+ // not read — the contradiction this phase exists to remove. An array of only-unresolvable
1464
+ // entries therefore yields no weakening claim at all.
1465
+ const { external, unresolvable } = externalWriteEntries(fsb.allowWrite, { root, home, tmp });
1466
+ if (external.length) {
1467
+ out.push({ source, key: `${SANDBOX_KEY}.filesystem.allowWrite`, weakens: 'fs_outside_repo', detail: `${HOST_HONORS_QUALIFIER}, ${external.length} declared path(s) resolve OUTSIDE the repo and $TMPDIR and are writable: ${external.map((p) => JSON.stringify(p)).join(', ')}` });
1468
+ }
1469
+ if (unresolvable) {
1470
+ out.push({ source, key: `${SANDBOX_KEY}.filesystem.allowWrite`, weakens: 'fs_outside_repo', unverifiable: true, detail: `${unresolvable} declared entr(ies) could not be resolved (not a non-empty string) — what they would make writable is UNKNOWN` });
1471
+ }
1413
1472
  }
1414
1473
  if (sb.allowUnsandboxedCommands === true) {
1415
- out.push({ source, key: `${SANDBOX_KEY}.allowUnsandboxedCommands`, weakens: 'every sandbox red-line', detail: 'commands may run unsandboxed' });
1474
+ out.push({ source, key: `${SANDBOX_KEY}.allowUnsandboxedCommands`, weakens: 'every sandbox red-line', detail: `${HOST_HONORS_QUALIFIER}, commands may run unsandboxed` });
1416
1475
  }
1417
1476
  if (Array.isArray(sb.excludedCommands) && sb.excludedCommands.length) {
1418
1477
  // The bridge tier's OWN wrapper names are tier-known ONLY with the proof above (the
@@ -1423,9 +1482,9 @@ const collectSandboxWeakenings = (sources) => {
1423
1482
  const tierKnown = sb.excludedCommands.filter((c) => isTierKnownExclusion(source, c));
1424
1483
  const foreign = sb.excludedCommands.filter((c) => !tierKnown.includes(c));
1425
1484
  if (foreign.length) {
1426
- out.push({ source, key: `${SANDBOX_KEY}.excludedCommands`, weakens: 'every sandbox red-line', detail: `${foreign.length} command(s) run UNSANDBOXED (network/fs confinement not applied to them)${tierKnown.length ? `; ${tierKnown.length} bridge-review wrapper exclusion(s) are tier-known and not flagged` : ''}` });
1485
+ out.push({ source, key: `${SANDBOX_KEY}.excludedCommands`, weakens: 'every sandbox red-line', detail: `${foreign.length} command(s) run UNSANDBOXED ${HOST_HONORS_QUALIFIER} (network/fs confinement not applied to them)${tierKnown.length ? `; ${tierKnown.length} bridge-review wrapper exclusion(s) are tier-known and not flagged` : ''}` });
1427
1486
  } else if (tierKnown.length) {
1428
- out.push({ source, key: `${SANDBOX_KEY}.excludedCommands`, weakens: null, tierKnown: true, detail: `${tierKnown.length} bridge-review wrapper exclusion(s) (${tierKnown.join(', ')}) — tier-known: the consented bridge-wrappers tier runs them outside the sandbox (network), and its allow rules are present in the project settings` });
1487
+ out.push({ source, key: `${SANDBOX_KEY}.excludedCommands`, weakens: null, tierKnown: true, detail: `${tierKnown.length} bridge-review wrapper exclusion(s) (${tierKnown.join(', ')}) — tier-known: ${HOST_HONORS_QUALIFIER}, the consented bridge-wrappers tier runs them outside the sandbox (network), and its allow rules are present in the project settings` });
1429
1488
  }
1430
1489
  }
1431
1490
  return out;
@@ -1472,10 +1531,10 @@ export const formatAutonomyResult = (r) => {
1472
1531
  `policy: ${r.source} · per-activity: ${formatActivityLevels(r.activities)} · effective global autonomy: ${r.level}`,
1473
1532
  ];
1474
1533
  if (r.level === AUTONOMY_PROMPT && !Object.values(r.activities).every((v) => v.autonomy === AUTONOMY_PROMPT)) {
1475
- lines.push(' note: global autonomy is `prompt` because not every activity is `sandbox` — set every activity to sandbox (set-autonomy) to enable global auto-allow (conservative unanimity; the sandbox still confines).');
1534
+ lines.push(` note: global autonomy is \`prompt\` because not every activity is \`sandbox\` — set every activity to sandbox (set-autonomy) to enable global auto-allow (conservative unanimity; the sandbox still confines ${HOST_HONORS_QUALIFIER}).`);
1476
1535
  }
1477
1536
  lines.push(
1478
- `sandbox: ${SANDBOX_ENABLED_KEY}=true, ${SANDBOX_AUTOALLOW_KEY}=${r.sandbox[SANDBOX_AUTOALLOW_KEY]} (${r.level === AUTONOMY_SANDBOX ? 'auto-allow confined commands' : 'auto-allow OFF — confine only'})`,
1537
+ `sandbox: ${SANDBOX_ENABLED_KEY}=true, ${SANDBOX_AUTOALLOW_KEY}=${r.sandbox[SANDBOX_AUTOALLOW_KEY]} (${r.level === AUTONOMY_SANDBOX ? 'auto-allow confined commands' : 'auto-allow OFF — confine only'}, ${HOST_HONORS_QUALIFIER})`,
1479
1538
  `permissions.${DEFAULT_MODE_KEY}: ${r.wrote ? 'set to' : 'would set to'} ${r.defaultMode}`,
1480
1539
  `permissions.ask (render-owned red-lines): ${r.ask.length ? r.ask.join(', ') : '(none)'}`,
1481
1540
  `permissions.deny (render-owned red-lines): ${r.deny.length ? r.deny.join(', ') : '(none)'}`,
@@ -1484,15 +1543,26 @@ export const formatAutonomyResult = (r) => {
1484
1543
  for (const n of r.notes) lines.push(` note: ${n}`);
1485
1544
  for (const d of r.degrades) lines.push(` ⚠ DEGRADE: ${d}`);
1486
1545
  for (const m of r.localMasks ?? []) {
1487
- lines.push(` ⚠ ${SETTINGS_LOCAL_FILE} sets ${m.key}=${JSON.stringify(m.local)}, which MASKS this render's ${m.key}=${JSON.stringify(m.rendered)} (local > project) — the local value wins, so the render is not effective for that key; the local file is the maintainer's and is never written by the kit.`);
1546
+ // "the local value wins" is a claim about a settings key's runtime effect wherever the masked key
1547
+ // is a sandbox one — on a host that ignores sandbox.* NEITHER value takes effect. The scope
1548
+ // boundary is deliberate: permissions.* precedence is the harness's own documented permission
1549
+ // model (the layer this whole mode rests on), so those masks stay flat statements.
1550
+ const scoped = m.key.startsWith(`${SANDBOX_KEY}.`) ? ` ${HOST_HONORS_QUALIFIER}` : '';
1551
+ lines.push(` ⚠ ${SETTINGS_LOCAL_FILE} sets ${m.key}=${JSON.stringify(m.local)}, which MASKS this render's ${m.key}=${JSON.stringify(m.rendered)} (local > project) — the local value wins${scoped}, so the render is not effective for that key; the local file is the maintainer's and is never written by the kit.`);
1488
1552
  }
1489
1553
  for (const b of r.redlineBypass ?? []) {
1490
1554
  lines.push(` ⚠ DEGRADE: ${b.source} has a pre-existing allow entry ${b.entry} that would BYPASS the rendered red-line(s) ${b.redlines.join('/')} (a matching allow rule AUTO-APPROVES the command, defeating ask/deny) — remove it by hand; this render never touches permissions.allow.`);
1491
1555
  }
1492
1556
  for (const w of r.sandboxWeakenings ?? []) {
1493
1557
  if (w.tierKnown) lines.push(` note: ${w.source} has ${w.key} (${w.detail}).`);
1558
+ else if (w.unverifiable) lines.push(` ⚠ DEGRADE: ${w.source} has ${w.key} (${w.detail}) — the rendered ${w.weakens} red-line CANNOT BE VERIFIED against it (no claim either way); fix the declared value by hand, then re-run this preview.`);
1494
1559
  else lines.push(` ⚠ DEGRADE: ${w.source} has ${w.key} (${w.detail}), which WEAKENS the rendered ${w.weakens} red-line — the render preserves your sandbox tuning (never clobbers it), so remove it by hand if you want the red-line fully enforced.`);
1495
1560
  }
1561
+ // The notice is UNCONDITIONAL: every autonomy render asserts a settings-key runtime effect (the
1562
+ // sandbox line and its notes at minimum), so a clean deployment would otherwise read those as
1563
+ // promises. It names the unknown once for the whole render; the per-line qualifiers above carry it
1564
+ // for a line quoted in isolation.
1565
+ lines.push(` ${HOST_HONORS_NOTICE}`);
1496
1566
  lines.push(AUTONOMY_RESIDUAL_NOTICE);
1497
1567
  if (!r.wrote) lines.push(`re-run with ${FLAG_APPLY} to write .claude/settings.json (only the render-owned blocks change).`);
1498
1568
  return lines.join(LF);
@@ -1530,7 +1600,13 @@ export const writeAutonomyProfile = ({ cwd, apply = false } = {}, deps = {}) =>
1530
1600
  { source: SETTINGS_LOCAL_FILE, data: preflight.localSettings?.data },
1531
1601
  ];
1532
1602
  const redlineBypass = collectRedlineBypass(settingsSources);
1533
- const sandboxWeakenings = collectSandboxWeakenings(settingsSources);
1603
+ // The allowWrite degrade resolves its entries before judging them, so it needs the same anchors a
1604
+ // host resolving the key would use: the project root, the resolved home, and $TMPDIR.
1605
+ const sandboxWeakenings = collectSandboxWeakenings(settingsSources, {
1606
+ root: projectDir,
1607
+ home: deps.home ?? homedir(),
1608
+ tmp: (deps.env ?? process.env).TMPDIR ?? tmpdir(),
1609
+ });
1534
1610
  const resultBase = {
1535
1611
  autonomy: true,
1536
1612
  source,
@@ -1631,6 +1707,9 @@ export const formatAutonomyCheck = (c) =>
1631
1707
  ? [
1632
1708
  `autonomy --check: IN SYNC — ${SETTINGS_FILE} matches the ${c.source} render (level ${c.level}).`,
1633
1709
  ...(c.degrades ?? []).map((d) => ` ⚠ DEGRADE: ${d}`),
1710
+ // The gate surface carries the same unknown as the preview whenever it states one of these
1711
+ // effects; a --check read in isolation must not be the one place the promise reads flat.
1712
+ ...((c.degrades ?? []).length ? [` ${HOST_HONORS_NOTICE}`] : []),
1634
1713
  ].join('\n')
1635
1714
  : [
1636
1715
  `autonomy --check: DRIFT — ${SETTINGS_FILE} diverges from the ${c.source} render (level ${c.level}):`,