@sabaiway/agent-workflow-kit 14.27.0 → 14.29.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.
@@ -22,9 +22,7 @@
22
22
  // skipped-item line — never a crash, never a fabricated item. The consented
23
23
  // `velocity-profile --bridge-tier` seeds used bridges' allow rules, <wrapper> * exclusions
24
24
  // (codex-exec * where an execute slot delegates), hosts in sandbox.network.allowedDomains and
25
- // state dirs in sandbox.filesystem.allowWrite; the sandbox-lane item's convergence is a NEUTRAL
26
- // fingerprint-bound acknowledgement recorded by the consent-gated ack writer into the family-owned
27
- // docs/ai/acks.json (AD-055 relocated it off the host settings schema), never a security key (D4).
25
+ // state dirs in sandbox.filesystem.allowWrite; its recipe: line names the missing entries.
28
26
  //
29
27
  // Read-only: never writes, never commits, never runs a subscription CLI. The reused probes are all
30
28
  // exported read-only surfaces of their owning tools (velocity/autonomy/doctor/backends/recipes/
@@ -43,15 +41,13 @@ import {
43
41
  probeSandboxAvailability,
44
42
  isExecutableFile,
45
43
  readSettingsFile,
46
- BRIDGE_REVIEW_WRAPPERS,
47
- BRIDGE_REVIEW_MODE,
48
44
  SETTINGS_FILE,
49
45
  SETTINGS_LOCAL_FILE,
50
46
  } from './velocity-profile.mjs';
51
47
  import { loadAutonomy, isSparseSeedConfig, AUTONOMY_REL } from './autonomy-config.mjs';
52
48
  import { deriveDoctorPlan, TRUSTED_DIRS } from './autonomy-doctor.mjs';
53
49
  import { laneOf, applySlot } from './write-lanes.mjs';
54
- import { detectBackends, findOnPath } from './detect-backends.mjs';
50
+ import { detectBackends } from './detect-backends.mjs';
55
51
  import { isDirectRun } from './direct-run.mjs';
56
52
  import { ACTIVITIES, resolveActivityRecipe, composeReadiness, safeLine } from './recipes.mjs';
57
53
  import { surveyFamily, surveyGateHook, surveyAdrLayoutStrict } from './family-registry.mjs';
@@ -80,8 +76,8 @@ import { CHECKER_CLAIM, INITIAL_ADOPTION_REASON, SOURCE_SIZE_GATE_ID, classifySo
80
76
  import { resolveDeclaredDir, dirCovers, isResolvableDeclaredEntry } from './declared-paths.mjs';
81
77
  import { resolveGitHooksPath } from './commit-guard.mjs';
82
78
  import { loadConfig } from './orchestration-config.mjs';
83
- import { bundledSandboxRecipe } from './bridge-sandbox-recipe.mjs';
84
- import { homeRelative, resolveWritableDir } from './bridge-state-dirs.mjs';
79
+ import { homeRelative } from './bridge-state-dirs.mjs';
80
+ import { sandboxEnabled, HOST_HONORS_QUALIFIER } from './bridge-wiring.mjs';
85
81
  import { assertContainedRealPath } from './fs-safe.mjs';
86
82
  import { loadWorktreesConfig, resolveProbeDir } from './worktrees.mjs';
87
83
  import { preflightCheapAgents, EXECUTOR_VEHICLE } from './cheap-agents.mjs';
@@ -199,7 +195,6 @@ export const SEVERITIES = Object.freeze({
199
195
  'adr-store-migration': SEVERITY_ATTENTION,
200
196
  'sandbox-masks': SEVERITY_OPTIONAL,
201
197
  'sandbox-masks.unfenced-mount': SEVERITY_ATTENTION,
202
- 'sandbox-lane': SEVERITY_OPTIONAL,
203
198
  'worktrees-dir': SEVERITY_OPTIONAL,
204
199
  // The layer is opt-in, so both arms are OFFERS under the frozen registry (attention is a CONFIGURED
205
200
  // declaration that is broken): an absent store offers the seed, a store with no live contract offers
@@ -249,6 +244,8 @@ export const composeVerdict = ({ attention, optional, skipped }) => {
249
244
  // WHAT is never truncated: the leaf's suite pins every composed WHAT under the cap.
250
245
  export const WHATS = Object.freeze({
251
246
  'velocity-core': 'routine read-only commands still prompt — {n} audited read-only allowlist entr(ies) not seeded',
247
+ 'velocity-core.rule': 'in auto mode a harness apply is judged instead of asked — the apply ask rule is not seeded',
248
+ 'velocity-core.both': 'routine read-only commands still prompt, and in auto mode a harness apply is judged instead of asked — {n} entr(ies) not seeded',
252
249
  'kit-tools-tier': "the kit's own read-only tools still prompt — {n} kit-tools tier entr(ies) not seeded",
253
250
  'bridge-tier': 'bridge-wrappers tier incomplete — {n} entr(ies) missing (allow rules, exclusions, hosts or state dirs of used bridges)',
254
251
  'autonomy-policy': 'no {path} — the computed defaults apply implicitly (red-lines ask/deny; every activity floors at prompt)',
@@ -281,7 +278,6 @@ export const WHATS = Object.freeze({
281
278
  'adr-store-migration': 'still on the retired 3-tier ADR layout — {shape}',
282
279
  'sandbox-masks': '{n} sandbox mask(s) clutter git status — the managed exclude block is absent or stale',
283
280
  'sandbox-masks.stale-real': '{n} sandbox mask(s) clutter git status — the exclude block is stale; {m} fenced entr(ies) are REAL paths (a fresh apply drops them)',
284
- 'sandbox-lane': 'the wired review wrappers declare a session-sandbox recipe (egress hosts + writable state dirs) not yet acknowledged for this project',
285
281
  'worktrees-dir': 'write access to the worktrees parent dir {dir} is not confirmed — provision may still stop',
286
282
  'spec-adoption': 'feature-spec store absent (docs/ai/specs) — no feature contract can govern a plan here yet; seed the store, or record the decline',
287
283
  'profile-gap': '{what}',
@@ -323,7 +319,7 @@ const templateBudget = (template) => ITEM_LINE_CAP - template.replace(/\{[A-Za-z
323
319
 
324
320
  // ── the frozen benefit registry (fact-true; pinned by tests) ────────────────────────────────────
325
321
  export const BENEFITS = Object.freeze({
326
- 'velocity-core': 'velocity — routine read-only commands stop prompting while the maintainer is away',
322
+ 'velocity-core': 'velocity — routine read-only commands stop prompting while the maintainer is away; the harness asks before each harness apply',
327
323
  'kit-tools-tier': "velocity — the kit's own read-only tools run promptless (audited, resolved-absolute tier)",
328
324
  'bridge-tier':
329
325
  'velocity — used bridges get <wrapper> * exclusions (codex-exec * if delegated), hosts, state dirs (allowWrite); reviews: code-mode allows',
@@ -345,7 +341,6 @@ export const BENEFITS = Object.freeze({
345
341
  'family-freshness': 'currency — placed family members carry the latest shipped fixes and features',
346
342
  'adr-store-migration': 'durability — every decision becomes its own file with a generated navigator, instead of one hand-rotated pile',
347
343
  'sandbox-masks': 'zero clutter — a mask git ignores through the block leaves git status; a mount-only one leaves git add -A and the review fingerprint too',
348
- 'sandbox-lane': 'discoverability — the manifest-declared observed sandbox recipe for bridge runs surfaces itself instead of waiting to be asked',
349
344
  'worktrees-dir': 'parallel features — the host-specific write allowance or terminal fallback is surfaced before provision',
350
345
  'spec-adoption': 'contracts — a plan names the contract it builds to, and a change to a governed slice is visible at review instead of after it',
351
346
  'profile-gap': 'full flow — each difference from the reference profile is previewed by its own writer; a declined one returns only at a lineage step',
@@ -373,7 +368,6 @@ export const OPT_IN_CAPABILITIES = Object.freeze([
373
368
  { id: 'kit-tools-tier', mode: 'velocity', advisorKey: 'kit-tools-tier' },
374
369
  { id: 'bridge-tier', mode: 'velocity', advisorKey: 'bridge-tier' },
375
370
  { id: 'autonomy-render', mode: 'velocity', advisorKey: 'autonomy-render' },
376
- { id: 'sandbox-lane', mode: 'velocity', advisorKey: 'sandbox-lane' },
377
371
  { id: 'autonomy-policy', mode: 'set-autonomy', advisorKey: 'autonomy-policy' },
378
372
  { id: 'sandbox-provision', mode: 'autonomy-doctor', advisorKey: 'sandbox-provision' },
379
373
  { id: 'gates-declaration', mode: 'gates', advisorKey: 'gates-declaration' },
@@ -450,8 +444,10 @@ const probeVelocityItems = ({ root, deps, add, skip }) => {
450
444
  // deliberately carries NO defensive catch (a dead branch is not honesty; the preflight catch
451
445
  // above owns the real failure modes).
452
446
  const core = planVelocityProfile(preflight, {});
453
- if (core.toAdd.length > 0) {
454
- add('velocity-core', fillTemplate(WHATS['velocity-core'], { n: core.toAdd.length }), applyLine(''));
447
+ if (core.toAdd.length > 0 || core.askToAdd.length > 0) {
448
+ const template = core.toAdd.length === 0 ? WHATS['velocity-core.rule']
449
+ : core.askToAdd.length === 0 ? WHATS['velocity-core'] : WHATS['velocity-core.both'];
450
+ add('velocity-core', fillTemplate(template, { n: core.toAdd.length + core.askToAdd.length }), applyLine(''));
455
451
  }
456
452
  try {
457
453
  const kt = planVelocityProfile(preflight, { kitTools: true });
@@ -469,7 +465,34 @@ const probeVelocityItems = ({ root, deps, add, skip }) => {
469
465
  });
470
466
  const delta = bt.bridgeToAdd.length + bt.excludedToAdd.length + bt.hostsToAdd.length + bt.dirsToAdd.length;
471
467
  if (delta > 0) {
472
- add('bridge-tier', fillTemplate(WHATS['bridge-tier'], { n: delta }), applyLine(' --bridge-tier'));
468
+ const entrySeparator = ', ';
469
+ const missing = [
470
+ ['allow rules', bt.bridgeToAdd],
471
+ ['exclusions', bt.excludedToAdd],
472
+ ['hosts', bt.hostsToAdd],
473
+ ['state dirs', bt.dirsToAdd],
474
+ ].filter(([, entries]) => entries.length > 0)
475
+ .map(([kind, entries]) => `${kind}: ${entries.join(entrySeparator)}`).join('; ');
476
+ const executeMissing = [...bt.executeToAdd.excludedCommands, ...bt.executeToAdd.hosts, ...bt.executeToAdd.dirs];
477
+ const executeDetail = executeMissing.length > 0
478
+ ? `; the execution wrapper codex-exec needs: ${executeMissing.join(entrySeparator)}`
479
+ : '';
480
+ const hostCondition = (() => {
481
+ try {
482
+ return sandboxEnabled(preflight.projectSettings.data, preflight.localSettings.data)
483
+ ? ` — once added, wired ${HOST_HONORS_QUALIFIER}`
484
+ : '';
485
+ } catch (err) {
486
+ return ` — host condition unchecked: ${oneLineOf(err?.message ?? String(err))}`;
487
+ }
488
+ })();
489
+ add(
490
+ 'bridge-tier',
491
+ fillTemplate(WHATS['bridge-tier'], { n: delta }),
492
+ applyLine(' --bridge-tier'),
493
+ 'bridge-tier',
494
+ `missing — ${missing}${executeDetail}${hostCondition}`,
495
+ );
473
496
  }
474
497
  } catch (err) {
475
498
  skip('bridge-tier', err);
@@ -1177,10 +1200,9 @@ export const recipeFingerprint = ({ hosts, dirs, home }) => {
1177
1200
  return factFingerprint(JSON.stringify({ hosts: [...hosts].sort(), dirs: [...new Set(dirs.map(norm))].sort() }));
1178
1201
  };
1179
1202
 
1180
- // The ack store (D4; AD-055 Part I) is the kit-owned PRIMARY ack channel; the legacy settings-scope
1181
- // keys below are read for one deprecation window. An ack lane exists for a state the maintainer can
1182
- // only ANSWER, never converge — a dead checker/producer pair is fixed, not acknowledged, so no lane
1183
- // names it. The store's path, keys, lane registry and reader are ack-store.mjs (re-exported above).
1203
+ // The ack store (D4; AD-055 Part I) is the kit-owned ack channel. An ack lane exists for a state the
1204
+ // maintainer can only ANSWER, never converge — a dead checker/producer pair is fixed, not acknowledged,
1205
+ // so no lane names it. The store's path, keys, lane registry and reader are ack-store.mjs.
1184
1206
 
1185
1207
  // The opt-in read-lane toggle file (AD-055 Part II) — the SAME kit-owned docs/ai/lanes.json the
1186
1208
  // placed hook reads live. The read-lane item offers to enable it once the hook is placed+wired.
@@ -1255,11 +1277,6 @@ const declarationCarriesMarker = (root, deps) => {
1255
1277
  return declaration.outcome === 'loaded' && declaration.gates.some((gate) => Object.hasOwn(gate, LCOV_PRODUCER_KEY));
1256
1278
  };
1257
1279
 
1258
- // The LEGACY neutral ack namespace (pre-AD-055): read from BOTH settings scopes until the next kit
1259
- // MAJOR (2.0.0) so a never-migrated host stays converged across the deprecation window (Decisions 3).
1260
- export const SANDBOX_LANE_ACK_PARENT = 'agentWorkflow';
1261
- export const SANDBOX_LANE_ACK_KEY = 'sandboxLaneAck';
1262
-
1263
1280
  // Read the opt-in read-lane toggle for the read-lane item. An ABSENT file (or absent docs/ai) →
1264
1281
  // false (the lane is off — offer it). `readLane === true` → enabled (converged). A parse/IO error on
1265
1282
  // an EXISTING file, a symlinked ancestor/leaf, an escape, or a non-object root THROWS — the probe
@@ -1290,7 +1307,7 @@ const readReadLaneToggle = (root, deps) => {
1290
1307
  // D3: the risk-marked keys — every key here has a per-item posture note in the mode doc, surfaced
1291
1308
  // at the consent moment; the static contract test asserts EXACT bidirectional coverage
1292
1309
  // (risk-marked keys == mode-doc note keys — a dropped note goes red, not silent).
1293
- export const RISK_NOTED_KEYS = Object.freeze(['bridge-tier', 'sandbox-lane', 'read-lane', 'worktrees-dir', 'adr-store-migration', 'gates-inert', 'source-size', 'gate-hook', 'mcp-channel', 'spec-adoption', 'enforcement', 'profile-gap', 'jev-connect', 'jev-skill']);
1310
+ export const RISK_NOTED_KEYS = Object.freeze(['bridge-tier', 'read-lane', 'worktrees-dir', 'adr-store-migration', 'gates-inert', 'source-size', 'gate-hook', 'mcp-channel', 'spec-adoption', 'enforcement', 'profile-gap', 'jev-connect', 'jev-skill']);
1294
1311
 
1295
1312
  // The feature-spec layer's adoption state (contract: kit/spec-adoption). The canon lets a plan cite
1296
1313
  // zero governing specs while a project adopts the layer, and nothing ever said whether adoption had
@@ -1323,63 +1340,6 @@ export const probeSpecAdoption = ({ root, deps, add, skip }) => {
1323
1340
  }
1324
1341
  };
1325
1342
 
1326
- const probeSandboxLane = ({ root, deps, add, skip }) => {
1327
- try {
1328
- const settings = readSettingsFile(join(root, SETTINGS_FILE), { ...deps, cwd: root });
1329
- const localSettings = readSettingsFile(join(root, SETTINGS_LOCAL_FILE), { ...deps, cwd: root });
1330
- const sandbox = settings.data?.sandbox;
1331
- const excluded = Array.isArray(sandbox?.excludedCommands) ? sandbox.excludedCommands : [];
1332
- const probePlaced = deps.findWrapper ?? ((cmd) => findOnPath(cmd, deps).state === 'present');
1333
- // Wired = the two-surface proof the tier wrote before AD-172 (the wrapper in the PROJECT
1334
- // excludedCommands + its code-mode allow rule, either scope) — surfacing the recipe before those
1335
- // exist would front-run the bridge-tier item (codex terminal). The hosts surface (AD-172) is
1336
- // deliberately not required: hosts and state dirs may still be missing, so the recipe may
1337
- // render beside a bridge-tier item offering the re-run. Byte-form from the tier's own constants.
1338
- const allowRules = [
1339
- ...(Array.isArray(settings.data?.permissions?.allow) ? settings.data.permissions.allow : []),
1340
- ...(Array.isArray(localSettings.data?.permissions?.allow) ? localSettings.data.permissions.allow : []),
1341
- ];
1342
- const wired = BRIDGE_REVIEW_WRAPPERS.filter(
1343
- (w) => (excluded.includes(w) || excluded.includes(`${w} *`)) && probePlaced(w) && allowRules.includes(`Bash(${w} ${BRIDGE_REVIEW_MODE}:*)`),
1344
- );
1345
- if (wired.length === 0) return; // the tier's rules and exclusions are not wired — the bridge-tier item covers first
1346
- const { hosts, dirEntries } = bundledSandboxRecipe(wired, deps);
1347
- const env = deps.getenv ?? process.env;
1348
- const home = deps.home ?? homedir();
1349
- const dirs = [];
1350
- for (const entry of dirEntries) {
1351
- const resolved = resolveWritableDir(entry, { env, root });
1352
- if (!dirs.includes(resolved)) dirs.push(resolved);
1353
- }
1354
- const fingerprint = recipeFingerprint({ hosts, dirs, home });
1355
- // Convergence is the NEUTRAL fingerprint-bound acknowledgement: the item converges iff the
1356
- // CURRENT fingerprint equals the ack in ANY consulted store — the family-owned acks.json FIRST,
1357
- // then the legacy settings scopes; a stale value in one store is ignored when another matches
1358
- // (Decisions 2). A changed recipe (hosts, dirs, or an env override) re-fires the item (D4).
1359
- const acks = [
1360
- readAckValue(root, deps, ACKS_LANE_KEY),
1361
- settings.data?.[SANDBOX_LANE_ACK_PARENT]?.[SANDBOX_LANE_ACK_KEY],
1362
- localSettings.data?.[SANDBOX_LANE_ACK_PARENT]?.[SANDBOX_LANE_ACK_KEY],
1363
- ];
1364
- if (acks.includes(fingerprint)) return; // the acknowledged recipe — the item converged
1365
- // The item joins the CONSENT-GATED WRITER class (Decisions 4): the apply is the ack writer's
1366
- // PREVIEW one-liner (pure executable, cwd-independent), carrying the neutral fingerprint — never
1367
- // a security key. The LIVE recipe (egress hosts + resolved writable dirs) rides a separate
1368
- // rendered `recipe:` line (the fill source for the mode doc's lane-(2) hand-apply block); the
1369
- // fingerprint encodes it, so a changed recipe re-fires with a fresh command.
1370
- const recipe = `egress hosts [${hosts.join(', ')}]; writable state dirs [${dirs.join(', ')}] (observed-minimal; a blocked host names itself at run time)`;
1371
- add(
1372
- 'sandbox-lane',
1373
- fillTemplate(WHATS['sandbox-lane'], {}),
1374
- `node ${q(toolPath('ack-write.mjs'))} --fingerprint ${fingerprint} --cwd ${q(root)}`,
1375
- 'sandbox-lane',
1376
- recipe,
1377
- );
1378
- } catch (err) {
1379
- skip('sandbox-lane', err);
1380
- }
1381
- };
1382
-
1383
1343
  // D7 lane 1 — the DECLARATION confirmation. A settings entry is not proof of writable CAPABILITY
1384
1344
  // (runtime truth stays with the provision preflight's real create+delete probe); it is proof the
1385
1345
  // maintainer applied this item's own advice, which is what the item may converge on. Both scopes
@@ -1433,8 +1393,8 @@ const declaresWritableDir = (root, probeDir, deps) => {
1433
1393
  };
1434
1394
 
1435
1395
  // D7 lane 2 — the ack fallback for a host that ignores the settings key, bound to the PROBED DIR
1436
- // through the shared neutral fingerprint (the same home-symbolic canonicalization the sandbox-lane
1437
- // ack uses, so a home-anchored dir acks portably). The binding is to the RESOLVED probe dir, so the
1396
+ // through the worktrees-dir fingerprint's home-symbolic canonicalization, so a home-anchored dir
1397
+ // acks portably. The binding is to the RESOLVED probe dir, so the
1438
1398
  // item re-fires only when that resolved dir changes — two ABSENT parentDir values sharing an
1439
1399
  // existing ancestor resolve to the same dir and keep the same ack.
1440
1400
  export const worktreesDirFingerprint = (probeDir, home) => recipeFingerprint({ hosts: [], dirs: [probeDir], home });
@@ -1630,7 +1590,6 @@ const PROBES = Object.freeze([
1630
1590
  probeFamilyFreshness,
1631
1591
  probeAdrStore,
1632
1592
  probeMasksItem,
1633
- probeSandboxLane,
1634
1593
  probeWorktreesDir,
1635
1594
  probeMcpChannel,
1636
1595
  probeSpecAdoption,
@@ -1654,8 +1613,8 @@ export const buildRecommendations = ({ cwd, deps = {} } = {}) => {
1654
1613
  // smoke asserting a specific false-green never returns) can assert the exact outcome instead of
1655
1614
  // pattern-matching prose that is free to be reworded.
1656
1615
  // `detail` (optional) is an extra rendered `recipe:` line — factual context that is TOO LONG for
1657
- // the capped WHAT and does NOT belong in the pure-command apply (the sandbox-lane live recipe:
1658
- // egress hosts + resolved writable dirs; the worktrees-dir hand-apply-first grant advice; the
1616
+ // the capped WHAT and does NOT belong in the pure-command apply (the bridge-tier missing-entries
1617
+ // line; the worktrees-dir hand-apply-first grant advice; the
1659
1618
  // agents hidden-mode reconcile follow-up). Single-line like apply; absent for every other item.
1660
1619
  // Returns whether the item really RENDERED. One probe's disposition depends on another's having
1661
1620
  // spoken (the marker-stale ⟷ read-lane.stale precedence), and "the conditions still look right"
@@ -1746,8 +1705,8 @@ Usage:
1746
1705
 
1747
1706
  Computes the deterministic Recommendations section every kit upgrade ends with — VERDICT-FIRST:
1748
1707
  one composed verdict line opens every non-optimal render, then per item {severity · what is
1749
- sub-optimal · the benefit in one plain line · the lane · an optional \`recipe:\` line (the sandbox-lane live
1750
- recipe, the worktrees-dir hand-apply-first grant advice, or the agents hidden-mode reconcile
1708
+ sub-optimal · the benefit in one plain line · the lane · an optional \`recipe:\` line (the bridge-tier
1709
+ missing-entries line, the worktrees-dir hand-apply-first grant advice, or the agents hidden-mode reconcile
1751
1710
  follow-up) · the harness check line · the exact consent-gated apply one-liner}. --cwd is
1752
1711
  REQUIRED (the target project is explicit, never inferred from the shell's current directory). The
1753
1712
  section renders present-even-when-empty ("${RECOMMENDATIONS_EMPTY_LINE}"); a probe failure is a
@@ -1760,8 +1719,7 @@ sandbox.network.allowedDomains and state dirs in sandbox.filesystem.allowWrite.
1760
1719
  honors the settings sandbox keys, an excluded wrapper runs outside the sandbox, every sandboxed
1761
1720
  command of the project can reach each host, and every sandboxed command can write each state dir;
1762
1721
  plan/diff review modes and delegated execution keep their prompt. Items marked HAND-APPLY stay
1763
- the user's own step. The sandbox-lane convergence is a neutral fingerprint acknowledgement recorded by
1764
- the consent-gated ack writer into docs/ai/acks.json (never a security key).
1722
+ the user's own step.
1765
1723
 
1766
1724
  Read-only: never writes, never commits, never runs a subscription CLI. Exit codes: 0 report
1767
1725
  rendered (items or empty); 1 error; 2 usage.`;
@@ -13,10 +13,12 @@ import { SHELL_METACHARACTERS, hasShellMetacharacter, isSeedablePathToken } from
13
13
  import { isDirectRun } from './direct-run.mjs';
14
14
  import { compareSemver } from './semver-lite.mjs';
15
15
  import { BRIDGE_REVIEW_WRAPPERS, BRIDGE_REVIEW_MODE, KIT_GROUNDING_TOOL, usedBridges, bundledSandboxRecipe, exclusionOf } from './bridge-sandbox-recipe.mjs';
16
- import { stateDirsOf, missingStateDirs, mergeAllowWrite } from './bridge-state-dirs.mjs';
16
+ import { stateDirsOf, mergeAllowWrite } from './bridge-state-dirs.mjs';
17
+ import { HOST_HONORS_QUALIFIER, sandboxSurfaceDelta } from './bridge-wiring.mjs';
17
18
  import { loadConfig } from './orchestration-config.mjs';
18
19
  import { validateManifest } from './manifest/validate.mjs';
19
20
  export { BRIDGE_REVIEW_WRAPPERS, BRIDGE_REVIEW_MODE, KIT_GROUNDING_TOOL };
21
+ export { HOST_HONORS_QUALIFIER };
20
22
  // The declared-path resolution + segment containment the allowWrite degrade shares with the
21
23
  // advisor's worktrees-dir convergence lane — ONE leaf, so the two readings cannot drift.
22
24
  import { resolveDeclaredDir, dirCovers, isResolvableDeclaredEntry } from './declared-paths.mjs';
@@ -37,6 +39,8 @@ export const WORKFLOW_STAMP = 'docs/ai/.workflow-version';
37
39
  export const SAFE_DEFAULT_MODES = Object.freeze(['default', 'acceptEdits', 'plan']);
38
40
  export const UNSAFE_BYPASS_MODE = 'bypassPermissions';
39
41
  export const ACCEPT_EDITS_MODE = 'acceptEdits';
42
+ // The harness asks before every harness apply; an ask rule allows nothing.
43
+ export const APPLY_ASK_RULE = 'Bash(node *apply-danger-check.mjs*--apply*)';
40
44
 
41
45
  // The audited read-only core. Every entry is a Claude Code Bash allow pattern whose command runs NO
42
46
  // mutating operation and NO inline arbitrary code execution through its OWN flags (verified
@@ -303,18 +307,19 @@ const DO_NOT_ADD_WARNING = 'do not add';
303
307
  const ADD_BY_HAND = true;
304
308
  const MUTATING_SCRIPT_NAME_PATTERN = /(release|publish|deploy|push|version|commit|tag)/iu;
305
309
  const MUTATING_SCRIPT_HOOK_PATTERN = /^(pre|post)/iu;
310
+ const CODEX_BRIDGE = 'codex-cli-bridge';
311
+ const EXECUTE_ROLE = 'execute';
306
312
  // The one thing velocity must never seed — an explicit, greppable expression of the load-bearing
307
313
  // invariant (kept deliberately even though the read-only screen already rejects these, so the
308
314
  // refusal is named, tested, and produces a clear message).
309
315
  const MUTATING_ALLOW_COMMAND_PATTERN = /^(?:git\s+(?:commit|push)|npm\s+publish)(?:\s|$)/iu;
310
316
  // Whether a host APPLIES a `sandbox.*` settings key is not knowable from here: a settings-native
311
- // host honors it, while an IDE/session-imposed sandbox was observed ignoring the hand-applied
312
- // security keys in BOTH scopes (the advisor records the same limit in
317
+ // host honors it, while an IDE/session-imposed sandbox was observed ignoring the security keys
318
+ // in BOTH scopes (the advisor records the same limit in
313
319
  // references/modes/recommendations.md). So every claim about the RUNTIME EFFECT of a settings key —
314
320
  // the tier's exclusion routing and every weakening detail alike — carries this qualifier. The
315
321
  // CLASSIFICATION is untouched: which red-line a key would weaken, and that a proven tier exclusion
316
322
  // is a note rather than a weakening, are properties of the DECLARATION, not of a host.
317
- export const HOST_HONORS_QUALIFIER = 'where the host honors the settings sandbox keys';
318
323
  export const HOST_HONORS_NOTICE =
319
324
  `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.`;
320
325
 
@@ -332,6 +337,8 @@ absolute path (args wildcard), run-gates.mjs as ONE exact project-root-pinned by
332
337
  (project-exec - it runs YOUR declared gates.json), source-size-check.mjs as ONE exact --check
333
338
  byte-string (its read-only mode only - its writers keep prompting), and the writers' exact arg-free
334
339
  dry-run preview byte-strings. Never touches settings.local.json.
340
+ Every apply also appends the apply ask rule ${APPLY_ASK_RULE} to permissions.ask
341
+ once, so the harness asks before every harness apply; it allows nothing.
335
342
  --bridge-tier (own consent) derives each entry for a used bridge from the effective project recipes:
336
343
  review wrappers' CODE mode (codex-review code, agy-review code), the quoted grounding pre-step rule,
337
344
  and <wrapper> * exclusions in sandbox.excludedCommands. An execute slot delegating to codex adds
@@ -405,8 +412,8 @@ const validateSettingsShape = (data, relPath) => {
405
412
  }
406
413
  // The --autonomy render merges into permissions.ask / permissions.deny (arrays) and the top-level
407
414
  // `sandbox` object — a malformed one of these is a STOP with ZERO writes, never a merge-through-clobber
408
- // (the same precondition posture as permissions.allow above). The allowlist mode never touches these,
409
- // so this only tightens the autonomy path.
415
+ // (the same precondition posture as permissions.allow above). The allowlist mode also appends the
416
+ // apply ask rule to permissions.ask, so its shape is a precondition for both paths.
410
417
  if (data.permissions?.ask !== undefined && !Array.isArray(data.permissions.ask)) {
411
418
  throw makeVelocityProfileError(VELOCITY_MALFORMED, `${relPath}: permissions.ask must be an array`);
412
419
  }
@@ -584,14 +591,17 @@ const assertTargetWritable = (absPath, deps = {}) => {
584
591
 
585
592
  const formatJson = (data, eol) => `${JSON.stringify(data, null, SETTINGS_JSON_INDENT).replace(JSON_NEWLINE_PATTERN, eol)}${eol}`;
586
593
 
587
- const mergeProjectSettings = (projectData, toAdd, acceptEdits, excludedToAdd = [], hostsToAdd = []) => {
594
+ const mergeProjectSettings = (projectData, toAdd, acceptEdits, excludedToAdd = [], hostsToAdd = [], askToAdd = []) => {
588
595
  const base = projectData ?? {};
589
596
  const permissions = getPermissions(base);
590
597
  const allow = getAllowEntries(base);
591
598
  const mergedAllow = [...allow, ...toAdd.filter((entry) => !allow.includes(entry))];
599
+ const ask = permissions.ask ?? [];
600
+ const newAsk = askToAdd.filter((entry) => !ask.includes(entry));
592
601
  const mergedPermissions = {
593
602
  ...permissions,
594
603
  allow: mergedAllow,
604
+ ...(newAsk.length ? { ask: [...ask, ...newAsk] } : {}),
595
605
  ...(acceptEdits === true ? { defaultMode: ACCEPT_EDITS_MODE } : {}),
596
606
  };
597
607
  // The bridge tier's second and third surfaces: sandbox.excludedCommands and
@@ -623,6 +633,9 @@ const formatAllowlist = (result) => [
623
633
  `${result.wrote ? 'added' : 'would add'} read-only core entries: ${result.toAdd.length}`,
624
634
  ...formatEntryList(result.toAdd),
625
635
  `already present: ${result.alreadyPresent.length}`,
636
+ `${result.wrote ? 'added' : 'would add'} permissions.ask entries: ${result.askToAdd.length}`,
637
+ ...formatEntryList(result.askToAdd),
638
+ `already present (ask): ${result.askAlreadyPresent.length}`,
626
639
  ];
627
640
 
628
641
  // The tier's honest posture, printed on every --kit-tools run: run-gates is project-exec (never
@@ -976,6 +989,9 @@ const deriveUsedBridgeTier = (root, deps = {}) => {
976
989
  export const planVelocityProfile = (preflight, opts = {}) => {
977
990
  const { acceptEdits, kitTools, bridgeTier } = opts;
978
991
  const projectAllow = getAllowEntries(preflight.projectSettings?.data);
992
+ const projectAsk = getPermissions(preflight.projectSettings?.data).ask ?? [];
993
+ const askToAdd = projectAsk.includes(APPLY_ASK_RULE) ? [] : [APPLY_ASK_RULE];
994
+ const askAlreadyPresent = projectAsk.includes(APPLY_ASK_RULE) ? [APPLY_ASK_RULE] : [];
979
995
  const toAdd = UNIVERSAL_READONLY_ALLOWLIST.filter((entry) => !projectAllow.includes(entry));
980
996
  const alreadyPresent = UNIVERSAL_READONLY_ALLOWLIST.filter((entry) => projectAllow.includes(entry));
981
997
  const tier = kitTools === true ? deriveKitToolsAllowlist({ projectDir: preflight.cwd }) : [];
@@ -986,19 +1002,30 @@ export const planVelocityProfile = (preflight, opts = {}) => {
986
1002
  const bridgeToAdd = bridge.allow.filter((entry) => !projectAllow.includes(entry));
987
1003
  const bridgeAlreadyPresent = bridge.allow.filter((entry) => projectAllow.includes(entry));
988
1004
  const existingSandbox = isJsonObject(preflight.projectSettings?.data?.sandbox) ? preflight.projectSettings.data.sandbox : {};
989
- const existingExcluded = Array.isArray(existingSandbox.excludedCommands) ? existingSandbox.excludedCommands : [];
990
- const excludedToAdd = bridge.excludedCommands.filter((cmd) => !existingExcluded.includes(cmd));
991
- const excludedAlreadyPresent = bridge.excludedCommands.filter((cmd) => existingExcluded.includes(cmd));
992
- const existingHosts = bridgeTier === true ? assertNetworkMergeable(existingSandbox) : [];
993
- const hostsToAdd = bridge.hosts.filter((host) => !existingHosts.includes(host));
994
- const hostsAlreadyPresent = bridge.hosts.filter((host) => existingHosts.includes(host));
995
- const existingDirs = bridgeTier === true ? assertFilesystemMergeable(existingSandbox) : [];
1005
+ if (bridgeTier === true) {
1006
+ assertNetworkMergeable(existingSandbox);
1007
+ assertFilesystemMergeable(existingSandbox);
1008
+ }
996
1009
  const dirs = derived?.dirs ?? [];
997
- const dirsToAdd = derived ? missingStateDirs(dirs, existingDirs, { home: derived.home, root: preflight.cwd }) : [];
1010
+ const delta = derived
1011
+ ? sandboxSurfaceDelta({ excludedCommands: bridge.excludedCommands, hosts: bridge.hosts, dirs }, existingSandbox, { home: derived.home, root: preflight.cwd })
1012
+ : { excludedCommands: [], hosts: [], dirs: [] };
1013
+ const { excludedCommands: excludedToAdd, hosts: hostsToAdd, dirs: dirsToAdd } = delta;
1014
+ const excludedAlreadyPresent = bridge.excludedCommands.filter((cmd) => !excludedToAdd.includes(cmd));
1015
+ const hostsAlreadyPresent = bridge.hosts.filter((host) => !hostsToAdd.includes(host));
998
1016
  const dirsAlreadyPresent = dirs.filter((dir) => !dirsToAdd.includes(dir));
1017
+ const executeRecipe = derived?.used.some((entry) => entry.bridge === CODEX_BRIDGE && entry.roles.includes(EXECUTE_ROLE))
1018
+ ? bundledSandboxRecipe([{ bridge: CODEX_BRIDGE, roles: [EXECUTE_ROLE] }], opts)
1019
+ : null;
1020
+ const executeDirs = executeRecipe ? stateDirsOf(executeRecipe.dirEntries, { env: derived.env, root: preflight.cwd, home: derived.home }) : [];
1021
+ const executeToAdd = executeRecipe
1022
+ ? sandboxSurfaceDelta({ excludedCommands: executeRecipe.excludedCommands, hosts: executeRecipe.hosts, dirs: executeDirs }, existingSandbox, { home: derived.home, root: preflight.cwd })
1023
+ : { excludedCommands: [], hosts: [], dirs: [] };
999
1024
  return {
1000
1025
  toAdd,
1001
1026
  alreadyPresent,
1027
+ askToAdd,
1028
+ askAlreadyPresent,
1002
1029
  tierToAdd,
1003
1030
  tierAlreadyPresent,
1004
1031
  kitTools: kitTools === true,
@@ -1012,6 +1039,7 @@ export const planVelocityProfile = (preflight, opts = {}) => {
1012
1039
  hostsAlreadyPresent,
1013
1040
  dirsToAdd,
1014
1041
  dirsAlreadyPresent,
1042
+ executeToAdd,
1015
1043
  setsDefaultMode: acceptEdits === true,
1016
1044
  };
1017
1045
  };
@@ -1061,6 +1089,7 @@ export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true,
1061
1089
  acceptEdits,
1062
1090
  plan.excludedToAdd,
1063
1091
  plan.hostsToAdd,
1092
+ plan.askToAdd,
1064
1093
  ), plan.dirsToAdd);
1065
1094
  fs.writeFile(settingsPath, formatJson(merged, preflight.projectSettings.eol ?? LF), UTF8);
1066
1095
  return { wrote: true, dryRun: false, settingsPath, ...resultBase };
@@ -1568,7 +1597,7 @@ const collectSandboxWeakenings = (sources, { root, home, tmp }, tier) => {
1568
1597
  const foreign = fsb.allowWrite.filter((entry) => !known.includes(entry));
1569
1598
  addNotes(`${SANDBOX_KEY}.filesystem.allowWrite`, known, 'every sandboxed command can write it');
1570
1599
  // Only the entries that RESOLVE outside the repo and $TMPDIR are a weakening at all — an entry
1571
- // pointing INSIDE the repo grants nothing the red-line withholds, so reporting it would be an
1600
+ // pointing INSIDE the repo grants nothing beyond the red-line's own bound, so reporting it would be an
1572
1601
  // over-report. All-contained ⇒ no line at all.
1573
1602
  // TWO records, never one: a resolved external path IS a weakening, an unresolvable entry is
1574
1603
  // UNVERIFIABLE, and a single line carrying both would assert an effect for entries it could
@@ -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,