@sabaiway/agent-workflow-kit 14.26.0 → 14.27.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.
@@ -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
+ };
@@ -100,15 +100,16 @@ 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
103
+ into the project's `sandbox.network.allowedDomains` for every used bridge, and of a hand-applied
104
104
  session sandbox allowlist. Rules:
105
105
 
106
106
  - Each entry is a bare dotted hostname or a `*.family` wildcard — never a scheme/path/port.
107
107
  Entries must be unique. A malformed list **fails** `--strict` (the entries are pasted verbatim
108
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
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
112
113
  for **every** sandboxed command, which the tier's consent states. The **hand-apply** fallback
113
114
  stays for a harness-managed sandbox that ignores the settings keys.
114
115
  - Observed-minimal, honestly incomplete: a blocked host names itself at run time — extend the
@@ -132,8 +133,9 @@ declared path is only the **default under no override**. Rules:
132
133
  relative path. The advisor anchors those to the **target project root** (its pinned `--cwd`),
133
134
  matching what a wrapper invoked from the project root resolves — the documented dispatch form
134
135
  (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
136
+ - **The `--bridge-tier` seeds every used bridge's state dirs**, home-relative under the home
137
+ (absolute outside it), into `sandbox.filesystem.allowWrite` beside the wrapper's `<wrapper> *`
138
+ exclusion (`codex-exec *` for the execution wrapper) — the entries feed the
137
139
  sandbox-lane discoverability item (session/host sandbox config is hand-applied territory).
138
140
 
139
141
  ## Mode catalog (BRIDGE-MODES-CATALOG)
@@ -19,9 +19,10 @@
19
19
  // below) and VERDICT-FIRST (D1): every non-optimal state opens with ONE composed verdict line.
20
20
  // Registry strings are frozen tool DATA, fact-true, one line under the shape cap (D2); posture/
21
21
  // risk prose lives in the mode doc at the consent moment (D3). A probe failure is a stated
22
- // skipped-item line — never a crash, never a fabricated item. sandbox.filesystem.allowWrite stays
23
- // HAND-APPLY territory; sandbox.network.allowedDomains is seeded only by the consented
24
- // `velocity-profile --bridge-tier` (AD-172); the sandbox-lane item's convergence is a NEUTRAL
22
+ // skipped-item line — never a crash, never a fabricated item. The consented
23
+ // `velocity-profile --bridge-tier` seeds used bridges' allow rules, <wrapper> * exclusions
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
25
26
  // fingerprint-bound acknowledgement recorded by the consent-gated ack writer into the family-owned
26
27
  // docs/ai/acks.json (AD-055 relocated it off the host settings schema), never a security key (D4).
27
28
  //
@@ -80,6 +81,7 @@ import { resolveDeclaredDir, dirCovers, isResolvableDeclaredEntry } from './decl
80
81
  import { resolveGitHooksPath } from './commit-guard.mjs';
81
82
  import { loadConfig } from './orchestration-config.mjs';
82
83
  import { bundledSandboxRecipe } from './bridge-sandbox-recipe.mjs';
84
+ import { homeRelative, resolveWritableDir } from './bridge-state-dirs.mjs';
83
85
  import { assertContainedRealPath } from './fs-safe.mjs';
84
86
  import { loadWorktreesConfig, resolveProbeDir } from './worktrees.mjs';
85
87
  import { preflightCheapAgents, EXECUTOR_VEHICLE } from './cheap-agents.mjs';
@@ -248,7 +250,7 @@ export const composeVerdict = ({ attention, optional, skipped }) => {
248
250
  export const WHATS = Object.freeze({
249
251
  'velocity-core': 'routine read-only commands still prompt — {n} audited read-only allowlist entr(ies) not seeded',
250
252
  'kit-tools-tier': "the kit's own read-only tools still prompt — {n} kit-tools tier entr(ies) not seeded",
251
- 'bridge-tier': 'bridge-wrappers tier incomplete — {n} entr(ies) missing (allow rules, sandbox exclusions or declared hosts of placed bridges)',
253
+ 'bridge-tier': 'bridge-wrappers tier incomplete — {n} entr(ies) missing (allow rules, exclusions, hosts or state dirs of used bridges)',
252
254
  'autonomy-policy': 'no {path} — the computed defaults apply implicitly (red-lines ask/deny; every activity floors at prompt)',
253
255
  'autonomy-render': 'the declared autonomy policy is not rendered into .claude/settings.json — drift: {drift}',
254
256
  'sandbox-provision': 'the OS sandbox is unavailable: {reason}',
@@ -324,7 +326,7 @@ export const BENEFITS = Object.freeze({
324
326
  'velocity-core': 'velocity — routine read-only commands stop prompting while the maintainer is away',
325
327
  'kit-tools-tier': "velocity — the kit's own read-only tools run promptless (audited, resolved-absolute tier)",
326
328
  'bridge-tier':
327
- 'velocity — placed review wrappers run code-mode council reviews promptless (plan/diff modes and delegated execution keep their prompt)',
329
+ 'velocity — used bridges get <wrapper> * exclusions (codex-exec * if delegated), hosts, state dirs (allowWrite); reviews: code-mode allows',
328
330
  'autonomy-policy': 'clarity — the per-activity autonomy policy becomes an explicit, versioned declaration instead of implicit computed defaults',
329
331
  'autonomy-render': `velocity — confined commands auto-allow per your declared policy; ${DUAL_SECURITY_BENEFIT}`,
330
332
  'sandbox-provision': `velocity — confined ad-hoc commands stop prompting; ${DUAL_SECURITY_BENEFIT}`,
@@ -409,9 +411,9 @@ export const OPT_IN_CAPABILITIES = Object.freeze([
409
411
  // state that makes a configured subagent carrier run solo.
410
412
  { id: 'executor-vehicle', mode: 'agents', advisorKey: 'executor-vehicle' },
411
413
  // Exempt, not un-audited. `acceptEdits` auto-applies Edit/Write and auto-runs mkdir/touch/mv/cp:
412
- // a TRUST-POSTURE change. The kit never nudges a user toward weakening their approval posture (the
413
- // same doctrine that keeps sandbox filesystem allowances HAND-APPLY); velocity presents the
414
- // full honest posture at its own consent moment, where the user is already deciding.
414
+ // a TRUST-POSTURE change. Velocity presents its full posture at its own consent moment;
415
+ // the bridge tier separately seeds used bridges' allow rules, <wrapper> * exclusions
416
+ // (codex-exec * where delegated), hosts and state dirs (allowWrite).
415
417
  {
416
418
  id: 'accept-edits',
417
419
  mode: 'velocity',
@@ -460,8 +462,12 @@ const probeVelocityItems = ({ root, deps, add, skip }) => {
460
462
  skip('kit-tools-tier', err);
461
463
  }
462
464
  try {
463
- const bt = planVelocityProfile(preflight, { bridgeTier: true, findWrapper: deps.findWrapper, bundleRoot: deps.bundleRoot, readdir: deps.readdir, readFile: deps.readFile });
464
- const delta = bt.bridgeToAdd.length + bt.excludedToAdd.length + bt.hostsToAdd.length;
465
+ const bt = planVelocityProfile(preflight, {
466
+ bridgeTier: true, findWrapper: deps.findWrapper,
467
+ bundleRoot: deps.bundleRoot, readdir: deps.readdir, readFile: deps.readFile,
468
+ detect: deps.detect, env: deps.getenv ?? deps.env, home: deps.home,
469
+ });
470
+ const delta = bt.bridgeToAdd.length + bt.excludedToAdd.length + bt.hostsToAdd.length + bt.dirsToAdd.length;
465
471
  if (delta > 0) {
466
472
  add('bridge-tier', fillTemplate(WHATS['bridge-tier'], { n: delta }), applyLine(' --bridge-tier'));
467
473
  }
@@ -1159,18 +1165,6 @@ const probeMasksItem = ({ root, deps, add, skip }) => {
1159
1165
  // knob is retired in the wrappers (recognized, arms nothing) and an oversized code review is now a
1160
1166
  // chunked feed with a per-part delivery proof, so there is nothing left to offer.
1161
1167
 
1162
- // D6 resolution, mirroring the wrappers' byte-semantics (`${VAR:-default}` + the exact case-arms:
1163
- // `~` / `~/…` / `/…` ride as-given; EVERY other form — including `~user/…`, which the wrappers
1164
- // never resolve as a home path — anchors like a relative path). The advisor anchors to the TARGET
1165
- // PROJECT ROOT (the pinned --cwd), matching what a wrapper invoked from the project root resolves
1166
- // (the documented dispatch form; the wrapper itself anchors to its invocation $PWD).
1167
- const resolveWritableDir = (entry, { env, root }) => {
1168
- const value = entry.env == null ? '' : (env[entry.env] ?? '');
1169
- if (value === '') return entry.default;
1170
- if (value === '~' || value.startsWith('~/') || value.startsWith('/')) return value;
1171
- return resolve(root, value);
1172
- };
1173
-
1174
1168
  // The NEUTRAL recipe fingerprint (D4): a hash over the resolved hosts ∪ dirs data — an
1175
1169
  // acknowledgement token, never a security key. Canonical form is HOME-SYMBOLIC: an
1176
1170
  // absolute dir under the resolved home canonicalizes BACK to its `~/…` form and tilde forms stay
@@ -1179,14 +1173,7 @@ const resolveWritableDir = (entry, { env, root }) => {
1179
1173
  // churns between them); only a genuinely-outside-home absolute override stays absolute
1180
1174
  // (machine-specific by nature). Any change to the recipe re-fires the item.
1181
1175
  export const recipeFingerprint = ({ hosts, dirs, home }) => {
1182
- const homeAbs = resolve(home);
1183
- const norm = (d) => {
1184
- if (d === '~') return '~';
1185
- if (d.startsWith('~/')) return `~/${d.slice(2)}`;
1186
- const abs = resolve(d);
1187
- if (abs === homeAbs) return '~';
1188
- return abs.startsWith(`${homeAbs}/`) ? `~/${abs.slice(homeAbs.length + 1)}` : abs;
1189
- };
1176
+ const norm = (dir) => homeRelative(dir, home);
1190
1177
  return factFingerprint(JSON.stringify({ hosts: [...hosts].sort(), dirs: [...new Set(dirs.map(norm))].sort() }));
1191
1178
  };
1192
1179
 
@@ -1346,14 +1333,14 @@ const probeSandboxLane = ({ root, deps, add, skip }) => {
1346
1333
  // Wired = the two-surface proof the tier wrote before AD-172 (the wrapper in the PROJECT
1347
1334
  // excludedCommands + its code-mode allow rule, either scope) — surfacing the recipe before those
1348
1335
  // exist would front-run the bridge-tier item (codex terminal). The hosts surface (AD-172) is
1349
- // deliberately not required: the recipe also carries the writable dirs no tier seeds, so it may
1350
- // render beside a bridge-tier item offering the hosts re-run. Byte-form from the tier's own constants.
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.
1351
1338
  const allowRules = [
1352
1339
  ...(Array.isArray(settings.data?.permissions?.allow) ? settings.data.permissions.allow : []),
1353
1340
  ...(Array.isArray(localSettings.data?.permissions?.allow) ? localSettings.data.permissions.allow : []),
1354
1341
  ];
1355
1342
  const wired = BRIDGE_REVIEW_WRAPPERS.filter(
1356
- (w) => excluded.includes(w) && probePlaced(w) && allowRules.includes(`Bash(${w} ${BRIDGE_REVIEW_MODE}:*)`),
1343
+ (w) => (excluded.includes(w) || excluded.includes(`${w} *`)) && probePlaced(w) && allowRules.includes(`Bash(${w} ${BRIDGE_REVIEW_MODE}:*)`),
1357
1344
  );
1358
1345
  if (wired.length === 0) return; // the tier's rules and exclusions are not wired — the bridge-tier item covers first
1359
1346
  const { hosts, dirEntries } = bundledSandboxRecipe(wired, deps);
@@ -1767,8 +1754,13 @@ section renders present-even-when-empty ("${RECOMMENDATIONS_EMPTY_LINE}"); a pro
1767
1754
  stated skipped-item line. Apply lines are cwd-independent (absolute tool paths, a pinned --cwd;
1768
1755
  the doctor item pins via a cd prefix; the ONE exception is the set-autonomy item — a
1769
1756
  conversational skill invocation labeled "run IN the target project") and preserve each writer's
1770
- own consent semantics; sandbox filesystem allowances stay HAND-APPLY territory, the placed bridges' declared hosts reach sandbox.network.allowedDomains only through the
1771
- consented velocity-profile --bridge-tier, and the sandbox-lane convergence is a neutral fingerprint acknowledgement recorded by
1757
+ own consent semantics. The consented velocity-profile --bridge-tier seeds used bridges' code-mode
1758
+ allow rules, <wrapper> * exclusions (codex-exec * where an execute slot delegates), hosts in
1759
+ sandbox.network.allowedDomains and state dirs in sandbox.filesystem.allowWrite. Where the host
1760
+ honors the settings sandbox keys, an excluded wrapper runs outside the sandbox, every sandboxed
1761
+ command of the project can reach each host, and every sandboxed command can write each state dir;
1762
+ 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
1772
1764
  the consent-gated ack writer into docs/ai/acks.json (never a security key).
1773
1765
 
1774
1766
  Read-only: never writes, never commits, never runs a subscription CLI. Exit codes: 0 report