@sabaiway/agent-workflow-kit 1.44.0 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +6 -3
  3. package/SKILL.md +9 -1
  4. package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +87 -16
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +3 -0
  7. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +4 -2
  8. package/bridges/antigravity-cli-bridge/capability.json +3 -2
  9. package/bridges/codex-cli-bridge/SKILL.md +1 -1
  10. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +3 -0
  11. package/bridges/codex-cli-bridge/bin/codex-review.sh +90 -19
  12. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +4 -0
  13. package/bridges/codex-cli-bridge/capability.json +3 -2
  14. package/bridges/codex-cli-bridge/references/driving-codex.md +3 -2
  15. package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +3 -2
  16. package/capability.json +1 -1
  17. package/package.json +1 -1
  18. package/references/modes/bootstrap.md +1 -1
  19. package/references/modes/grounding.md +8 -7
  20. package/references/modes/recommendations.md +14 -0
  21. package/references/modes/review-state.md +1 -1
  22. package/references/modes/sandbox-masks.md +15 -0
  23. package/references/modes/upgrade.md +10 -7
  24. package/references/modes/velocity.md +13 -2
  25. package/references/shared/composition-handoff.md +21 -16
  26. package/references/shared/report-footer.md +5 -1
  27. package/references/templates/AGENTS.md +2 -1
  28. package/references/templates/autonomy.json +3 -0
  29. package/tools/autonomy-config.mjs +13 -3
  30. package/tools/commands.mjs +15 -1
  31. package/tools/delegation.mjs +9 -5
  32. package/tools/detect-backends.mjs +2 -2
  33. package/tools/doc-parity.mjs +8 -0
  34. package/tools/engine-source.mjs +6 -0
  35. package/tools/family-registry.mjs +21 -7
  36. package/tools/fold-completeness-run.mjs +5 -0
  37. package/tools/grounding.mjs +139 -22
  38. package/tools/inject-methodology.mjs +117 -28
  39. package/tools/manifest/schema.md +20 -0
  40. package/tools/manifest/validate.mjs +26 -0
  41. package/tools/procedures.mjs +61 -8
  42. package/tools/recipes.mjs +94 -15
  43. package/tools/recommendations.mjs +469 -0
  44. package/tools/review-ledger-write.mjs +14 -0
  45. package/tools/review-ledger.mjs +3 -2
  46. package/tools/review-state.mjs +101 -24
  47. package/tools/run-gates.mjs +3 -0
  48. package/tools/sandbox-masks.mjs +370 -0
  49. package/tools/set-recipe.mjs +13 -1
  50. package/tools/velocity-profile.mjs +228 -22
@@ -6,6 +6,9 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  // it never imports the policy fs-writer (autonomy-write.mjs) — the render owns the settings file, not
7
7
  // the policy file.
8
8
  import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, COMMAND_REDLINES } from './autonomy-config.mjs';
9
+ // The bridge-wrappers tier's placement probe (AD-044 Plan 4, Decision 2): a tier entry derives ONLY
10
+ // for a PLACED bridge wrapper — findOnPath is the same read-only PATH scan the backend detector uses.
11
+ import { findOnPath } from './detect-backends.mjs';
9
12
 
10
13
  // Velocity-profile core + writer: a fixed, audited read-only allowlist that an onboarding step seeds
11
14
  // into `.claude/settings.json` so routine read-only commands stop idling on approval prompts.
@@ -122,7 +125,7 @@ export const SHELL_READONLY = Object.freeze([
122
125
  // seed time from the RUNNING tool's own location — resolved-absolute, so a moved or reinstalled
123
126
  // skill leaves a stale rule that FAIL-SAFE prompts again (never a silent widening).
124
127
  //
125
- // Membership (9, frozen): the read-only kit tools plus run-gates.mjs — which is NOT read-only but
128
+ // Membership (10, frozen): the read-only kit tools plus run-gates.mjs — which is NOT read-only but
126
129
  // project-exec (it runs the project's OWN declared gates.json commands), so it seeds as ONE exact
127
130
  // byte-string pinned to this project root (`--cwd <resolved root>`): a wildcard would be BROADER
128
131
  // than the AD-037 hook boundary (`--cwd <dir>` executes an arbitrary OTHER project's gates.json)
@@ -136,6 +139,7 @@ export const KIT_READONLY_TOOLS = Object.freeze([
136
139
  'tools/detect-backends.mjs',
137
140
  'tools/commands.mjs',
138
141
  'tools/review-state.mjs',
142
+ 'tools/recommendations.mjs',
139
143
  KIT_RUN_GATES_TOOL,
140
144
  'tools/manifest/validate.mjs',
141
145
  'tools/release-scan.mjs',
@@ -156,6 +160,71 @@ const KIT_ROOT = fileURLToPath(new URL('..', import.meta.url));
156
160
  const KIT_TOOL_INVOKER = 'node';
157
161
  const RUN_GATES_CWD_FLAG = '--cwd';
158
162
 
163
+ // ── the opt-in --bridge-tier (AD-044 Plan 4, Decision 2) ────────────────────────────────
164
+ // The FROZEN membership source: review-role wrapper NAMES only, spelled bare — that is how the
165
+ // wrappers are invoked (setup-backends places them as symlinks in ~/.local/bin). Roles come from
166
+ // THIS constant, never from the detector's wrapperCmds (which carries no role labels):
167
+ // codex-exec (execution role) and agy-run (probe role) are deliberately ABSENT — delegated
168
+ // execution keeps its human prompt; the velocity pain this tier closes is council REVIEW runs.
169
+ export const BRIDGE_REVIEW_WRAPPERS = Object.freeze(['codex-review', 'agy-review']);
170
+ // Only the `code` review mode is auto-allowed (codex R2, Segment A): a bare `Bash(<wrapper>:*)`
171
+ // prefix would also cover the plan/diff file-argument modes, whose targets can point OUTSIDE the
172
+ // repo — the tier's stated surface is the unattended council CODE review, so the seeded prefix is
173
+ // `<wrapper> code`. Plan/diff invocations keep their prompt.
174
+ export const BRIDGE_REVIEW_MODE = 'code';
175
+ // The grounding pre-step tool (the reviews' facts assembler). Its tier entry is seeded in the
176
+ // EXACT byte-form the procedures advisor renders — a DOUBLE-QUOTED absolute path (deliberate
177
+ // there: a skill dir with a space must stay copy-pasteable) — so seeded↔rendered byte-parity
178
+ // holds; the screen accepts the quoted form ONLY for this one tool (a tier-scoped acceptance,
179
+ // never a general quote allowance).
180
+ export const KIT_GROUNDING_TOOL = 'tools/grounding.mjs';
181
+ const WRAPPER_PLACED = 'present';
182
+
183
+ // The quoted-grounding token: "<seedable-abs-path>/tools/grounding.mjs" — quotes stripped, the
184
+ // inner path must itself be seedable and end on the grounding tail. No other node tool may ride
185
+ // this class (a NEGATIVE screen pin).
186
+ const isQuotedGroundingToken = (token) => {
187
+ if (typeof token !== 'string' || token.length < 3 || !token.startsWith('"') || !token.endsWith('"')) return false;
188
+ const inner = token.slice(1, -1);
189
+ return isSeedablePathToken(inner) && inner.endsWith(`/${KIT_GROUNDING_TOOL}`);
190
+ };
191
+
192
+ /**
193
+ * Derive the opt-in bridge-wrappers tier: one bare-name wildcard allow rule per PLACED review
194
+ * wrapper (the frozen constant is the membership source; placement is a probe, never a role
195
+ * source), the SAME wrapper names for sandbox.excludedCommands (the harness runs an excluded
196
+ * command OUTSIDE the sandbox, so a plain allowlisted invocation needs no sandbox-bypass
197
+ * approval — the zero-prompt wiring), and the grounding pre-step rule in its rendered quoted
198
+ * byte-form (derived only when ≥1 review bridge is placed; an unseedable kit path is a stated
199
+ * skip). An absent bridge is a stated skip — its entry never derives.
200
+ */
201
+ export const deriveBridgeTierAllowlist = ({ findWrapper, groundingAbsPath } = {}) => {
202
+ const probe = findWrapper ?? ((cmd) => findOnPath(cmd).state === WRAPPER_PLACED);
203
+ const placed = BRIDGE_REVIEW_WRAPPERS.filter((cmd) => probe(cmd));
204
+ const skips = BRIDGE_REVIEW_WRAPPERS.filter((cmd) => !placed.includes(cmd)).map((cmd) => ({
205
+ entry: cmd,
206
+ 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)`,
207
+ }));
208
+ const allow = placed.map((cmd) => `Bash(${cmd} ${BRIDGE_REVIEW_MODE}:*)`);
209
+ const excludedCommands = [...placed];
210
+ // The grounding pre-step exists FOR agy (its grounded --facts reviews; codex grounds natively via
211
+ // the AGENTS.md auto-merge) — a codex-only install must not auto-allow an unused writer (codex R9).
212
+ if (placed.includes('agy-review')) {
213
+ // groundingAbsPath is a TEST seam only (an unseedable kit path — spaces — is not constructible
214
+ // from a test against the real checkout); production callers never pass it.
215
+ const groundingAbs = groundingAbsPath ?? join(KIT_ROOT, KIT_GROUNDING_TOOL);
216
+ if (isSeedablePathToken(groundingAbs)) {
217
+ allow.push(`Bash(${KIT_TOOL_INVOKER} "${groundingAbs}":*)`);
218
+ } else {
219
+ skips.push({
220
+ entry: groundingAbs,
221
+ 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',
222
+ });
223
+ }
224
+ }
225
+ return Object.freeze({ allow: Object.freeze(allow), excludedCommands: Object.freeze(excludedCommands), skips: Object.freeze(skips), placed: Object.freeze(placed) });
226
+ };
227
+
159
228
  // Characters that make a pattern NOT a single read-only command, so the screen rejects them.
160
229
  // Per current Claude Code permission semantics: the recognized command SEPARATORS are
161
230
  // `&& || ; | |& &` plus newline (each segment must be independently permitted, so a chained
@@ -228,6 +297,7 @@ const FLAG_DRY_RUN = '--dry-run';
228
297
  const FLAG_APPLY = '--apply';
229
298
  const FLAG_ACCEPT_EDITS = '--accept-edits';
230
299
  const FLAG_KIT_TOOLS = '--kit-tools';
300
+ const FLAG_BRIDGE_TIER = '--bridge-tier';
231
301
  const FLAG_AUTONOMY = '--autonomy';
232
302
  const FLAG_CHECK = '--check';
233
303
  const FLAG_CWD = '--cwd';
@@ -244,22 +314,29 @@ const MUTATING_ALLOW_COMMAND_PATTERN = /^(?:git\s+(?:commit|push)|npm\s+publish)
244
314
  const RESIDUAL_NOTICE =
245
315
  '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.';
246
316
 
247
- const USAGE = `usage: velocity-profile [--dry-run | --apply] [--kit-tools] [--accept-edits] [--cwd <dir>] [--help]
317
+ const USAGE = `usage: velocity-profile [--dry-run | --apply] [--kit-tools] [--bridge-tier] [--accept-edits] [--cwd <dir>] [--help]
248
318
  velocity-profile --autonomy [--apply] [--cwd <dir>] (render the autonomy policy)
249
319
  velocity-profile --autonomy --check [--cwd <dir>] (read-only drift gate)
250
320
 
251
321
  Allowlist mode (default): seeds the fixed read-only Claude Code allowlist into .claude/settings.json.
252
322
  Default is --dry-run. --apply writes; --accept-edits only sets defaultMode when applying.
253
- --kit-tools additionally seeds the audited kit-tool tier: 8 read-only kit tools by resolved
323
+ --kit-tools additionally seeds the audited kit-tool tier: 9 read-only kit tools by resolved
254
324
  absolute path (args wildcard), run-gates.mjs as ONE exact project-root-pinned byte-string
255
325
  (project-exec - it runs YOUR declared gates.json), and the writers' exact arg-free dry-run
256
326
  preview byte-strings. Never touches settings.local.json.
327
+ --bridge-tier (own consent) seeds the bridge REVIEW wrappers' CODE mode for PLACED bridges
328
+ (codex-review code, agy-review code - never the execution/probe wrappers, never plan/diff modes)
329
+ + the quoted grounding pre-step rule, and the wrapper names into sandbox.excludedCommands (they
330
+ need network - the harness runs them outside the sandbox). Consented posture: an auto-allowed
331
+ review wrapper runs UNATTENDED and sends the assembled repo payload to its subscription backend
332
+ (see the printed tier notice).
257
333
 
258
334
  --autonomy renders docs/ai/autonomy.json into the settings blocks it OWNS — the sandbox block +
259
335
  permissions.ask/deny red-lines + permissions.defaultMode. POLICY-ONLY: never seeds the allowlist and
260
336
  leaves permissions.allow untouched. Preview by default; --apply writes; --check is a read-only drift
261
- gate (exit 1 on drift). --autonomy cannot combine with --accept-edits or --kit-tools (allowlist-mode
262
- flags). Refuses an absent policy (seed one first with set-autonomy). Never touches settings.local.json.`;
337
+ gate (exit 1 on drift). --autonomy cannot combine with --accept-edits, --kit-tools, or --bridge-tier
338
+ (allowlist-mode flags). Refuses an absent policy (seed one first with set-autonomy). Never touches
339
+ settings.local.json.`;
263
340
 
264
341
  const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
265
342
 
@@ -328,6 +405,12 @@ const validateSettingsShape = (data, relPath) => {
328
405
  if (data.sandbox !== undefined && !isJsonObject(data.sandbox)) {
329
406
  throw makeVelocityProfileError(VELOCITY_MALFORMED, `${relPath}: sandbox must be a JSON object`);
330
407
  }
408
+ // The bridge tier merges into sandbox.excludedCommands — a malformed (non-array) value is a STOP
409
+ // with ZERO writes (the same fail-closed posture as permissions.allow above), never a silent
410
+ // treat-as-empty overwrite (codex R1, Segment A).
411
+ if (isJsonObject(data.sandbox) && data.sandbox.excludedCommands !== undefined && !Array.isArray(data.sandbox.excludedCommands)) {
412
+ throw makeVelocityProfileError(VELOCITY_MALFORMED, `${relPath}: sandbox.excludedCommands must be an array`);
413
+ }
331
414
  return data;
332
415
  };
333
416
 
@@ -464,13 +547,31 @@ const isKitToolShapedCommand = (entry) => {
464
547
  return cmd !== undefined && tokenizeCommand(cmd)[0] === KIT_TOOL_INVOKER;
465
548
  };
466
549
 
467
- const collectPreExistingNonReadonly = (sources, derivedTier = []) =>
550
+ // A bridge-tier-SHAPED entry (a bare frozen-tier wrapper wildcard, or the quoted-grounding form)
551
+ // passes the shape screen — but only the entries the tier derives for THIS host (its PLACED
552
+ // bridges + this kit's grounding path) are audited; an absent-bridge or foreign-path spelling
553
+ // stays flagged for hand review (the same derived-set discipline as the kit-tools tier).
554
+ const isBridgeTierShapedCommand = (entry) => {
555
+ const cmd = getBashAllowCommand(entry);
556
+ if (cmd === undefined) return false;
557
+ const tokens = tokenizeCommand(cmd);
558
+ return (
559
+ BRIDGE_REVIEW_WRAPPERS.includes(tokens[0]) ||
560
+ (tokens[0] === KIT_TOOL_INVOKER && tokens.length === 2 && isQuotedGroundingToken(tokens[1]))
561
+ );
562
+ };
563
+
564
+ const collectPreExistingNonReadonly = (sources, derivedTier = [], derivedBridgeAllow = []) =>
468
565
  sources.flatMap(({ source, data }) =>
469
566
  getAllowEntries(data)
470
567
  .filter((entry) => typeof entry === 'string' && BASH_PERMISSION_PATTERN.test(entry))
471
568
  .filter(
472
569
  (entry) =>
473
- !screenAllowlistEntry(entry) || (isKitToolShapedCommand(entry) && !derivedTier.includes(entry)),
570
+ !screenAllowlistEntry(entry) ||
571
+ // The quoted grounding rule is node-shaped AND bridge-tier-shaped: membership in EITHER
572
+ // derived set exempts it — the advisory never flags an entry a tier itself seeds.
573
+ (isKitToolShapedCommand(entry) && !derivedTier.includes(entry) && !derivedBridgeAllow.includes(entry)) ||
574
+ (isBridgeTierShapedCommand(entry) && !derivedBridgeAllow.includes(entry)),
474
575
  )
475
576
  .map((entry) => ({ source, entry })),
476
577
  );
@@ -488,7 +589,7 @@ const assertTargetWritable = (absPath, deps = {}) => {
488
589
 
489
590
  const formatJson = (data, eol) => `${JSON.stringify(data, null, SETTINGS_JSON_INDENT).replace(JSON_NEWLINE_PATTERN, eol)}${eol}`;
490
591
 
491
- const mergeProjectSettings = (projectData, toAdd, acceptEdits) => {
592
+ const mergeProjectSettings = (projectData, toAdd, acceptEdits, excludedToAdd = []) => {
492
593
  const base = projectData ?? {};
493
594
  const permissions = getPermissions(base);
494
595
  const allow = getAllowEntries(base);
@@ -498,7 +599,16 @@ const mergeProjectSettings = (projectData, toAdd, acceptEdits) => {
498
599
  allow: mergedAllow,
499
600
  ...(acceptEdits === true ? { defaultMode: ACCEPT_EDITS_MODE } : {}),
500
601
  };
501
- return { ...base, permissions: mergedPermissions };
602
+ // The bridge tier's second surface: sandbox.excludedCommands (merge-don't-clobber — foreign
603
+ // entries and sandbox sub-keys preserved; only the tier's wrapper names append, deduped). The
604
+ // flagless/kit-tools paths pass no entries, so the sandbox block stays untouched for them.
605
+ const existingSandbox = isJsonObject(base.sandbox) ? base.sandbox : {};
606
+ const existingExcluded = Array.isArray(existingSandbox.excludedCommands) ? existingSandbox.excludedCommands : [];
607
+ const newExcluded = excludedToAdd.filter((cmd) => !existingExcluded.includes(cmd));
608
+ const sandboxBlock = newExcluded.length
609
+ ? { sandbox: { ...existingSandbox, excludedCommands: [...existingExcluded, ...newExcluded] } }
610
+ : {};
611
+ return { ...base, permissions: mergedPermissions, ...sandboxBlock };
502
612
  };
503
613
 
504
614
  const formatEntryList = (entries) => (entries.length ? entries.map((entry) => ` - ${entry}`) : [' - (none)']);
@@ -524,6 +634,25 @@ const formatKitTier = (result) =>
524
634
  ]
525
635
  : [];
526
636
 
637
+ // The bridge tier's honest posture, printed on EVERY --bridge-tier run (test-pinned — the codex-R2
638
+ // informed-consent resolution): the exfiltration surface is stated, never pretended away.
639
+ export const KIT_BRIDGE_TIER_NOTICE =
640
+ '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).';
641
+
642
+ const formatBridgeTier = (result) =>
643
+ result.bridgeTier
644
+ ? [
645
+ `${result.wrote ? 'added' : 'would add'} bridge-wrappers tier allow entries: ${result.bridgeToAdd.length}`,
646
+ ...formatEntryList(result.bridgeToAdd),
647
+ `already present (bridge tier): ${result.bridgeAlreadyPresent.length}`,
648
+ `${result.wrote ? 'added' : 'would add'} sandbox.excludedCommands entries: ${result.excludedToAdd.length}`,
649
+ ...formatEntryList(result.excludedToAdd),
650
+ `already present (excludedCommands): ${result.excludedAlreadyPresent.length}`,
651
+ ...result.bridgeSkips.map((s) => ` skipped: ${s.reason}`),
652
+ KIT_BRIDGE_TIER_NOTICE,
653
+ ]
654
+ : [];
655
+
527
656
  const formatGateAdvisory = (gateCandidates) => [
528
657
  'gate advisory: candidates you may add BY HAND to .claude/settings.json or settings.local.json; this tool will NOT add them',
529
658
  ...(gateCandidates.length
@@ -549,6 +678,7 @@ const formatVelocityProfileResult = (result) =>
549
678
  result.dryRun ? 'agent-workflow velocity profile - DRY RUN (no changes)' : 'agent-workflow velocity profile - APPLY',
550
679
  ...formatAllowlist(result),
551
680
  ...formatKitTier(result),
681
+ ...formatBridgeTier(result),
552
682
  formatDefaultMode(result),
553
683
  ...formatGateAdvisory(result.gateCandidates),
554
684
  ...formatPreExistingAdvisory(result.preExistingNonReadonly),
@@ -572,7 +702,15 @@ export const screenAllowlistEntry = (pattern) => {
572
702
  if (tokens[0] === NPM_COMMAND) return NPM_READONLY_SUBCOMMANDS.includes(getSubcommand(tokens));
573
703
  // Kit-tool wildcard class: `node <abs kit tool>` + args wildcard. run-gates is deliberately NOT
574
704
  // here (Decision 3: only its exact root-pinned form below — a wildcard would cover `--cwd <any>`).
575
- if (tokens[0] === KIT_TOOL_INVOKER) return tokens.length === 2 && isKitToolPathToken(tokens[1], KIT_WILDCARD_TOOLS);
705
+ // The quoted-grounding form is the bridge tier's ONE quoted acceptance (never a general quote
706
+ // allowance): `node "<abs>/tools/grounding.mjs"` exactly.
707
+ if (tokens[0] === KIT_TOOL_INVOKER) {
708
+ return tokens.length === 2 && (isKitToolPathToken(tokens[1], KIT_WILDCARD_TOOLS) || isQuotedGroundingToken(tokens[1]));
709
+ }
710
+ // Bridge-review-wrapper class (AD-044 Plan 4): EXACTLY `<frozen-tier wrapper> code` + the args
711
+ // wildcard — never codex-exec/agy-run, never the bare or plan/diff spellings (those file-argument
712
+ // modes can read outside the repo; codex R2).
713
+ if (BRIDGE_REVIEW_WRAPPERS.includes(tokens[0])) return tokens.length === 2 && tokens[1] === BRIDGE_REVIEW_MODE;
576
714
  return isSingleShellToken(wildcardCmd, tokens) && SHELL_READONLY.includes(wildcardCmd);
577
715
  }
578
716
  const exactCmd = getBashExactCommand(pattern);
@@ -591,7 +729,7 @@ export const screenAllowlistEntry = (pattern) => {
591
729
  };
592
730
 
593
731
  /**
594
- * Derive the opt-in kit-tools tier for a project: 8 wildcard entries (resolved-absolute script
732
+ * Derive the opt-in kit-tools tier for a project: 9 wildcard entries (resolved-absolute script
595
733
  * path + args wildcard), ONE exact run-gates entry pinned to the resolved project root, and the
596
734
  * writer-preview exact dry-run byte-strings. Pure derivation — a stale path simply prompts again.
597
735
  */
@@ -721,12 +859,22 @@ export const preflightVelocityProfile = ({ cwd }, deps = {}) => {
721
859
  return [];
722
860
  }
723
861
  })();
862
+ // Same defensive posture for the bridge tier: a probe failure falls back to flagging every
863
+ // bridge-shaped entry — over-flagging is the safe direction for a read-only advisory.
864
+ const derivedBridge = (() => {
865
+ try {
866
+ return deriveBridgeTierAllowlist({ findWrapper: deps.findWrapper }).allow;
867
+ } catch {
868
+ return [];
869
+ }
870
+ })();
724
871
  const preExistingNonReadonly = collectPreExistingNonReadonly(
725
872
  [
726
873
  { source: SETTINGS_FILE, data: projectSettings.data },
727
874
  { source: SETTINGS_LOCAL_FILE, data: localSettings.data },
728
875
  ],
729
876
  derivedTier,
877
+ derivedBridge,
730
878
  );
731
879
  const gateCandidates = discoverGateCandidates(readPackageJson(projectDir, deps));
732
880
 
@@ -744,20 +892,40 @@ export const preflightVelocityProfile = ({ cwd }, deps = {}) => {
744
892
  };
745
893
  };
746
894
 
747
- export const planVelocityProfile = (preflight, { acceptEdits, kitTools } = {}) => {
895
+ export const planVelocityProfile = (preflight, { acceptEdits, kitTools, bridgeTier, findWrapper } = {}) => {
748
896
  const projectAllow = getAllowEntries(preflight.projectSettings?.data);
749
897
  const toAdd = UNIVERSAL_READONLY_ALLOWLIST.filter((entry) => !projectAllow.includes(entry));
750
898
  const alreadyPresent = UNIVERSAL_READONLY_ALLOWLIST.filter((entry) => projectAllow.includes(entry));
751
899
  const tier = kitTools === true ? deriveKitToolsAllowlist({ projectDir: preflight.cwd }) : [];
752
900
  const tierToAdd = tier.filter((entry) => !projectAllow.includes(entry));
753
901
  const tierAlreadyPresent = tier.filter((entry) => projectAllow.includes(entry));
754
- return { toAdd, alreadyPresent, tierToAdd, tierAlreadyPresent, kitTools: kitTools === true, setsDefaultMode: acceptEdits === true };
902
+ const bridge = bridgeTier === true ? deriveBridgeTierAllowlist({ findWrapper }) : { allow: [], excludedCommands: [], skips: [], placed: [] };
903
+ const bridgeToAdd = bridge.allow.filter((entry) => !projectAllow.includes(entry));
904
+ const bridgeAlreadyPresent = bridge.allow.filter((entry) => projectAllow.includes(entry));
905
+ const existingSandbox = isJsonObject(preflight.projectSettings?.data?.sandbox) ? preflight.projectSettings.data.sandbox : {};
906
+ const existingExcluded = Array.isArray(existingSandbox.excludedCommands) ? existingSandbox.excludedCommands : [];
907
+ const excludedToAdd = bridge.excludedCommands.filter((cmd) => !existingExcluded.includes(cmd));
908
+ const excludedAlreadyPresent = bridge.excludedCommands.filter((cmd) => existingExcluded.includes(cmd));
909
+ return {
910
+ toAdd,
911
+ alreadyPresent,
912
+ tierToAdd,
913
+ tierAlreadyPresent,
914
+ kitTools: kitTools === true,
915
+ bridgeTier: bridgeTier === true,
916
+ bridgeToAdd,
917
+ bridgeAlreadyPresent,
918
+ bridgeSkips: bridge.skips,
919
+ excludedToAdd,
920
+ excludedAlreadyPresent,
921
+ setsDefaultMode: acceptEdits === true,
922
+ };
755
923
  };
756
924
 
757
- export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true, kitTools = false } = {}, deps = {}) => {
925
+ export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true, kitTools = false, bridgeTier = false } = {}, deps = {}) => {
758
926
  const projectDir = cwd ?? deps.cwd ?? process.cwd();
759
927
  const preflight = preflightVelocityProfile({ cwd: projectDir }, deps);
760
- const plan = planVelocityProfile(preflight, { acceptEdits, kitTools });
928
+ const plan = planVelocityProfile(preflight, { acceptEdits, kitTools, bridgeTier, findWrapper: deps.findWrapper });
761
929
  // Drift guard runs on BOTH dry-run and apply (so a dry-run faithfully predicts the apply) and
762
930
  // validates the FULL audited core, not just the to-add delta — a drifted core entry is caught even
763
931
  // when it is already present in the user's allow list (and toAdd is a subset of the core anyway).
@@ -768,6 +936,13 @@ export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true,
768
936
  const tier = deriveKitToolsAllowlist({ projectDir });
769
937
  validateProfile(tier, [...UNIVERSAL_READONLY_ALLOWLIST, ...tier]);
770
938
  }
939
+ // A --bridge-tier run validates the DERIVED bridge entries the same way — the third audit point
940
+ // (Decision 2): every seeded bridge entry passes the screen AND sits in the audited set, so the
941
+ // flagless advisory can never flag an entry the tier itself seeded.
942
+ if (bridgeTier === true) {
943
+ const bridge = deriveBridgeTierAllowlist({ findWrapper: deps.findWrapper });
944
+ validateProfile(bridge.allow, [...UNIVERSAL_READONLY_ALLOWLIST, ...bridge.allow]);
945
+ }
771
946
  const resultBase = { ...preflight, ...plan };
772
947
  if (dryRun) return { wrote: false, dryRun: true, ...resultBase };
773
948
 
@@ -781,7 +956,12 @@ export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true,
781
956
  const fs = fsDeps(deps);
782
957
  const settingsPath = join(projectDir, SETTINGS_FILE);
783
958
  if (preflight.claudeDirAbsent) fs.mkdir(join(projectDir, CLAUDE_DIR), { recursive: true });
784
- const merged = mergeProjectSettings(preflight.projectSettings.data, [...plan.toAdd, ...plan.tierToAdd], acceptEdits);
959
+ const merged = mergeProjectSettings(
960
+ preflight.projectSettings.data,
961
+ [...plan.toAdd, ...plan.tierToAdd, ...plan.bridgeToAdd],
962
+ acceptEdits,
963
+ plan.excludedToAdd,
964
+ );
785
965
  fs.writeFile(settingsPath, formatJson(merged, preflight.projectSettings.eol ?? LF), UTF8);
786
966
  return { wrote: true, dryRun: false, settingsPath, ...resultBase };
787
967
  };
@@ -1002,8 +1182,18 @@ const collectRedlineBypass = (sources) =>
1002
1182
  // escape the sandbox entirely. The render owns only enabled/autoAllow and preserves other sandbox
1003
1183
  // sub-keys (merge-don't-clobber, never a silent clobber of the user's sandbox tuning), so a pre-existing
1004
1184
  // weakening sub-key is REPORTED loudly (remove it by hand) — never silently carried as security.
1005
- const collectSandboxWeakenings = (sources) =>
1006
- sources.flatMap(({ source, data }) => {
1185
+ const collectSandboxWeakenings = (sources) => {
1186
+ // Tier-known PROOF (codex R4): an excludedCommands entry is downgraded to a note ONLY when it is
1187
+ // demonstrably the consented tier's own output — it lives in the PROJECT settings.json (the file
1188
+ // the tier writes; a local-file exclusion is never tier output) AND the matching derived
1189
+ // code-mode allow rule is present there. A bare name match alone proves nothing.
1190
+ const project = sources.find((s) => s.source === SETTINGS_FILE);
1191
+ const projectAllow = getAllowEntries(project?.data);
1192
+ const isTierKnownExclusion = (source, cmd) =>
1193
+ source === SETTINGS_FILE &&
1194
+ BRIDGE_REVIEW_WRAPPERS.includes(cmd) &&
1195
+ projectAllow.includes(`Bash(${cmd} ${BRIDGE_REVIEW_MODE}:*)`);
1196
+ return sources.flatMap(({ source, data }) => {
1007
1197
  const sb = isJsonObject(data?.[SANDBOX_KEY]) ? data[SANDBOX_KEY] : {};
1008
1198
  const out = [];
1009
1199
  const net = isJsonObject(sb.network) ? sb.network : {};
@@ -1018,10 +1208,22 @@ const collectSandboxWeakenings = (sources) =>
1018
1208
  out.push({ source, key: `${SANDBOX_KEY}.allowUnsandboxedCommands`, weakens: 'every sandbox red-line', detail: 'commands may run unsandboxed' });
1019
1209
  }
1020
1210
  if (Array.isArray(sb.excludedCommands) && sb.excludedCommands.length) {
1021
- out.push({ source, key: `${SANDBOX_KEY}.excludedCommands`, weakens: 'every sandbox red-line', detail: `${sb.excludedCommands.length} command(s) run UNSANDBOXED (network/fs confinement not applied to them)` });
1211
+ // The bridge tier's OWN wrapper names are tier-known ONLY with the proof above (the
1212
+ // --bridge-tier consent covers exactly this posture — the wrappers need network and
1213
+ // genuinely run unsandboxed); then they are an informational note, never a weakening flag
1214
+ // (the Decision-2 self-consistency bar). Every OTHER excluded command — hand-added names,
1215
+ // local-file exclusions, tier names without their allow rules — stays a loud weakening.
1216
+ const tierKnown = sb.excludedCommands.filter((c) => isTierKnownExclusion(source, c));
1217
+ const foreign = sb.excludedCommands.filter((c) => !tierKnown.includes(c));
1218
+ if (foreign.length) {
1219
+ 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` : ''}` });
1220
+ } else if (tierKnown.length) {
1221
+ 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` });
1222
+ }
1022
1223
  }
1023
1224
  return out;
1024
1225
  });
1226
+ };
1025
1227
 
1026
1228
  // collectLocalMasks(localData, render) → the render-owned keys a settings.local.json value MASKS
1027
1229
  // (local > project, so a differing local value defeats the rendered one silently). The render owns
@@ -1071,7 +1273,8 @@ export const formatAutonomyResult = (r) => {
1071
1273
  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.`);
1072
1274
  }
1073
1275
  for (const w of r.sandboxWeakenings ?? []) {
1074
- 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.`);
1276
+ if (w.tierKnown) lines.push(` note: ${w.source} has ${w.key} (${w.detail}).`);
1277
+ 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.`);
1075
1278
  }
1076
1279
  lines.push(AUTONOMY_RESIDUAL_NOTICE);
1077
1280
  if (!r.wrote) lines.push(`re-run with ${FLAG_APPLY} to write .claude/settings.json (only the render-owned blocks change).`);
@@ -1204,6 +1407,7 @@ export const parseArgs = (argv) => {
1204
1407
  if (arg === FLAG_APPLY) return { ...state, apply: true };
1205
1408
  if (arg === FLAG_ACCEPT_EDITS) return { ...state, acceptEdits: true };
1206
1409
  if (arg === FLAG_KIT_TOOLS) return { ...state, kitTools: true };
1410
+ if (arg === FLAG_BRIDGE_TIER) return { ...state, bridgeTier: true };
1207
1411
  if (arg === FLAG_AUTONOMY) return { ...state, autonomy: true };
1208
1412
  if (arg === FLAG_CHECK) return { ...state, check: true };
1209
1413
  if (arg === FLAG_CWD) {
@@ -1214,7 +1418,7 @@ export const parseArgs = (argv) => {
1214
1418
  if (arg.startsWith('-')) throw fail(EXIT_USAGE, `unknown flag: ${arg}`);
1215
1419
  throw fail(EXIT_USAGE, `unexpected argument: ${arg}`);
1216
1420
  },
1217
- { help: false, dryRunFlag: false, apply: false, acceptEdits: false, kitTools: false, autonomy: false, check: false, cwd: undefined, skipNext: false },
1421
+ { help: false, dryRunFlag: false, apply: false, acceptEdits: false, kitTools: false, bridgeTier: false, autonomy: false, check: false, cwd: undefined, skipNext: false },
1218
1422
  );
1219
1423
 
1220
1424
  if (parsed.dryRunFlag && parsed.apply) throw fail(EXIT_USAGE, `${FLAG_DRY_RUN} and ${FLAG_APPLY} cannot be used together`);
@@ -1224,6 +1428,7 @@ export const parseArgs = (argv) => {
1224
1428
  // Reject the mix LOUDLY rather than silently ignoring a flag.
1225
1429
  if (parsed.autonomy && parsed.acceptEdits) throw fail(EXIT_USAGE, `${FLAG_AUTONOMY} sets ${DEFAULT_MODE_KEY} from the policy — ${FLAG_ACCEPT_EDITS} (an allowlist-mode flag) cannot be combined with it`);
1226
1430
  if (parsed.autonomy && parsed.kitTools) throw fail(EXIT_USAGE, `${FLAG_KIT_TOOLS} is an allowlist-mode flag — it cannot be combined with ${FLAG_AUTONOMY} (a policy-only render)`);
1431
+ if (parsed.autonomy && parsed.bridgeTier) throw fail(EXIT_USAGE, `${FLAG_BRIDGE_TIER} is an allowlist-mode flag — it cannot be combined with ${FLAG_AUTONOMY} (a policy-only render)`);
1227
1432
  if (parsed.check && !parsed.autonomy) throw fail(EXIT_USAGE, `${FLAG_CHECK} is only valid with ${FLAG_AUTONOMY}`);
1228
1433
  if (parsed.check && parsed.apply) throw fail(EXIT_USAGE, `${FLAG_CHECK} is read-only — it cannot be combined with ${FLAG_APPLY}`);
1229
1434
  return {
@@ -1232,6 +1437,7 @@ export const parseArgs = (argv) => {
1232
1437
  apply: parsed.apply,
1233
1438
  acceptEdits: parsed.acceptEdits,
1234
1439
  kitTools: parsed.kitTools,
1440
+ bridgeTier: parsed.bridgeTier,
1235
1441
  autonomy: parsed.autonomy,
1236
1442
  check: parsed.check,
1237
1443
  cwd: parsed.cwd,
@@ -1261,7 +1467,7 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
1261
1467
  return EXIT_OK;
1262
1468
  }
1263
1469
  const result = writeVelocityProfile(
1264
- { cwd, acceptEdits: args.acceptEdits, dryRun: args.dryRun, kitTools: args.kitTools },
1470
+ { cwd, acceptEdits: args.acceptEdits, dryRun: args.dryRun, kitTools: args.kitTools, bridgeTier: args.bridgeTier },
1265
1471
  deps,
1266
1472
  );
1267
1473
  log(formatVelocityProfileResult(result));