@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { accessSync, constants, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
3
3
|
import { homedir, tmpdir } from 'node:os';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
@@ -7,14 +7,18 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
// it never imports the policy fs-writer (autonomy-write.mjs) — the render owns the settings file, not
|
|
8
8
|
// the policy file.
|
|
9
9
|
import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, COMMAND_REDLINES } from './autonomy-config.mjs';
|
|
10
|
-
|
|
11
|
-
// for a PLACED bridge wrapper — findOnPath is the same read-only PATH scan the backend detector uses.
|
|
12
|
-
import { findOnPath } from './detect-backends.mjs';
|
|
10
|
+
import { detectBackends, findOnPath, wrapperCmdFor } from './detect-backends.mjs';
|
|
13
11
|
// The seedable-path predicate lives in the pure leaf so the renders spell a path exactly as it is seeded.
|
|
14
12
|
import { SHELL_METACHARACTERS, hasShellMetacharacter, isSeedablePathToken } from './repo-lex.mjs';
|
|
15
13
|
import { isDirectRun } from './direct-run.mjs';
|
|
16
14
|
import { compareSemver } from './semver-lite.mjs';
|
|
17
|
-
import { bundledSandboxRecipe } from './bridge-sandbox-recipe.mjs';
|
|
15
|
+
import { BRIDGE_REVIEW_WRAPPERS, BRIDGE_REVIEW_MODE, KIT_GROUNDING_TOOL, usedBridges, bundledSandboxRecipe, exclusionOf } from './bridge-sandbox-recipe.mjs';
|
|
16
|
+
import { stateDirsOf, mergeAllowWrite } from './bridge-state-dirs.mjs';
|
|
17
|
+
import { HOST_HONORS_QUALIFIER, sandboxSurfaceDelta } from './bridge-wiring.mjs';
|
|
18
|
+
import { loadConfig } from './orchestration-config.mjs';
|
|
19
|
+
import { validateManifest } from './manifest/validate.mjs';
|
|
20
|
+
export { BRIDGE_REVIEW_WRAPPERS, BRIDGE_REVIEW_MODE, KIT_GROUNDING_TOOL };
|
|
21
|
+
export { HOST_HONORS_QUALIFIER };
|
|
18
22
|
// The declared-path resolution + segment containment the allowWrite degrade shares with the
|
|
19
23
|
// advisor's worktrees-dir convergence lane — ONE leaf, so the two readings cannot drift.
|
|
20
24
|
import { resolveDeclaredDir, dirCovers, isResolvableDeclaredEntry } from './declared-paths.mjs';
|
|
@@ -197,27 +201,6 @@ const KIT_ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
|
197
201
|
const KIT_TOOL_INVOKER = 'node';
|
|
198
202
|
const RUN_GATES_CWD_FLAG = '--cwd';
|
|
199
203
|
|
|
200
|
-
// ── the opt-in --bridge-tier (AD-044 Plan 4, Decision 2) ────────────────────────────────
|
|
201
|
-
// The FROZEN membership source: review-role wrapper NAMES only, spelled bare — that is how the
|
|
202
|
-
// wrappers are invoked (setup-backends places them as symlinks in ~/.local/bin). Roles come from
|
|
203
|
-
// THIS constant, never from the detector's wrapperCmds (which carries no role labels): codex-exec
|
|
204
|
-
// (execution role) and agy-run (probe role) are deliberately ABSENT. The velocity pain this tier
|
|
205
|
-
// closes is council REVIEW runs; codex-exec's nested-sandbox recovery is handled the canon way —
|
|
206
|
-
// route it outside ON the OBSERVED failure (orchestration.md §5), NOT a preemptive tier seed (AD-054
|
|
207
|
-
// final council: a blanket exclusion at opt-in contradicts «never a preemptive blanket»), guided by
|
|
208
|
-
// codex-exec.sh's own nested-sandbox detection hint.
|
|
209
|
-
export const BRIDGE_REVIEW_WRAPPERS = Object.freeze(['codex-review', 'agy-review']);
|
|
210
|
-
// Only the `code` review mode is auto-allowed (Segment A): a bare `Bash(<wrapper>:*)`
|
|
211
|
-
// prefix would also cover the plan/diff file-argument modes, whose targets can point OUTSIDE the
|
|
212
|
-
// repo — the tier's stated surface is the unattended council CODE review, so the seeded prefix is
|
|
213
|
-
// `<wrapper> code`. Plan/diff invocations keep their prompt.
|
|
214
|
-
export const BRIDGE_REVIEW_MODE = 'code';
|
|
215
|
-
// The grounding pre-step tool (the reviews' facts assembler). Its tier entry is seeded in the
|
|
216
|
-
// EXACT byte-form the procedures advisor renders — a DOUBLE-QUOTED absolute path (deliberate
|
|
217
|
-
// there: a skill dir with a space must stay copy-pasteable) — so seeded↔rendered byte-parity
|
|
218
|
-
// holds; the screen accepts the quoted form ONLY for this one tool (a tier-scoped acceptance,
|
|
219
|
-
// never a general quote allowance).
|
|
220
|
-
export const KIT_GROUNDING_TOOL = 'tools/grounding.mjs';
|
|
221
204
|
const WRAPPER_PLACED = 'present';
|
|
222
205
|
|
|
223
206
|
// The quoted-grounding token: "<seedable-abs-path>/tools/grounding.mjs" — quotes stripped, the
|
|
@@ -229,41 +212,6 @@ const isQuotedGroundingToken = (token) => {
|
|
|
229
212
|
return isSeedablePathToken(inner) && inner.endsWith(`/${KIT_GROUNDING_TOOL}`);
|
|
230
213
|
};
|
|
231
214
|
|
|
232
|
-
/**
|
|
233
|
-
* Derive the opt-in bridge-wrappers tier: one code-mode allow rule per PLACED review wrapper (the
|
|
234
|
-
* frozen constant is the membership source; placement is a probe, never a role source), the SAME
|
|
235
|
-
* wrapper names for sandbox.excludedCommands (the harness runs an excluded command OUTSIDE the
|
|
236
|
-
* sandbox, so a plain allowlisted invocation needs no sandbox-bypass approval — the zero-prompt
|
|
237
|
-
* wiring), and the grounding pre-step rule in its rendered quoted byte-form (derived only when
|
|
238
|
-
* agy-review is placed; an unseedable kit path is a stated skip). An absent bridge is a stated skip.
|
|
239
|
-
*/
|
|
240
|
-
export const deriveBridgeTierAllowlist = ({ findWrapper, groundingAbsPath } = {}) => {
|
|
241
|
-
const probe = findWrapper ?? ((cmd) => findOnPath(cmd).state === WRAPPER_PLACED);
|
|
242
|
-
const placed = BRIDGE_REVIEW_WRAPPERS.filter((cmd) => probe(cmd));
|
|
243
|
-
const skips = BRIDGE_REVIEW_WRAPPERS.filter((cmd) => !placed.includes(cmd)).map((cmd) => ({
|
|
244
|
-
entry: cmd,
|
|
245
|
-
reason: `bridge wrapper "${cmd}" is not placed on PATH — its allow rule is not seeded (place the bridge with /agent-workflow-kit setup, then re-run)`,
|
|
246
|
-
}));
|
|
247
|
-
const allow = placed.map((cmd) => `Bash(${cmd} ${BRIDGE_REVIEW_MODE}:*)`);
|
|
248
|
-
const excludedCommands = [...placed];
|
|
249
|
-
// The grounding pre-step exists FOR agy (its grounded --facts reviews; codex grounds natively via
|
|
250
|
-
// the AGENTS.md auto-merge) — a codex-only install must not auto-allow an unused writer.
|
|
251
|
-
if (placed.includes('agy-review')) {
|
|
252
|
-
// groundingAbsPath is a TEST seam only (an unseedable kit path — spaces — is not constructible
|
|
253
|
-
// from a test against the real checkout); production callers never pass it.
|
|
254
|
-
const groundingAbs = groundingAbsPath ?? join(KIT_ROOT, KIT_GROUNDING_TOOL);
|
|
255
|
-
if (isSeedablePathToken(groundingAbs)) {
|
|
256
|
-
allow.push(`Bash(${KIT_TOOL_INVOKER} "${groundingAbs}":*)`);
|
|
257
|
-
} else {
|
|
258
|
-
skips.push({
|
|
259
|
-
entry: groundingAbs,
|
|
260
|
-
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',
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
return Object.freeze({ allow: Object.freeze(allow), excludedCommands: Object.freeze(excludedCommands), skips: Object.freeze(skips), placed: Object.freeze(placed) });
|
|
265
|
-
};
|
|
266
|
-
|
|
267
215
|
// Characters that make a pattern NOT a single read-only command, so the screen rejects them.
|
|
268
216
|
// Per current Claude Code permission semantics: the recognized command SEPARATORS are
|
|
269
217
|
// `&& || ; | |& &` plus newline (each segment must be independently permitted, so a chained
|
|
@@ -357,18 +305,19 @@ const DO_NOT_ADD_WARNING = 'do not add';
|
|
|
357
305
|
const ADD_BY_HAND = true;
|
|
358
306
|
const MUTATING_SCRIPT_NAME_PATTERN = /(release|publish|deploy|push|version|commit|tag)/iu;
|
|
359
307
|
const MUTATING_SCRIPT_HOOK_PATTERN = /^(pre|post)/iu;
|
|
308
|
+
const CODEX_BRIDGE = 'codex-cli-bridge';
|
|
309
|
+
const EXECUTE_ROLE = 'execute';
|
|
360
310
|
// The one thing velocity must never seed — an explicit, greppable expression of the load-bearing
|
|
361
311
|
// invariant (kept deliberately even though the read-only screen already rejects these, so the
|
|
362
312
|
// refusal is named, tested, and produces a clear message).
|
|
363
313
|
const MUTATING_ALLOW_COMMAND_PATTERN = /^(?:git\s+(?:commit|push)|npm\s+publish)(?:\s|$)/iu;
|
|
364
314
|
// Whether a host APPLIES a `sandbox.*` settings key is not knowable from here: a settings-native
|
|
365
|
-
// host honors it, while an IDE/session-imposed sandbox was observed ignoring the
|
|
366
|
-
//
|
|
315
|
+
// host honors it, while an IDE/session-imposed sandbox was observed ignoring the security keys
|
|
316
|
+
// in BOTH scopes (the advisor records the same limit in
|
|
367
317
|
// references/modes/recommendations.md). So every claim about the RUNTIME EFFECT of a settings key —
|
|
368
318
|
// the tier's exclusion routing and every weakening detail alike — carries this qualifier. The
|
|
369
319
|
// CLASSIFICATION is untouched: which red-line a key would weaken, and that a proven tier exclusion
|
|
370
320
|
// is a note rather than a weakening, are properties of the DECLARATION, not of a host.
|
|
371
|
-
export const HOST_HONORS_QUALIFIER = 'where the host honors the settings sandbox keys';
|
|
372
321
|
export const HOST_HONORS_NOTICE =
|
|
373
322
|
`host-conditional: whether a host applies the sandbox.* settings keys is not knowable from here — a settings-native host honors them, a harness-managed session sandbox may ignore them in BOTH scopes, so each runtime effect above is stated "${HOST_HONORS_QUALIFIER}" rather than promised; what a key would weaken is a property of the declaration and is stated flat.`;
|
|
374
323
|
|
|
@@ -386,14 +335,15 @@ absolute path (args wildcard), run-gates.mjs as ONE exact project-root-pinned by
|
|
|
386
335
|
(project-exec - it runs YOUR declared gates.json), source-size-check.mjs as ONE exact --check
|
|
387
336
|
byte-string (its read-only mode only - its writers keep prompting), and the writers' exact arg-free
|
|
388
337
|
dry-run preview byte-strings. Never touches settings.local.json.
|
|
389
|
-
--bridge-tier (own consent)
|
|
390
|
-
(codex-review code, agy-review code
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
every sandboxed command of the project can
|
|
395
|
-
|
|
396
|
-
|
|
338
|
+
--bridge-tier (own consent) derives each entry for a used bridge from the effective project recipes:
|
|
339
|
+
review wrappers' CODE mode (codex-review code, agy-review code), the quoted grounding pre-step rule,
|
|
340
|
+
and <wrapper> * exclusions in sandbox.excludedCommands. An execute slot delegating to codex adds
|
|
341
|
+
codex-exec * as an exclusion; execution keeps its prompt, as do the review plan/diff modes.
|
|
342
|
+
${HOST_HONORS_QUALIFIER}, an excluded wrapper runs outside the sandbox;
|
|
343
|
+
sandbox.network.allowedDomains means every sandboxed command of the project can reach those hosts;
|
|
344
|
+
sandbox.filesystem.allowWrite means every sandboxed command can write those state dirs.
|
|
345
|
+
Consented posture: an auto-allowed review wrapper runs UNATTENDED and sends the assembled repo
|
|
346
|
+
payload to its subscription backend (see the printed tier notice).
|
|
397
347
|
|
|
398
348
|
--autonomy renders docs/ai/autonomy.json into the settings blocks it OWNS — the sandbox block +
|
|
399
349
|
permissions.ask/deny red-lines + permissions.defaultMode. POLICY-ONLY: never seeds the allowlist and
|
|
@@ -696,11 +646,9 @@ const formatKitTier = (result) =>
|
|
|
696
646
|
// The bridge tier's honest posture, printed on EVERY --bridge-tier run: the informed-consent
|
|
697
647
|
// resolution states the exfiltration surface, never pretends it away.
|
|
698
648
|
export const KIT_BRIDGE_TIER_NOTICE =
|
|
699
|
-
'bridge-wrappers tier:
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
`${HOST_HONORS_QUALIFIER} the harness runs an excluded command OUTSIDE the sandbox (the wrappers need network), so a plain allowlisted invocation triggers no sandbox-bypass approval — whether a host honors them is not knowable from here, and where it does not the wrapper simply starts sandboxed (fail-safe, never a silent widening). ` +
|
|
703
|
-
`The placed bridges' declared hosts (each bundled manifest's networkHosts) are seeded into sandbox.network.allowedDomains IN THE PROJECT settings.json: ${HOST_HONORS_QUALIFIER}, a wrapper the harness keeps sandboxed still reaches them, and every sandboxed command of the project can then reach those hosts, not only the wrappers — that widening is part of this consent, and the --autonomy preview reports it as a network weakening; the writable state dirs (writableDirs) stay hand-apply. ` +
|
|
649
|
+
'bridge-wrappers tier: each entry derives for a used bridge from the effective project recipes and host readiness. Review allows cover only CODE mode (`codex-review code`, `agy-review code`) plus the grounding pre-step rule in its rendered quoted byte-form; the plan/diff modes keep their prompt because their file arguments can point outside the repo. An execute slot delegating to codex adds codex-exec * to sandbox.excludedCommands and keeps its prompt, with no execution allow rule; agy-run is not seeded. ' +
|
|
650
|
+
'POSTURE (what this consent covers): an auto-allowed review wrapper runs UNATTENDED — it reads any repo file it is pointed at and sends the assembled payload to its subscription backend. Prefix rules cannot inspect arguments, so readable file arguments (agy\'s --facts/--decided) ride the same consent; tier entries get NO PreToolUse-hook coverage. The grounding entry\'s writer surface is bounded by grounding.mjs\'s OWN scratch-destination guard (a tracked or in-repo-not-ignored --out is refused by the tool). ' +
|
|
651
|
+
`The tier writes the PROJECT settings.json: ${HOST_HONORS_QUALIFIER}, an excluded wrapper runs outside the sandbox (<wrapper> * in sandbox.excludedCommands), every sandboxed command of the project can reach the declared hosts (sandbox.network.allowedDomains), and every sandboxed command can write the declared state dirs (sandbox.filesystem.allowWrite). These surfaces are part of the bridge-wrappers consent. Whether a host honors them is not knowable from here; an exclusion only in settings.local.json was live-observed not to route. ` +
|
|
704
652
|
'INVOCATION SHAPE: a prefix rule matches only a PLAIN invocation starting with the wrapper name — an env-var prefix or a compound chain never matches (redirects are fine).';
|
|
705
653
|
|
|
706
654
|
const formatBridgeTier = (result) =>
|
|
@@ -719,6 +667,13 @@ const formatBridgeTier = (result) =>
|
|
|
719
667
|
`already present (allowedDomains): ${result.hostsAlreadyPresent.length}`,
|
|
720
668
|
]
|
|
721
669
|
: []),
|
|
670
|
+
...(result.dirsToAdd.length + result.dirsAlreadyPresent.length
|
|
671
|
+
? [
|
|
672
|
+
`${result.wrote ? 'added' : 'would add'} sandbox.filesystem.allowWrite entries: ${result.dirsToAdd.length}`,
|
|
673
|
+
...formatEntryList(result.dirsToAdd),
|
|
674
|
+
`already present (allowWrite): ${result.dirsAlreadyPresent.length}`,
|
|
675
|
+
]
|
|
676
|
+
: []),
|
|
722
677
|
...result.bridgeSkips.map((s) => ` skipped: ${s.reason}`),
|
|
723
678
|
KIT_BRIDGE_TIER_NOTICE,
|
|
724
679
|
]
|
|
@@ -941,7 +896,8 @@ export const preflightVelocityProfile = ({ cwd }, deps = {}) => {
|
|
|
941
896
|
// bridge-shaped entry — over-flagging is the safe direction for a read-only advisory.
|
|
942
897
|
const derivedBridge = (() => {
|
|
943
898
|
try {
|
|
944
|
-
|
|
899
|
+
const probe = deps.findWrapper ?? ((cmd) => findOnPath(cmd).state === WRAPPER_PLACED);
|
|
900
|
+
return bundledSandboxRecipe(BRIDGE_REVIEW_WRAPPERS.filter(probe), deps).allow;
|
|
945
901
|
} catch {
|
|
946
902
|
return [];
|
|
947
903
|
}
|
|
@@ -982,24 +938,76 @@ const assertNetworkMergeable = (sandbox) => {
|
|
|
982
938
|
return sandbox.network?.allowedDomains ?? [];
|
|
983
939
|
};
|
|
984
940
|
|
|
985
|
-
|
|
941
|
+
const assertFilesystemMergeable = (sandbox) => {
|
|
942
|
+
if (sandbox.filesystem !== undefined && !isJsonObject(sandbox.filesystem)) {
|
|
943
|
+
throw makeVelocityProfileError(VELOCITY_MALFORMED, `${SETTINGS_FILE}: sandbox.filesystem must be a JSON object`);
|
|
944
|
+
}
|
|
945
|
+
const allowWrite = sandbox.filesystem?.allowWrite;
|
|
946
|
+
if (allowWrite !== undefined && !Array.isArray(allowWrite)) {
|
|
947
|
+
throw makeVelocityProfileError(VELOCITY_MALFORMED, `${SETTINGS_FILE}: sandbox.filesystem.allowWrite must be an array`);
|
|
948
|
+
}
|
|
949
|
+
if (allowWrite?.some((entry) => !isResolvableDeclaredEntry(entry))) {
|
|
950
|
+
throw makeVelocityProfileError(VELOCITY_MALFORMED, `${SETTINGS_FILE}: sandbox.filesystem.allowWrite entries must be non-blank strings`);
|
|
951
|
+
}
|
|
952
|
+
return allowWrite ?? [];
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
// Readiness as a wrapper started from the project root sees it: every relative path the detector probes
|
|
956
|
+
// (an env override such as CODEX_HOME=state) resolves against the root, never this process's cwd, so the
|
|
957
|
+
// danger check and the writer it spawns with cwd=root derive one used set.
|
|
958
|
+
export const detectAtRoot = (root, { env, home }) => {
|
|
959
|
+
const at = (path) => resolve(root, path);
|
|
960
|
+
return detectBackends({
|
|
961
|
+
getenv: env, home,
|
|
962
|
+
exists: (path) => existsSync(at(path)), stat: (path) => statSync(at(path)),
|
|
963
|
+
access: (path) => accessSync(at(path), constants.X_OK), realpath: (path) => realpathSync(at(path)),
|
|
964
|
+
validate: (dir) => validateManifest(at(dir)),
|
|
965
|
+
});
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
const deriveUsedBridgeTier = (root, deps = {}) => {
|
|
969
|
+
const env = deps.env ?? process.env;
|
|
970
|
+
const home = deps.home ?? homedir();
|
|
971
|
+
const config = loadConfig(root).config;
|
|
972
|
+
const readiness = (deps.detect ?? (() => detectAtRoot(root, { env, home })))();
|
|
973
|
+
const used = usedBridges(config, readiness);
|
|
974
|
+
const recipe = bundledSandboxRecipe(used, deps);
|
|
975
|
+
const dirs = stateDirsOf(recipe.dirEntries, { env, root, home });
|
|
976
|
+
return { used, recipe, dirs, env, home };
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
export const planVelocityProfile = (preflight, opts = {}) => {
|
|
980
|
+
const { acceptEdits, kitTools, bridgeTier } = opts;
|
|
986
981
|
const projectAllow = getAllowEntries(preflight.projectSettings?.data);
|
|
987
982
|
const toAdd = UNIVERSAL_READONLY_ALLOWLIST.filter((entry) => !projectAllow.includes(entry));
|
|
988
983
|
const alreadyPresent = UNIVERSAL_READONLY_ALLOWLIST.filter((entry) => projectAllow.includes(entry));
|
|
989
984
|
const tier = kitTools === true ? deriveKitToolsAllowlist({ projectDir: preflight.cwd }) : [];
|
|
990
985
|
const tierToAdd = tier.filter((entry) => !projectAllow.includes(entry));
|
|
991
986
|
const tierAlreadyPresent = tier.filter((entry) => projectAllow.includes(entry));
|
|
992
|
-
const
|
|
987
|
+
const derived = bridgeTier === true ? deriveUsedBridgeTier(preflight.cwd, opts) : null;
|
|
988
|
+
const bridge = derived?.recipe ?? { allow: [], excludedCommands: [], hosts: [], skips: [] };
|
|
993
989
|
const bridgeToAdd = bridge.allow.filter((entry) => !projectAllow.includes(entry));
|
|
994
990
|
const bridgeAlreadyPresent = bridge.allow.filter((entry) => projectAllow.includes(entry));
|
|
995
991
|
const existingSandbox = isJsonObject(preflight.projectSettings?.data?.sandbox) ? preflight.projectSettings.data.sandbox : {};
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
const
|
|
1001
|
-
const
|
|
1002
|
-
|
|
992
|
+
if (bridgeTier === true) {
|
|
993
|
+
assertNetworkMergeable(existingSandbox);
|
|
994
|
+
assertFilesystemMergeable(existingSandbox);
|
|
995
|
+
}
|
|
996
|
+
const dirs = derived?.dirs ?? [];
|
|
997
|
+
const delta = derived
|
|
998
|
+
? sandboxSurfaceDelta({ excludedCommands: bridge.excludedCommands, hosts: bridge.hosts, dirs }, existingSandbox, { home: derived.home, root: preflight.cwd })
|
|
999
|
+
: { excludedCommands: [], hosts: [], dirs: [] };
|
|
1000
|
+
const { excludedCommands: excludedToAdd, hosts: hostsToAdd, dirs: dirsToAdd } = delta;
|
|
1001
|
+
const excludedAlreadyPresent = bridge.excludedCommands.filter((cmd) => !excludedToAdd.includes(cmd));
|
|
1002
|
+
const hostsAlreadyPresent = bridge.hosts.filter((host) => !hostsToAdd.includes(host));
|
|
1003
|
+
const dirsAlreadyPresent = dirs.filter((dir) => !dirsToAdd.includes(dir));
|
|
1004
|
+
const executeRecipe = derived?.used.some((entry) => entry.bridge === CODEX_BRIDGE && entry.roles.includes(EXECUTE_ROLE))
|
|
1005
|
+
? bundledSandboxRecipe([{ bridge: CODEX_BRIDGE, roles: [EXECUTE_ROLE] }], opts)
|
|
1006
|
+
: null;
|
|
1007
|
+
const executeDirs = executeRecipe ? stateDirsOf(executeRecipe.dirEntries, { env: derived.env, root: preflight.cwd, home: derived.home }) : [];
|
|
1008
|
+
const executeToAdd = executeRecipe
|
|
1009
|
+
? sandboxSurfaceDelta({ excludedCommands: executeRecipe.excludedCommands, hosts: executeRecipe.hosts, dirs: executeDirs }, existingSandbox, { home: derived.home, root: preflight.cwd })
|
|
1010
|
+
: { excludedCommands: [], hosts: [], dirs: [] };
|
|
1003
1011
|
return {
|
|
1004
1012
|
toAdd,
|
|
1005
1013
|
alreadyPresent,
|
|
@@ -1014,6 +1022,9 @@ export const planVelocityProfile = (preflight, { acceptEdits, kitTools, bridgeTi
|
|
|
1014
1022
|
excludedAlreadyPresent,
|
|
1015
1023
|
hostsToAdd,
|
|
1016
1024
|
hostsAlreadyPresent,
|
|
1025
|
+
dirsToAdd,
|
|
1026
|
+
dirsAlreadyPresent,
|
|
1027
|
+
executeToAdd,
|
|
1017
1028
|
setsDefaultMode: acceptEdits === true,
|
|
1018
1029
|
};
|
|
1019
1030
|
};
|
|
@@ -1022,13 +1033,10 @@ export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true,
|
|
|
1022
1033
|
const projectDir = cwd ?? deps.cwd ?? process.cwd();
|
|
1023
1034
|
const preflight = preflightVelocityProfile({ cwd: projectDir }, deps);
|
|
1024
1035
|
const plan = planVelocityProfile(preflight, {
|
|
1036
|
+
...deps,
|
|
1025
1037
|
acceptEdits,
|
|
1026
1038
|
kitTools,
|
|
1027
1039
|
bridgeTier,
|
|
1028
|
-
findWrapper: deps.findWrapper,
|
|
1029
|
-
bundleRoot: deps.bundleRoot,
|
|
1030
|
-
readdir: deps.readdir,
|
|
1031
|
-
readFile: deps.readFile,
|
|
1032
1040
|
});
|
|
1033
1041
|
// Drift guard runs on BOTH dry-run and apply (so a dry-run faithfully predicts the apply) and
|
|
1034
1042
|
// validates the FULL audited core, not just the to-add delta — a drifted core entry is caught even
|
|
@@ -1044,8 +1052,8 @@ export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true,
|
|
|
1044
1052
|
// (Decision 2): every seeded bridge entry passes the screen AND sits in the audited set, so the
|
|
1045
1053
|
// flagless advisory can never flag an entry the tier itself seeded.
|
|
1046
1054
|
if (bridgeTier === true) {
|
|
1047
|
-
const
|
|
1048
|
-
validateProfile(
|
|
1055
|
+
const bridgeAllow = [...plan.bridgeToAdd, ...plan.bridgeAlreadyPresent];
|
|
1056
|
+
validateProfile(bridgeAllow, [...UNIVERSAL_READONLY_ALLOWLIST, ...bridgeAllow]);
|
|
1049
1057
|
}
|
|
1050
1058
|
const resultBase = { ...preflight, ...plan };
|
|
1051
1059
|
if (dryRun) return { wrote: false, dryRun: true, ...resultBase };
|
|
@@ -1060,13 +1068,13 @@ export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true,
|
|
|
1060
1068
|
const fs = fsDeps(deps);
|
|
1061
1069
|
const settingsPath = join(projectDir, SETTINGS_FILE);
|
|
1062
1070
|
if (preflight.claudeDirAbsent) fs.mkdir(join(projectDir, CLAUDE_DIR), { recursive: true });
|
|
1063
|
-
const merged = mergeProjectSettings(
|
|
1071
|
+
const merged = mergeAllowWrite(mergeProjectSettings(
|
|
1064
1072
|
preflight.projectSettings.data,
|
|
1065
1073
|
[...plan.toAdd, ...plan.tierToAdd, ...plan.bridgeToAdd],
|
|
1066
1074
|
acceptEdits,
|
|
1067
1075
|
plan.excludedToAdd,
|
|
1068
1076
|
plan.hostsToAdd,
|
|
1069
|
-
);
|
|
1077
|
+
), plan.dirsToAdd);
|
|
1070
1078
|
fs.writeFile(settingsPath, formatJson(merged, preflight.projectSettings.eol ?? LF), UTF8);
|
|
1071
1079
|
return { wrote: true, dryRun: false, settingsPath, ...resultBase };
|
|
1072
1080
|
};
|
|
@@ -1489,23 +1497,76 @@ const externalWriteEntries = (entries, { root, home, tmp }) => {
|
|
|
1489
1497
|
};
|
|
1490
1498
|
};
|
|
1491
1499
|
|
|
1492
|
-
const
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1500
|
+
const cacheBundleReader = (read) => {
|
|
1501
|
+
const entries = new Map();
|
|
1502
|
+
return (path, ...args) => {
|
|
1503
|
+
if (!entries.has(path)) entries.set(path, read(path, ...args));
|
|
1504
|
+
return entries.get(path);
|
|
1505
|
+
};
|
|
1506
|
+
};
|
|
1507
|
+
|
|
1508
|
+
// The --autonomy preview's tier-known set (spec velocity-profile): only a used role whose consent
|
|
1509
|
+
// proof the PROJECT settings carry — a review role's derived code-mode allow rule, the execute
|
|
1510
|
+
// role's `codex-exec *` exclusion — makes its derived surfaces notes; any derivation failure is
|
|
1511
|
+
// returned as `error`, and the caller then judges every entry as revision 3 did.
|
|
1512
|
+
const deriveBridgeConsent = (data, root, deps) => {
|
|
1513
|
+
try {
|
|
1514
|
+
const bundleDeps = {
|
|
1515
|
+
...deps,
|
|
1516
|
+
readdir: cacheBundleReader(deps.readdir ?? readdirSync),
|
|
1517
|
+
readFile: cacheBundleReader(deps.readFile ?? readFileSync),
|
|
1518
|
+
};
|
|
1519
|
+
const derived = deriveUsedBridgeTier(root, bundleDeps);
|
|
1520
|
+
const allow = getAllowEntries(data);
|
|
1521
|
+
const excluded = Array.isArray(data?.sandbox?.excludedCommands) ? data.sandbox.excludedCommands : [];
|
|
1522
|
+
const proven = derived.used.map(({ bridge, roles }) => ({
|
|
1523
|
+
bridge,
|
|
1524
|
+
roles: roles.filter((role) => {
|
|
1525
|
+
const wrapper = wrapperCmdFor(bridge, role);
|
|
1526
|
+
const exclusion = exclusionOf(wrapper);
|
|
1527
|
+
if (role === 'execute') return derived.recipe.excludedCommands.includes(exclusion) && excluded.includes(exclusion);
|
|
1528
|
+
const rule = `Bash(${wrapper} ${BRIDGE_REVIEW_MODE}:*)`;
|
|
1529
|
+
return derived.recipe.allow.includes(rule) && allow.includes(rule);
|
|
1530
|
+
}),
|
|
1531
|
+
})).filter(({ roles }) => roles.length);
|
|
1532
|
+
const allProven = JSON.stringify(proven) === JSON.stringify(derived.used);
|
|
1533
|
+
const recipe = allProven
|
|
1534
|
+
? derived.recipe
|
|
1535
|
+
: bundledSandboxRecipe(proven, bundleDeps);
|
|
1536
|
+
const knownDirs = allProven ? derived.dirs : stateDirsOf(recipe.dirEntries, { env: derived.env, root, home: derived.home });
|
|
1537
|
+
const bareReview = recipe.excludedCommands
|
|
1538
|
+
.map((entry) => entry.slice(0, -2))
|
|
1539
|
+
.filter((cmd) => BRIDGE_REVIEW_WRAPPERS.includes(cmd));
|
|
1540
|
+
return { dirs: derived.dirs, knownDirs, hosts: recipe.hosts, excluded: [...recipe.excludedCommands, ...bareReview] };
|
|
1541
|
+
} catch (err) {
|
|
1542
|
+
return { error: err?.message ?? String(err) };
|
|
1543
|
+
}
|
|
1544
|
+
};
|
|
1545
|
+
|
|
1546
|
+
const collectSandboxWeakenings = (sources, { root, home, tmp }, tier) => {
|
|
1497
1547
|
const project = sources.find((s) => s.source === SETTINGS_FILE);
|
|
1498
1548
|
const projectAllow = getAllowEntries(project?.data);
|
|
1549
|
+
const judged = tier && !tier.error;
|
|
1550
|
+
const isTierKnown = (source, entry, entries) => source === SETTINGS_FILE && judged && entries.includes(entry);
|
|
1499
1551
|
const isTierKnownExclusion = (source, cmd) =>
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1552
|
+
judged
|
|
1553
|
+
? isTierKnown(source, cmd, tier.excluded)
|
|
1554
|
+
: source === SETTINGS_FILE && BRIDGE_REVIEW_WRAPPERS.includes(cmd) &&
|
|
1555
|
+
projectAllow.includes(`Bash(${cmd} ${BRIDGE_REVIEW_MODE}:*)`);
|
|
1503
1556
|
return sources.flatMap(({ source, data }) => {
|
|
1504
1557
|
const sb = isJsonObject(data?.[SANDBOX_KEY]) ? data[SANDBOX_KEY] : {};
|
|
1505
1558
|
const out = [];
|
|
1559
|
+
const addNotes = (key, entries, detail) => {
|
|
1560
|
+
for (const entry of entries) out.push({ source, key, entry, tierKnown: true, detail });
|
|
1561
|
+
};
|
|
1506
1562
|
const net = isJsonObject(sb.network) ? sb.network : {};
|
|
1507
1563
|
if (Array.isArray(net.allowedDomains) && net.allowedDomains.length) {
|
|
1508
|
-
|
|
1564
|
+
const known = net.allowedDomains.filter((entry) => isTierKnown(source, entry, tier?.hosts));
|
|
1565
|
+
const foreign = net.allowedDomains.filter((entry) => !known.includes(entry));
|
|
1566
|
+
addNotes(`${SANDBOX_KEY}.network.allowedDomains`, known, 'every sandboxed command of the project can reach it');
|
|
1567
|
+
if (foreign.length) {
|
|
1568
|
+
out.push({ source, key: `${SANDBOX_KEY}.network.allowedDomains`, weakens: 'network', detail: `${foreign.length} pre-allowed domain(s) — ${HOST_HONORS_QUALIFIER}, egress to them is not gated` });
|
|
1569
|
+
}
|
|
1509
1570
|
}
|
|
1510
1571
|
const fsb = isJsonObject(sb.filesystem) ? sb.filesystem : {};
|
|
1511
1572
|
// A PRESENT but unreadable declaration is reported, never assumed empty: what it would make
|
|
@@ -1516,16 +1577,24 @@ const collectSandboxWeakenings = (sources, { root, home, tmp }) => {
|
|
|
1516
1577
|
// instead of asserting an effect it does not know.
|
|
1517
1578
|
out.push({ source, key: `${SANDBOX_KEY}.filesystem.allowWrite`, weakens: 'fs_outside_repo', unverifiable: true, detail: `the declared value is not an array (${typeof fsb.allowWrite}) — it cannot be read, so what it would make writable is UNKNOWN` });
|
|
1518
1579
|
} else if (Array.isArray(fsb.allowWrite) && fsb.allowWrite.length) {
|
|
1580
|
+
const known = fsb.allowWrite.filter((entry) => isTierKnown(source, entry, tier?.knownDirs));
|
|
1581
|
+
const foreign = fsb.allowWrite.filter((entry) => !known.includes(entry));
|
|
1582
|
+
addNotes(`${SANDBOX_KEY}.filesystem.allowWrite`, known, 'every sandboxed command can write it');
|
|
1519
1583
|
// Only the entries that RESOLVE outside the repo and $TMPDIR are a weakening at all — an entry
|
|
1520
|
-
// pointing INSIDE the repo grants nothing the red-line
|
|
1584
|
+
// pointing INSIDE the repo grants nothing beyond the red-line's own bound, so reporting it would be an
|
|
1521
1585
|
// over-report. All-contained ⇒ no line at all.
|
|
1522
1586
|
// TWO records, never one: a resolved external path IS a weakening, an unresolvable entry is
|
|
1523
1587
|
// UNVERIFIABLE, and a single line carrying both would assert an effect for entries it could
|
|
1524
|
-
// not read
|
|
1525
|
-
|
|
1526
|
-
const { external, unresolvable } = externalWriteEntries(fsb.allowWrite, { root, home, tmp });
|
|
1588
|
+
// not read. An array of only-unresolvable entries therefore yields no weakening claim at all.
|
|
1589
|
+
const { external, unresolvable } = externalWriteEntries(foreign, { root, home, tmp });
|
|
1527
1590
|
if (external.length) {
|
|
1528
|
-
|
|
1591
|
+
const covered = source === SETTINGS_FILE && judged
|
|
1592
|
+
? tier.dirs.filter((dir) => foreign.some((entry) => isResolvableDeclaredEntry(entry) && entry !== dir &&
|
|
1593
|
+
external.includes(resolveDeclaredDir(entry, { home, root })) &&
|
|
1594
|
+
dirCovers(resolveDeclaredDir(entry, { home, root }), resolveDeclaredDir(dir, { home, root }))))
|
|
1595
|
+
: [];
|
|
1596
|
+
const coveringDetail = covered.map((dir) => `; covers the bridge state dir ${dir}`).join('');
|
|
1597
|
+
out.push({ source, key: `${SANDBOX_KEY}.filesystem.allowWrite`, weakens: 'fs_outside_repo', detail: `${HOST_HONORS_QUALIFIER}, ${external.length} declared path(s) resolve OUTSIDE the repo and $TMPDIR and are writable: ${external.map((p) => JSON.stringify(p)).join(', ')}${coveringDetail}` });
|
|
1529
1598
|
}
|
|
1530
1599
|
if (unresolvable) {
|
|
1531
1600
|
out.push({ source, key: `${SANDBOX_KEY}.filesystem.allowWrite`, weakens: 'fs_outside_repo', unverifiable: true, detail: `${unresolvable} declared entr(ies) could not be resolved (not a non-empty string) — what they would make writable is UNKNOWN` });
|
|
@@ -1535,17 +1604,20 @@ const collectSandboxWeakenings = (sources, { root, home, tmp }) => {
|
|
|
1535
1604
|
out.push({ source, key: `${SANDBOX_KEY}.allowUnsandboxedCommands`, weakens: 'every sandbox red-line', detail: `${HOST_HONORS_QUALIFIER}, commands may run unsandboxed` });
|
|
1536
1605
|
}
|
|
1537
1606
|
if (Array.isArray(sb.excludedCommands) && sb.excludedCommands.length) {
|
|
1538
|
-
// The bridge tier's OWN
|
|
1539
|
-
//
|
|
1540
|
-
//
|
|
1541
|
-
// (the Decision-2 self-consistency bar). Every OTHER excluded command — hand-added names,
|
|
1542
|
-
// local-file exclusions, tier names without their allow rules — stays a loud weakening.
|
|
1607
|
+
// The bridge tier's OWN exclusions are tier-known ONLY with their consent proof; then they are
|
|
1608
|
+
// a note, never a weakening flag. Every OTHER excluded command — hand-added names, local-file
|
|
1609
|
+
// exclusions, tier names without their proof — stays a loud weakening.
|
|
1543
1610
|
const tierKnown = sb.excludedCommands.filter((c) => isTierKnownExclusion(source, c));
|
|
1544
1611
|
const foreign = sb.excludedCommands.filter((c) => !tierKnown.includes(c));
|
|
1612
|
+
// A bare name matches only the argument-free command, so its note never claims real calls leave the sandbox.
|
|
1613
|
+
if (judged) {
|
|
1614
|
+
addNotes(`${SANDBOX_KEY}.excludedCommands`, tierKnown.filter((c) => c.endsWith(' *')), 'the wrapper runs outside the sandbox');
|
|
1615
|
+
addNotes(`${SANDBOX_KEY}.excludedCommands`, tierKnown.filter((c) => !c.endsWith(' *')), 'only its argument-free invocation runs outside the sandbox');
|
|
1616
|
+
}
|
|
1545
1617
|
if (foreign.length) {
|
|
1546
|
-
out.push({ source, key: `${SANDBOX_KEY}.excludedCommands`, weakens: 'every sandbox red-line', detail: `${foreign.length} command(s) run UNSANDBOXED ${HOST_HONORS_QUALIFIER} (network/fs confinement not applied to them)${tierKnown.length ? `; ${tierKnown.length} bridge-review wrapper exclusion(s) are tier-known and not flagged` : ''}` });
|
|
1547
|
-
} else if (tierKnown.length) {
|
|
1548
|
-
out.push({ source, key: `${SANDBOX_KEY}.excludedCommands`, weakens: null, tierKnown: true, detail: `${tierKnown.length} bridge-review wrapper exclusion(s) (${tierKnown.join(', ')}) — tier-known: ${HOST_HONORS_QUALIFIER},
|
|
1618
|
+
out.push({ source, key: `${SANDBOX_KEY}.excludedCommands`, weakens: 'every sandbox red-line', detail: `${foreign.length} command(s) run UNSANDBOXED ${HOST_HONORS_QUALIFIER} (network/fs confinement not applied to them)${!judged && tierKnown.length ? `; ${tierKnown.length} bridge-review wrapper exclusion(s) are tier-known and not flagged` : ''}` });
|
|
1619
|
+
} else if (!judged && tierKnown.length) {
|
|
1620
|
+
out.push({ source, key: `${SANDBOX_KEY}.excludedCommands`, weakens: null, tierKnown: true, detail: `${tierKnown.length} bridge-review wrapper exclusion(s) (${tierKnown.join(', ')}) — tier-known: ${HOST_HONORS_QUALIFIER}, only their argument-free invocations run outside the sandbox, and their allow rules are present in the project settings` });
|
|
1549
1621
|
}
|
|
1550
1622
|
}
|
|
1551
1623
|
return out;
|
|
@@ -1614,8 +1686,10 @@ export const formatAutonomyResult = (r) => {
|
|
|
1614
1686
|
for (const b of r.redlineBypass ?? []) {
|
|
1615
1687
|
lines.push(` ⚠ DEGRADE: ${b.source} has a pre-existing allow entry ${b.entry} that would BYPASS the rendered red-line(s) ${b.redlines.join('/')} (a matching allow rule AUTO-APPROVES the command, defeating ask/deny) — remove it by hand; this render never touches permissions.allow.`);
|
|
1616
1688
|
}
|
|
1689
|
+
if (r.bridgeTierError) lines.push(` note: bridge-tier entries not judged — ${r.bridgeTierError}`);
|
|
1617
1690
|
for (const w of r.sandboxWeakenings ?? []) {
|
|
1618
|
-
if (w.tierKnown) lines.push(` note: ${w.source} has ${w.key} (${w.detail}
|
|
1691
|
+
if (w.tierKnown && hasOwn(w, 'entry')) lines.push(` note: ${w.source} has ${w.key} entry ${JSON.stringify(w.entry)} — tier-known: part of the bridge-wrappers tier consent; ${HOST_HONORS_QUALIFIER}, ${w.detail}.`);
|
|
1692
|
+
else if (w.tierKnown) lines.push(` note: ${w.source} has ${w.key} (${w.detail}).`);
|
|
1619
1693
|
else if (w.unverifiable) lines.push(` ⚠ DEGRADE: ${w.source} has ${w.key} (${w.detail}) — the rendered ${w.weakens} red-line CANNOT BE VERIFIED against it (no claim either way); fix the declared value by hand, then re-run this preview.`);
|
|
1620
1694
|
else lines.push(` ⚠ DEGRADE: ${w.source} has ${w.key} (${w.detail}), which WEAKENS the rendered ${w.weakens} red-line — the render preserves your sandbox tuning (never clobbers it), so remove it by hand if you want the red-line fully enforced.`);
|
|
1621
1695
|
}
|
|
@@ -1663,11 +1737,12 @@ export const writeAutonomyProfile = ({ cwd, apply = false } = {}, deps = {}) =>
|
|
|
1663
1737
|
const redlineBypass = collectRedlineBypass(settingsSources);
|
|
1664
1738
|
// The allowWrite degrade resolves its entries before judging them, so it needs the same anchors a
|
|
1665
1739
|
// host resolving the key would use: the project root, the resolved home, and $TMPDIR.
|
|
1740
|
+
const bridgeConsent = deriveBridgeConsent(preflight.projectSettings?.data, projectDir, deps);
|
|
1666
1741
|
const sandboxWeakenings = collectSandboxWeakenings(settingsSources, {
|
|
1667
1742
|
root: projectDir,
|
|
1668
1743
|
home: deps.home ?? homedir(),
|
|
1669
1744
|
tmp: (deps.env ?? process.env).TMPDIR ?? tmpdir(),
|
|
1670
|
-
});
|
|
1745
|
+
}, bridgeConsent);
|
|
1671
1746
|
const resultBase = {
|
|
1672
1747
|
autonomy: true,
|
|
1673
1748
|
source,
|
|
@@ -1677,6 +1752,7 @@ export const writeAutonomyProfile = ({ cwd, apply = false } = {}, deps = {}) =>
|
|
|
1677
1752
|
localMasks,
|
|
1678
1753
|
redlineBypass,
|
|
1679
1754
|
sandboxWeakenings,
|
|
1755
|
+
bridgeTierError: bridgeConsent.error,
|
|
1680
1756
|
stamp: preflight.stamp,
|
|
1681
1757
|
stampOk: preflight.stampOk,
|
|
1682
1758
|
};
|
package/tools/write-lanes.mjs
CHANGED
|
@@ -41,7 +41,6 @@ export const VARIANT_LANES = Object.freeze({
|
|
|
41
41
|
'adr-store-migration': LANE_CHAT,
|
|
42
42
|
'sandbox-masks': LANE_CHAT,
|
|
43
43
|
'sandbox-masks.unfenced-mount': LANE_CHAT,
|
|
44
|
-
'sandbox-lane': LANE_CHAT,
|
|
45
44
|
'worktrees-dir': LANE_CHAT,
|
|
46
45
|
'spec-adoption': LANE_CHAT,
|
|
47
46
|
'profile-gap': LANE_CHAT,
|