@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.
@@ -3,13 +3,13 @@
3
3
  // (docs/ai/acks.json, AD-055 Part I). An upgrade Recommendations item whose state can only be
4
4
  // ANSWERED renders THIS tool's PREVIEW one-liner; the preview prints the exact `--apply` command;
5
5
  // the agent runs `--apply` only after the mode doc's §3 informed-consent confirmation. It records a
6
- // NEUTRAL fingerprint acknowledgement — never a security key; the kit never writes sandbox
7
- // filesystem allowances, and allowedDomains only through velocity-profile --bridge-tier.
6
+ // NEUTRAL fingerprint acknowledgement — never a security key; the bridge sandbox keys are written
7
+ // by velocity-profile --bridge-tier alone.
8
8
  //
9
- // The wording is per-LANE or genuinely neutral, never "recipe": the sandbox lane acknowledges a
10
- // session-sandbox recipe, but the coverage-domain lane acknowledges a census FACT and the
11
- // source-size-copy lane a set of declared tool claims. One lane's noun stated over all of them was
12
- // simply false about the others.
9
+ // The wording is per-LANE or neutral, never "recipe": each lane's fingerprint stands for a different
10
+ // thing — the coverage-domain lane a census FACT, the source-size-copy lane declared tool claims.
11
+ // The default lane's advisor item is retired; the lane is kept so a value already on disk keeps its
12
+ // meaning (seen).
13
13
  //
14
14
  // Family writer discipline (velocity / orchestration-write / gate-hook), verbatim:
15
15
  // • preview-then-mutate — `--dry-run` is the DEFAULT and writes nothing; `--apply` writes;
@@ -45,8 +45,8 @@ const JSON_INDENT = 2;
45
45
  // minted over a recipe or over a canonical fact string) — a fail-closed guard so the store never
46
46
  // records a malformed or injected value.
47
47
  export const FINGERPRINT_PATTERN = /^[0-9a-f]{16}$/u;
48
- // The lane this writer records when none is named — the original single-lane contract, so every
49
- // pre-existing sandbox-lane invocation and its rendered one-liner stay byte-identical.
48
+ // The default lane is the original single-lane contract: without --lane, an invocation records what
49
+ // it always did. Its advisor item is retired; the lane is kept.
50
50
  export const DEFAULT_ACK_LANE = 'sandbox-lane';
51
51
 
52
52
  export const ACK_WRITE_STOP = 'ACK_WRITE_STOP';
@@ -155,9 +155,9 @@ const USAGE = `usage: ack-write --fingerprint <16-hex> [--lane <${Object.keys(AC
155
155
 
156
156
  Records a NEUTRAL acknowledgement into ${ACKS_FILE} (the family-owned ack store — no host settings
157
157
  validator guards it). --lane names which advisor item is being acknowledged (default
158
- "${DEFAULT_ACK_LANE}") and what the fingerprint stands for — a session-sandbox recipe, a worktrees
159
- probe dir, a tracked-tree census fact, a set of declared tool claims; each lane owns one top-level
160
- key. Default is --dry-run (a preview; writes
158
+ "${DEFAULT_ACK_LANE}") and what the fingerprint stands for — a worktrees probe dir, a tracked-tree
159
+ census fact, a set of declared tool claims; each lane owns one top-level key. The default lane's
160
+ advisor item is retired (a recorded value keeps its meaning). Default is --dry-run (a preview; writes
161
161
  nothing) and prints the exact --apply command. --apply merges that ONE key into ${ACKS_FILE},
162
162
  preserving every existing key. Refuses an absent docs/ai deployment; never a security key; never
163
163
  commits.`;
@@ -6,12 +6,14 @@ import { homedir } from 'node:os';
6
6
  import { basename, dirname, isAbsolute, join, relative, resolve, win32 } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import {
9
- BRIDGE_REVIEW_WRAPPERS, CLAUDE_DIR, KIT_GROUNDING_TOOL, RENDER_OWNED_REDLINE_RULES,
9
+ CLAUDE_DIR, RENDER_OWNED_REDLINE_RULES,
10
10
  SAFE_DEFAULT_MODES, SETTINGS_FILE, SETTINGS_LOCAL_FILE, UNIVERSAL_READONLY_ALLOWLIST,
11
- deriveBridgeTierAllowlist, deriveKitToolsAllowlist, mergeAutonomySettings,
11
+ deriveKitToolsAllowlist, detectAtRoot, mergeAutonomySettings,
12
12
  writeAutonomyProfile, writeVelocityProfile,
13
13
  } from './velocity-profile.mjs';
14
- import { bundledSandboxRecipe } from './bridge-sandbox-recipe.mjs';
14
+ import { KIT_GROUNDING_TOOL, bundledSandboxRecipe, usedBridges } from './bridge-sandbox-recipe.mjs';
15
+ import { mergeAllowWrite, stateDirsOf } from './bridge-state-dirs.mjs';
16
+ import { loadConfig } from './orchestration-config.mjs';
15
17
  import { HOOK_FILE_REL, HOOKS_DIR, buildHookSettingsEntry, mergeHookEntry, writeGateHook } from './gate-hook.mjs';
16
18
  import { writeMcp } from './mcp.mjs';
17
19
  import { ENABLED_KEY, MCP_JSON_REL, SERVERS_KEY, SERVER_NAME, allowRulesFor, buildServerEntry } from './mcp-registration.mjs';
@@ -140,11 +142,12 @@ export const judgeChange = ({ variant, files, scope }) => {
140
142
  };
141
143
  const writerFacts = (facts, deps) => ({
142
144
  ...deps, ...facts,
145
+ detect: deps.detect ?? facts.detect,
143
146
  findWrapper: (name) => findOnPath(name, { getenv: facts.env }).state === 'present',
144
147
  findOnPath: (name) => findOnPath(name, { getenv: facts.env }),
145
148
  });
146
149
 
147
- export const scopeOf = (variant, facts) => {
150
+ export const scopeOf = (variant, facts, deps = {}) => {
148
151
  const allowed = {};
149
152
  const set = (key, values) => {
150
153
  allowed[key] = (value) => values.some((entry) => equal(entry, value));
@@ -153,12 +156,13 @@ export const scopeOf = (variant, facts) => {
153
156
  const allow = [...UNIVERSAL_READONLY_ALLOWLIST];
154
157
  if (variant === 'kit-tools-tier') allow.push(...deriveKitToolsAllowlist({ projectDir: facts.root }));
155
158
  if (variant === 'bridge-tier') {
156
- const deps = writerFacts(facts, {});
157
- const placed = BRIDGE_REVIEW_WRAPPERS.filter(deps.findWrapper);
158
- allow.push(...deriveBridgeTierAllowlist({ findWrapper: deps.findWrapper,
159
- groundingAbsPath: join(facts.toolsDir, '..', KIT_GROUNDING_TOOL) }).allow);
160
- set('sandbox.excludedCommands', placed);
161
- set('sandbox.network.allowedDomains', bundledSandboxRecipe(placed, deps).hosts);
159
+ const detect = deps.detect ?? facts.detect ?? (() => detectAtRoot(facts.root, { env: facts.env, home: facts.home }));
160
+ const used = usedBridges(loadConfig(facts.root).config, detect());
161
+ const recipe = bundledSandboxRecipe(used, { groundingAbsPath: join(facts.toolsDir, '..', KIT_GROUNDING_TOOL) });
162
+ allow.push(...recipe.allow);
163
+ set('sandbox.excludedCommands', recipe.excludedCommands);
164
+ set('sandbox.network.allowedDomains', recipe.hosts);
165
+ set('sandbox.filesystem.allowWrite', stateDirsOf(recipe.dirEntries, facts));
162
166
  }
163
167
  set('permissions.allow', allow);
164
168
  } else if (variant === 'autonomy-render') {
@@ -189,7 +193,7 @@ export const scopeOf = (variant, facts) => {
189
193
  return {
190
194
  admits: ({ path, key, change, value }) => {
191
195
  const correctFile = key.startsWith(`${SERVERS_KEY}.`) ? path === MCP_JSON_REL : path === SETTINGS_FILE;
192
- const gains = key === 'hooks.PreToolUse' || key === ENABLED_KEY || key === CREDENTIALS;
196
+ const gains = variant === 'bridge-tier' || key === 'hooks.PreToolUse' || key === ENABLED_KEY || key === CREDENTIALS;
193
197
  return correctFile && (!gains || change === 'added') && Boolean(allowed[key]?.(value));
194
198
  },
195
199
  admitsPath: (path, directory = false) => directory ? directories.includes(path)
@@ -220,7 +224,7 @@ export const plannedChange = (variant, facts, deps = {}) => {
220
224
  const plan = writeVelocityProfile({ cwd: facts.root, dryRun: true,
221
225
  kitTools: variant === 'kit-tools-tier', bridgeTier: variant === 'bridge-tier' }, writerDeps);
222
226
  const before = plan.projectSettings.data ?? {};
223
- const after = structuredClone(before);
227
+ let after = structuredClone(before);
224
228
  after.permissions = { ...before.permissions,
225
229
  allow: [...(before.permissions?.allow ?? []), ...plan.toAdd, ...plan.tierToAdd, ...plan.bridgeToAdd] };
226
230
  if (plan.excludedToAdd.length || plan.hostsToAdd.length) {
@@ -229,6 +233,7 @@ export const plannedChange = (variant, facts, deps = {}) => {
229
233
  if (plan.hostsToAdd.length) after.sandbox.network = { ...before.sandbox?.network,
230
234
  allowedDomains: [...(before.sandbox?.network?.allowedDomains ?? []), ...plan.hostsToAdd] };
231
235
  }
236
+ after = mergeAllowWrite(after, plan.dirsToAdd);
232
237
  addFile(SETTINGS_FILE, plan.projectSettings.data, after);
233
238
  } else if (variant === 'autonomy-render') {
234
239
  const render = writeAutonomyProfile({ cwd: facts.root, apply: false }, writerDeps);
@@ -272,7 +277,7 @@ export const plannedChange = (variant, facts, deps = {}) => {
272
277
 
273
278
  export const checkApply = (variant, facts, deps = {}) => {
274
279
  const plan = plannedChange(variant, facts, deps);
275
- const scope = scopeOf(variant, facts);
280
+ const scope = scopeOf(variant, facts, deps);
276
281
  const { entries, refused } = judgeChange({ variant, files: plan.files, scope });
277
282
  const lines = [];
278
283
  const display = (path) => facts.root && covers(facts.root, path) ? relative(facts.root, path) : path;
@@ -344,7 +349,7 @@ export const main = (argv, io = {}) => {
344
349
  platform: resolved.platform, claudeDir: args['claude-dir'] };
345
350
  let result;
346
351
  try {
347
- result = checkApply(args.variant, facts);
352
+ result = checkApply(args.variant, facts, { detect: resolved.detect });
348
353
  } catch (error) {
349
354
  for (const line of error.lines ?? [error.message]) resolved.log(line);
350
355
  return 3;
@@ -1,23 +1,91 @@
1
- // bridge-sandbox-recipe.mjs — the one reader of the bundled bridges' declared sandbox surfaces
2
- // (contract: bridges/velocity-profile). The bridge tier seeds the hosts it returns and the
3
- // Recommendations sandbox-lane recipe renders hosts and dirs from it, so the two cannot drift.
1
+ // bridge-sandbox-recipe.mjs — the one reader of the bundled bridges' declared sandbox surfaces and
2
+ // the one derivation of the bridge tier (contract: bridges/velocity-profile, revision 4). The tier
3
+ // writer, the advisor's delta, the danger check and the autonomy preview read the same
4
+ // `bundledSandboxRecipe(usedBridges(...))`, so the four cannot drift.
4
5
  //
5
6
  // Dependency-free, Node >= 22. No side effects on import.
6
7
 
7
8
  import { readFileSync, readdirSync } from 'node:fs';
8
- import { join } from 'node:path';
9
+ import { dirname, join, resolve } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
9
11
  import { DEFAULT_BUNDLE_ROOT } from './bridge-settings-read.mjs';
12
+ import { ACTIVITIES, BACKEND_PRIORITY, REVIEW_CMD_ALIASES } from './carriers.mjs';
13
+ import { planRecipe, resolveActivityRecipe } from './recipes.mjs';
14
+ import { isReadyMember } from './review-roster-resolve.mjs';
15
+ import { isSeedablePathToken } from './repo-lex.mjs';
10
16
 
11
- // The manifest-declared session-sandbox recipe surfaces of every BUNDLED bridge whose review
12
- // wrapper is in the wired set — networkHosts ∪ writableDirs, derived from the manifests (the
13
- // single documentation source), never hardcoded here.
14
- export const bundledSandboxRecipe = (placedWrappers, deps) => {
17
+ // The review wrappers, spelled bare as invoked. Only their `code` mode is auto-allowed: a bare
18
+ // `Bash(<wrapper>:*)` prefix would also cover the plan/diff file-argument modes, whose targets can
19
+ // point outside the repo, so those keep their prompt.
20
+ export const BRIDGE_REVIEW_WRAPPERS = Object.freeze(['codex-review', 'agy-review']);
21
+ export const BRIDGE_REVIEW_MODE = 'code';
22
+ // The grounding pre-step (agy's facts assembler), seeded in the EXACT quoted byte-form the
23
+ // procedures advisor renders, so seeded and rendered stay byte-equal.
24
+ export const KIT_GROUNDING_TOOL = 'tools/grounding.mjs';
25
+ const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
26
+ const GROUNDING_WRAPPER = 'agy-review';
27
+ // An exclusion is Bash rule syntax: a bare name matches only the argument-free command, so the
28
+ // tier writes `<wrapper> *`, which matches it with arguments.
29
+ export const exclusionOf = (wrapper) => `${wrapper} *`;
30
+
31
+ const ROLE_ORDER = Object.freeze(['review', 'execute']);
32
+ const SLOT_ROLE = Object.freeze({ review: 'review', execute: 'execute' });
33
+
34
+ // The bridges the project's recipes USE: every review slot and every execute-type slot of every
35
+ // activity, resolved to its EFFECTIVE recipe over the config and the readiness (a degraded recipe
36
+ // names what it degrades to; an absent config is the computed default). A ready provider has its
37
+ // wrapper on PATH, so used implies placed; a bridge no slot uses is absent however placed.
38
+ export const usedBridges = (config, readiness) => {
39
+ const roles = new Map();
40
+ const take = (bridge, role) => {
41
+ if (!BACKEND_PRIORITY.includes(bridge)) return;
42
+ if (!roles.has(bridge)) roles.set(bridge, new Set());
43
+ roles.get(bridge).add(role);
44
+ };
45
+ for (const [activity, def] of Object.entries(ACTIVITIES)) {
46
+ for (const [slot, type] of Object.entries(def.slots)) {
47
+ const role = SLOT_ROLE[type];
48
+ if (role === undefined) continue;
49
+ const resolved = resolveActivityRecipe({ config: config ?? {}, readiness, activity, slot });
50
+ if (resolved.roster) {
51
+ for (const row of resolved.roster) {
52
+ if (row.kind === 'bridge' && isReadyMember(row)) take(REVIEW_CMD_ALIASES[row.member].backend, role);
53
+ }
54
+ continue;
55
+ }
56
+ for (const step of planRecipe(resolved.recipe, readiness).dispatch) take(step.backend, role);
57
+ }
58
+ }
59
+ return [...roles.keys()].sort().map((bridge) => ({
60
+ bridge,
61
+ roles: ROLE_ORDER.filter((role) => roles.get(bridge).has(role)),
62
+ }));
63
+ };
64
+
65
+ // An array of review-wrapper NAMES (the flagless advisory's call in velocity-profile.mjs) reads as review-role entries.
66
+ const rolesFor = (used, manifest) => {
67
+ if (used.every((entry) => typeof entry === 'string')) {
68
+ return used.includes(manifest?.roles?.review?.cmd) ? ['review'] : [];
69
+ }
70
+ return used.find((entry) => entry?.bridge === manifest?.name)?.roles ?? [];
71
+ };
72
+
73
+ const pushOnce = (list, value) => {
74
+ if (!list.includes(value)) list.push(value);
75
+ };
76
+
77
+ // The surfaces of the used bridges over the bundled manifests, in manifest-name order, each list in
78
+ // its own order, a value already taken skipped.
79
+ export const bundledSandboxRecipe = (used, deps = {}) => {
15
80
  const readFile = deps.readFile ?? readFileSync;
16
81
  const readDir = deps.readdir ?? readdirSync;
17
82
  const bundleRoot = deps.bundleRoot ?? DEFAULT_BUNDLE_ROOT;
83
+ const allow = [];
84
+ const excludedCommands = [];
18
85
  const hosts = [];
19
86
  const dirEntries = [];
20
- for (const dir of readDir(bundleRoot)) {
87
+ const skips = [];
88
+ for (const dir of [...readDir(bundleRoot)].sort()) {
21
89
  // An unreadable/unparsable bundled manifest must NOT thin the recipe silently — a partial
22
90
  // recipe rendered as complete is worse than none. The throw reaches the caller: a stated skip
23
91
  // in the advisor, a STOP with zero writes in the bridge tier.
@@ -29,12 +97,31 @@ export const bundledSandboxRecipe = (placedWrappers, deps) => {
29
97
  if (err?.code === 'ENOTDIR') continue;
30
98
  throw new Error(`bundled manifest unreadable: ${join(dir, 'capability.json')} — ${err?.message ?? err}`);
31
99
  }
32
- const reviewCmd = manifest?.roles?.review?.cmd;
33
- if (!reviewCmd || !placedWrappers.includes(reviewCmd)) continue;
34
- if (Array.isArray(manifest.networkHosts)) {
35
- for (const h of manifest.networkHosts) if (!hosts.includes(h)) hosts.push(h);
100
+ const roles = rolesFor(used, manifest);
101
+ if (roles.length === 0) continue;
102
+ for (const role of roles) {
103
+ const cmd = manifest?.roles?.[role]?.cmd;
104
+ if (!cmd) continue;
105
+ pushOnce(excludedCommands, exclusionOf(cmd));
106
+ if (role !== 'review') continue;
107
+ pushOnce(allow, `Bash(${cmd} ${BRIDGE_REVIEW_MODE}:*)`);
108
+ if (cmd === GROUNDING_WRAPPER) {
109
+ // groundingAbsPath binds the danger check's scope to the kit path it checks
110
+ // (apply-danger-check.mjs) and lets a test build an unseedable path; the writer never passes it.
111
+ const groundingAbs = deps.groundingAbsPath ?? join(KIT_ROOT, KIT_GROUNDING_TOOL);
112
+ if (isSeedablePathToken(groundingAbs)) pushOnce(allow, `Bash(node "${groundingAbs}":*)`);
113
+ else skips.push({
114
+ entry: groundingAbs,
115
+ 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',
116
+ });
117
+ }
118
+ }
119
+ if (Array.isArray(manifest.networkHosts)) for (const host of manifest.networkHosts) pushOnce(hosts, host);
120
+ if (Array.isArray(manifest.writableDirs)) {
121
+ for (const entry of manifest.writableDirs) {
122
+ if (!dirEntries.some((taken) => JSON.stringify(taken) === JSON.stringify(entry))) dirEntries.push(entry);
123
+ }
36
124
  }
37
- if (Array.isArray(manifest.writableDirs)) dirEntries.push(...manifest.writableDirs);
38
125
  }
39
- return { hosts, dirEntries };
126
+ return { allow, excludedCommands, hosts, dirEntries, skips };
40
127
  };
@@ -0,0 +1,94 @@
1
+ // bridge-state-dirs.mjs — the one leaf that turns the bundled bridges' declared writable state dirs
2
+ // (`writableDirs`) into `sandbox.filesystem.allowWrite` entries (contract: bridges/velocity-profile).
3
+ // The bridge tier writes them, the advisor counts them, the danger check admits them, the autonomy
4
+ // preview names them: one resolution, one home-relative spelling, one merge, so the four cannot drift.
5
+ //
6
+ // A LEAF: imports declared-paths.mjs and node:path only. No side effects on import. Dependency-free,
7
+ // Node >= 22.
8
+
9
+ import { dirname, relative, resolve } from 'node:path';
10
+ import { dirCovers, isResolvableDeclaredEntry, resolveDeclaredDir } from './declared-paths.mjs';
11
+
12
+ // D6 resolution, mirroring the wrappers' byte-semantics (`${VAR:-default}` + the exact case-arms:
13
+ // `~` / `~/…` / `/…` ride as-given; EVERY other form — including `~user/…`, which the wrappers
14
+ // never resolve as a home path — anchors like a relative path). Anchored to the TARGET PROJECT ROOT,
15
+ // matching what a wrapper invoked from the project root resolves.
16
+ export const resolveWritableDir = (entry, { env, root }) => {
17
+ const value = entry.env == null ? '' : (env[entry.env] ?? '');
18
+ if (value === '') return entry.default;
19
+ if (value === '~' || value.startsWith('~/') || value.startsWith('/')) return value;
20
+ return resolve(root, value);
21
+ };
22
+
23
+ // The HOME-SYMBOLIC spelling: tilde forms stay symbolic, an absolute dir under the home (by path
24
+ // segment, never a string prefix) folds back to `~/…`, anything outside the home stays absolute.
25
+ export const homeRelative = (dir, home) => {
26
+ if (dir === '~') return '~';
27
+ if (dir.startsWith('~/')) return `~/${dir.slice(2)}`;
28
+ const homeAbs = resolve(home);
29
+ const abs = resolve(dir);
30
+ if (abs === homeAbs) return '~';
31
+ return dirCovers(homeAbs, abs) ? `~/${relative(homeAbs, abs)}` : abs;
32
+ };
33
+
34
+ const ancestorsOf = (dir) => {
35
+ const out = [];
36
+ for (let at = dirname(dir); at !== dir; dir = at, at = dirname(at)) out.push(at);
37
+ return out;
38
+ };
39
+
40
+ // A grant on `/`, on the home, or on an ancestor of the home or of the root would hand every
41
+ // sandboxed command the user's whole tree — never a bridge's state dir. The override that produced
42
+ // it is named, so the user knows which variable to fix.
43
+ const dangerOf = (abs, { home, root }) => {
44
+ const homeAbs = resolve(home);
45
+ if (abs === '/') return 'the filesystem root';
46
+ if (abs === homeAbs) return 'the home directory';
47
+ if (ancestorsOf(homeAbs).includes(abs)) return 'an ancestor of the home directory';
48
+ if (ancestorsOf(resolve(root)).includes(abs)) return 'an ancestor of the project root';
49
+ return null;
50
+ };
51
+
52
+ export const stateDirsOf = (dirEntries, { env, root, home }) => {
53
+ const dirs = [];
54
+ for (const entry of dirEntries) {
55
+ const raw = resolveWritableDir(entry, { env, root });
56
+ const danger = dangerOf(resolveDeclaredDir(raw, { home, root }), { home, root });
57
+ if (danger !== null) {
58
+ const source = entry.env == null ? 'its declared default' : entry.env;
59
+ throw new Error(`writable state dir "${raw}" from ${source} resolves to ${danger} — refusing to grant it; point ${source} at the bridge's own state dir`);
60
+ }
61
+ const rendered = homeRelative(raw, home);
62
+ if (!dirs.includes(rendered)) dirs.push(rendered);
63
+ }
64
+ return dirs;
65
+ };
66
+
67
+ // The dirs no existing allowWrite entry covers (resolved both sides, containment by path segment).
68
+ export const missingStateDirs = (dirs, allowWrite, { home, root }) => {
69
+ const existing = (Array.isArray(allowWrite) ? allowWrite : [])
70
+ .filter(isResolvableDeclaredEntry)
71
+ .map((entry) => resolveDeclaredDir(entry, { home, root }));
72
+ return dirs.filter((dir) => {
73
+ const abs = resolveDeclaredDir(dir, { home, root });
74
+ return !existing.some((container) => dirCovers(container, abs));
75
+ });
76
+ };
77
+
78
+ // The ONE writer of `sandbox.filesystem.allowWrite`: the other sandbox and filesystem sub-keys stay
79
+ // where they are, each dir not already present appends once. Pure: returns a new object, and with
80
+ // nothing to add returns the input unchanged (no sandbox key is created for nothing).
81
+ export const mergeAllowWrite = (settings, dirs) => {
82
+ const base = settings ?? {};
83
+ const existing = Array.isArray(base.sandbox?.filesystem?.allowWrite) ? base.sandbox.filesystem.allowWrite : [];
84
+ const toAppend = [];
85
+ for (const dir of dirs) if (!existing.includes(dir) && !toAppend.includes(dir)) toAppend.push(dir);
86
+ if (toAppend.length === 0) return base;
87
+ return {
88
+ ...base,
89
+ sandbox: {
90
+ ...base.sandbox,
91
+ filesystem: { ...base.sandbox?.filesystem, allowWrite: [...existing, ...toAppend] },
92
+ },
93
+ };
94
+ };
@@ -0,0 +1,196 @@
1
+ // bridge-wiring.mjs — the leaf that judges the project sandbox's coverage of ready bridges.
2
+ // Contract: bridges/velocity-profile, part bridge-wiring. Dependency-free, Node >= 22.
3
+ // No side effects on import.
4
+
5
+ import { closeSync, constants, fstatSync, openSync, readFileSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { bundledSandboxRecipe, usedBridges } from './bridge-sandbox-recipe.mjs';
9
+ import { missingStateDirs, stateDirsOf } from './bridge-state-dirs.mjs';
10
+ import { loadConfig } from './orchestration-config.mjs';
11
+ import { BACKEND_PRIORITY } from './carriers.mjs';
12
+ import { READY } from './detect-backends.mjs';
13
+
14
+ export const HOST_HONORS_QUALIFIER = 'where the host honors the settings sandbox keys';
15
+ export const WIRED = 'wired';
16
+ export const UNWIRED = 'not wired';
17
+ export const UNCHECKED = 'unchecked';
18
+ export const WIRING_ROUTE = 'wire it on the chat yes of /agent-workflow-kit upgrade';
19
+ export const WIRING_ROUTE_UNUSED = 'no slot uses it for review — once one does, the chat yes of /agent-workflow-kit upgrade wires it';
20
+ export const SANDBOX_ENABLED_CONDITION = `enabled in the project settings — ready means wired ${HOST_HONORS_QUALIFIER}`;
21
+ export const SANDBOX_NOT_ENABLED_CONDITION = 'not enabled in the project settings — ready means installed';
22
+
23
+ const PROJECT_FILE = 'settings.json';
24
+ const LOCAL_FILE = 'settings.local.json';
25
+ const SETTINGS_DIR = '.claude';
26
+ const UTF8 = 'utf8';
27
+ const ABSENT = 'ENOENT';
28
+ const NOT_REGULAR = 'not a regular file';
29
+ const REVIEW = 'review';
30
+ const COVERED = 'covered';
31
+ const NOT_COVERED = 'not covered';
32
+ const ENABLED = 'enabled';
33
+ const SANDBOX = 'sandbox';
34
+ const SURFACE_KEYS = Object.freeze(['excludedCommands', 'hosts', 'dirs']);
35
+
36
+ const isPlainObject = (value) => value !== null && typeof value === 'object'
37
+ && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
38
+
39
+ const assertObject = (value, key) => {
40
+ if (!isPlainObject(value)) throw new Error(`${key} must be a plain JSON object`);
41
+ return value;
42
+ };
43
+
44
+ const sandboxOf = (data, file) => {
45
+ if (data === undefined) return undefined;
46
+ assertObject(data, file);
47
+ if (!Object.hasOwn(data, SANDBOX)) return undefined;
48
+ return assertObject(data.sandbox, `${file}: sandbox`);
49
+ };
50
+
51
+ export const sandboxEnabled = (projectData, localData) => {
52
+ const project = sandboxOf(projectData, PROJECT_FILE);
53
+ const local = sandboxOf(localData, LOCAL_FILE);
54
+ const useLocal = local !== undefined && Object.hasOwn(local, ENABLED);
55
+ const judged = useLocal ? local : project;
56
+ const file = useLocal ? LOCAL_FILE : PROJECT_FILE;
57
+ if (judged === undefined || !Object.hasOwn(judged, ENABLED)) return false;
58
+ if (typeof judged.enabled !== 'boolean') throw new Error(`${file}: enabled must be a boolean`);
59
+ return judged.enabled === true;
60
+ };
61
+
62
+ const readObjectKey = (object, key) => {
63
+ if (!Object.hasOwn(object, key)) return {};
64
+ return assertObject(object[key], key);
65
+ };
66
+
67
+ const readArrayKey = (object, key) => {
68
+ if (!Object.hasOwn(object, key)) return [];
69
+ if (!Array.isArray(object[key])) throw new Error(`${key} must be an array`);
70
+ return object[key];
71
+ };
72
+
73
+ export const sandboxSurfaceDelta = (recipe, sandbox, { home, root }) => {
74
+ const base = sandbox === undefined ? {} : assertObject(sandbox, SANDBOX);
75
+ const exclusions = readArrayKey(base, 'excludedCommands');
76
+ const network = readObjectKey(base, 'network');
77
+ const filesystem = readObjectKey(base, 'filesystem');
78
+ const hosts = readArrayKey(network, 'allowedDomains');
79
+ const allowWrite = readArrayKey(filesystem, 'allowWrite');
80
+ if (allowWrite.some((entry) => typeof entry !== 'string' || entry.trim().length === 0)) {
81
+ throw new Error('allowWrite entries must be non-blank strings');
82
+ }
83
+ return {
84
+ excludedCommands: recipe.excludedCommands.filter((entry) => !exclusions.includes(entry)),
85
+ hosts: recipe.hosts.filter((entry) => !hosts.includes(entry)),
86
+ dirs: missingStateDirs(recipe.dirs, allowWrite, { home, root }),
87
+ };
88
+ };
89
+
90
+ const emptyDelta = () => ({ excludedCommands: [], hosts: [], dirs: [] });
91
+ const countDelta = (delta) => SURFACE_KEYS.reduce((total, key) => total + delta[key].length, 0);
92
+ const unionDeltas = (deltas) => Object.fromEntries(SURFACE_KEYS.map((key) => [
93
+ key, [...new Set(deltas.flatMap((delta) => delta[key]))],
94
+ ]));
95
+
96
+ // One non-blocking descriptor for the type check and the read: a FIFO swapped in at the path never blocks.
97
+ const readRegular = (path) => {
98
+ const descriptor = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
99
+ try {
100
+ if (!fstatSync(descriptor).isFile()) throw new Error(NOT_REGULAR);
101
+ return readFileSync(descriptor, UTF8);
102
+ } finally {
103
+ closeSync(descriptor);
104
+ }
105
+ };
106
+
107
+ const readSettings = (root, file) => {
108
+ const path = join(root, SETTINGS_DIR, file);
109
+ const raw = (() => {
110
+ try {
111
+ return readRegular(path);
112
+ } catch (error) {
113
+ if (error?.code === ABSENT) return undefined;
114
+ throw new Error(`${file}: ${error?.message ?? error}`);
115
+ }
116
+ })();
117
+ if (raw === undefined) return undefined;
118
+ try {
119
+ return JSON.parse(raw);
120
+ } catch (error) {
121
+ throw new Error(`${file}: ${error?.message ?? error}`);
122
+ }
123
+ };
124
+
125
+ const baseEntry = (bridge) => ({
126
+ bridge,
127
+ roles: [],
128
+ used: false,
129
+ state: WIRED,
130
+ review: COVERED,
131
+ missing: emptyDelta(),
132
+ count: 0,
133
+ reason: null,
134
+ route: null,
135
+ reviewRoute: null,
136
+ });
137
+
138
+ const judgeBridge = (bridge, used, sandbox, context, deps) => {
139
+ const roles = used.find((entry) => entry.bridge === bridge)?.roles ?? [];
140
+ const judgedRoles = roles.length === 0 ? [REVIEW] : roles;
141
+ const deltas = new Map([...new Set([...judgedRoles, REVIEW])].map((role) => {
142
+ const recipe = bundledSandboxRecipe([{ bridge, roles: [role] }], deps);
143
+ if (recipe.excludedCommands.length === 0) {
144
+ throw new Error(`bundled manifest ${join(bridge, 'capability.json')} missing or lacking roles.${role}.cmd`);
145
+ }
146
+ const dirs = stateDirsOf(recipe.dirEntries, context);
147
+ return [role, sandboxSurfaceDelta({ ...recipe, dirs }, sandbox, context)];
148
+ }));
149
+ const missing = unionDeltas(judgedRoles.map((role) => deltas.get(role)));
150
+ const count = countDelta(missing);
151
+ const isUsed = roles.length !== 0;
152
+ const reviewMissing = countDelta(deltas.get(REVIEW)) !== 0;
153
+ return {
154
+ ...baseEntry(bridge),
155
+ roles,
156
+ used: isUsed,
157
+ state: count === 0 ? WIRED : UNWIRED,
158
+ review: reviewMissing ? NOT_COVERED : COVERED,
159
+ missing,
160
+ count,
161
+ route: count === 0 ? null : (isUsed ? WIRING_ROUTE : WIRING_ROUTE_UNUSED),
162
+ reviewRoute: reviewMissing ? (roles.includes(REVIEW) ? WIRING_ROUTE : WIRING_ROUTE_UNUSED) : null,
163
+ };
164
+ };
165
+
166
+ export const composeBridgeWiring = ({ root, readiness }, deps = {}) => {
167
+ const ready = BACKEND_PRIORITY.filter((bridge) => readiness.some((row) => row.name === bridge && row.readiness === READY));
168
+ try {
169
+ const project = readSettings(root, PROJECT_FILE);
170
+ const local = readSettings(root, LOCAL_FILE);
171
+ if (!sandboxEnabled(project, local)) {
172
+ return {
173
+ enabled: false,
174
+ reason: null,
175
+ condition: SANDBOX_NOT_ENABLED_CONDITION,
176
+ bridges: ready.map(baseEntry),
177
+ };
178
+ }
179
+ if (deps.preflight) deps.preflight();
180
+ const config = (deps.loadConfig ?? loadConfig)(root).config;
181
+ const used = usedBridges(config, readiness);
182
+ const context = { root, env: deps.env ?? process.env, home: deps.home ?? homedir() };
183
+ const bridges = ready.map((bridge) => judgeBridge(bridge, used, project?.sandbox, context, deps));
184
+ return { enabled: true, reason: null, condition: SANDBOX_ENABLED_CONDITION, bridges };
185
+ } catch (error) {
186
+ const reason = String(error?.message ?? error).replace(/[\r\n]+/g, ' ');
187
+ return {
188
+ enabled: null,
189
+ reason,
190
+ condition: `${UNCHECKED} — ${reason}`,
191
+ bridges: ready.map((bridge) => ({
192
+ ...baseEntry(bridge), state: UNCHECKED, review: UNCHECKED, reason,
193
+ })),
194
+ };
195
+ }
196
+ };
@@ -100,19 +100,21 @@ drift-guarded set-equal to this block by the bridge `bin/*.test.mjs` suites (hel
100
100
  `networkHosts` declares the hosts the backend CLI is **observed** to contact (synthetic examples:
101
101
  `*.api.backend.example`, `accounts.backend.example`; the real observed lists live in each bridge's
102
102
  `capability.json`) — the **single source** of the hosts `velocity-profile --bridge-tier` seeds
103
- into the project's `sandbox.network.allowedDomains` for every placed bridge, and of a hand-applied
104
- session sandbox allowlist. Rules:
103
+ into the project's `sandbox.network.allowedDomains` for every used bridge. Rules:
105
104
 
106
105
  - Each entry is a bare dotted hostname or a `*.family` wildcard — never a scheme/path/port.
107
- Entries must be unique. A malformed list **fails** `--strict` (the entries are pasted verbatim
108
- into allowlist lines by the Recommendations advisor).
109
- - **The `--bridge-tier` seed is the primary lane** (AD-172): beside the wrappers' exclusion it
110
- merges the placed bridges' hosts into the project's `sandbox.network.allowedDomains`, so a
111
- settings-native harness that drops the exclusion still admits them; that pre-allow widens egress
112
- for **every** sandboxed command, which the tier's consent states. The **hand-apply** fallback
113
- stays for a harness-managed sandbox that ignores the settings keys.
106
+ Entries must be unique. A malformed list **fails** `--strict` (the tier writes the entries into
107
+ `sandbox.network.allowedDomains` and the Recommendations advisor renders them on its
108
+ `bridge-tier` item's `recipe:` line).
109
+ - **The `--bridge-tier` seed is the primary lane** (AD-172): for every used bridge it merges
110
+ hosts into the project's `sandbox.network.allowedDomains` beside the wrapper's `<wrapper> *`
111
+ exclusion (`codex-exec *` for the execution wrapper). A settings-native harness that drops the
112
+ exclusion still admits them; that pre-allow widens egress
113
+ for **every** sandboxed command, which the tier's consent states. A harness-managed sandbox
114
+ that ignores the settings keys keeps the harness's own controls — its scoped rules, a session
115
+ allowance, its consent at the point of action — and the kit writes no shape for it.
114
116
  - Observed-minimal, honestly incomplete: a blocked host names itself at run time — extend the
115
- hand-applied list by hand; the manifest list records what was actually observed.
117
+ manifest list where it was observed; the list records what was actually observed.
116
118
 
117
119
  ## Writable dirs (REC-UX-REWORK, D6)
118
120
 
@@ -123,8 +125,8 @@ declared path is only the **default under no override**. Rules:
123
125
 
124
126
  - Each entry: `env` is `null` or an **UPPER_SNAKE_CASE env-var name**; `default` is a
125
127
  **`~/`-anchored or absolute POSIX path** — no globs, no trailing slash, no `..` traversal.
126
- Defaults must be unique. A malformed list **fails** `--strict` (the resolved dir is rendered
127
- into a hand-applied recipe line by the Recommendations advisor).
128
+ Defaults must be unique. A malformed list **fails** `--strict` (the resolved dir is written into
129
+ `sandbox.filesystem.allowWrite` and rendered on the Recommendations advisor's one-line `recipe:` line).
128
130
  - **Resolution is the advisor's, at run time, mirroring the wrapper's byte-semantics:** a
129
131
  NON-EMPTY env value wins (an EMPTY env value ≡ unset — the `${VAR:-default}` form); the
130
132
  wrapper's exact case-arms apply — `~`, `~/…` and absolute forms ride as-given, EVERY other
@@ -132,9 +134,10 @@ declared path is only the **default under no override**. Rules:
132
134
  relative path. The advisor anchors those to the **target project root** (its pinned `--cwd`),
133
135
  matching what a wrapper invoked from the project root resolves — the documented dispatch form
134
136
  (the wrapper itself anchors to its invocation `$PWD`); else the `default` applies.
135
- - Unlike `networkHosts`, this is a **documentation source** only: the kit never seeds
136
- `sandbox.filesystem.allowWrite` or any other filesystem allowance — the entries feed the
137
- sandbox-lane discoverability item (session/host sandbox config is hand-applied territory).
137
+ - **The `--bridge-tier` seeds every used bridge's state dirs**, home-relative under the home
138
+ (absolute outside it), into `sandbox.filesystem.allowWrite` beside the wrapper's `<wrapper> *`
139
+ exclusion (`codex-exec *` for the execution wrapper) — the advisor's `bridge-tier` item counts
140
+ and names each missing dir.
138
141
 
139
142
  ## Mode catalog (BRIDGE-MODES-CATALOG)
140
143