@sabaiway/agent-workflow-kit 1.14.0 → 1.15.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 +38 -0
- package/README.md +2 -1
- package/SKILL.md +25 -5
- package/capability.json +1 -1
- package/package.json +1 -1
- package/tools/uninstall.mjs +58 -10
- package/tools/uninstall.test.mjs +29 -0
- package/tools/velocity-profile.mjs +571 -0
- package/tools/velocity-profile.test.mjs +496 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
|
|
5
|
+
// Velocity-profile core + writer: a fixed, audited read-only allowlist that an onboarding step seeds
|
|
6
|
+
// into `.claude/settings.json` so routine read-only commands stop idling on approval prompts.
|
|
7
|
+
//
|
|
8
|
+
// Load-bearing invariant: the seeded allowlist must NEVER permit `git commit` / `git push` /
|
|
9
|
+
// `npm publish` — commit stays the single human approval checkpoint.
|
|
10
|
+
|
|
11
|
+
// Deployment-lineage head this velocity build targets; bump together with agent-workflow-memory
|
|
12
|
+
// LINEAGE_HEAD when the deployed docs/ai structure changes.
|
|
13
|
+
export const EXPECTED_WORKFLOW_VERSION = '1.3.0';
|
|
14
|
+
export const SETTINGS_FILE = '.claude/settings.json';
|
|
15
|
+
export const SETTINGS_LOCAL_FILE = '.claude/settings.local.json';
|
|
16
|
+
export const CLAUDE_DIR = '.claude';
|
|
17
|
+
export const WORKFLOW_STAMP = 'docs/ai/.workflow-version';
|
|
18
|
+
export const SAFE_DEFAULT_MODES = Object.freeze(['default', 'acceptEdits', 'plan']);
|
|
19
|
+
export const UNSAFE_BYPASS_MODE = 'bypassPermissions';
|
|
20
|
+
export const ACCEPT_EDITS_MODE = 'acceptEdits';
|
|
21
|
+
|
|
22
|
+
// The audited read-only core. Every entry is a Claude Code Bash allow pattern whose command runs NO
|
|
23
|
+
// mutating operation and NO inline arbitrary code execution through its OWN flags (verified
|
|
24
|
+
// empirically, not assumed) — the only own-flag residual is a bounded WRITE via `--output`
|
|
25
|
+
// (git diff/log/show). (The SEPARATE settings-level residual — redirection writes + command
|
|
26
|
+
// substitution exec — is a property of Claude Code's allow-rule parsing, documented at
|
|
27
|
+
// SHELL_METACHARACTERS below.) Commands with an inline write/exec flag are deliberately EXCLUDED, e.g. `git grep`
|
|
28
|
+
// (`--open-files-in-pager=<cmd>` runs a program), `sort` (`-o` writes, `--compress-program=<cmd>`
|
|
29
|
+
// runs a program), `echo`/`find` (redirect-/`-delete`-writable), `gh` (`gh api` can POST),
|
|
30
|
+
// `node`/`npx`/`npm run`/`npm install`/`npm pack` (arbitrary/lifecycle code), bare `git`/`npm`.
|
|
31
|
+
// `git diff`/`git log`/`git show` are kept: their only residual is a BOUNDED WRITE via
|
|
32
|
+
// `--output=<file>` (the same category as shell output redirection, documented below), not code
|
|
33
|
+
// execution — inline `--ext-diff` exec needs a `-c diff.external=` / env prefix that breaks the
|
|
34
|
+
// allow-pattern match.
|
|
35
|
+
export const UNIVERSAL_READONLY_ALLOWLIST = Object.freeze([
|
|
36
|
+
'Bash(git status:*)',
|
|
37
|
+
'Bash(git diff:*)',
|
|
38
|
+
'Bash(git log:*)',
|
|
39
|
+
'Bash(git show:*)',
|
|
40
|
+
'Bash(git ls-files:*)',
|
|
41
|
+
'Bash(git check-ignore:*)',
|
|
42
|
+
'Bash(git branch --list:*)',
|
|
43
|
+
'Bash(npm view:*)',
|
|
44
|
+
'Bash(npm ls:*)',
|
|
45
|
+
'Bash(npm outdated:*)',
|
|
46
|
+
'Bash(ls:*)',
|
|
47
|
+
'Bash(cat:*)',
|
|
48
|
+
'Bash(head:*)',
|
|
49
|
+
'Bash(tail:*)',
|
|
50
|
+
'Bash(wc:*)',
|
|
51
|
+
'Bash(readlink:*)',
|
|
52
|
+
'Bash(which:*)',
|
|
53
|
+
'Bash(grep:*)',
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
// Per-tool POSITIVE allowlists, as frozen arrays. NOTE: `Object.freeze` on a Set does NOT prevent
|
|
57
|
+
// `.add()` (it only freezes own properties), so an exported frozen Set could be mutated at runtime to
|
|
58
|
+
// widen the screen — frozen arrays + `.includes()` are genuinely immutable in ESM strict mode.
|
|
59
|
+
export const GIT_READONLY_SUBCOMMANDS = Object.freeze([
|
|
60
|
+
'status',
|
|
61
|
+
'diff',
|
|
62
|
+
'log',
|
|
63
|
+
'show',
|
|
64
|
+
'ls-files',
|
|
65
|
+
'check-ignore',
|
|
66
|
+
'branch --list',
|
|
67
|
+
]);
|
|
68
|
+
export const NPM_READONLY_SUBCOMMANDS = Object.freeze(['view', 'ls', 'outdated']);
|
|
69
|
+
export const SHELL_READONLY = Object.freeze([
|
|
70
|
+
'ls',
|
|
71
|
+
'cat',
|
|
72
|
+
'head',
|
|
73
|
+
'tail',
|
|
74
|
+
'wc',
|
|
75
|
+
'readlink',
|
|
76
|
+
'which',
|
|
77
|
+
'grep',
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
// Characters that make a pattern NOT a single read-only command, so the screen rejects them.
|
|
81
|
+
// Per current Claude Code permission semantics: the recognized command SEPARATORS are
|
|
82
|
+
// `&& || ; | |& &` plus newline (each segment must be independently permitted, so a chained
|
|
83
|
+
// `&& git push` is denied). BUT redirections (`>`/`>>`) are NOT separators, and command
|
|
84
|
+
// substitution `$(...)`/backticks are NOT a documented separator either (the published docs are
|
|
85
|
+
// silent on whether substitution is extracted and independently permission-checked — treated here
|
|
86
|
+
// as the worst case: NOT extracted). So a seeded read-only prefix can still WRITE via `cmd > file`,
|
|
87
|
+
// and via substitution `cmd $(other-cmd)` could even RUN another command. The screen rejects any
|
|
88
|
+
// seed pattern carrying these metacharacters so the SEEDED patterns are always single read-only
|
|
89
|
+
// commands; but the RUNTIME residual (redirection, command substitution, a kept command's own
|
|
90
|
+
// `--output=<file>` write flag) means a seeded read-only entry is a TRUST-POSTURE convenience, NOT
|
|
91
|
+
// a sandbox. velocity never ADDS commit/push/publish as allow rules and keeps acceptEdits opt-in;
|
|
92
|
+
// full runtime closure (rejecting substitution + mutating commands at invocation) is the deferred
|
|
93
|
+
// PreToolUse hook (queue.md follow-up), not something settings-level allow rules can enforce.
|
|
94
|
+
export const SHELL_METACHARACTERS = Object.freeze([
|
|
95
|
+
'&', '|', ';', '<', '>', '$', '`', '(', ')',
|
|
96
|
+
'\n', '\r', '\t', '\\', '{', '}', '*', '?', '#', '~', '!',
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
export const VELOCITY_OFFCORE = 'VELOCITY_OFFCORE';
|
|
100
|
+
export const VELOCITY_NON_READONLY = 'VELOCITY_NON_READONLY';
|
|
101
|
+
export const VELOCITY_INVALID_ARGUMENT = 'VELOCITY_INVALID_ARGUMENT';
|
|
102
|
+
export const VELOCITY_STAMP = 'VELOCITY_STAMP';
|
|
103
|
+
export const VELOCITY_UNSAFE_MODE = 'VELOCITY_UNSAFE_MODE';
|
|
104
|
+
export const VELOCITY_MALFORMED = 'VELOCITY_MALFORMED';
|
|
105
|
+
export const VELOCITY_SYMLINK = 'VELOCITY_SYMLINK';
|
|
106
|
+
|
|
107
|
+
const VELOCITY_ERROR_NAME = 'VelocityProfileError';
|
|
108
|
+
const ERROR_PREFIX = '[agent-workflow-kit]';
|
|
109
|
+
const BASH_ALLOW_PATTERN = /^Bash\((.+):\*\)$/u;
|
|
110
|
+
const BASH_PERMISSION_PATTERN = /^Bash\(.+\)$/u;
|
|
111
|
+
const WHITESPACE_PATTERN = /\s+/u;
|
|
112
|
+
const GIT_COMMAND = 'git';
|
|
113
|
+
const NPM_COMMAND = 'npm';
|
|
114
|
+
const NPM_RUN_COMMAND = 'npm run';
|
|
115
|
+
const PACKAGE_FILE = 'package.json';
|
|
116
|
+
const SETTINGS_JSON_INDENT = 2;
|
|
117
|
+
const EXIT_USAGE = 2;
|
|
118
|
+
const EXIT_PRECONDITION = 1;
|
|
119
|
+
const EXIT_OK = 0;
|
|
120
|
+
const ENOENT = 'ENOENT';
|
|
121
|
+
const UTF8 = 'utf8';
|
|
122
|
+
const LF = '\n';
|
|
123
|
+
const CRLF = '\r\n';
|
|
124
|
+
const JSON_NEWLINE_PATTERN = /\n/gu;
|
|
125
|
+
const FLAG_DRY_RUN = '--dry-run';
|
|
126
|
+
const FLAG_APPLY = '--apply';
|
|
127
|
+
const FLAG_ACCEPT_EDITS = '--accept-edits';
|
|
128
|
+
const FLAG_CWD = '--cwd';
|
|
129
|
+
const FLAG_HELP = '--help';
|
|
130
|
+
const SHORT_FLAG_HELP = '-h';
|
|
131
|
+
const DO_NOT_ADD_WARNING = 'do not add';
|
|
132
|
+
const ADD_BY_HAND = true;
|
|
133
|
+
const MUTATING_SCRIPT_NAME_PATTERN = /(release|publish|deploy|push|version|commit|tag)/iu;
|
|
134
|
+
const MUTATING_SCRIPT_HOOK_PATTERN = /^(pre|post)/iu;
|
|
135
|
+
// The one thing velocity must never seed — an explicit, greppable expression of the load-bearing
|
|
136
|
+
// invariant (kept deliberately even though the read-only screen already rejects these, so the
|
|
137
|
+
// refusal is named, tested, and produces a clear message).
|
|
138
|
+
const MUTATING_ALLOW_COMMAND_PATTERN = /^(?:git\s+(?:commit|push)|npm\s+publish)(?:\s|$)/iu;
|
|
139
|
+
const RESIDUAL_NOTICE =
|
|
140
|
+
'residual: seeded read-only allow entries are a trust-posture convenience, NOT a sandbox; settings-level rules cannot inspect runtime redirection/command-substitution; commit/push/publish are never allowlisted (a DIRECT invocation still ASKs, but the substitution/redirection residual is not closed here); full closure is the deferred PreToolUse hook.';
|
|
141
|
+
|
|
142
|
+
const USAGE = `usage: velocity-profile [--dry-run | --apply] [--accept-edits] [--cwd <dir>] [--help]
|
|
143
|
+
|
|
144
|
+
Seeds the fixed read-only Claude Code allowlist into .claude/settings.json.
|
|
145
|
+
Default is --dry-run. --apply writes; --accept-edits only sets defaultMode when applying.`;
|
|
146
|
+
|
|
147
|
+
const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
|
|
148
|
+
|
|
149
|
+
export const makeVelocityProfileError = (code, message, fields = {}) =>
|
|
150
|
+
Object.assign(new Error(`${ERROR_PREFIX} ${message}`), { name: VELOCITY_ERROR_NAME, code, ...fields });
|
|
151
|
+
|
|
152
|
+
const fsDeps = (deps = {}) => ({
|
|
153
|
+
exists: deps.exists ?? deps.existsSync ?? existsSync,
|
|
154
|
+
lstat: deps.lstat ?? deps.lstatSync ?? lstatSync,
|
|
155
|
+
mkdir: deps.mkdir ?? deps.mkdirSync ?? mkdirSync,
|
|
156
|
+
readFile: deps.readFile ?? deps.readFileSync ?? readFileSync,
|
|
157
|
+
writeFile: deps.writeFile ?? deps.writeFileSync ?? writeFileSync,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const lstatNoFollow = (absPath, fs) => {
|
|
161
|
+
try {
|
|
162
|
+
return fs.lstat(absPath);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (err && err.code === ENOENT) return null;
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const relativeToCwd = (absPath, cwd) => (cwd ? relative(cwd, absPath) || '.' : absPath);
|
|
170
|
+
|
|
171
|
+
const isJsonObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
172
|
+
|
|
173
|
+
const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
|
174
|
+
|
|
175
|
+
const readFileLoud = (absPath, relPath, fs) => {
|
|
176
|
+
try {
|
|
177
|
+
return fs.readFile(absPath, UTF8);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
throw makeVelocityProfileError(VELOCITY_MALFORMED, `${relPath}: unreadable (${err.code ?? err.message})`);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const parseJsonLoud = (raw, relPath) => {
|
|
184
|
+
try {
|
|
185
|
+
return JSON.parse(raw);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
throw makeVelocityProfileError(VELOCITY_MALFORMED, `${relPath}: malformed JSON (${err.message})`);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const validateSettingsShape = (data, relPath) => {
|
|
192
|
+
if (!isJsonObject(data)) {
|
|
193
|
+
throw makeVelocityProfileError(VELOCITY_MALFORMED, `${relPath}: root must be a JSON object`);
|
|
194
|
+
}
|
|
195
|
+
if (data.permissions !== undefined && !isJsonObject(data.permissions)) {
|
|
196
|
+
throw makeVelocityProfileError(VELOCITY_MALFORMED, `${relPath}: permissions must be a JSON object`);
|
|
197
|
+
}
|
|
198
|
+
if (data.permissions?.allow !== undefined && !Array.isArray(data.permissions.allow)) {
|
|
199
|
+
throw makeVelocityProfileError(VELOCITY_MALFORMED, `${relPath}: permissions.allow must be an array`);
|
|
200
|
+
}
|
|
201
|
+
return data;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const getPermissions = (data) => (isJsonObject(data?.permissions) ? data.permissions : {});
|
|
205
|
+
|
|
206
|
+
const getAllowEntries = (data) => {
|
|
207
|
+
const allow = getPermissions(data).allow;
|
|
208
|
+
return Array.isArray(allow) ? allow : [];
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const getDefaultMode = (data) => {
|
|
212
|
+
const permissions = getPermissions(data);
|
|
213
|
+
return hasOwn(permissions, 'defaultMode')
|
|
214
|
+
? { present: true, value: permissions.defaultMode }
|
|
215
|
+
: { present: false, value: undefined };
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const hasShellMetacharacter = (cmd) => SHELL_METACHARACTERS.some((ch) => cmd.includes(ch));
|
|
219
|
+
|
|
220
|
+
const tokenizeCommand = (cmd) => cmd.trim().split(WHITESPACE_PATTERN).filter(Boolean);
|
|
221
|
+
|
|
222
|
+
const getSubcommand = (tokens) => tokens.slice(1).join(' ');
|
|
223
|
+
|
|
224
|
+
const getBashAllowCommand = (pattern) => {
|
|
225
|
+
if (typeof pattern !== 'string') return undefined;
|
|
226
|
+
const match = pattern.match(BASH_ALLOW_PATTERN);
|
|
227
|
+
return match ? match[1] : undefined;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const isSingleShellToken = (cmd, tokens) => tokens.length === 1 && cmd === tokens[0];
|
|
231
|
+
|
|
232
|
+
const isScriptMap = (scripts) => Boolean(scripts) && typeof scripts === 'object' && !Array.isArray(scripts);
|
|
233
|
+
|
|
234
|
+
const isMutatingScriptName = (name) =>
|
|
235
|
+
MUTATING_SCRIPT_NAME_PATTERN.test(name) || MUTATING_SCRIPT_HOOK_PATTERN.test(name);
|
|
236
|
+
|
|
237
|
+
const makeGateCandidate = (name) => ({
|
|
238
|
+
command: `${NPM_RUN_COMMAND} ${name}`,
|
|
239
|
+
addByHand: ADD_BY_HAND,
|
|
240
|
+
...(isMutatingScriptName(name) ? { warn: DO_NOT_ADD_WARNING } : {}),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const matchesMutatingAllowCommand = (entry) => {
|
|
244
|
+
const cmd = getBashAllowCommand(entry);
|
|
245
|
+
return Boolean(cmd) && MUTATING_ALLOW_COMMAND_PATTERN.test(cmd.trim());
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const readStamp = (absPath, deps = {}) => {
|
|
249
|
+
const fs = fsDeps(deps);
|
|
250
|
+
try {
|
|
251
|
+
if (!fs.exists(absPath)) return null;
|
|
252
|
+
const stamp = String(fs.readFile(absPath, UTF8)).trim();
|
|
253
|
+
return stamp.length ? stamp : null;
|
|
254
|
+
} catch {
|
|
255
|
+
// An unreadable stamp == not a valid deployment stamp; --apply STOPs ("found none") while
|
|
256
|
+
// --dry-run stays usable on any project (the stamp is enforced only on the write path).
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const readPackageJson = (cwd, deps = {}) => {
|
|
262
|
+
const fs = fsDeps(deps);
|
|
263
|
+
const absPath = join(cwd, PACKAGE_FILE);
|
|
264
|
+
if (!fs.exists(absPath)) return null;
|
|
265
|
+
// package.json feeds ONLY the read-only gate advisory — a malformed one degrades to "no
|
|
266
|
+
// candidates"; it must NEVER block the settings write.
|
|
267
|
+
try {
|
|
268
|
+
return JSON.parse(fs.readFile(absPath, UTF8));
|
|
269
|
+
} catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const assertClaudeDirSafe = (cwd, deps = {}) => {
|
|
275
|
+
const fs = fsDeps(deps);
|
|
276
|
+
const absPath = join(cwd, CLAUDE_DIR);
|
|
277
|
+
const stat = (() => {
|
|
278
|
+
try {
|
|
279
|
+
return lstatNoFollow(absPath, fs);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
throw makeVelocityProfileError(VELOCITY_SYMLINK, `${CLAUDE_DIR}: unreadable (${err.code ?? err.message})`);
|
|
282
|
+
}
|
|
283
|
+
})();
|
|
284
|
+
if (stat === null) return { claudeDirAbsent: true };
|
|
285
|
+
if (stat.isSymbolicLink()) {
|
|
286
|
+
throw makeVelocityProfileError(VELOCITY_SYMLINK, `${CLAUDE_DIR} is a symlink - refusing to write through it`);
|
|
287
|
+
}
|
|
288
|
+
if (!stat.isDirectory()) {
|
|
289
|
+
throw makeVelocityProfileError(VELOCITY_SYMLINK, `${CLAUDE_DIR} exists but is not a directory - refusing to write through it`);
|
|
290
|
+
}
|
|
291
|
+
return { claudeDirAbsent: false };
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const collectPreExistingNonReadonly = (sources) =>
|
|
295
|
+
sources.flatMap(({ source, data }) =>
|
|
296
|
+
getAllowEntries(data)
|
|
297
|
+
.filter((entry) => typeof entry === 'string' && BASH_PERMISSION_PATTERN.test(entry))
|
|
298
|
+
.filter((entry) => !screenAllowlistEntry(entry))
|
|
299
|
+
.map((entry) => ({ source, entry })),
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const assertTargetWritable = (absPath, deps = {}) => {
|
|
303
|
+
const fs = fsDeps(deps);
|
|
304
|
+
const stat = lstatNoFollow(absPath, fs);
|
|
305
|
+
if (stat !== null && !stat.isFile()) {
|
|
306
|
+
throw makeVelocityProfileError(
|
|
307
|
+
VELOCITY_SYMLINK,
|
|
308
|
+
`${SETTINGS_FILE} exists but is not a regular file - refusing to clobber it`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const formatJson = (data, eol) => `${JSON.stringify(data, null, SETTINGS_JSON_INDENT).replace(JSON_NEWLINE_PATTERN, eol)}${eol}`;
|
|
314
|
+
|
|
315
|
+
const mergeProjectSettings = (projectData, toAdd, acceptEdits) => {
|
|
316
|
+
const base = projectData ?? {};
|
|
317
|
+
const permissions = getPermissions(base);
|
|
318
|
+
const allow = getAllowEntries(base);
|
|
319
|
+
const mergedAllow = [...allow, ...toAdd.filter((entry) => !allow.includes(entry))];
|
|
320
|
+
const mergedPermissions = {
|
|
321
|
+
...permissions,
|
|
322
|
+
allow: mergedAllow,
|
|
323
|
+
...(acceptEdits === true ? { defaultMode: ACCEPT_EDITS_MODE } : {}),
|
|
324
|
+
};
|
|
325
|
+
return { ...base, permissions: mergedPermissions };
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const formatEntryList = (entries) => (entries.length ? entries.map((entry) => ` - ${entry}`) : [' - (none)']);
|
|
329
|
+
|
|
330
|
+
const formatAllowlist = (result) => [
|
|
331
|
+
`${result.wrote ? 'added' : 'would add'} read-only core entries: ${result.toAdd.length}`,
|
|
332
|
+
...formatEntryList(result.toAdd),
|
|
333
|
+
`already present: ${result.alreadyPresent.length}`,
|
|
334
|
+
];
|
|
335
|
+
|
|
336
|
+
const formatGateAdvisory = (gateCandidates) => [
|
|
337
|
+
'gate advisory: candidates you may add BY HAND to .claude/settings.json or settings.local.json; this tool will NOT add them',
|
|
338
|
+
...(gateCandidates.length
|
|
339
|
+
? gateCandidates.map((candidate) => ` - ${candidate.command}${candidate.warn ? ` (${candidate.warn})` : ''}`)
|
|
340
|
+
: [' - (none)']),
|
|
341
|
+
];
|
|
342
|
+
|
|
343
|
+
const formatPreExistingAdvisory = (preExistingNonReadonly) =>
|
|
344
|
+
preExistingNonReadonly.length
|
|
345
|
+
? [
|
|
346
|
+
'pre-existing non-read-only Bash allow entries: consider removing by hand',
|
|
347
|
+
...preExistingNonReadonly.map(({ source, entry }) => ` - ${source}: ${entry}`),
|
|
348
|
+
]
|
|
349
|
+
: [];
|
|
350
|
+
|
|
351
|
+
const formatDefaultMode = (result) =>
|
|
352
|
+
result.wrote
|
|
353
|
+
? `defaultMode: ${result.setsDefaultMode ? `set to ${ACCEPT_EDITS_MODE}` : 'not set by this run'}`
|
|
354
|
+
: `defaultMode: ${result.setsDefaultMode ? `would set to ${ACCEPT_EDITS_MODE}` : 'would not be set by this run'}`;
|
|
355
|
+
|
|
356
|
+
const formatVelocityProfileResult = (result) =>
|
|
357
|
+
[
|
|
358
|
+
result.dryRun ? 'agent-workflow velocity profile - DRY RUN (no changes)' : 'agent-workflow velocity profile - APPLY',
|
|
359
|
+
...formatAllowlist(result),
|
|
360
|
+
formatDefaultMode(result),
|
|
361
|
+
...formatGateAdvisory(result.gateCandidates),
|
|
362
|
+
...formatPreExistingAdvisory(result.preExistingNonReadonly),
|
|
363
|
+
RESIDUAL_NOTICE,
|
|
364
|
+
].join(LF);
|
|
365
|
+
|
|
366
|
+
// Usage errors from `fail` carry an explicit exitCode (2); every other thrown error — including all
|
|
367
|
+
// VELOCITY_* precondition errors — maps to the precondition exit (1).
|
|
368
|
+
const exitCodeFor = (err) => err?.exitCode ?? EXIT_PRECONDITION;
|
|
369
|
+
|
|
370
|
+
// `:*` is the trailing word-boundary wildcard: equivalent to a trailing ` *`, recognized only at the
|
|
371
|
+
// end of a pattern per Claude Code semantics.
|
|
372
|
+
export const screenAllowlistEntry = (pattern) => {
|
|
373
|
+
const cmd = getBashAllowCommand(pattern);
|
|
374
|
+
if (!cmd) return false;
|
|
375
|
+
if (hasShellMetacharacter(cmd)) return false;
|
|
376
|
+
|
|
377
|
+
const tokens = tokenizeCommand(cmd);
|
|
378
|
+
if (tokens[0] === GIT_COMMAND) return GIT_READONLY_SUBCOMMANDS.includes(getSubcommand(tokens));
|
|
379
|
+
if (tokens[0] === NPM_COMMAND) return NPM_READONLY_SUBCOMMANDS.includes(getSubcommand(tokens));
|
|
380
|
+
return isSingleShellToken(cmd, tokens) && SHELL_READONLY.includes(cmd);
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
export const discoverGateCandidates = (packageJson) => {
|
|
384
|
+
const scripts = packageJson?.scripts;
|
|
385
|
+
if (!isScriptMap(scripts)) return [];
|
|
386
|
+
return Object.keys(scripts).map(makeGateCandidate);
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Validate that what we are about to write is a subset of the audited read-only core: every entry
|
|
391
|
+
* must (a) NOT be a commit/push/publish allow entry, (b) pass the read-only screen, and (c) be a
|
|
392
|
+
* member of UNIVERSAL_READONLY_ALLOWLIST (a guard against the constant drifting). Pure; owns no exit
|
|
393
|
+
* codes — a CLI maps the typed `.code` to a process exit.
|
|
394
|
+
*/
|
|
395
|
+
export const validateProfile = (allowEntries = UNIVERSAL_READONLY_ALLOWLIST) => {
|
|
396
|
+
if (!Array.isArray(allowEntries)) {
|
|
397
|
+
throw makeVelocityProfileError(VELOCITY_INVALID_ARGUMENT, 'allow entries must be an array', { entry: allowEntries });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
for (const entry of allowEntries) {
|
|
401
|
+
if (matchesMutatingAllowCommand(entry)) {
|
|
402
|
+
throw makeVelocityProfileError(VELOCITY_NON_READONLY, `refuses to seed a commit/push/publish allow entry: ${entry}`, { entry });
|
|
403
|
+
}
|
|
404
|
+
if (!screenAllowlistEntry(entry)) {
|
|
405
|
+
throw makeVelocityProfileError(VELOCITY_NON_READONLY, `not a read-only allow entry: ${entry}`, { entry });
|
|
406
|
+
}
|
|
407
|
+
if (!UNIVERSAL_READONLY_ALLOWLIST.includes(entry)) {
|
|
408
|
+
throw makeVelocityProfileError(VELOCITY_OFFCORE, `allow entry is outside the audited core: ${entry}`, { entry });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return { ok: true, count: allowEntries.length };
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
export const readSettingsFile = (absPath, deps = {}) => {
|
|
416
|
+
const fs = fsDeps(deps);
|
|
417
|
+
const relPath = relativeToCwd(absPath, deps.cwd);
|
|
418
|
+
const stat = lstatNoFollow(absPath, fs);
|
|
419
|
+
if (stat === null) return { present: false };
|
|
420
|
+
const raw = readFileLoud(absPath, relPath, fs);
|
|
421
|
+
const data = validateSettingsShape(parseJsonLoud(raw, relPath), relPath);
|
|
422
|
+
return { present: true, data, eol: raw.includes(CRLF) ? CRLF : LF };
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const isUnsafeMode = (mode) => mode !== undefined && !SAFE_DEFAULT_MODES.includes(mode);
|
|
426
|
+
|
|
427
|
+
export const resolveEffectiveMode = (projectData, localData) => {
|
|
428
|
+
const projectMode = getDefaultMode(projectData);
|
|
429
|
+
const localMode = getDefaultMode(localData);
|
|
430
|
+
const effectiveMode = localMode.present ? localMode.value : projectMode.value;
|
|
431
|
+
return {
|
|
432
|
+
effectiveMode,
|
|
433
|
+
// Presence-based, NOT effective-based: a SAFE local override must not mask a committed unsafe
|
|
434
|
+
// mode in settings.json (merge-don't-clobber would otherwise preserve it for everyone on write).
|
|
435
|
+
bypassPermissionsPresent: projectMode.value === UNSAFE_BYPASS_MODE || localMode.value === UNSAFE_BYPASS_MODE,
|
|
436
|
+
unsafeModePresent: isUnsafeMode(projectMode.value) || isUnsafeMode(localMode.value),
|
|
437
|
+
};
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
export const preflightVelocityProfile = ({ cwd }, deps = {}) => {
|
|
441
|
+
const projectDir = cwd ?? deps.cwd ?? process.cwd();
|
|
442
|
+
const stamp = readStamp(join(projectDir, WORKFLOW_STAMP), { ...deps, cwd: projectDir });
|
|
443
|
+
const stampOk = stamp === EXPECTED_WORKFLOW_VERSION;
|
|
444
|
+
const { claudeDirAbsent } = assertClaudeDirSafe(projectDir, deps);
|
|
445
|
+
// A symlinked / non-regular settings.json STOPs on BOTH dry-run and apply (read-only check here),
|
|
446
|
+
// so a dry-run never promises a write the apply would refuse.
|
|
447
|
+
assertTargetWritable(join(projectDir, SETTINGS_FILE), deps);
|
|
448
|
+
const projectSettings = readSettingsFile(join(projectDir, SETTINGS_FILE), { ...deps, cwd: projectDir });
|
|
449
|
+
const localSettings = readSettingsFile(join(projectDir, SETTINGS_LOCAL_FILE), { ...deps, cwd: projectDir });
|
|
450
|
+
const { effectiveMode, bypassPermissionsPresent, unsafeModePresent } = resolveEffectiveMode(projectSettings.data, localSettings.data);
|
|
451
|
+
|
|
452
|
+
if (bypassPermissionsPresent) {
|
|
453
|
+
throw makeVelocityProfileError(
|
|
454
|
+
VELOCITY_UNSAFE_MODE,
|
|
455
|
+
`${UNSAFE_BYPASS_MODE} appears in Claude settings - refusing because it auto-approves Bash`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
if (unsafeModePresent) {
|
|
459
|
+
throw makeVelocityProfileError(
|
|
460
|
+
VELOCITY_UNSAFE_MODE,
|
|
461
|
+
`an unsafe or unknown permissions.defaultMode is present in Claude settings - accepted modes: ${SAFE_DEFAULT_MODES.join(', ')}`,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const preExistingNonReadonly = collectPreExistingNonReadonly([
|
|
466
|
+
{ source: SETTINGS_FILE, data: projectSettings.data },
|
|
467
|
+
{ source: SETTINGS_LOCAL_FILE, data: localSettings.data },
|
|
468
|
+
]);
|
|
469
|
+
const gateCandidates = discoverGateCandidates(readPackageJson(projectDir, deps));
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
cwd: projectDir,
|
|
473
|
+
stamp,
|
|
474
|
+
stampOk,
|
|
475
|
+
claudeDirAbsent,
|
|
476
|
+
projectSettings,
|
|
477
|
+
localSettings,
|
|
478
|
+
effectiveMode,
|
|
479
|
+
bypassPermissionsPresent,
|
|
480
|
+
preExistingNonReadonly,
|
|
481
|
+
gateCandidates,
|
|
482
|
+
};
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
export const planVelocityProfile = (preflight, { acceptEdits } = {}) => {
|
|
486
|
+
const projectAllow = getAllowEntries(preflight.projectSettings?.data);
|
|
487
|
+
const toAdd = UNIVERSAL_READONLY_ALLOWLIST.filter((entry) => !projectAllow.includes(entry));
|
|
488
|
+
const alreadyPresent = UNIVERSAL_READONLY_ALLOWLIST.filter((entry) => projectAllow.includes(entry));
|
|
489
|
+
return { toAdd, alreadyPresent, setsDefaultMode: acceptEdits === true };
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true } = {}, deps = {}) => {
|
|
493
|
+
const projectDir = cwd ?? deps.cwd ?? process.cwd();
|
|
494
|
+
const preflight = preflightVelocityProfile({ cwd: projectDir }, deps);
|
|
495
|
+
const plan = planVelocityProfile(preflight, { acceptEdits });
|
|
496
|
+
// Drift guard runs on BOTH dry-run and apply (so a dry-run faithfully predicts the apply) and
|
|
497
|
+
// validates the FULL audited core, not just the to-add delta — a drifted core entry is caught even
|
|
498
|
+
// when it is already present in the user's allow list (and toAdd is a subset of the core anyway).
|
|
499
|
+
validateProfile();
|
|
500
|
+
const resultBase = { ...preflight, ...plan };
|
|
501
|
+
if (dryRun) return { wrote: false, dryRun: true, ...resultBase };
|
|
502
|
+
|
|
503
|
+
if (!preflight.stampOk) {
|
|
504
|
+
throw makeVelocityProfileError(
|
|
505
|
+
VELOCITY_STAMP,
|
|
506
|
+
`not a deployed agent-workflow project at lineage ${EXPECTED_WORKFLOW_VERSION} (found ${preflight.stamp ?? 'none'}) - run init/upgrade first`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const fs = fsDeps(deps);
|
|
511
|
+
const settingsPath = join(projectDir, SETTINGS_FILE);
|
|
512
|
+
if (preflight.claudeDirAbsent) fs.mkdir(join(projectDir, CLAUDE_DIR), { recursive: true });
|
|
513
|
+
const merged = mergeProjectSettings(preflight.projectSettings.data, plan.toAdd, acceptEdits);
|
|
514
|
+
fs.writeFile(settingsPath, formatJson(merged, preflight.projectSettings.eol ?? LF), UTF8);
|
|
515
|
+
return { wrote: true, dryRun: false, settingsPath, ...resultBase };
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
export const parseArgs = (argv) => {
|
|
519
|
+
const parsed = argv.reduce(
|
|
520
|
+
(state, arg, index, allArgs) => {
|
|
521
|
+
if (state.skipNext) return { ...state, skipNext: false };
|
|
522
|
+
if (arg === FLAG_HELP || arg === SHORT_FLAG_HELP) return { ...state, help: true };
|
|
523
|
+
if (arg === FLAG_DRY_RUN) return { ...state, dryRunFlag: true };
|
|
524
|
+
if (arg === FLAG_APPLY) return { ...state, apply: true };
|
|
525
|
+
if (arg === FLAG_ACCEPT_EDITS) return { ...state, acceptEdits: true };
|
|
526
|
+
if (arg === FLAG_CWD) {
|
|
527
|
+
const next = allArgs[index + 1];
|
|
528
|
+
if (next === undefined || next.startsWith('-')) throw fail(EXIT_USAGE, `${FLAG_CWD} needs a directory argument`);
|
|
529
|
+
return { ...state, cwd: next, skipNext: true };
|
|
530
|
+
}
|
|
531
|
+
if (arg.startsWith('-')) throw fail(EXIT_USAGE, `unknown flag: ${arg}`);
|
|
532
|
+
throw fail(EXIT_USAGE, `unexpected argument: ${arg}`);
|
|
533
|
+
},
|
|
534
|
+
{ help: false, dryRunFlag: false, apply: false, acceptEdits: false, cwd: undefined, skipNext: false },
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
if (parsed.dryRunFlag && parsed.apply) throw fail(EXIT_USAGE, `${FLAG_DRY_RUN} and ${FLAG_APPLY} cannot be used together`);
|
|
538
|
+
return {
|
|
539
|
+
help: parsed.help,
|
|
540
|
+
dryRun: parsed.apply ? false : true,
|
|
541
|
+
apply: parsed.apply,
|
|
542
|
+
acceptEdits: parsed.acceptEdits,
|
|
543
|
+
cwd: parsed.cwd,
|
|
544
|
+
};
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
548
|
+
const log = deps.log ?? console.log;
|
|
549
|
+
const errlog = deps.errlog ?? console.error;
|
|
550
|
+
try {
|
|
551
|
+
const args = parseArgs(argv);
|
|
552
|
+
if (args.help) {
|
|
553
|
+
log(USAGE);
|
|
554
|
+
return EXIT_OK;
|
|
555
|
+
}
|
|
556
|
+
const result = writeVelocityProfile(
|
|
557
|
+
{ cwd: args.cwd ?? deps.cwd ?? process.cwd(), acceptEdits: args.acceptEdits, dryRun: args.dryRun },
|
|
558
|
+
deps,
|
|
559
|
+
);
|
|
560
|
+
log(formatVelocityProfileResult(result));
|
|
561
|
+
return EXIT_OK;
|
|
562
|
+
} catch (err) {
|
|
563
|
+
const exitCode = exitCodeFor(err);
|
|
564
|
+
errlog(err?.message ?? String(err));
|
|
565
|
+
if (exitCode === EXIT_USAGE) errlog(USAGE);
|
|
566
|
+
return exitCode;
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
export const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
571
|
+
if (isDirectRun) process.exit(main(process.argv.slice(2)));
|