@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.
- package/CHANGELOG.md +69 -0
- package/README.md +1 -1
- package/SKILL.md +1 -1
- package/bridges/codex-cli-bridge/SKILL.md +1 -1
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +7 -7
- package/bridges/codex-cli-bridge/bin/codex-review.sh +1 -1
- package/bridges/codex-cli-bridge/capability.json +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/recipes.md +2 -0
- package/references/modes/recommendations.md +9 -10
- package/references/modes/set-recipe.md +1 -1
- package/references/modes/velocity.md +5 -6
- package/references/shared/report-footer.md +29 -32
- package/tools/ack-write.mjs +11 -11
- package/tools/apply-danger-check.mjs +19 -14
- package/tools/bridge-sandbox-recipe.mjs +102 -15
- package/tools/bridge-state-dirs.mjs +94 -0
- package/tools/bridge-wiring.mjs +196 -0
- package/tools/manifest/schema.md +18 -15
- package/tools/manifest/validate.mjs +11 -11
- package/tools/recipes.mjs +75 -15
- package/tools/recommendations.mjs +64 -118
- package/tools/velocity-profile.mjs +195 -119
- package/tools/write-lanes.mjs +0 -1
package/tools/ack-write.mjs
CHANGED
|
@@ -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
|
|
7
|
-
//
|
|
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
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
|
49
|
-
//
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
9
|
+
CLAUDE_DIR, RENDER_OWNED_REDLINE_RULES,
|
|
10
10
|
SAFE_DEFAULT_MODES, SETTINGS_FILE, SETTINGS_LOCAL_FILE, UNIVERSAL_READONLY_ALLOWLIST,
|
|
11
|
-
|
|
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
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
set('sandbox.excludedCommands',
|
|
161
|
-
set('sandbox.network.allowedDomains',
|
|
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
|
-
|
|
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
|
|
3
|
-
//
|
|
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
|
|
12
|
-
// wrapper
|
|
13
|
-
//
|
|
14
|
-
export const
|
|
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
|
-
|
|
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
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
};
|
package/tools/manifest/schema.md
CHANGED
|
@@ -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
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
|
127
|
-
|
|
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
|
-
-
|
|
136
|
-
`sandbox.filesystem.allowWrite`
|
|
137
|
-
|
|
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
|
|