@sabaiway/agent-workflow-kit 1.34.0 → 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +1 -0
  3. package/SKILL.md +23 -13
  4. package/bin/install.mjs +15 -0
  5. package/bridges/antigravity-cli-bridge/SKILL.md +13 -1
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +96 -1
  7. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +204 -1
  8. package/bridges/antigravity-cli-bridge/bin/agy.sh +94 -0
  9. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +245 -1
  10. package/bridges/antigravity-cli-bridge/capability.json +17 -1
  11. package/bridges/codex-cli-bridge/SKILL.md +15 -1
  12. package/bridges/codex-cli-bridge/bin/codex-exec.sh +113 -0
  13. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +288 -0
  14. package/bridges/codex-cli-bridge/bin/codex-review.sh +114 -1
  15. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +229 -1
  16. package/bridges/codex-cli-bridge/capability.json +29 -1
  17. package/capability.json +1 -1
  18. package/package.json +1 -1
  19. package/references/modes/bridge-settings.md +29 -0
  20. package/references/modes/review-ledger.md +28 -0
  21. package/references/modes/set-autonomy.md +29 -0
  22. package/references/modes/upgrade.md +8 -2
  23. package/references/modes/velocity.md +13 -0
  24. package/tools/atomic-write.mjs +59 -21
  25. package/tools/autonomy-config.mjs +306 -0
  26. package/tools/autonomy-write.mjs +27 -0
  27. package/tools/bridge-settings-read.mjs +221 -0
  28. package/tools/bridge-settings.mjs +288 -0
  29. package/tools/commands.mjs +21 -0
  30. package/tools/family-registry.mjs +12 -1
  31. package/tools/manifest/schema.md +31 -0
  32. package/tools/manifest/validate.mjs +85 -0
  33. package/tools/procedures.mjs +26 -4
  34. package/tools/recipes.mjs +16 -6
  35. package/tools/renderers.mjs +7 -0
  36. package/tools/review-ledger-write.mjs +257 -0
  37. package/tools/review-ledger.mjs +508 -0
  38. package/tools/seed-gates.mjs +45 -4
  39. package/tools/set-autonomy.mjs +195 -0
  40. package/tools/setup-backends.mjs +107 -9
  41. package/tools/velocity-profile.mjs +468 -5
  42. package/tools/view-model.mjs +7 -0
@@ -1,6 +1,11 @@
1
- import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { join, relative, resolve } from 'node:path';
3
3
  import { fileURLToPath, pathToFileURL } from 'node:url';
4
+ // The --autonomy render reads the per-project autonomy policy through the read-only autonomy core
5
+ // (AD-044). This file is the family's one .claude/settings.json writer, so the policy render lives here;
6
+ // it never imports the policy fs-writer (autonomy-write.mjs) — the render owns the settings file, not
7
+ // the policy file.
8
+ import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, COMMAND_REDLINES } from './autonomy-config.mjs';
4
9
 
5
10
  // Velocity-profile core + writer: a fixed, audited read-only allowlist that an onboarding step seeds
6
11
  // into `.claude/settings.json` so routine read-only commands stop idling on approval prompts.
@@ -195,6 +200,8 @@ export const VELOCITY_STAMP = 'VELOCITY_STAMP';
195
200
  export const VELOCITY_UNSAFE_MODE = 'VELOCITY_UNSAFE_MODE';
196
201
  export const VELOCITY_MALFORMED = 'VELOCITY_MALFORMED';
197
202
  export const VELOCITY_SYMLINK = 'VELOCITY_SYMLINK';
203
+ // The --autonomy render refuses an absent policy file (a writer renders only a declared policy).
204
+ export const VELOCITY_NO_POLICY = 'VELOCITY_NO_POLICY';
198
205
 
199
206
  const VELOCITY_ERROR_NAME = 'VelocityProfileError';
200
207
  const ERROR_PREFIX = '[agent-workflow-kit]';
@@ -221,6 +228,8 @@ const FLAG_DRY_RUN = '--dry-run';
221
228
  const FLAG_APPLY = '--apply';
222
229
  const FLAG_ACCEPT_EDITS = '--accept-edits';
223
230
  const FLAG_KIT_TOOLS = '--kit-tools';
231
+ const FLAG_AUTONOMY = '--autonomy';
232
+ const FLAG_CHECK = '--check';
224
233
  const FLAG_CWD = '--cwd';
225
234
  const FLAG_HELP = '--help';
226
235
  const SHORT_FLAG_HELP = '-h';
@@ -236,13 +245,21 @@ const RESIDUAL_NOTICE =
236
245
  'residual: seeded read-only allow entries are a trust-posture convenience, NOT a sandbox; settings-level rules cannot inspect runtime redirection/command-substitution/--output writes; commit/push/publish are never allowlisted (a DIRECT invocation still ASKs, but the runtime residual is not closed here); the residual guard ships as the opt-in PreToolUse hook — Mode: hook (/agent-workflow-kit hook). floor (never auto-approved, with or without the tier): every writer --apply/--write/--yes still prompts; clobber-protection STOPs still stop; the three release asks (commit/push/publish) stay maintainer-owned.';
237
246
 
238
247
  const USAGE = `usage: velocity-profile [--dry-run | --apply] [--kit-tools] [--accept-edits] [--cwd <dir>] [--help]
248
+ velocity-profile --autonomy [--apply] [--cwd <dir>] (render the autonomy policy)
249
+ velocity-profile --autonomy --check [--cwd <dir>] (read-only drift gate)
239
250
 
240
- Seeds the fixed read-only Claude Code allowlist into .claude/settings.json.
251
+ Allowlist mode (default): seeds the fixed read-only Claude Code allowlist into .claude/settings.json.
241
252
  Default is --dry-run. --apply writes; --accept-edits only sets defaultMode when applying.
242
253
  --kit-tools additionally seeds the audited kit-tool tier: 8 read-only kit tools by resolved
243
254
  absolute path (args wildcard), run-gates.mjs as ONE exact project-root-pinned byte-string
244
255
  (project-exec - it runs YOUR declared gates.json), and the writers' exact arg-free dry-run
245
- preview byte-strings. Never touches settings.local.json.`;
256
+ preview byte-strings. Never touches settings.local.json.
257
+
258
+ --autonomy renders docs/ai/autonomy.json into the settings blocks it OWNS — the sandbox block +
259
+ permissions.ask/deny red-lines + permissions.defaultMode. POLICY-ONLY: never seeds the allowlist and
260
+ leaves permissions.allow untouched. Preview by default; --apply writes; --check is a read-only drift
261
+ gate (exit 1 on drift). --autonomy cannot combine with --accept-edits or --kit-tools (allowlist-mode
262
+ flags). Refuses an absent policy (seed one first with set-autonomy). Never touches settings.local.json.`;
246
263
 
247
264
  const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
248
265
 
@@ -298,6 +315,19 @@ const validateSettingsShape = (data, relPath) => {
298
315
  if (data.permissions?.allow !== undefined && !Array.isArray(data.permissions.allow)) {
299
316
  throw makeVelocityProfileError(VELOCITY_MALFORMED, `${relPath}: permissions.allow must be an array`);
300
317
  }
318
+ // The --autonomy render merges into permissions.ask / permissions.deny (arrays) and the top-level
319
+ // `sandbox` object — a malformed one of these is a STOP with ZERO writes, never a merge-through-clobber
320
+ // (the same precondition posture as permissions.allow above). The allowlist mode never touches these,
321
+ // so this only tightens the autonomy path.
322
+ if (data.permissions?.ask !== undefined && !Array.isArray(data.permissions.ask)) {
323
+ throw makeVelocityProfileError(VELOCITY_MALFORMED, `${relPath}: permissions.ask must be an array`);
324
+ }
325
+ if (data.permissions?.deny !== undefined && !Array.isArray(data.permissions.deny)) {
326
+ throw makeVelocityProfileError(VELOCITY_MALFORMED, `${relPath}: permissions.deny must be an array`);
327
+ }
328
+ if (data.sandbox !== undefined && !isJsonObject(data.sandbox)) {
329
+ throw makeVelocityProfileError(VELOCITY_MALFORMED, `${relPath}: sandbox must be a JSON object`);
330
+ }
301
331
  return data;
302
332
  };
303
333
 
@@ -756,6 +786,414 @@ export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true,
756
786
  return { wrote: true, dryRun: false, settingsPath, ...resultBase };
757
787
  };
758
788
 
789
+ // ── the --autonomy render (AD-044) ───────────────────────────────────────────────────────────────
790
+ // Render docs/ai/autonomy.json into the .claude/settings.json blocks the render OWNS — the `sandbox`
791
+ // block + permissions.ask/deny red-lines + permissions.defaultMode — per the Decision-6 invariant,
792
+ // wired to what the REAL claude 2.1.185 accepts (Step 3.1). POLICY-ONLY: never seeds the read-only
793
+ // allowlist and never touches permissions.allow as a value; a sibling of the flagless allowlist merge
794
+ // (mergeProjectSettings) that follows the identical merge-don't-clobber discipline. Preview-then-apply,
795
+ // dry-run default. Refuses an absent policy (a writer renders only a declared policy).
796
+
797
+ // The exact 2.1.185 sandbox settings keys (Step 3.1, characterized against the real CLI + official docs).
798
+ const SANDBOX_KEY = 'sandbox';
799
+ const SANDBOX_ENABLED_KEY = 'enabled';
800
+ const SANDBOX_AUTOALLOW_KEY = 'autoAllowBashIfSandboxed';
801
+ const DEFAULT_MODE_KEY = 'defaultMode';
802
+ const AUTONOMY_SANDBOX = 'sandbox';
803
+ const AUTONOMY_PROMPT = 'prompt';
804
+ // Decision 6: sandbox level ⇒ defaultMode acceptEdits; prompt level ⇒ defaultMode default.
805
+ const DEFAULT_MODE_FOR = Object.freeze({ [AUTONOMY_SANDBOX]: ACCEPT_EDITS_MODE, [AUTONOMY_PROMPT]: 'default' });
806
+ // The command red-lines → their Bash permission rule. The `:*` wildcard MATCHES argument-bearing forms
807
+ // (`git commit -m x`, `git push origin main`, `npm publish --tag latest`) — an exact `Bash(git commit)`
808
+ // would not (Step 3.1). These rules are the render-owned entries under permissions.ask / permissions.deny.
809
+ // ACCEPTED RESIDUAL (AD-044, do NOT try to enumerate away): Claude's prefix rules cannot cover an
810
+ // obfuscated spelling — a global-option form (`git -c user.name=x commit`) or a shell/wrapper escape
811
+ // falls outside `Bash(git commit:*)`, so under sandbox auto-allow it would not prompt. Enumerating every
812
+ // prefix is impossible; the command red-lines are a BEST-EFFORT checkpoint, not a security boundary (the
813
+ // same settings-level residual velocity documents). The real enforcement is the sandbox + the maintainer's
814
+ // approval process + the repo no-auto-commit constraint — see AUTONOMY_RESIDUAL_NOTICE.
815
+ const REDLINE_COMMAND_RULES = Object.freeze({
816
+ commit: 'Bash(git commit:*)',
817
+ push: 'Bash(git push:*)',
818
+ publish: 'Bash(npm publish:*)',
819
+ });
820
+ export const RENDER_OWNED_REDLINE_RULES = Object.freeze(Object.values(REDLINE_COMMAND_RULES));
821
+ // The Linux sandbox binaries (Step 3.1: BOTH are required for the sandbox to INITIALIZE — a missing
822
+ // socat does NOT degrade only network; the whole sandbox falls back to unsandboxed).
823
+ const SANDBOX_LINUX_BINARIES = Object.freeze(['bwrap', 'socat']);
824
+
825
+ const AUTONOMY_RESIDUAL_NOTICE =
826
+ 'floor + residual (always, sandbox available or not): the command red-lines gate the DIRECT forms ' +
827
+ '(`git commit …` / `git push …` / `npm publish …`) as a best-effort checkpoint, NOT a security ' +
828
+ 'boundary. RESIDUAL: Claude prefix-based ask/deny rules cannot exhaustively cover obfuscated forms — ' +
829
+ 'a global-option spelling (`git -c user.name=x commit`) or a shell/wrapper escape is not caught by ' +
830
+ 'the ask rule, and under sandbox auto-allow it would not prompt (the same settings-level residual ' +
831
+ 'velocity documents; enumerating every form is impossible). The REAL backstops are the SANDBOX (a ' +
832
+ 'genuine OS boundary, once available), the maintainer\'s commit/push/publish approval process, and ' +
833
+ 'the repo no-auto-commit constraint — never these settings rules alone. Any bypass/weakening ' +
834
+ 'warnings above are also best-effort: their ABSENCE is not proof no bypass exists. This render writes ' +
835
+ 'ONLY .claude/settings.json (never settings.local.json), touches ONLY the sandbox block + ' +
836
+ 'permissions.ask/deny/defaultMode, and leaves permissions.allow untouched.';
837
+
838
+ // isExecutableFile — true iff `p` is a REGULAR file with an execute bit (statSync FOLLOWS a symlink, so
839
+ // a symlinked binary resolves to its target). A directory or a non-executable file NAMED bwrap/socat
840
+ // must NOT count as the binary (else the loud sandbox-unavailable degrade would be wrongly suppressed).
841
+ const isExecutableFile = (p) => {
842
+ try {
843
+ const st = statSync(p);
844
+ return st.isFile() && (st.mode & 0o111) !== 0;
845
+ } catch {
846
+ return false;
847
+ }
848
+ };
849
+
850
+ // hasBinaryOnPath — a read-only PATH scan (no spawn, fully injectable): is `name` present as an
851
+ // EXECUTABLE regular file in any PATH dir? The runtime probe is deterministic (Step 3.1 is the separate
852
+ // one-time MANUAL characterization).
853
+ const hasBinaryOnPath = (name, env, isExec) =>
854
+ ((env && env.PATH) || '').split(':').filter(Boolean).some((dir) => isExec(join(dir, name)));
855
+
856
+ // probeSandboxAvailability(deps) → { platform, sandbox, available, missing, reason }. Read-only: platform
857
+ // (process.platform) + EXECUTABLE-binary presence via injectable deps (deps.platform / deps.env /
858
+ // deps.hasBinary / deps.isExecutable). NEVER launches a live `claude`. The degrade RULE is derived from
859
+ // Step 3.1: Linux needs bwrap+socat (both, or NO sandbox); macOS Seatbelt is built-in; native Windows
860
+ // unsupported → WSL2.
861
+ export const probeSandboxAvailability = (deps = {}) => {
862
+ const platform = deps.platform ?? process.platform;
863
+ const env = deps.env ?? process.env;
864
+ const isExec = deps.isExecutable ?? isExecutableFile;
865
+ const hasBinary = deps.hasBinary ?? ((name) => hasBinaryOnPath(name, env, isExec));
866
+ if (platform === 'darwin') {
867
+ return { platform, sandbox: 'seatbelt', available: true, missing: [], reason: 'macOS Seatbelt (built-in)' };
868
+ }
869
+ if (platform === 'linux') {
870
+ const missing = SANDBOX_LINUX_BINARIES.filter((b) => !hasBinary(b));
871
+ return {
872
+ platform,
873
+ sandbox: 'bubblewrap',
874
+ available: missing.length === 0,
875
+ missing,
876
+ reason: missing.length === 0
877
+ ? `Linux sandbox ready (${SANDBOX_LINUX_BINARIES.join(' + ')} present)`
878
+ : `Linux sandbox needs ${SANDBOX_LINUX_BINARIES.join(' + ')} — missing: ${missing.join(', ')} (the whole sandbox falls back to unsandboxed)`,
879
+ };
880
+ }
881
+ return { platform, sandbox: 'none', available: false, missing: [], reason: `native ${platform} sandbox unsupported — use WSL2 on Windows` };
882
+ };
883
+
884
+ // effectiveAutonomyLevel(resolved) → the ONE global level the (global, static) settings file renders.
885
+ // The autonomy policy is per-activity (Decision 2), but .claude/settings.json is global and the
886
+ // plan.end/exec.end checkpoints are BEHAVIORAL, not machine-enforced (Decision 1). Global auto-allow is
887
+ // a global settings behavior, so it engages only when EVERY activity is opted into `sandbox` (conservative
888
+ // unanimity — the safe floor); a mixed/default policy keeps the global `prompt` floor (the sandbox still
889
+ // confines). The preview surfaces the per-activity levels + this collapse loudly (never silent).
890
+ export const effectiveAutonomyLevel = (resolved) => {
891
+ const levels = Object.values(resolved.activities).map((a) => a.autonomy);
892
+ return levels.length > 0 && levels.every((l) => l === AUTONOMY_SANDBOX) ? AUTONOMY_SANDBOX : AUTONOMY_PROMPT;
893
+ };
894
+
895
+ // renderAutonomySettings(resolved, probe) → the render-owned blocks + honesty notes/degrades. PURE. The
896
+ // sandbox is ALWAYS enabled (the Decision-1 floor: BOTH levels confine); auto-allow + defaultMode follow
897
+ // the collapsed level. Command red-lines land under ask/deny by value. The three non-command red-lines
898
+ // are the sandbox DEFAULTS (network prompt-on-egress; fs cwd+$TMPDIR confine) — where 2.1.185 cannot
899
+ // express a value distinctly it DEGRADES LOUDLY (never a silent allow, never silent pretend-security).
900
+ export const renderAutonomySettings = (resolved, probe) => {
901
+ const level = effectiveAutonomyLevel(resolved);
902
+ const wantsSandbox = level === AUTONOMY_SANDBOX;
903
+ const sandbox = { [SANDBOX_ENABLED_KEY]: true, [SANDBOX_AUTOALLOW_KEY]: wantsSandbox };
904
+ const defaultMode = DEFAULT_MODE_FOR[level];
905
+ const ask = [];
906
+ const deny = [];
907
+ for (const rl of COMMAND_REDLINES) {
908
+ (resolved.redlines[rl] === 'deny' ? deny : ask).push(REDLINE_COMMAND_RULES[rl]);
909
+ }
910
+ const notes = [];
911
+ const degrades = [];
912
+ // network — 2.1.185 regular settings cannot HARD-BLOCK egress; both values render as no allowedDomains
913
+ // (prompt-on-egress). deny DEGRADES LOUDLY (can't hard-block); ask is the faithful sandbox default.
914
+ if (resolved.redlines.network === 'deny') {
915
+ degrades.push('network=deny requested, but claude 2.1.185 regular settings cannot HARD-BLOCK egress — rendered as prompt-on-egress (the sandbox default: no domains pre-allowed, a new domain still prompts). A silent hard block needs managed settings (allowManagedDomainsOnly).');
916
+ } else {
917
+ notes.push('network=ask → prompt on each new domain (the sandbox default; no domains pre-allowed).');
918
+ }
919
+ // credentials — 2.1.185 has NO sandbox.credentials at all (deny added 2.1.187, mask 2.1.199). BOTH
920
+ // values degrade loudly — never silent pretend-security.
921
+ degrades.push(`credentials=${resolved.redlines.credentials} requested, but claude 2.1.185 has NO sandbox credential denial (deny added in 2.1.187, mask in 2.1.199) — NPM_TOKEN/GITHUB_TOKEN and ~/.ssh are NOT hidden from sandboxed commands. Upgrade to 2.1.187+ for sandbox.credentials.`);
922
+ // fs_outside_repo — the sandbox default is a HARD confine to cwd+$TMPDIR; 2.1.185 has no
923
+ // prompt-on-outside-write, so `ask` DEGRADES LOUDLY to the deny form (hard confine).
924
+ if (resolved.redlines.fs_outside_repo === 'ask') {
925
+ degrades.push('fs_outside_repo=ask requested, but claude 2.1.185 has no prompt-on-outside-write — rendered as the deny form (writes hard-confined to cwd+$TMPDIR; an outside write is blocked, then auto-retried through the normal permission flow).');
926
+ } else {
927
+ notes.push('fs_outside_repo=deny → writes confined to cwd+$TMPDIR (the sandbox default).');
928
+ }
929
+ // sandbox availability (Step 3.3 probe) — a LOUD degrade where the OS can't sandbox; the red-lines +
930
+ // defaultMode still land (they are permission rules, sandbox-independent).
931
+ if (!probe.available) {
932
+ degrades.push(`sandbox UNAVAILABLE on this host (${probe.reason}) — claude renders the sandbox block but WARNS and runs UNSANDBOXED: ad-hoc scripts will still PROMPT and network/fs confinement is NOT enforced until it is available (Plan 2 offers to install the missing dependency). The red-lines + defaultMode still apply. failIfUnavailable is left UNSET so the session is never bricked.`);
933
+ }
934
+ return { level, activities: resolved.activities, sandbox, defaultMode, ask, deny, notes, degrades };
935
+ };
936
+
937
+ // mergeAutonomySettings(projectData, render) → merged settings.json (merge-don't-clobber). Touches ONLY
938
+ // the render-owned blocks: the sandbox key (foreign sub-keys preserved), permissions.defaultMode, and
939
+ // the render-owned red-line rules under permissions.ask/deny (a policy flip MOVES a rule between ask and
940
+ // deny rather than duplicating it; foreign ask/deny entries preserved). permissions.allow is UNTOUCHED
941
+ // as a value; every foreign top-level key (e.g. the gate-hook `hooks` block) is preserved.
942
+ export const mergeAutonomySettings = (projectData, render) => {
943
+ const base = projectData ?? {};
944
+ const permissions = getPermissions(base);
945
+ const existingSandbox = isJsonObject(base[SANDBOX_KEY]) ? base[SANDBOX_KEY] : {};
946
+ const existingAsk = Array.isArray(permissions.ask) ? permissions.ask : [];
947
+ const existingDeny = Array.isArray(permissions.deny) ? permissions.deny : [];
948
+ const stripOwned = (arr) => arr.filter((e) => !RENDER_OWNED_REDLINE_RULES.includes(e));
949
+ const mergedAsk = [...stripOwned(existingAsk), ...render.ask];
950
+ const mergedDeny = [...stripOwned(existingDeny), ...render.deny];
951
+ const mergedPermissions = { ...permissions, [DEFAULT_MODE_KEY]: render.defaultMode };
952
+ if (mergedAsk.length) mergedPermissions.ask = mergedAsk;
953
+ else delete mergedPermissions.ask;
954
+ if (mergedDeny.length) mergedPermissions.deny = mergedDeny;
955
+ else delete mergedPermissions.deny;
956
+ const mergedSandbox = { ...existingSandbox, [SANDBOX_ENABLED_KEY]: true, [SANDBOX_AUTOALLOW_KEY]: render.sandbox[SANDBOX_AUTOALLOW_KEY] };
957
+ return { ...base, [SANDBOX_KEY]: mergedSandbox, permissions: mergedPermissions };
958
+ };
959
+
960
+ // Each command red-line as [tool, subcommand], for bypass detection over the real Claude allow-rule
961
+ // semantics. Global options can sit between the tool and the subcommand (`git -c x commit`,
962
+ // `npm --registry=r publish`), so bypass detection scans for the subcommand ANYWHERE in the tokens
963
+ // rather than requiring a strict prefix.
964
+ const REDLINE_COMMAND_WORDS = Object.freeze({ commit: ['git', 'commit'], push: ['git', 'push'], publish: ['npm', 'publish'] });
965
+
966
+ // allowEntryBypass(entry) → the red-lines a single allow entry would AUTO-APPROVE (or null). Parse the
967
+ // entry's command (wildcard `Bash(C:*)` or exact `Bash(C)`). It bypasses red-line R = <tool> <sub> when
968
+ // the command's first token is <tool> AND either (a) it is the bare-tool WILDCARD `Bash(git:*)` /
969
+ // `Bash(npm:*)` (subsumes every subcommand of that tool), or (b) <sub> appears anywhere in the tokens
970
+ // — covering `git commit`, `git -c user.name=x commit`, `npm --registry=r publish`, and exact
971
+ // `Bash(git commit)`. Conservative (over-flags a benign `git branch commit` — a warning, never a write);
972
+ // the render-owned `:*` forms alone would miss broad/exact/global-option allows.
973
+ const allowEntryBypass = (entry) => {
974
+ const wild = getBashAllowCommand(entry);
975
+ const cmd = wild ?? getBashExactCommand(entry);
976
+ if (cmd === undefined) return null;
977
+ const tokens = tokenizeCommand(cmd);
978
+ const hit = [];
979
+ for (const [rl, [tool, sub]] of Object.entries(REDLINE_COMMAND_WORDS)) {
980
+ if (tokens[0] !== tool) continue;
981
+ // A bare-tool WILDCARD (`Bash(git:*)`) subsumes every subcommand; otherwise the subcommand must
982
+ // appear as a token. An EXACT bare-tool (`Bash(git)`) matches only the literal "git" (no bypass).
983
+ if ((wild !== undefined && tokens.length === 1) || tokens.slice(1).includes(sub)) hit.push(rl);
984
+ }
985
+ return hit.length ? hit : null;
986
+ };
987
+
988
+ // collectRedlineBypass(sources) → the pre-existing allow entries (per file) that would AUTO-APPROVE a
989
+ // render-owned command red-line (commit/push/publish), defeating the rendered ask/deny. The render never
990
+ // touches allow, so it cannot fix this — it REPORTS it loudly (remove the allow entry by hand).
991
+ const collectRedlineBypass = (sources) =>
992
+ sources.flatMap(({ source, data }) =>
993
+ getAllowEntries(data).flatMap((entry) => {
994
+ const redlines = allowEntryBypass(entry);
995
+ return redlines ? [{ source, entry, redlines }] : [];
996
+ }));
997
+
998
+ // collectSandboxWeakenings(sources) → foreign `sandbox` sub-keys (in either settings file) that WEAKEN a
999
+ // rendered red-line: `allowedDomains` pre-allows egress (weakens network); a filesystem write-allowance
1000
+ // permits writes outside cwd (weakens fs_outside_repo); `allowUnsandboxedCommands:true` lets commands
1001
+ // escape the sandbox entirely. The render owns only enabled/autoAllow and preserves other sandbox
1002
+ // sub-keys (merge-don't-clobber, never a silent clobber of the user's sandbox tuning), so a pre-existing
1003
+ // weakening sub-key is REPORTED loudly (remove it by hand) — never silently carried as security.
1004
+ const collectSandboxWeakenings = (sources) =>
1005
+ sources.flatMap(({ source, data }) => {
1006
+ const sb = isJsonObject(data?.[SANDBOX_KEY]) ? data[SANDBOX_KEY] : {};
1007
+ const out = [];
1008
+ const net = isJsonObject(sb.network) ? sb.network : {};
1009
+ if (Array.isArray(net.allowedDomains) && net.allowedDomains.length) {
1010
+ out.push({ source, key: `${SANDBOX_KEY}.network.allowedDomains`, weakens: 'network', detail: `${net.allowedDomains.length} pre-allowed domain(s) — egress to them is not gated` });
1011
+ }
1012
+ const fsb = isJsonObject(sb.filesystem) ? sb.filesystem : {};
1013
+ if (Array.isArray(fsb.allowWrite) && fsb.allowWrite.length) {
1014
+ out.push({ source, key: `${SANDBOX_KEY}.filesystem.allowWrite`, weakens: 'fs_outside_repo', detail: `${fsb.allowWrite.length} path(s) writable outside cwd+$TMPDIR` });
1015
+ }
1016
+ if (sb.allowUnsandboxedCommands === true) {
1017
+ out.push({ source, key: `${SANDBOX_KEY}.allowUnsandboxedCommands`, weakens: 'every sandbox red-line', detail: 'commands may run unsandboxed' });
1018
+ }
1019
+ if (Array.isArray(sb.excludedCommands) && sb.excludedCommands.length) {
1020
+ out.push({ source, key: `${SANDBOX_KEY}.excludedCommands`, weakens: 'every sandbox red-line', detail: `${sb.excludedCommands.length} command(s) run UNSANDBOXED (network/fs confinement not applied to them)` });
1021
+ }
1022
+ return out;
1023
+ });
1024
+
1025
+ // collectLocalMasks(localData, render) → the render-owned keys a settings.local.json value MASKS
1026
+ // (local > project, so a differing local value defeats the rendered one silently). The render owns
1027
+ // exactly permissions.defaultMode + sandbox.enabled + sandbox.autoAllowBashIfSandboxed — the COMPLETE
1028
+ // finite set of scalar render-owned keys (the ask/deny red-line arrays MERGE across scopes and are
1029
+ // covered by collectRedlineBypass). Reported loudly; the local file is the maintainer's, never written.
1030
+ const collectLocalMasks = (localData, render) => {
1031
+ const local = localData ?? {};
1032
+ const localPerms = getPermissions(local);
1033
+ const localSandbox = isJsonObject(local[SANDBOX_KEY]) ? local[SANDBOX_KEY] : {};
1034
+ const masks = [];
1035
+ if (hasOwn(localPerms, DEFAULT_MODE_KEY) && localPerms[DEFAULT_MODE_KEY] !== render.defaultMode) {
1036
+ masks.push({ key: `permissions.${DEFAULT_MODE_KEY}`, local: localPerms[DEFAULT_MODE_KEY], rendered: render.defaultMode });
1037
+ }
1038
+ for (const k of [SANDBOX_ENABLED_KEY, SANDBOX_AUTOALLOW_KEY]) {
1039
+ if (hasOwn(localSandbox, k) && localSandbox[k] !== render.sandbox[k]) {
1040
+ masks.push({ key: `${SANDBOX_KEY}.${k}`, local: localSandbox[k], rendered: render.sandbox[k] });
1041
+ }
1042
+ }
1043
+ return masks;
1044
+ };
1045
+
1046
+ const formatActivityLevels = (activities) =>
1047
+ Object.entries(activities).map(([a, v]) => `${a}=${v.autonomy}`).join(', ');
1048
+
1049
+ export const formatAutonomyResult = (r) => {
1050
+ const lines = [
1051
+ r.wrote ? 'agent-workflow autonomy render - APPLY' : 'agent-workflow autonomy render - DRY RUN (no changes)',
1052
+ `policy: ${r.source} · per-activity: ${formatActivityLevels(r.activities)} · effective global autonomy: ${r.level}`,
1053
+ ];
1054
+ if (r.level === AUTONOMY_PROMPT && !Object.values(r.activities).every((v) => v.autonomy === AUTONOMY_PROMPT)) {
1055
+ lines.push(' note: global autonomy is `prompt` because not every activity is `sandbox` — set every activity to sandbox (set-autonomy) to enable global auto-allow (conservative unanimity; the sandbox still confines).');
1056
+ }
1057
+ lines.push(
1058
+ `sandbox: ${SANDBOX_ENABLED_KEY}=true, ${SANDBOX_AUTOALLOW_KEY}=${r.sandbox[SANDBOX_AUTOALLOW_KEY]} (${r.level === AUTONOMY_SANDBOX ? 'auto-allow confined commands' : 'auto-allow OFF — confine only'})`,
1059
+ `permissions.${DEFAULT_MODE_KEY}: ${r.wrote ? 'set to' : 'would set to'} ${r.defaultMode}`,
1060
+ `permissions.ask (render-owned red-lines): ${r.ask.length ? r.ask.join(', ') : '(none)'}`,
1061
+ `permissions.deny (render-owned red-lines): ${r.deny.length ? r.deny.join(', ') : '(none)'}`,
1062
+ 'permissions.allow: untouched (policy-only render)',
1063
+ );
1064
+ for (const n of r.notes) lines.push(` note: ${n}`);
1065
+ for (const d of r.degrades) lines.push(` ⚠ DEGRADE: ${d}`);
1066
+ for (const m of r.localMasks ?? []) {
1067
+ lines.push(` ⚠ ${SETTINGS_LOCAL_FILE} sets ${m.key}=${JSON.stringify(m.local)}, which MASKS this render's ${m.key}=${JSON.stringify(m.rendered)} (local > project) — the local value wins, so the render is not effective for that key; the local file is the maintainer's and is never written by the kit.`);
1068
+ }
1069
+ for (const b of r.redlineBypass ?? []) {
1070
+ 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.`);
1071
+ }
1072
+ for (const w of r.sandboxWeakenings ?? []) {
1073
+ 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.`);
1074
+ }
1075
+ lines.push(AUTONOMY_RESIDUAL_NOTICE);
1076
+ if (!r.wrote) lines.push(`re-run with ${FLAG_APPLY} to write .claude/settings.json (only the render-owned blocks change).`);
1077
+ return lines.join(LF);
1078
+ };
1079
+
1080
+ // writeAutonomyProfile({ cwd, apply }, deps) → the preview/apply result. Reuses preflightVelocityProfile
1081
+ // for the SAFETY bar (refuse bypassPermissions / unsafe mode in either settings file, symlink-safe,
1082
+ // stamp gate on apply, resolveEffectiveMode across BOTH files). Refuses an absent policy loudly.
1083
+ export const writeAutonomyProfile = ({ cwd, apply = false } = {}, deps = {}) => {
1084
+ const projectDir = cwd ?? deps.cwd ?? process.cwd();
1085
+ const readFile = deps.readFile ?? deps.readFileSync ?? readFileSync;
1086
+ const lstat = deps.lstat ?? deps.lstatSync ?? lstatSync;
1087
+ const { config, source } = loadAutonomy(projectDir, readFile, lstat);
1088
+ if (source === 'none' || config === null) {
1089
+ throw makeVelocityProfileError(
1090
+ VELOCITY_NO_POLICY,
1091
+ `no ${AUTONOMY_REL} to render — seed a policy first (set-autonomy previews, then --write; or hand-edit it), then re-run the autonomy render`,
1092
+ );
1093
+ }
1094
+ const resolved = resolveAutonomy(config);
1095
+ const probe = probeSandboxAvailability(deps);
1096
+ const render = renderAutonomySettings(resolved, probe);
1097
+ const preflight = preflightVelocityProfile({ cwd: projectDir }, deps);
1098
+ // local-mask honesty: a settings.local.json value for ANY render-owned key (defaultMode + the sandbox
1099
+ // enable/auto-allow keys) that differs from the render's MASKS it (local > project). Reported loudly;
1100
+ // the local file is the maintainer's and is never touched.
1101
+ const localMasks = collectLocalMasks(preflight.localSettings?.data, render);
1102
+ // Red-line-bypass honesty: a pre-existing `permissions.allow` entry (in EITHER settings file) that
1103
+ // matches a render-owned command red-line (commit/push/publish) AUTO-APPROVES the command, defeating
1104
+ // the render's ask/deny placement. The render never touches allow, so it cannot fix this — it REPORTS
1105
+ // it loudly (remove the allow entry by hand). Reuse matchesMutatingAllowCommand (the velocity allow-screen
1106
+ // predicate) so the two surfaces agree on what a mutating allow entry is.
1107
+ const settingsSources = [
1108
+ { source: SETTINGS_FILE, data: preflight.projectSettings?.data },
1109
+ { source: SETTINGS_LOCAL_FILE, data: preflight.localSettings?.data },
1110
+ ];
1111
+ const redlineBypass = collectRedlineBypass(settingsSources);
1112
+ const sandboxWeakenings = collectSandboxWeakenings(settingsSources);
1113
+ const resultBase = {
1114
+ autonomy: true,
1115
+ source,
1116
+ probe,
1117
+ ...render,
1118
+ effectiveMode: preflight.effectiveMode,
1119
+ localMasks,
1120
+ redlineBypass,
1121
+ sandboxWeakenings,
1122
+ stamp: preflight.stamp,
1123
+ stampOk: preflight.stampOk,
1124
+ };
1125
+ if (!apply) return { wrote: false, dryRun: true, ...resultBase };
1126
+
1127
+ if (!preflight.stampOk) {
1128
+ throw makeVelocityProfileError(
1129
+ VELOCITY_STAMP,
1130
+ `not a deployed agent-workflow project at lineage ${EXPECTED_WORKFLOW_VERSION} (found ${preflight.stamp ?? 'none'}) - run init/upgrade first`,
1131
+ );
1132
+ }
1133
+ const fs = fsDeps(deps);
1134
+ const settingsPath = join(projectDir, SETTINGS_FILE);
1135
+ if (preflight.claudeDirAbsent) fs.mkdir(join(projectDir, CLAUDE_DIR), { recursive: true });
1136
+ const merged = mergeAutonomySettings(preflight.projectSettings.data, render);
1137
+ fs.writeFile(settingsPath, formatJson(merged, preflight.projectSettings.eol ?? LF), UTF8);
1138
+ return { wrote: true, dryRun: false, settingsPath, mergedSettings: merged, ...resultBase };
1139
+ };
1140
+
1141
+ // checkAutonomyProfile({ cwd }, deps) → the read-only drift-guard: recompute the render from
1142
+ // docs/ai/autonomy.json + the probe, compare against the live .claude/settings.json blocks the render
1143
+ // OWNS (sandbox.enabled / sandbox.autoAllowBashIfSandboxed / permissions.defaultMode / the render-owned
1144
+ // red-line rules' array placement). Hand-edits OUTSIDE those blocks never flag (merge-don't-clobber
1145
+ // boundary); a hand-edit INSIDE a render-owned block flags as drift naming the exact key. Refuses an
1146
+ // absent policy loudly (nothing to check against).
1147
+ export const checkAutonomyProfile = ({ cwd } = {}, deps = {}) => {
1148
+ const projectDir = cwd ?? deps.cwd ?? process.cwd();
1149
+ const readFile = deps.readFile ?? deps.readFileSync ?? readFileSync;
1150
+ const lstat = deps.lstat ?? deps.lstatSync ?? lstatSync;
1151
+ const { config, source } = loadAutonomy(projectDir, readFile, lstat);
1152
+ if (source === 'none' || config === null) {
1153
+ throw makeVelocityProfileError(VELOCITY_NO_POLICY, `no ${AUTONOMY_REL} to check against — seed a policy first (set-autonomy previews, then --write)`);
1154
+ }
1155
+ const render = renderAutonomySettings(resolveAutonomy(config), probeSandboxAvailability(deps));
1156
+ const settings = readSettingsFile(join(projectDir, SETTINGS_FILE), { ...deps, cwd: projectDir });
1157
+ const data = settings.present ? settings.data : {};
1158
+ const drift = [];
1159
+ const liveSandbox = isJsonObject(data[SANDBOX_KEY]) ? data[SANDBOX_KEY] : {};
1160
+ if (liveSandbox[SANDBOX_ENABLED_KEY] !== true) {
1161
+ drift.push(`${SANDBOX_KEY}.${SANDBOX_ENABLED_KEY}: expected true, found ${JSON.stringify(liveSandbox[SANDBOX_ENABLED_KEY])}`);
1162
+ }
1163
+ if (liveSandbox[SANDBOX_AUTOALLOW_KEY] !== render.sandbox[SANDBOX_AUTOALLOW_KEY]) {
1164
+ drift.push(`${SANDBOX_KEY}.${SANDBOX_AUTOALLOW_KEY}: expected ${render.sandbox[SANDBOX_AUTOALLOW_KEY]}, found ${JSON.stringify(liveSandbox[SANDBOX_AUTOALLOW_KEY])}`);
1165
+ }
1166
+ const perms = getPermissions(data);
1167
+ if (perms[DEFAULT_MODE_KEY] !== render.defaultMode) {
1168
+ drift.push(`permissions.${DEFAULT_MODE_KEY}: expected ${render.defaultMode}, found ${JSON.stringify(perms[DEFAULT_MODE_KEY])}`);
1169
+ }
1170
+ const liveAsk = Array.isArray(perms.ask) ? perms.ask : [];
1171
+ const liveDeny = Array.isArray(perms.deny) ? perms.deny : [];
1172
+ // Count occurrences (not includes()): a DUPLICATE render-owned rule is drift too — the render emits
1173
+ // each owned rule exactly once, so a re-apply would dedup it; the live block no longer matches.
1174
+ const countIn = (arr, rule) => arr.filter((e) => e === rule).length;
1175
+ for (const rl of COMMAND_REDLINES) {
1176
+ const rule = REDLINE_COMMAND_RULES[rl];
1177
+ const expectDeny = render.deny.includes(rule);
1178
+ const want = expectDeny ? 'deny' : 'ask';
1179
+ const other = expectDeny ? 'ask' : 'deny';
1180
+ const inExpected = countIn(expectDeny ? liveDeny : liveAsk, rule);
1181
+ const inOther = countIn(expectDeny ? liveAsk : liveDeny, rule);
1182
+ if (inExpected !== 1) drift.push(`permissions.${want}: expected exactly one ${rule} (redlines.${rl}=${want}), found ${inExpected}`);
1183
+ if (inOther !== 0) drift.push(`permissions.${other}: ${rule} present ${inOther}× but redlines.${rl}=${want} (belongs under ${want})`);
1184
+ }
1185
+ return { inSync: drift.length === 0, drift, source, level: render.level, settingsPresent: settings.present };
1186
+ };
1187
+
1188
+ export const formatAutonomyCheck = (c) =>
1189
+ c.inSync
1190
+ ? `autonomy --check: IN SYNC — ${SETTINGS_FILE} matches the ${c.source} render (level ${c.level}).`
1191
+ : [
1192
+ `autonomy --check: DRIFT — ${SETTINGS_FILE} diverges from the ${c.source} render (level ${c.level}):`,
1193
+ ...c.drift.map((d) => ` ✗ ${d}`),
1194
+ `re-run \`velocity ${FLAG_AUTONOMY} ${FLAG_APPLY}\` to reconcile (only the render-owned blocks change).`,
1195
+ ].join(LF);
1196
+
759
1197
  export const parseArgs = (argv) => {
760
1198
  const parsed = argv.reduce(
761
1199
  (state, arg, index, allArgs) => {
@@ -765,6 +1203,8 @@ export const parseArgs = (argv) => {
765
1203
  if (arg === FLAG_APPLY) return { ...state, apply: true };
766
1204
  if (arg === FLAG_ACCEPT_EDITS) return { ...state, acceptEdits: true };
767
1205
  if (arg === FLAG_KIT_TOOLS) return { ...state, kitTools: true };
1206
+ if (arg === FLAG_AUTONOMY) return { ...state, autonomy: true };
1207
+ if (arg === FLAG_CHECK) return { ...state, check: true };
768
1208
  if (arg === FLAG_CWD) {
769
1209
  const next = allArgs[index + 1];
770
1210
  if (next === undefined || next.startsWith('-')) throw fail(EXIT_USAGE, `${FLAG_CWD} needs a directory argument`);
@@ -773,16 +1213,26 @@ export const parseArgs = (argv) => {
773
1213
  if (arg.startsWith('-')) throw fail(EXIT_USAGE, `unknown flag: ${arg}`);
774
1214
  throw fail(EXIT_USAGE, `unexpected argument: ${arg}`);
775
1215
  },
776
- { help: false, dryRunFlag: false, apply: false, acceptEdits: false, kitTools: false, cwd: undefined, skipNext: false },
1216
+ { help: false, dryRunFlag: false, apply: false, acceptEdits: false, kitTools: false, autonomy: false, check: false, cwd: undefined, skipNext: false },
777
1217
  );
778
1218
 
779
1219
  if (parsed.dryRunFlag && parsed.apply) throw fail(EXIT_USAGE, `${FLAG_DRY_RUN} and ${FLAG_APPLY} cannot be used together`);
1220
+ // The --autonomy render is a SEPARATE mode from the allowlist seeding: the allowlist-only flags
1221
+ // (--accept-edits sets defaultMode; --kit-tools seeds the kit tier) are meaningless under a
1222
+ // policy-driven render (defaultMode comes from the policy; --autonomy never seeds the allowlist).
1223
+ // Reject the mix LOUDLY rather than silently ignoring a flag.
1224
+ if (parsed.autonomy && parsed.acceptEdits) throw fail(EXIT_USAGE, `${FLAG_AUTONOMY} sets ${DEFAULT_MODE_KEY} from the policy — ${FLAG_ACCEPT_EDITS} (an allowlist-mode flag) cannot be combined with it`);
1225
+ if (parsed.autonomy && parsed.kitTools) throw fail(EXIT_USAGE, `${FLAG_KIT_TOOLS} is an allowlist-mode flag — it cannot be combined with ${FLAG_AUTONOMY} (a policy-only render)`);
1226
+ if (parsed.check && !parsed.autonomy) throw fail(EXIT_USAGE, `${FLAG_CHECK} is only valid with ${FLAG_AUTONOMY}`);
1227
+ if (parsed.check && parsed.apply) throw fail(EXIT_USAGE, `${FLAG_CHECK} is read-only — it cannot be combined with ${FLAG_APPLY}`);
780
1228
  return {
781
1229
  help: parsed.help,
782
1230
  dryRun: parsed.apply ? false : true,
783
1231
  apply: parsed.apply,
784
1232
  acceptEdits: parsed.acceptEdits,
785
1233
  kitTools: parsed.kitTools,
1234
+ autonomy: parsed.autonomy,
1235
+ check: parsed.check,
786
1236
  cwd: parsed.cwd,
787
1237
  };
788
1238
  };
@@ -796,8 +1246,21 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
796
1246
  log(USAGE);
797
1247
  return EXIT_OK;
798
1248
  }
1249
+ const cwd = args.cwd ?? deps.cwd ?? process.cwd();
1250
+ // The --autonomy render is a separate mode from the allowlist seeding (policy → the render-owned
1251
+ // settings blocks); --check turns it into a read-only drift gate (exit 1 on drift).
1252
+ if (args.autonomy) {
1253
+ if (args.check) {
1254
+ const check = checkAutonomyProfile({ cwd }, deps);
1255
+ log(formatAutonomyCheck(check));
1256
+ return check.inSync ? EXIT_OK : EXIT_PRECONDITION;
1257
+ }
1258
+ const result = writeAutonomyProfile({ cwd, apply: args.apply }, deps);
1259
+ log(formatAutonomyResult(result));
1260
+ return EXIT_OK;
1261
+ }
799
1262
  const result = writeVelocityProfile(
800
- { cwd: args.cwd ?? deps.cwd ?? process.cwd(), acceptEdits: args.acceptEdits, dryRun: args.dryRun, kitTools: args.kitTools },
1263
+ { cwd, acceptEdits: args.acceptEdits, dryRun: args.dryRun, kitTools: args.kitTools },
801
1264
  deps,
802
1265
  );
803
1266
  log(formatVelocityProfileResult(result));
@@ -32,6 +32,13 @@ const bridgeVm = (b) => ({
32
32
  readiness: b.readiness,
33
33
  // preserve the three-state wrapper status (present | missing | unknown) — the renderer maps to a glyph.
34
34
  wrappers: (b.wrappers ?? []).map((w) => ({ cmd: w.cmd, state: w.state })),
35
+ // fact-only host-level settings: the active knobs for this bridge, or a localized error; null when
36
+ // nothing is set (the renderer then adds no sub-line — the block stays as it was before any knob).
37
+ settings: b.settings?.error
38
+ ? { error: b.settings.error }
39
+ : b.settings?.active?.length
40
+ ? { active: b.settings.active.map((a) => ({ key: a.key, value: a.value, source: a.source })) }
41
+ : null,
35
42
  });
36
43
 
37
44
  const visibilityVm = (v) => {