@sabaiway/agent-workflow-kit 1.35.0 → 1.37.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 +70 -0
- package/README.md +2 -0
- package/SKILL.md +24 -14
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/fold-completeness.md +22 -0
- package/references/modes/review-ledger.md +28 -0
- package/references/modes/set-autonomy.md +29 -0
- package/references/modes/velocity.md +13 -0
- package/tools/autonomy-config.mjs +306 -0
- package/tools/autonomy-write.mjs +27 -0
- package/tools/commands.mjs +21 -0
- package/tools/fold-completeness-run.mjs +526 -0
- package/tools/fold-completeness.mjs +364 -0
- package/tools/procedures.mjs +12 -3
- package/tools/review-ledger-write.mjs +261 -0
- package/tools/review-ledger.mjs +535 -0
- package/tools/seed-gates.mjs +45 -4
- package/tools/set-autonomy.mjs +195 -0
- package/tools/velocity-profile.mjs +468 -5
|
@@ -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
|
-
|
|
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
|
|
1263
|
+
{ cwd, acceptEdits: args.acceptEdits, dryRun: args.dryRun, kitTools: args.kitTools },
|
|
801
1264
|
deps,
|
|
802
1265
|
);
|
|
803
1266
|
log(formatVelocityProfileResult(result));
|