@sabaiway/agent-workflow-kit 14.26.0 → 14.28.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,4 +1,4 @@
1
- import { existsSync, lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
1
+ import { accessSync, constants, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { basename, dirname, join, relative, resolve } from 'node:path';
3
3
  import { homedir, tmpdir } from 'node:os';
4
4
  import { fileURLToPath } from 'node:url';
@@ -7,14 +7,18 @@ import { fileURLToPath } from 'node:url';
7
7
  // it never imports the policy fs-writer (autonomy-write.mjs) — the render owns the settings file, not
8
8
  // the policy file.
9
9
  import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, COMMAND_REDLINES } from './autonomy-config.mjs';
10
- // The bridge-wrappers tier's placement probe (AD-044 Plan 4, Decision 2): a tier entry derives ONLY
11
- // for a PLACED bridge wrapper — findOnPath is the same read-only PATH scan the backend detector uses.
12
- import { findOnPath } from './detect-backends.mjs';
10
+ import { detectBackends, findOnPath, wrapperCmdFor } from './detect-backends.mjs';
13
11
  // The seedable-path predicate lives in the pure leaf so the renders spell a path exactly as it is seeded.
14
12
  import { SHELL_METACHARACTERS, hasShellMetacharacter, isSeedablePathToken } from './repo-lex.mjs';
15
13
  import { isDirectRun } from './direct-run.mjs';
16
14
  import { compareSemver } from './semver-lite.mjs';
17
- import { bundledSandboxRecipe } from './bridge-sandbox-recipe.mjs';
15
+ import { BRIDGE_REVIEW_WRAPPERS, BRIDGE_REVIEW_MODE, KIT_GROUNDING_TOOL, usedBridges, bundledSandboxRecipe, exclusionOf } from './bridge-sandbox-recipe.mjs';
16
+ import { stateDirsOf, mergeAllowWrite } from './bridge-state-dirs.mjs';
17
+ import { HOST_HONORS_QUALIFIER, sandboxSurfaceDelta } from './bridge-wiring.mjs';
18
+ import { loadConfig } from './orchestration-config.mjs';
19
+ import { validateManifest } from './manifest/validate.mjs';
20
+ export { BRIDGE_REVIEW_WRAPPERS, BRIDGE_REVIEW_MODE, KIT_GROUNDING_TOOL };
21
+ export { HOST_HONORS_QUALIFIER };
18
22
  // The declared-path resolution + segment containment the allowWrite degrade shares with the
19
23
  // advisor's worktrees-dir convergence lane — ONE leaf, so the two readings cannot drift.
20
24
  import { resolveDeclaredDir, dirCovers, isResolvableDeclaredEntry } from './declared-paths.mjs';
@@ -197,27 +201,6 @@ const KIT_ROOT = fileURLToPath(new URL('..', import.meta.url));
197
201
  const KIT_TOOL_INVOKER = 'node';
198
202
  const RUN_GATES_CWD_FLAG = '--cwd';
199
203
 
200
- // ── the opt-in --bridge-tier (AD-044 Plan 4, Decision 2) ────────────────────────────────
201
- // The FROZEN membership source: review-role wrapper NAMES only, spelled bare — that is how the
202
- // wrappers are invoked (setup-backends places them as symlinks in ~/.local/bin). Roles come from
203
- // THIS constant, never from the detector's wrapperCmds (which carries no role labels): codex-exec
204
- // (execution role) and agy-run (probe role) are deliberately ABSENT. The velocity pain this tier
205
- // closes is council REVIEW runs; codex-exec's nested-sandbox recovery is handled the canon way —
206
- // route it outside ON the OBSERVED failure (orchestration.md §5), NOT a preemptive tier seed (AD-054
207
- // final council: a blanket exclusion at opt-in contradicts «never a preemptive blanket»), guided by
208
- // codex-exec.sh's own nested-sandbox detection hint.
209
- export const BRIDGE_REVIEW_WRAPPERS = Object.freeze(['codex-review', 'agy-review']);
210
- // Only the `code` review mode is auto-allowed (Segment A): a bare `Bash(<wrapper>:*)`
211
- // prefix would also cover the plan/diff file-argument modes, whose targets can point OUTSIDE the
212
- // repo — the tier's stated surface is the unattended council CODE review, so the seeded prefix is
213
- // `<wrapper> code`. Plan/diff invocations keep their prompt.
214
- export const BRIDGE_REVIEW_MODE = 'code';
215
- // The grounding pre-step tool (the reviews' facts assembler). Its tier entry is seeded in the
216
- // EXACT byte-form the procedures advisor renders — a DOUBLE-QUOTED absolute path (deliberate
217
- // there: a skill dir with a space must stay copy-pasteable) — so seeded↔rendered byte-parity
218
- // holds; the screen accepts the quoted form ONLY for this one tool (a tier-scoped acceptance,
219
- // never a general quote allowance).
220
- export const KIT_GROUNDING_TOOL = 'tools/grounding.mjs';
221
204
  const WRAPPER_PLACED = 'present';
222
205
 
223
206
  // The quoted-grounding token: "<seedable-abs-path>/tools/grounding.mjs" — quotes stripped, the
@@ -229,41 +212,6 @@ const isQuotedGroundingToken = (token) => {
229
212
  return isSeedablePathToken(inner) && inner.endsWith(`/${KIT_GROUNDING_TOOL}`);
230
213
  };
231
214
 
232
- /**
233
- * Derive the opt-in bridge-wrappers tier: one code-mode allow rule per PLACED review wrapper (the
234
- * frozen constant is the membership source; placement is a probe, never a role source), the SAME
235
- * wrapper names for sandbox.excludedCommands (the harness runs an excluded command OUTSIDE the
236
- * sandbox, so a plain allowlisted invocation needs no sandbox-bypass approval — the zero-prompt
237
- * wiring), and the grounding pre-step rule in its rendered quoted byte-form (derived only when
238
- * agy-review is placed; an unseedable kit path is a stated skip). An absent bridge is a stated skip.
239
- */
240
- export const deriveBridgeTierAllowlist = ({ findWrapper, groundingAbsPath } = {}) => {
241
- const probe = findWrapper ?? ((cmd) => findOnPath(cmd).state === WRAPPER_PLACED);
242
- const placed = BRIDGE_REVIEW_WRAPPERS.filter((cmd) => probe(cmd));
243
- const skips = BRIDGE_REVIEW_WRAPPERS.filter((cmd) => !placed.includes(cmd)).map((cmd) => ({
244
- entry: cmd,
245
- reason: `bridge wrapper "${cmd}" is not placed on PATH — its allow rule is not seeded (place the bridge with /agent-workflow-kit setup, then re-run)`,
246
- }));
247
- const allow = placed.map((cmd) => `Bash(${cmd} ${BRIDGE_REVIEW_MODE}:*)`);
248
- const excludedCommands = [...placed];
249
- // The grounding pre-step exists FOR agy (its grounded --facts reviews; codex grounds natively via
250
- // the AGENTS.md auto-merge) — a codex-only install must not auto-allow an unused writer.
251
- if (placed.includes('agy-review')) {
252
- // groundingAbsPath is a TEST seam only (an unseedable kit path — spaces — is not constructible
253
- // from a test against the real checkout); production callers never pass it.
254
- const groundingAbs = groundingAbsPath ?? join(KIT_ROOT, KIT_GROUNDING_TOOL);
255
- if (isSeedablePathToken(groundingAbs)) {
256
- allow.push(`Bash(${KIT_TOOL_INVOKER} "${groundingAbs}":*)`);
257
- } else {
258
- skips.push({
259
- entry: groundingAbs,
260
- reason: 'the kit path is not a POSIX absolute path free of spaces/metacharacters/quoting — the grounding pre-step rule is not seeded (its prompt stays); add a hand-picked entry if you accept the spelling',
261
- });
262
- }
263
- }
264
- return Object.freeze({ allow: Object.freeze(allow), excludedCommands: Object.freeze(excludedCommands), skips: Object.freeze(skips), placed: Object.freeze(placed) });
265
- };
266
-
267
215
  // Characters that make a pattern NOT a single read-only command, so the screen rejects them.
268
216
  // Per current Claude Code permission semantics: the recognized command SEPARATORS are
269
217
  // `&& || ; | |& &` plus newline (each segment must be independently permitted, so a chained
@@ -357,18 +305,19 @@ const DO_NOT_ADD_WARNING = 'do not add';
357
305
  const ADD_BY_HAND = true;
358
306
  const MUTATING_SCRIPT_NAME_PATTERN = /(release|publish|deploy|push|version|commit|tag)/iu;
359
307
  const MUTATING_SCRIPT_HOOK_PATTERN = /^(pre|post)/iu;
308
+ const CODEX_BRIDGE = 'codex-cli-bridge';
309
+ const EXECUTE_ROLE = 'execute';
360
310
  // The one thing velocity must never seed — an explicit, greppable expression of the load-bearing
361
311
  // invariant (kept deliberately even though the read-only screen already rejects these, so the
362
312
  // refusal is named, tested, and produces a clear message).
363
313
  const MUTATING_ALLOW_COMMAND_PATTERN = /^(?:git\s+(?:commit|push)|npm\s+publish)(?:\s|$)/iu;
364
314
  // Whether a host APPLIES a `sandbox.*` settings key is not knowable from here: a settings-native
365
- // host honors it, while an IDE/session-imposed sandbox was observed ignoring the hand-applied
366
- // security keys in BOTH scopes (the advisor records the same limit in
315
+ // host honors it, while an IDE/session-imposed sandbox was observed ignoring the security keys
316
+ // in BOTH scopes (the advisor records the same limit in
367
317
  // references/modes/recommendations.md). So every claim about the RUNTIME EFFECT of a settings key —
368
318
  // the tier's exclusion routing and every weakening detail alike — carries this qualifier. The
369
319
  // CLASSIFICATION is untouched: which red-line a key would weaken, and that a proven tier exclusion
370
320
  // is a note rather than a weakening, are properties of the DECLARATION, not of a host.
371
- export const HOST_HONORS_QUALIFIER = 'where the host honors the settings sandbox keys';
372
321
  export const HOST_HONORS_NOTICE =
373
322
  `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.`;
374
323
 
@@ -386,14 +335,15 @@ absolute path (args wildcard), run-gates.mjs as ONE exact project-root-pinned by
386
335
  (project-exec - it runs YOUR declared gates.json), source-size-check.mjs as ONE exact --check
387
336
  byte-string (its read-only mode only - its writers keep prompting), and the writers' exact arg-free
388
337
  dry-run preview byte-strings. Never touches settings.local.json.
389
- --bridge-tier (own consent) seeds the bridge REVIEW wrappers' CODE mode for PLACED bridges
390
- (codex-review code, agy-review code - never the execution/probe wrappers, never plan/diff modes)
391
- + the quoted grounding pre-step rule, and the wrapper names into sandbox.excludedCommands (they
392
- need network - ${HOST_HONORS_QUALIFIER} the harness runs them outside the sandbox). It also
393
- seeds the placed bridges' declared hosts into sandbox.network.allowedDomains - ${HOST_HONORS_QUALIFIER}
394
- every sandboxed command of the project can then reach those hosts; the writable state dirs stay
395
- hand-apply. Consented posture: an auto-allowed review wrapper runs UNATTENDED and sends the
396
- assembled repo payload to its subscription backend (see the printed tier notice).
338
+ --bridge-tier (own consent) derives each entry for a used bridge from the effective project recipes:
339
+ review wrappers' CODE mode (codex-review code, agy-review code), the quoted grounding pre-step rule,
340
+ and <wrapper> * exclusions in sandbox.excludedCommands. An execute slot delegating to codex adds
341
+ codex-exec * as an exclusion; execution keeps its prompt, as do the review plan/diff modes.
342
+ ${HOST_HONORS_QUALIFIER}, an excluded wrapper runs outside the sandbox;
343
+ sandbox.network.allowedDomains means every sandboxed command of the project can reach those hosts;
344
+ sandbox.filesystem.allowWrite means every sandboxed command can write those state dirs.
345
+ Consented posture: an auto-allowed review wrapper runs UNATTENDED and sends the assembled repo
346
+ payload to its subscription backend (see the printed tier notice).
397
347
 
398
348
  --autonomy renders docs/ai/autonomy.json into the settings blocks it OWNS — the sandbox block +
399
349
  permissions.ask/deny red-lines + permissions.defaultMode. POLICY-ONLY: never seeds the allowlist and
@@ -696,11 +646,9 @@ const formatKitTier = (result) =>
696
646
  // The bridge tier's honest posture, printed on EVERY --bridge-tier run: the informed-consent
697
647
  // resolution states the exfiltration surface, never pretends it away.
698
648
  export const KIT_BRIDGE_TIER_NOTICE =
699
- '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): ' +
700
- // The interpolated seams in this notice: the shared host-conditional qualifier, so the tier's
701
- // routing promise and the render's degrade lines can never drift apart.
702
- `${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). ` +
703
- `The placed bridges' declared hosts (each bundled manifest's networkHosts) are seeded into sandbox.network.allowedDomains IN THE PROJECT settings.json: ${HOST_HONORS_QUALIFIER}, a wrapper the harness keeps sandboxed still reaches them, and every sandboxed command of the project can then reach those hosts, not only the wrappers — that widening is part of this consent, and the --autonomy preview reports it as a network weakening; the writable state dirs (writableDirs) stay hand-apply. ` +
649
+ 'bridge-wrappers tier: each entry derives for a used bridge from the effective project recipes and host readiness. Review allows cover only CODE mode (`codex-review code`, `agy-review code`) plus the grounding pre-step rule in its rendered quoted byte-form; the plan/diff modes keep their prompt because their file arguments can point outside the repo. An execute slot delegating to codex adds codex-exec * to sandbox.excludedCommands and keeps its prompt, with no execution allow rule; agy-run is not seeded. ' +
650
+ '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. Prefix rules cannot inspect arguments, so readable file arguments (agy\'s --facts/--decided) ride the same consent; 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). ' +
651
+ `The tier writes the PROJECT settings.json: ${HOST_HONORS_QUALIFIER}, an excluded wrapper runs outside the sandbox (<wrapper> * in sandbox.excludedCommands), every sandboxed command of the project can reach the declared hosts (sandbox.network.allowedDomains), and every sandboxed command can write the declared state dirs (sandbox.filesystem.allowWrite). These surfaces are part of the bridge-wrappers consent. Whether a host honors them is not knowable from here; an exclusion only in settings.local.json was live-observed not to route. ` +
704
652
  '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).';
705
653
 
706
654
  const formatBridgeTier = (result) =>
@@ -719,6 +667,13 @@ const formatBridgeTier = (result) =>
719
667
  `already present (allowedDomains): ${result.hostsAlreadyPresent.length}`,
720
668
  ]
721
669
  : []),
670
+ ...(result.dirsToAdd.length + result.dirsAlreadyPresent.length
671
+ ? [
672
+ `${result.wrote ? 'added' : 'would add'} sandbox.filesystem.allowWrite entries: ${result.dirsToAdd.length}`,
673
+ ...formatEntryList(result.dirsToAdd),
674
+ `already present (allowWrite): ${result.dirsAlreadyPresent.length}`,
675
+ ]
676
+ : []),
722
677
  ...result.bridgeSkips.map((s) => ` skipped: ${s.reason}`),
723
678
  KIT_BRIDGE_TIER_NOTICE,
724
679
  ]
@@ -941,7 +896,8 @@ export const preflightVelocityProfile = ({ cwd }, deps = {}) => {
941
896
  // bridge-shaped entry — over-flagging is the safe direction for a read-only advisory.
942
897
  const derivedBridge = (() => {
943
898
  try {
944
- return deriveBridgeTierAllowlist({ findWrapper: deps.findWrapper }).allow;
899
+ const probe = deps.findWrapper ?? ((cmd) => findOnPath(cmd).state === WRAPPER_PLACED);
900
+ return bundledSandboxRecipe(BRIDGE_REVIEW_WRAPPERS.filter(probe), deps).allow;
945
901
  } catch {
946
902
  return [];
947
903
  }
@@ -982,24 +938,76 @@ const assertNetworkMergeable = (sandbox) => {
982
938
  return sandbox.network?.allowedDomains ?? [];
983
939
  };
984
940
 
985
- export const planVelocityProfile = (preflight, { acceptEdits, kitTools, bridgeTier, findWrapper, bundleRoot, readdir, readFile } = {}) => {
941
+ const assertFilesystemMergeable = (sandbox) => {
942
+ if (sandbox.filesystem !== undefined && !isJsonObject(sandbox.filesystem)) {
943
+ throw makeVelocityProfileError(VELOCITY_MALFORMED, `${SETTINGS_FILE}: sandbox.filesystem must be a JSON object`);
944
+ }
945
+ const allowWrite = sandbox.filesystem?.allowWrite;
946
+ if (allowWrite !== undefined && !Array.isArray(allowWrite)) {
947
+ throw makeVelocityProfileError(VELOCITY_MALFORMED, `${SETTINGS_FILE}: sandbox.filesystem.allowWrite must be an array`);
948
+ }
949
+ if (allowWrite?.some((entry) => !isResolvableDeclaredEntry(entry))) {
950
+ throw makeVelocityProfileError(VELOCITY_MALFORMED, `${SETTINGS_FILE}: sandbox.filesystem.allowWrite entries must be non-blank strings`);
951
+ }
952
+ return allowWrite ?? [];
953
+ };
954
+
955
+ // Readiness as a wrapper started from the project root sees it: every relative path the detector probes
956
+ // (an env override such as CODEX_HOME=state) resolves against the root, never this process's cwd, so the
957
+ // danger check and the writer it spawns with cwd=root derive one used set.
958
+ export const detectAtRoot = (root, { env, home }) => {
959
+ const at = (path) => resolve(root, path);
960
+ return detectBackends({
961
+ getenv: env, home,
962
+ exists: (path) => existsSync(at(path)), stat: (path) => statSync(at(path)),
963
+ access: (path) => accessSync(at(path), constants.X_OK), realpath: (path) => realpathSync(at(path)),
964
+ validate: (dir) => validateManifest(at(dir)),
965
+ });
966
+ };
967
+
968
+ const deriveUsedBridgeTier = (root, deps = {}) => {
969
+ const env = deps.env ?? process.env;
970
+ const home = deps.home ?? homedir();
971
+ const config = loadConfig(root).config;
972
+ const readiness = (deps.detect ?? (() => detectAtRoot(root, { env, home })))();
973
+ const used = usedBridges(config, readiness);
974
+ const recipe = bundledSandboxRecipe(used, deps);
975
+ const dirs = stateDirsOf(recipe.dirEntries, { env, root, home });
976
+ return { used, recipe, dirs, env, home };
977
+ };
978
+
979
+ export const planVelocityProfile = (preflight, opts = {}) => {
980
+ const { acceptEdits, kitTools, bridgeTier } = opts;
986
981
  const projectAllow = getAllowEntries(preflight.projectSettings?.data);
987
982
  const toAdd = UNIVERSAL_READONLY_ALLOWLIST.filter((entry) => !projectAllow.includes(entry));
988
983
  const alreadyPresent = UNIVERSAL_READONLY_ALLOWLIST.filter((entry) => projectAllow.includes(entry));
989
984
  const tier = kitTools === true ? deriveKitToolsAllowlist({ projectDir: preflight.cwd }) : [];
990
985
  const tierToAdd = tier.filter((entry) => !projectAllow.includes(entry));
991
986
  const tierAlreadyPresent = tier.filter((entry) => projectAllow.includes(entry));
992
- const bridge = bridgeTier === true ? deriveBridgeTierAllowlist({ findWrapper }) : { allow: [], excludedCommands: [], skips: [], placed: [] };
987
+ const derived = bridgeTier === true ? deriveUsedBridgeTier(preflight.cwd, opts) : null;
988
+ const bridge = derived?.recipe ?? { allow: [], excludedCommands: [], hosts: [], skips: [] };
993
989
  const bridgeToAdd = bridge.allow.filter((entry) => !projectAllow.includes(entry));
994
990
  const bridgeAlreadyPresent = bridge.allow.filter((entry) => projectAllow.includes(entry));
995
991
  const existingSandbox = isJsonObject(preflight.projectSettings?.data?.sandbox) ? preflight.projectSettings.data.sandbox : {};
996
- const existingExcluded = Array.isArray(existingSandbox.excludedCommands) ? existingSandbox.excludedCommands : [];
997
- const excludedToAdd = bridge.excludedCommands.filter((cmd) => !existingExcluded.includes(cmd));
998
- const excludedAlreadyPresent = bridge.excludedCommands.filter((cmd) => existingExcluded.includes(cmd));
999
- const existingHosts = bridgeTier === true ? assertNetworkMergeable(existingSandbox) : [];
1000
- const hosts = bridgeTier === true ? bundledSandboxRecipe(bridge.placed, { bundleRoot, readdir, readFile }).hosts : [];
1001
- const hostsToAdd = hosts.filter((host) => !existingHosts.includes(host));
1002
- const hostsAlreadyPresent = hosts.filter((host) => existingHosts.includes(host));
992
+ if (bridgeTier === true) {
993
+ assertNetworkMergeable(existingSandbox);
994
+ assertFilesystemMergeable(existingSandbox);
995
+ }
996
+ const dirs = derived?.dirs ?? [];
997
+ const delta = derived
998
+ ? sandboxSurfaceDelta({ excludedCommands: bridge.excludedCommands, hosts: bridge.hosts, dirs }, existingSandbox, { home: derived.home, root: preflight.cwd })
999
+ : { excludedCommands: [], hosts: [], dirs: [] };
1000
+ const { excludedCommands: excludedToAdd, hosts: hostsToAdd, dirs: dirsToAdd } = delta;
1001
+ const excludedAlreadyPresent = bridge.excludedCommands.filter((cmd) => !excludedToAdd.includes(cmd));
1002
+ const hostsAlreadyPresent = bridge.hosts.filter((host) => !hostsToAdd.includes(host));
1003
+ const dirsAlreadyPresent = dirs.filter((dir) => !dirsToAdd.includes(dir));
1004
+ const executeRecipe = derived?.used.some((entry) => entry.bridge === CODEX_BRIDGE && entry.roles.includes(EXECUTE_ROLE))
1005
+ ? bundledSandboxRecipe([{ bridge: CODEX_BRIDGE, roles: [EXECUTE_ROLE] }], opts)
1006
+ : null;
1007
+ const executeDirs = executeRecipe ? stateDirsOf(executeRecipe.dirEntries, { env: derived.env, root: preflight.cwd, home: derived.home }) : [];
1008
+ const executeToAdd = executeRecipe
1009
+ ? sandboxSurfaceDelta({ excludedCommands: executeRecipe.excludedCommands, hosts: executeRecipe.hosts, dirs: executeDirs }, existingSandbox, { home: derived.home, root: preflight.cwd })
1010
+ : { excludedCommands: [], hosts: [], dirs: [] };
1003
1011
  return {
1004
1012
  toAdd,
1005
1013
  alreadyPresent,
@@ -1014,6 +1022,9 @@ export const planVelocityProfile = (preflight, { acceptEdits, kitTools, bridgeTi
1014
1022
  excludedAlreadyPresent,
1015
1023
  hostsToAdd,
1016
1024
  hostsAlreadyPresent,
1025
+ dirsToAdd,
1026
+ dirsAlreadyPresent,
1027
+ executeToAdd,
1017
1028
  setsDefaultMode: acceptEdits === true,
1018
1029
  };
1019
1030
  };
@@ -1022,13 +1033,10 @@ export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true,
1022
1033
  const projectDir = cwd ?? deps.cwd ?? process.cwd();
1023
1034
  const preflight = preflightVelocityProfile({ cwd: projectDir }, deps);
1024
1035
  const plan = planVelocityProfile(preflight, {
1036
+ ...deps,
1025
1037
  acceptEdits,
1026
1038
  kitTools,
1027
1039
  bridgeTier,
1028
- findWrapper: deps.findWrapper,
1029
- bundleRoot: deps.bundleRoot,
1030
- readdir: deps.readdir,
1031
- readFile: deps.readFile,
1032
1040
  });
1033
1041
  // Drift guard runs on BOTH dry-run and apply (so a dry-run faithfully predicts the apply) and
1034
1042
  // validates the FULL audited core, not just the to-add delta — a drifted core entry is caught even
@@ -1044,8 +1052,8 @@ export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true,
1044
1052
  // (Decision 2): every seeded bridge entry passes the screen AND sits in the audited set, so the
1045
1053
  // flagless advisory can never flag an entry the tier itself seeded.
1046
1054
  if (bridgeTier === true) {
1047
- const bridge = deriveBridgeTierAllowlist({ findWrapper: deps.findWrapper });
1048
- validateProfile(bridge.allow, [...UNIVERSAL_READONLY_ALLOWLIST, ...bridge.allow]);
1055
+ const bridgeAllow = [...plan.bridgeToAdd, ...plan.bridgeAlreadyPresent];
1056
+ validateProfile(bridgeAllow, [...UNIVERSAL_READONLY_ALLOWLIST, ...bridgeAllow]);
1049
1057
  }
1050
1058
  const resultBase = { ...preflight, ...plan };
1051
1059
  if (dryRun) return { wrote: false, dryRun: true, ...resultBase };
@@ -1060,13 +1068,13 @@ export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true,
1060
1068
  const fs = fsDeps(deps);
1061
1069
  const settingsPath = join(projectDir, SETTINGS_FILE);
1062
1070
  if (preflight.claudeDirAbsent) fs.mkdir(join(projectDir, CLAUDE_DIR), { recursive: true });
1063
- const merged = mergeProjectSettings(
1071
+ const merged = mergeAllowWrite(mergeProjectSettings(
1064
1072
  preflight.projectSettings.data,
1065
1073
  [...plan.toAdd, ...plan.tierToAdd, ...plan.bridgeToAdd],
1066
1074
  acceptEdits,
1067
1075
  plan.excludedToAdd,
1068
1076
  plan.hostsToAdd,
1069
- );
1077
+ ), plan.dirsToAdd);
1070
1078
  fs.writeFile(settingsPath, formatJson(merged, preflight.projectSettings.eol ?? LF), UTF8);
1071
1079
  return { wrote: true, dryRun: false, settingsPath, ...resultBase };
1072
1080
  };
@@ -1489,23 +1497,76 @@ const externalWriteEntries = (entries, { root, home, tmp }) => {
1489
1497
  };
1490
1498
  };
1491
1499
 
1492
- const collectSandboxWeakenings = (sources, { root, home, tmp }) => {
1493
- // Tier-known PROOF: an excludedCommands entry is downgraded to a note ONLY when it is
1494
- // demonstrably the consented tier's own output — it lives in the PROJECT settings.json (the file
1495
- // the tier writes; a local-file exclusion is never tier output) AND the matching derived
1496
- // code-mode allow rule is present there. A bare name match alone proves nothing.
1500
+ const cacheBundleReader = (read) => {
1501
+ const entries = new Map();
1502
+ return (path, ...args) => {
1503
+ if (!entries.has(path)) entries.set(path, read(path, ...args));
1504
+ return entries.get(path);
1505
+ };
1506
+ };
1507
+
1508
+ // The --autonomy preview's tier-known set (spec velocity-profile): only a used role whose consent
1509
+ // proof the PROJECT settings carry — a review role's derived code-mode allow rule, the execute
1510
+ // role's `codex-exec *` exclusion — makes its derived surfaces notes; any derivation failure is
1511
+ // returned as `error`, and the caller then judges every entry as revision 3 did.
1512
+ const deriveBridgeConsent = (data, root, deps) => {
1513
+ try {
1514
+ const bundleDeps = {
1515
+ ...deps,
1516
+ readdir: cacheBundleReader(deps.readdir ?? readdirSync),
1517
+ readFile: cacheBundleReader(deps.readFile ?? readFileSync),
1518
+ };
1519
+ const derived = deriveUsedBridgeTier(root, bundleDeps);
1520
+ const allow = getAllowEntries(data);
1521
+ const excluded = Array.isArray(data?.sandbox?.excludedCommands) ? data.sandbox.excludedCommands : [];
1522
+ const proven = derived.used.map(({ bridge, roles }) => ({
1523
+ bridge,
1524
+ roles: roles.filter((role) => {
1525
+ const wrapper = wrapperCmdFor(bridge, role);
1526
+ const exclusion = exclusionOf(wrapper);
1527
+ if (role === 'execute') return derived.recipe.excludedCommands.includes(exclusion) && excluded.includes(exclusion);
1528
+ const rule = `Bash(${wrapper} ${BRIDGE_REVIEW_MODE}:*)`;
1529
+ return derived.recipe.allow.includes(rule) && allow.includes(rule);
1530
+ }),
1531
+ })).filter(({ roles }) => roles.length);
1532
+ const allProven = JSON.stringify(proven) === JSON.stringify(derived.used);
1533
+ const recipe = allProven
1534
+ ? derived.recipe
1535
+ : bundledSandboxRecipe(proven, bundleDeps);
1536
+ const knownDirs = allProven ? derived.dirs : stateDirsOf(recipe.dirEntries, { env: derived.env, root, home: derived.home });
1537
+ const bareReview = recipe.excludedCommands
1538
+ .map((entry) => entry.slice(0, -2))
1539
+ .filter((cmd) => BRIDGE_REVIEW_WRAPPERS.includes(cmd));
1540
+ return { dirs: derived.dirs, knownDirs, hosts: recipe.hosts, excluded: [...recipe.excludedCommands, ...bareReview] };
1541
+ } catch (err) {
1542
+ return { error: err?.message ?? String(err) };
1543
+ }
1544
+ };
1545
+
1546
+ const collectSandboxWeakenings = (sources, { root, home, tmp }, tier) => {
1497
1547
  const project = sources.find((s) => s.source === SETTINGS_FILE);
1498
1548
  const projectAllow = getAllowEntries(project?.data);
1549
+ const judged = tier && !tier.error;
1550
+ const isTierKnown = (source, entry, entries) => source === SETTINGS_FILE && judged && entries.includes(entry);
1499
1551
  const isTierKnownExclusion = (source, cmd) =>
1500
- source === SETTINGS_FILE &&
1501
- BRIDGE_REVIEW_WRAPPERS.includes(cmd) &&
1502
- projectAllow.includes(`Bash(${cmd} ${BRIDGE_REVIEW_MODE}:*)`);
1552
+ judged
1553
+ ? isTierKnown(source, cmd, tier.excluded)
1554
+ : source === SETTINGS_FILE && BRIDGE_REVIEW_WRAPPERS.includes(cmd) &&
1555
+ projectAllow.includes(`Bash(${cmd} ${BRIDGE_REVIEW_MODE}:*)`);
1503
1556
  return sources.flatMap(({ source, data }) => {
1504
1557
  const sb = isJsonObject(data?.[SANDBOX_KEY]) ? data[SANDBOX_KEY] : {};
1505
1558
  const out = [];
1559
+ const addNotes = (key, entries, detail) => {
1560
+ for (const entry of entries) out.push({ source, key, entry, tierKnown: true, detail });
1561
+ };
1506
1562
  const net = isJsonObject(sb.network) ? sb.network : {};
1507
1563
  if (Array.isArray(net.allowedDomains) && net.allowedDomains.length) {
1508
- 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` });
1564
+ const known = net.allowedDomains.filter((entry) => isTierKnown(source, entry, tier?.hosts));
1565
+ const foreign = net.allowedDomains.filter((entry) => !known.includes(entry));
1566
+ addNotes(`${SANDBOX_KEY}.network.allowedDomains`, known, 'every sandboxed command of the project can reach it');
1567
+ if (foreign.length) {
1568
+ out.push({ source, key: `${SANDBOX_KEY}.network.allowedDomains`, weakens: 'network', detail: `${foreign.length} pre-allowed domain(s) — ${HOST_HONORS_QUALIFIER}, egress to them is not gated` });
1569
+ }
1509
1570
  }
1510
1571
  const fsb = isJsonObject(sb.filesystem) ? sb.filesystem : {};
1511
1572
  // A PRESENT but unreadable declaration is reported, never assumed empty: what it would make
@@ -1516,16 +1577,24 @@ const collectSandboxWeakenings = (sources, { root, home, tmp }) => {
1516
1577
  // instead of asserting an effect it does not know.
1517
1578
  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` });
1518
1579
  } else if (Array.isArray(fsb.allowWrite) && fsb.allowWrite.length) {
1580
+ const known = fsb.allowWrite.filter((entry) => isTierKnown(source, entry, tier?.knownDirs));
1581
+ const foreign = fsb.allowWrite.filter((entry) => !known.includes(entry));
1582
+ addNotes(`${SANDBOX_KEY}.filesystem.allowWrite`, known, 'every sandboxed command can write it');
1519
1583
  // Only the entries that RESOLVE outside the repo and $TMPDIR are a weakening at all — an entry
1520
- // pointing INSIDE the repo grants nothing the red-line withholds, so reporting it would be an
1584
+ // pointing INSIDE the repo grants nothing beyond the red-line's own bound, so reporting it would be an
1521
1585
  // over-report. All-contained ⇒ no line at all.
1522
1586
  // TWO records, never one: a resolved external path IS a weakening, an unresolvable entry is
1523
1587
  // UNVERIFIABLE, and a single line carrying both would assert an effect for entries it could
1524
- // not read — the contradiction this phase exists to remove. An array of only-unresolvable
1525
- // entries therefore yields no weakening claim at all.
1526
- const { external, unresolvable } = externalWriteEntries(fsb.allowWrite, { root, home, tmp });
1588
+ // not read. An array of only-unresolvable entries therefore yields no weakening claim at all.
1589
+ const { external, unresolvable } = externalWriteEntries(foreign, { root, home, tmp });
1527
1590
  if (external.length) {
1528
- 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(', ')}` });
1591
+ const covered = source === SETTINGS_FILE && judged
1592
+ ? tier.dirs.filter((dir) => foreign.some((entry) => isResolvableDeclaredEntry(entry) && entry !== dir &&
1593
+ external.includes(resolveDeclaredDir(entry, { home, root })) &&
1594
+ dirCovers(resolveDeclaredDir(entry, { home, root }), resolveDeclaredDir(dir, { home, root }))))
1595
+ : [];
1596
+ const coveringDetail = covered.map((dir) => `; covers the bridge state dir ${dir}`).join('');
1597
+ 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(', ')}${coveringDetail}` });
1529
1598
  }
1530
1599
  if (unresolvable) {
1531
1600
  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` });
@@ -1535,17 +1604,20 @@ const collectSandboxWeakenings = (sources, { root, home, tmp }) => {
1535
1604
  out.push({ source, key: `${SANDBOX_KEY}.allowUnsandboxedCommands`, weakens: 'every sandbox red-line', detail: `${HOST_HONORS_QUALIFIER}, commands may run unsandboxed` });
1536
1605
  }
1537
1606
  if (Array.isArray(sb.excludedCommands) && sb.excludedCommands.length) {
1538
- // The bridge tier's OWN wrapper names are tier-known ONLY with the proof above (the
1539
- // --bridge-tier consent covers exactly this posture — the wrappers need network and
1540
- // genuinely run unsandboxed); then they are an informational note, never a weakening flag
1541
- // (the Decision-2 self-consistency bar). Every OTHER excluded command — hand-added names,
1542
- // local-file exclusions, tier names without their allow rules — stays a loud weakening.
1607
+ // The bridge tier's OWN exclusions are tier-known ONLY with their consent proof; then they are
1608
+ // a note, never a weakening flag. Every OTHER excluded command — hand-added names, local-file
1609
+ // exclusions, tier names without their proof — stays a loud weakening.
1543
1610
  const tierKnown = sb.excludedCommands.filter((c) => isTierKnownExclusion(source, c));
1544
1611
  const foreign = sb.excludedCommands.filter((c) => !tierKnown.includes(c));
1612
+ // A bare name matches only the argument-free command, so its note never claims real calls leave the sandbox.
1613
+ if (judged) {
1614
+ addNotes(`${SANDBOX_KEY}.excludedCommands`, tierKnown.filter((c) => c.endsWith(' *')), 'the wrapper runs outside the sandbox');
1615
+ addNotes(`${SANDBOX_KEY}.excludedCommands`, tierKnown.filter((c) => !c.endsWith(' *')), 'only its argument-free invocation runs outside the sandbox');
1616
+ }
1545
1617
  if (foreign.length) {
1546
- 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` : ''}` });
1547
- } else if (tierKnown.length) {
1548
- 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` });
1618
+ 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)${!judged && tierKnown.length ? `; ${tierKnown.length} bridge-review wrapper exclusion(s) are tier-known and not flagged` : ''}` });
1619
+ } else if (!judged && tierKnown.length) {
1620
+ 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}, only their argument-free invocations run outside the sandbox, and their allow rules are present in the project settings` });
1549
1621
  }
1550
1622
  }
1551
1623
  return out;
@@ -1614,8 +1686,10 @@ export const formatAutonomyResult = (r) => {
1614
1686
  for (const b of r.redlineBypass ?? []) {
1615
1687
  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.`);
1616
1688
  }
1689
+ if (r.bridgeTierError) lines.push(` note: bridge-tier entries not judged — ${r.bridgeTierError}`);
1617
1690
  for (const w of r.sandboxWeakenings ?? []) {
1618
- if (w.tierKnown) lines.push(` note: ${w.source} has ${w.key} (${w.detail}).`);
1691
+ if (w.tierKnown && hasOwn(w, 'entry')) lines.push(` note: ${w.source} has ${w.key} entry ${JSON.stringify(w.entry)} — tier-known: part of the bridge-wrappers tier consent; ${HOST_HONORS_QUALIFIER}, ${w.detail}.`);
1692
+ else if (w.tierKnown) lines.push(` note: ${w.source} has ${w.key} (${w.detail}).`);
1619
1693
  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.`);
1620
1694
  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.`);
1621
1695
  }
@@ -1663,11 +1737,12 @@ export const writeAutonomyProfile = ({ cwd, apply = false } = {}, deps = {}) =>
1663
1737
  const redlineBypass = collectRedlineBypass(settingsSources);
1664
1738
  // The allowWrite degrade resolves its entries before judging them, so it needs the same anchors a
1665
1739
  // host resolving the key would use: the project root, the resolved home, and $TMPDIR.
1740
+ const bridgeConsent = deriveBridgeConsent(preflight.projectSettings?.data, projectDir, deps);
1666
1741
  const sandboxWeakenings = collectSandboxWeakenings(settingsSources, {
1667
1742
  root: projectDir,
1668
1743
  home: deps.home ?? homedir(),
1669
1744
  tmp: (deps.env ?? process.env).TMPDIR ?? tmpdir(),
1670
- });
1745
+ }, bridgeConsent);
1671
1746
  const resultBase = {
1672
1747
  autonomy: true,
1673
1748
  source,
@@ -1677,6 +1752,7 @@ export const writeAutonomyProfile = ({ cwd, apply = false } = {}, deps = {}) =>
1677
1752
  localMasks,
1678
1753
  redlineBypass,
1679
1754
  sandboxWeakenings,
1755
+ bridgeTierError: bridgeConsent.error,
1680
1756
  stamp: preflight.stamp,
1681
1757
  stampOk: preflight.stampOk,
1682
1758
  };
@@ -41,7 +41,6 @@ export const VARIANT_LANES = Object.freeze({
41
41
  'adr-store-migration': LANE_CHAT,
42
42
  'sandbox-masks': LANE_CHAT,
43
43
  'sandbox-masks.unfenced-mount': LANE_CHAT,
44
- 'sandbox-lane': LANE_CHAT,
45
44
  'worktrees-dir': LANE_CHAT,
46
45
  'spec-adoption': LANE_CHAT,
47
46
  'profile-gap': LANE_CHAT,