@getmarrow/mcp 3.9.95 → 3.9.97
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/README.md +11 -1
- package/dist/cli.js +64 -1
- package/dist/cli.js.map +1 -1
- package/dist/hook-contract.d.ts +2 -2
- package/dist/hook-contract.js +1 -1
- package/dist/hook-pre-action.d.ts +48 -1
- package/dist/hook-pre-action.d.ts.map +1 -1
- package/dist/hook-pre-action.js +371 -91
- package/dist/hook-pre-action.js.map +1 -1
- package/dist/hook-tool-policy.d.ts +39 -0
- package/dist/hook-tool-policy.d.ts.map +1 -1
- package/dist/hook-tool-policy.js +620 -23
- package/dist/hook-tool-policy.js.map +1 -1
- package/dist/hook.d.ts +2 -0
- package/dist/hook.d.ts.map +1 -1
- package/dist/hook.js +14 -1
- package/dist/hook.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +69 -16
- package/dist/index.js.map +1 -1
- package/dist/lifecycle-spool.d.ts +25 -0
- package/dist/lifecycle-spool.d.ts.map +1 -1
- package/dist/lifecycle-spool.js +117 -19
- package/dist/lifecycle-spool.js.map +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/hook-tool-policy.js
CHANGED
|
@@ -6,7 +6,19 @@ exports.isOfficialMarrowMcpEvent = isOfficialMarrowMcpEvent;
|
|
|
6
6
|
exports.isMcpHookTool = isMcpHookTool;
|
|
7
7
|
exports.isProtectedShellMutation = isProtectedShellMutation;
|
|
8
8
|
exports.hookToolCommand = hookToolCommand;
|
|
9
|
+
exports.parseShellSegments = parseShellSegments;
|
|
10
|
+
exports.normalizedToolPath = normalizedToolPath;
|
|
11
|
+
exports.isSecretPath = isSecretPath;
|
|
12
|
+
exports.isProtectedWriteTarget = isProtectedWriteTarget;
|
|
13
|
+
exports.isSecretVariableName = isSecretVariableName;
|
|
14
|
+
exports.shellSegmentVerdict = shellSegmentVerdict;
|
|
15
|
+
exports.toolTargetPaths = toolTargetPaths;
|
|
16
|
+
exports.isSecretMaterialAccess = isSecretMaterialAccess;
|
|
17
|
+
exports.toolClassificationText = toolClassificationText;
|
|
18
|
+
exports.isShellGovernedTool = isShellGovernedTool;
|
|
9
19
|
exports.isReadOnlyToolEvent = isReadOnlyToolEvent;
|
|
20
|
+
const node_os_1 = require("node:os");
|
|
21
|
+
const node_path_1 = require("node:path");
|
|
10
22
|
const READ_ONLY_TOOLS = new Set([
|
|
11
23
|
'read',
|
|
12
24
|
'read_file',
|
|
@@ -34,11 +46,56 @@ const READ_ONLY_TOOLS = new Set([
|
|
|
34
46
|
'marrow_orient',
|
|
35
47
|
'marrow_ask',
|
|
36
48
|
]);
|
|
49
|
+
// Session-local planning state: no external side effect, so words in a task
|
|
50
|
+
// subject such as "publish" never make the bookkeeping call itself protected.
|
|
51
|
+
const LOCAL_TASK_TOOLS = new Set(['taskcreate', 'taskupdate', 'taskoutput', 'todowrite', 'tasklist', 'taskget', 'todoread']);
|
|
52
|
+
// Tools that read file content; a secret target makes the read protected.
|
|
53
|
+
const CONTENT_READ_TOOLS = new Set(['read', 'read_file', 'notebookread', 'grep']);
|
|
54
|
+
// File-editing tools are judged by the file they write, never by the text they write.
|
|
55
|
+
const FILE_EDIT_TOOLS = new Set(['edit', 'write', 'multiedit', 'search_replace', 'notebookedit', 'write_to_file', 'replace_in_file']);
|
|
56
|
+
const SHELL_TOOLS = new Set(['bash', 'run_terminal_command', 'shell', 'execute_command', 'run_shell_command']);
|
|
37
57
|
const READ_ONLY_BASH_COMMANDS = new Set([
|
|
38
58
|
'read', 'grep', 'rg', 'ls', 'cat', 'find', 'tail', 'head', 'wc', 'file',
|
|
39
59
|
'stat', 'which', 'type', 'echo', 'printf', 'pwd', 'date', 'env', 'printenv',
|
|
40
|
-
'whoami', 'uname',
|
|
60
|
+
'whoami', 'uname', 'jq', 'cd', 'sort', 'cut', 'tr', 'nl', 'du', 'basename',
|
|
61
|
+
'dirname', 'realpath', 'readlink', 'true',
|
|
41
62
|
]);
|
|
63
|
+
// Read-only programs whose arguments are names, text, or metadata targets rather
|
|
64
|
+
// than file content, so a secret-looking argument is not a secret read.
|
|
65
|
+
const NON_CONTENT_BASH_COMMANDS = new Set([
|
|
66
|
+
'ls', 'find', 'stat', 'file', 'du', 'which', 'type', 'echo', 'printf', 'pwd',
|
|
67
|
+
'date', 'whoami', 'uname', 'cd', 'basename', 'dirname', 'realpath', 'readlink',
|
|
68
|
+
'true', 'tr', 'env', 'printenv',
|
|
69
|
+
]);
|
|
70
|
+
const READ_ONLY_TOOLCHAIN_COMMANDS = [
|
|
71
|
+
/^(?:node|npm)\s+(?:-v|--version)$/i,
|
|
72
|
+
/^(?:npm|pnpm|yarn)\s+(?:test|audit(?!\s+fix)|run\s+(?:test|check|lint|typecheck|build))(?:\s|$)/i,
|
|
73
|
+
/^(?:node\s+--test|npx\s+(?:vitest|tsc\s+--noemit)|pytest|python(?:3)?\s+-m\s+(?:pytest|unittest)|cargo\s+(?:test|check)|go\s+test)(?:\s|$)/i,
|
|
74
|
+
/^(?:npm|pnpm)\s+(?:view|info|show|ls|list|outdated|explain|why)(?:\s|$)/i,
|
|
75
|
+
/^yarn\s+(?:info|list|why|outdated)(?:\s|$)/i,
|
|
76
|
+
];
|
|
77
|
+
const READ_ONLY_GIT_SUBCOMMANDS = new Set(['status', 'diff', 'show', 'log', 'branch', 'rev-parse', 'ls-files', 'ls-remote']);
|
|
78
|
+
const READ_ONLY_GH_COMMAND = /^(?:pr\s+(?:view|list|status|diff|checks)|issue\s+(?:view|list|status)|run\s+(?:view|list)|repo\s+view|release\s+(?:view|list)|workflow\s+(?:view|list))(?:\s|$)/i;
|
|
79
|
+
const STANDARD_PROGRAM_PATH = /^\/(?:usr\/)?(?:local\/)?s?bin\/([A-Za-z0-9._-]+)$/;
|
|
80
|
+
const SECRET_STORE_DIRECTORY = /^(?:\.ssh|\.gnupg|\.aws|\.azure|\.kube|\.docker|\.password-store|\.secrets?)$/i;
|
|
81
|
+
// A plainly named secrets/credentials directory holds secret material, but source code may live in one too.
|
|
82
|
+
const NAMED_SECRET_DIRECTORY = /^(?:secrets?|credentials?)$/i;
|
|
83
|
+
const SECRET_FILE = /^(?:\.env(?:\..+)?|.+\.env|\.dev\.vars|\.git-credentials|\.netrc|_netrc|\.npmrc|\.yarnrc\.ya?ml|\.pypirc|\.pgpass|\.my\.cnf|\.htpasswd|\.?credentials\.(?:json|ya?ml|toml|ini|csv|txt|xml)|secrets?\.(?:json|ya?ml|toml|env|txt)|id_(?:rsa|dsa|ecdsa|ed25519)(?:_[A-Za-z0-9]+)?|.+\.(?:pem|key|p12|pfx|jks|keystore|kdbx|ppk))$/i;
|
|
84
|
+
const ENV_TEMPLATE_FILE = /(?:^|\.)env\.(?:example|sample|template|dist|defaults?)$/i;
|
|
85
|
+
const SECRET_KEYWORD_FILE = /(?:^|[._-])(?:secrets?|credentials?|tokens?|api[_-]?keys?|passwords?|private[_-]?keys?)(?:[._-]|$)/i;
|
|
86
|
+
const SOURCE_OR_DOC_FILE = /\.(?:[cm]?[jt]sx?|md|mdx|rst|py|rb|go|rs|java|kt|swift|c|cc|cpp|h|hpp|cs|php|sh|bash|zsh|ps1|sql|html|css|scss|vue|svelte|map|snap|lock)$/i;
|
|
87
|
+
const SECRET_VARIABLE_PART = new Set(['KEY', 'APIKEY', 'TOKEN', 'SECRET', 'PASSWORD', 'PASSWD', 'PAT', 'CREDENTIAL', 'CREDENTIALS', 'AUTH']);
|
|
88
|
+
const GOVERNANCE_CONTROL_FILE = /(?:^|\/)\.marrow\/control\.json$/;
|
|
89
|
+
const MAX_SHELL_ANALYSIS_BYTES = 8192;
|
|
90
|
+
// Characters whose shell meaning the splitter does not model exactly. Bash
|
|
91
|
+
// treats a lone carriage return, other control characters and Unicode spaces
|
|
92
|
+
// or line separators as word characters, so splitting on them could hide one
|
|
93
|
+
// command inside another; such input takes the conservative whole-command path.
|
|
94
|
+
const UNMODELED_SHELL_CHARACTER = /[\u0000-\u0008\u000b-\u001f\u007f-\u00a0\u00ad\u061c\u1680\u180e\u2000-\u200f\u2028-\u202f\u205f-\u206f\u3000\ufeff\ufff9-\ufffb]/;
|
|
95
|
+
// Programs whose options can execute, write, or change what runs; an expanded
|
|
96
|
+
// variable could supply such an option, so they are not read-only with one.
|
|
97
|
+
const OPTION_SENSITIVE_PROGRAMS = new Set(['find', 'rg', 'sort', 'git', 'gh', 'date', 'env', 'file', 'npm', 'pnpm', 'yarn', 'node', 'npx',
|
|
98
|
+
'pytest', 'python', 'python3', 'cargo', 'go']);
|
|
42
99
|
const MUTATION_TOOL_VERB = /(?:^|__|_)(?:create|update|delete|remove|write|edit|send|post|put|patch|execute|run|deploy|publish|merge|push|commit|revoke|rotate|charge|refund|cancel|approve)(?:_|$)/;
|
|
43
100
|
const READ_ONLY_TOOL_VERB = /(?:^|__|_)(?:get|list|read|search|find|fetch|status|inspect|query)(?:_|$)/;
|
|
44
101
|
const PROTECTED_SHELL_MUTATION_FAMILIES = [
|
|
@@ -139,14 +196,558 @@ function hookToolCommand(event) {
|
|
|
139
196
|
return '';
|
|
140
197
|
}
|
|
141
198
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
199
|
+
/**
|
|
200
|
+
* Splits a shell command into simple commands at |, ||, &&, ;, & and newlines,
|
|
201
|
+
* honoring quotes. Returns null for syntax whose effect cannot be judged from
|
|
202
|
+
* the words alone (command or process substitution, subshells, heredocs,
|
|
203
|
+
* complex parameter expansion); callers then fall back to whole-command rules.
|
|
204
|
+
*/
|
|
205
|
+
function parseShellSegments(command) {
|
|
206
|
+
if (command.length > MAX_SHELL_ANALYSIS_BYTES || UNMODELED_SHELL_CHARACTER.test(command))
|
|
207
|
+
return null;
|
|
208
|
+
const segments = [];
|
|
209
|
+
let segment = { words: [], outputs: [], inputs: [], expansions: [] };
|
|
210
|
+
let word = '';
|
|
211
|
+
let inWord = false;
|
|
212
|
+
let redirect = null;
|
|
213
|
+
const finishWord = () => {
|
|
214
|
+
if (!inWord)
|
|
215
|
+
return;
|
|
216
|
+
if (redirect === 'out' || redirect === 'in')
|
|
217
|
+
(redirect === 'out' ? segment.outputs : segment.inputs).push(word);
|
|
218
|
+
else if (redirect) {
|
|
219
|
+
// >&2 and <&0 duplicate descriptors; any other >&target names a file.
|
|
220
|
+
if (!/^(?:\d+|-|\d+-)$/.test(word))
|
|
221
|
+
(redirect === 'dup_out' ? segment.outputs : segment.inputs).push(word);
|
|
222
|
+
}
|
|
223
|
+
else
|
|
224
|
+
segment.words.push(word);
|
|
225
|
+
redirect = null;
|
|
226
|
+
word = '';
|
|
227
|
+
inWord = false;
|
|
228
|
+
};
|
|
229
|
+
const finishSegment = () => {
|
|
230
|
+
finishWord();
|
|
231
|
+
if (redirect)
|
|
232
|
+
return false;
|
|
233
|
+
if (segment.words.length || segment.outputs.length || segment.inputs.length)
|
|
234
|
+
segments.push(segment);
|
|
235
|
+
segment = { words: [], outputs: [], inputs: [], expansions: [] };
|
|
236
|
+
return true;
|
|
237
|
+
};
|
|
238
|
+
// Reads $NAME, ${NAME} or a special parameter at index; returns the consumed length, or -1 when unsupported.
|
|
239
|
+
const expansion = (index) => {
|
|
240
|
+
const rest = command.slice(index);
|
|
241
|
+
// $( runs a command; $'...' and $"..." decode escapes the words would hide.
|
|
242
|
+
if (rest.startsWith('$(') || rest.startsWith("$'") || rest.startsWith('$"'))
|
|
243
|
+
return -1;
|
|
244
|
+
const braced = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}/.exec(rest);
|
|
245
|
+
if (braced) {
|
|
246
|
+
segment.expansions.push(braced[1]);
|
|
247
|
+
word += braced[0];
|
|
248
|
+
inWord = true;
|
|
249
|
+
return braced[0].length;
|
|
250
|
+
}
|
|
251
|
+
if (rest.startsWith('${'))
|
|
252
|
+
return -1;
|
|
253
|
+
const named = /^\$([A-Za-z_][A-Za-z0-9_]*)/.exec(rest);
|
|
254
|
+
if (named) {
|
|
255
|
+
segment.expansions.push(named[1]);
|
|
256
|
+
word += named[0];
|
|
257
|
+
inWord = true;
|
|
258
|
+
return named[0].length;
|
|
259
|
+
}
|
|
260
|
+
word += '$';
|
|
261
|
+
inWord = true;
|
|
262
|
+
return 1;
|
|
263
|
+
};
|
|
264
|
+
let index = 0;
|
|
265
|
+
while (index < command.length) {
|
|
266
|
+
const char = command[index];
|
|
267
|
+
const next = command[index + 1];
|
|
268
|
+
if (char === "'") {
|
|
269
|
+
const end = command.indexOf("'", index + 1);
|
|
270
|
+
if (end < 0)
|
|
271
|
+
return null;
|
|
272
|
+
word += command.slice(index + 1, end);
|
|
273
|
+
inWord = true;
|
|
274
|
+
index = end + 1;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (char === '"') {
|
|
278
|
+
inWord = true;
|
|
279
|
+
index += 1;
|
|
280
|
+
let closed = false;
|
|
281
|
+
while (index < command.length) {
|
|
282
|
+
const inner = command[index];
|
|
283
|
+
if (inner === '"') {
|
|
284
|
+
closed = true;
|
|
285
|
+
index += 1;
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
if (inner === '`')
|
|
289
|
+
return null;
|
|
290
|
+
if (inner === '\\' && index + 1 < command.length && '$`"\\\n'.includes(command[index + 1])) {
|
|
291
|
+
if (command[index + 1] !== '\n')
|
|
292
|
+
word += command[index + 1];
|
|
293
|
+
index += 2;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (inner === '$') {
|
|
297
|
+
const consumed = expansion(index);
|
|
298
|
+
if (consumed < 0)
|
|
299
|
+
return null;
|
|
300
|
+
index += consumed;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
word += inner;
|
|
304
|
+
index += 1;
|
|
305
|
+
}
|
|
306
|
+
if (!closed)
|
|
307
|
+
return null;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (char === '\\') {
|
|
311
|
+
if (next === undefined)
|
|
312
|
+
return null;
|
|
313
|
+
if (next !== '\n') {
|
|
314
|
+
word += next;
|
|
315
|
+
inWord = true;
|
|
316
|
+
}
|
|
317
|
+
index += 2;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
// Braces expand or group commands, so the words would not be what runs.
|
|
321
|
+
if (char === '`' || char === '(' || char === ')' || char === '{')
|
|
322
|
+
return null;
|
|
323
|
+
if (char === '$') {
|
|
324
|
+
const consumed = expansion(index);
|
|
325
|
+
if (consumed < 0)
|
|
326
|
+
return null;
|
|
327
|
+
index += consumed;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (char === ' ' || char === '\t') {
|
|
331
|
+
finishWord();
|
|
332
|
+
index += 1;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (char === '\n' || char === ';') {
|
|
336
|
+
if (!finishSegment())
|
|
337
|
+
return null;
|
|
338
|
+
index += 1;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (char === '|') {
|
|
342
|
+
if (!finishSegment())
|
|
343
|
+
return null;
|
|
344
|
+
index += next === '|' || next === '&' ? 2 : 1;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (char === '&') {
|
|
348
|
+
if (next === '>') {
|
|
349
|
+
finishWord();
|
|
350
|
+
if (redirect)
|
|
351
|
+
return null;
|
|
352
|
+
redirect = 'out';
|
|
353
|
+
index += command[index + 2] === '>' ? 3 : 2;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (!finishSegment())
|
|
357
|
+
return null;
|
|
358
|
+
index += next === '&' ? 2 : 1;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (char === '>' || char === '<') {
|
|
362
|
+
// A digits-only word directly before the operator is a descriptor (2>).
|
|
363
|
+
if (inWord && /^\d+$/.test(word)) {
|
|
364
|
+
word = '';
|
|
365
|
+
inWord = false;
|
|
366
|
+
}
|
|
367
|
+
else
|
|
368
|
+
finishWord();
|
|
369
|
+
if (redirect)
|
|
370
|
+
return null;
|
|
371
|
+
if (char === '<' && next === '<')
|
|
372
|
+
return null;
|
|
373
|
+
if (next === '(')
|
|
374
|
+
return null;
|
|
375
|
+
let cursor = index + 1;
|
|
376
|
+
if (char === '>' && (command[cursor] === '>' || command[cursor] === '|'))
|
|
377
|
+
cursor += 1;
|
|
378
|
+
if (command[cursor] === '&') {
|
|
379
|
+
redirect = char === '>' ? 'dup_out' : 'dup_in';
|
|
380
|
+
cursor += 1;
|
|
381
|
+
}
|
|
382
|
+
else if (char === '<' && command[cursor] === '>') {
|
|
383
|
+
redirect = 'out';
|
|
384
|
+
cursor += 1;
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
redirect = char === '>' ? 'out' : 'in';
|
|
388
|
+
}
|
|
389
|
+
index = cursor;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (char === '#' && !inWord) {
|
|
393
|
+
while (index < command.length && command[index] !== '\n')
|
|
394
|
+
index += 1;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
word += char;
|
|
398
|
+
inWord = true;
|
|
399
|
+
index += 1;
|
|
400
|
+
}
|
|
401
|
+
if (!finishSegment())
|
|
402
|
+
return null;
|
|
403
|
+
return segments;
|
|
404
|
+
}
|
|
405
|
+
function pathCandidates(value) {
|
|
406
|
+
const candidates = [value];
|
|
407
|
+
const assigned = value.indexOf('=');
|
|
408
|
+
if (assigned >= 0)
|
|
409
|
+
candidates.push(value.slice(assigned + 1));
|
|
410
|
+
const revision = value.lastIndexOf(':');
|
|
411
|
+
if (revision >= 0)
|
|
412
|
+
candidates.push(value.slice(revision + 1));
|
|
413
|
+
return candidates.filter(Boolean);
|
|
414
|
+
}
|
|
415
|
+
function homeDirectory() {
|
|
416
|
+
return process.env.HOME || (0, node_os_1.homedir)();
|
|
417
|
+
}
|
|
418
|
+
function expandHome(value) {
|
|
419
|
+
if (value === '~' || value.startsWith('~/'))
|
|
420
|
+
return `${homeDirectory()}${value.slice(1)}`;
|
|
421
|
+
return value.replace(/^\$(?:HOME|\{HOME\})(?=\/|$)/, homeDirectory());
|
|
422
|
+
}
|
|
423
|
+
/** Resolves ~, $HOME, ., .. and repeated slashes against the directory the command runs in. */
|
|
424
|
+
function normalizedToolPath(value, base = process.cwd()) {
|
|
425
|
+
return node_path_1.posix.resolve(base.replace(/\\/g, '/'), expandHome(value.trim().replace(/\\/g, '/')));
|
|
426
|
+
}
|
|
427
|
+
function secretPath(value, base) {
|
|
428
|
+
const cleaned = value.trim().replace(/\\/g, '/');
|
|
429
|
+
if (!cleaned)
|
|
430
|
+
return false;
|
|
431
|
+
const rawName = cleaned.replace(/\/+$/, '').split('/').pop() || '';
|
|
432
|
+
// A bare word such as "token" is usually a search term or argument, not a file.
|
|
433
|
+
const pathLike = cleaned.includes('/') || cleaned.startsWith('~') || rawName.startsWith('.') || /\.[A-Za-z0-9]+$/.test(rawName);
|
|
434
|
+
const path = normalizedToolPath(cleaned, base);
|
|
435
|
+
const parts = path.split('/').filter(Boolean);
|
|
436
|
+
const name = parts[parts.length - 1] || '';
|
|
437
|
+
const directories = pathLike ? parts : parts.slice(0, -1);
|
|
438
|
+
if (directories.some((part) => SECRET_STORE_DIRECTORY.test(part)))
|
|
439
|
+
return true;
|
|
440
|
+
if (!SOURCE_OR_DOC_FILE.test(name) && directories.some((part) => NAMED_SECRET_DIRECTORY.test(part)))
|
|
441
|
+
return true;
|
|
442
|
+
if (/^\/proc\/[^/]+\/environ$/.test(path) || /^\/etc\/g?shadow$/.test(path))
|
|
443
|
+
return true;
|
|
444
|
+
// Claude Code local settings can carry agent keys in their env block.
|
|
445
|
+
if (name === 'settings.local.json' && parts[parts.length - 2] === '.claude')
|
|
446
|
+
return true;
|
|
447
|
+
if (!pathLike)
|
|
448
|
+
return SECRET_FILE.test(name) && !ENV_TEMPLATE_FILE.test(name);
|
|
449
|
+
if (ENV_TEMPLATE_FILE.test(name))
|
|
450
|
+
return false;
|
|
451
|
+
if (SECRET_FILE.test(name))
|
|
452
|
+
return true;
|
|
453
|
+
return SECRET_KEYWORD_FILE.test(name) && !SOURCE_OR_DOC_FILE.test(name);
|
|
454
|
+
}
|
|
455
|
+
// A glob such as .env* or *.pem names secret files without spelling one out.
|
|
456
|
+
function globVariants(value) {
|
|
457
|
+
if (!/[*?[]/.test(value))
|
|
458
|
+
return [value];
|
|
459
|
+
const firstInClass = value.replace(/\[!?\^?([^\]])[^\]]*\]/g, '$1');
|
|
460
|
+
return [value, firstInClass.replace(/[*?]/g, ''), firstInClass.replace(/[*?]/g, 'x')];
|
|
461
|
+
}
|
|
462
|
+
/** True for a path that holds secret or credential material. */
|
|
463
|
+
function isSecretPath(value, base = process.cwd()) {
|
|
464
|
+
return pathCandidates(String(value || '')).flatMap(globVariants).some((candidate) => secretPath(candidate, base));
|
|
465
|
+
}
|
|
466
|
+
/** True for a secret file or Marrow's own local control state. */
|
|
467
|
+
function isProtectedWriteTarget(value, base = process.cwd()) {
|
|
468
|
+
return pathCandidates(String(value || '')).flatMap(globVariants).some((candidate) => secretPath(candidate, base)
|
|
469
|
+
|| GOVERNANCE_CONTROL_FILE.test(normalizedToolPath(candidate, base)));
|
|
470
|
+
}
|
|
471
|
+
/** True for an environment variable name that conventionally holds a secret. */
|
|
472
|
+
function isSecretVariableName(value) {
|
|
473
|
+
return String(value || '').toUpperCase().split('_').some((part) => SECRET_VARIABLE_PART.has(part));
|
|
474
|
+
}
|
|
475
|
+
function programName(value) {
|
|
476
|
+
if (!value.includes('/'))
|
|
477
|
+
return value.toLowerCase();
|
|
478
|
+
const standard = STANDARD_PROGRAM_PATH.exec(value);
|
|
479
|
+
return standard ? standard[1].toLowerCase() : null;
|
|
480
|
+
}
|
|
481
|
+
// Positional file arguments for content readers, skipping the pattern or filter
|
|
482
|
+
// operand of grep, rg and jq, and option values that are not files.
|
|
483
|
+
function contentReadTargets(program, args) {
|
|
484
|
+
const targets = [];
|
|
485
|
+
const patternFirst = ['grep', 'egrep', 'fgrep', 'rg', 'jq'].includes(program);
|
|
486
|
+
const valueOptions = program === 'jq'
|
|
487
|
+
? new Set(['--indent', '--tab'])
|
|
488
|
+
: new Set(['-A', '-B', '-C', '-m', '-d', '-D', '-g', '-t', '-T', '-j', '-M', '--max-count', '--context', '--glob', '--type', '--type-not', '--threads', '--max-columns']);
|
|
489
|
+
let patternSeen = !patternFirst;
|
|
490
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
491
|
+
const arg = args[index];
|
|
492
|
+
if (program === 'jq' && (arg === '--arg' || arg === '--argjson')) {
|
|
493
|
+
index += 2;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (program === 'jq' && (arg === '--slurpfile' || arg === '--rawfile')) {
|
|
497
|
+
if (args[index + 2] !== undefined)
|
|
498
|
+
targets.push(args[index + 2]);
|
|
499
|
+
index += 2;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (['-e', '--regexp', '-f', '--file', '--from-file'].includes(arg)) {
|
|
503
|
+
if (arg !== '-e' && arg !== '--regexp' && args[index + 1] !== undefined)
|
|
504
|
+
targets.push(args[index + 1]);
|
|
505
|
+
patternSeen = true;
|
|
506
|
+
index += 1;
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (valueOptions.has(arg)) {
|
|
510
|
+
index += 1;
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (arg.startsWith('-')) {
|
|
514
|
+
if (arg.includes('='))
|
|
515
|
+
targets.push(arg);
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
if (!patternSeen) {
|
|
519
|
+
patternSeen = true;
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
targets.push(arg);
|
|
523
|
+
}
|
|
524
|
+
return targets;
|
|
525
|
+
}
|
|
526
|
+
// GNU getopt and git accept any unambiguous prefix of a long option.
|
|
527
|
+
function longOptionPrefix(arg, names) {
|
|
528
|
+
if (!arg.startsWith('--'))
|
|
529
|
+
return false;
|
|
530
|
+
const name = arg.slice(2).split('=')[0];
|
|
531
|
+
return name.length > 0 && names.some((candidate) => candidate.startsWith(name));
|
|
532
|
+
}
|
|
533
|
+
const GIT_BRANCH_LIST_FLAGS = new Set(['--list', '-l', '-a', '--all', '-r', '--remotes', '-v', '-vv', '--verbose', '--show-current',
|
|
534
|
+
'-i', '--ignore-case', '--color', '--no-color', '--column', '--no-column', '--omit-empty']);
|
|
535
|
+
const GIT_BRANCH_VALUE_FLAGS = new Set(['--contains', '--no-contains', '--merged', '--no-merged', '--points-at', '--sort', '--format']);
|
|
536
|
+
// `git branch` only lists when every argument is a list option or a --list pattern.
|
|
537
|
+
function gitBranchListsOnly(args) {
|
|
538
|
+
const listing = args.includes('--list') || args.includes('-l');
|
|
539
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
540
|
+
const arg = args[index];
|
|
541
|
+
if (GIT_BRANCH_LIST_FLAGS.has(arg))
|
|
542
|
+
continue;
|
|
543
|
+
if (GIT_BRANCH_VALUE_FLAGS.has(arg)) {
|
|
544
|
+
index += 1;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
if ([...GIT_BRANCH_VALUE_FLAGS].some((flag) => arg.startsWith(`${flag}=`)))
|
|
548
|
+
continue;
|
|
549
|
+
if (!arg.startsWith('-') && listing)
|
|
550
|
+
continue;
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
function readOnlyProgramInvocation(program, args) {
|
|
556
|
+
if (program === 'git') {
|
|
557
|
+
let index = 0;
|
|
558
|
+
while (index < args.length) {
|
|
559
|
+
if (args[index] === '-C' && args[index + 1] !== undefined)
|
|
560
|
+
index += 2;
|
|
561
|
+
else if (args[index] === '--no-pager')
|
|
562
|
+
index += 1;
|
|
563
|
+
else
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
const subcommand = args[index] || '';
|
|
567
|
+
const rest = args.slice(index + 1);
|
|
568
|
+
if (!READ_ONLY_GIT_SUBCOMMANDS.has(subcommand))
|
|
569
|
+
return false;
|
|
570
|
+
if (subcommand === 'branch')
|
|
571
|
+
return gitBranchListsOnly(rest);
|
|
572
|
+
if (subcommand === 'ls-remote')
|
|
573
|
+
return !rest.some((arg) => longOptionPrefix(arg, ['upload-pack', 'exec']));
|
|
574
|
+
return !rest.some((arg) => longOptionPrefix(arg, ['output', 'ext-diff']));
|
|
575
|
+
}
|
|
576
|
+
if (program === 'gh')
|
|
577
|
+
return READ_ONLY_GH_COMMAND.test(args.join(' '));
|
|
578
|
+
if (READ_ONLY_TOOLCHAIN_COMMANDS.some((pattern) => pattern.test([program, ...args].join(' '))))
|
|
579
|
+
return true;
|
|
580
|
+
if (!READ_ONLY_BASH_COMMANDS.has(program))
|
|
581
|
+
return false;
|
|
582
|
+
if (program === 'find')
|
|
583
|
+
return !args.some((arg) => /^-(?:exec|execdir|ok|okdir|delete|fprint0?|fprintf|fls)$/.test(arg));
|
|
584
|
+
if (program === 'rg')
|
|
585
|
+
return !args.some((arg) => longOptionPrefix(arg, ['pre', 'hostname-bin']));
|
|
586
|
+
if (program === 'sort')
|
|
587
|
+
return !args.some((arg) => longOptionPrefix(arg, ['output', 'compress-program']) || /^-[A-Za-z]*o/.test(arg));
|
|
588
|
+
if (program === 'env')
|
|
589
|
+
return args.every((arg) => arg === '-0' || arg === '--null');
|
|
590
|
+
if (program === 'date')
|
|
591
|
+
return !args.some((arg) => /^-[A-Za-z]*s/.test(arg) || longOptionPrefix(arg, ['set']));
|
|
592
|
+
if (program === 'file')
|
|
593
|
+
return !args.some((arg) => /^-[A-Za-z]*C/.test(arg) || longOptionPrefix(arg, ['compile']));
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
/** Classifies one simple command: a read-only inspection, a secret access, or anything else. */
|
|
597
|
+
function shellSegmentVerdict(segment, cwd = process.cwd()) {
|
|
598
|
+
// After a cd Marrow cannot follow, relative paths are unknown, so nothing that follows is read-only.
|
|
599
|
+
const base = cwd ?? process.cwd();
|
|
600
|
+
const secret = (value) => isSecretPath(value, base);
|
|
601
|
+
const protectedTarget = (value) => isProtectedWriteTarget(value, base);
|
|
602
|
+
const writes = segment.outputs.filter((target) => !['/dev/null', '/dev/stdout', '/dev/stderr'].includes(target));
|
|
603
|
+
if (segment.inputs.some(secret) || writes.some(protectedTarget))
|
|
604
|
+
return 'secret';
|
|
605
|
+
if (!segment.words.length)
|
|
606
|
+
return writes.length || cwd === null ? 'other' : 'read';
|
|
607
|
+
// A leading assignment can redirect a pager or helper into arbitrary code.
|
|
608
|
+
const program = /^[A-Za-z_][A-Za-z0-9_]*=/.test(segment.words[0]) ? null : programName(segment.words[0]);
|
|
609
|
+
const args = segment.words.slice(1);
|
|
610
|
+
if (program === 'gh' && /^auth\s+(?:token\b|status\b.*(?:--show-token|-t\b))/.test(args.join(' ')))
|
|
611
|
+
return 'secret';
|
|
612
|
+
const expandedOption = program !== null && OPTION_SENSITIVE_PROGRAMS.has(program) && segment.expansions.length > 0;
|
|
613
|
+
if (program && !expandedOption && readOnlyProgramInvocation(program, args)) {
|
|
614
|
+
// Printing a secret variable copies it into the agent transcript.
|
|
615
|
+
if (segment.expansions.some(isSecretVariableName))
|
|
616
|
+
return 'secret';
|
|
617
|
+
if (program === 'printenv' && args.some(isSecretVariableName))
|
|
618
|
+
return 'secret';
|
|
619
|
+
// A bare env or printenv prints every secret the process holds.
|
|
620
|
+
if ((program === 'env' || program === 'printenv') && args.every((arg) => arg.startsWith('-')))
|
|
621
|
+
return 'secret';
|
|
622
|
+
if (program === 'jq' && args.some((arg) => /\$ENV\b|(?:^|[^A-Za-z0-9_$.])env\b/.test(arg)))
|
|
623
|
+
return 'secret';
|
|
624
|
+
if (program === 'cd' && args.some(secret))
|
|
625
|
+
return 'secret';
|
|
626
|
+
if (!NON_CONTENT_BASH_COMMANDS.has(program)) {
|
|
627
|
+
const targets = ['grep', 'egrep', 'fgrep', 'rg', 'jq'].includes(program) ? contentReadTargets(program, args) : args.filter((arg) => !arg.startsWith('-') || arg.includes('='));
|
|
628
|
+
if (targets.some(secret))
|
|
629
|
+
return 'secret';
|
|
630
|
+
}
|
|
631
|
+
return writes.length || cwd === null ? 'other' : 'read';
|
|
632
|
+
}
|
|
633
|
+
return segment.words.some(protectedTarget) ? 'secret' : 'other';
|
|
634
|
+
}
|
|
635
|
+
// The directory later simple commands run in after this one, or null when it cannot be known.
|
|
636
|
+
function nextWorkingDirectory(segment, cwd) {
|
|
637
|
+
if (cwd === null || programName(segment.words[0] || '') !== 'cd')
|
|
638
|
+
return cwd;
|
|
639
|
+
const targets = segment.words.slice(1).filter((arg) => arg !== '--');
|
|
640
|
+
if (!targets.length)
|
|
641
|
+
return homeDirectory();
|
|
642
|
+
if (targets.length > 1 || targets[0] === '-' || targets[0].startsWith('-') || segment.expansions.some((name) => name !== 'HOME'))
|
|
643
|
+
return null;
|
|
644
|
+
return normalizedToolPath(targets[0], cwd);
|
|
645
|
+
}
|
|
646
|
+
function shellAnalysis(command) {
|
|
647
|
+
const segments = parseShellSegments(command);
|
|
648
|
+
if (!segments)
|
|
649
|
+
return null;
|
|
650
|
+
let cwd = process.cwd();
|
|
651
|
+
const verdicts = segments.map((segment) => {
|
|
652
|
+
const verdict = shellSegmentVerdict(segment, cwd);
|
|
653
|
+
cwd = nextWorkingDirectory(segment, cwd);
|
|
654
|
+
return verdict;
|
|
655
|
+
});
|
|
656
|
+
return { segments, verdicts };
|
|
657
|
+
}
|
|
658
|
+
function stringLeaves(value, depth = 0, output = []) {
|
|
659
|
+
if (output.length >= 64 || depth > 4)
|
|
660
|
+
return output;
|
|
661
|
+
if (typeof value === 'string')
|
|
662
|
+
output.push(value);
|
|
663
|
+
else if (Array.isArray(value))
|
|
664
|
+
value.forEach((item) => stringLeaves(item, depth + 1, output));
|
|
665
|
+
else if (value && typeof value === 'object')
|
|
666
|
+
Object.values(value).forEach((item) => stringLeaves(item, depth + 1, output));
|
|
667
|
+
return output;
|
|
147
668
|
}
|
|
148
|
-
|
|
149
|
-
|
|
669
|
+
/** Files a file-reading or file-editing tool targets, including apply_patch file headers. */
|
|
670
|
+
function toolTargetPaths(event) {
|
|
671
|
+
const paths = [];
|
|
672
|
+
const input = asRecord(event.tool_input);
|
|
673
|
+
if (input) {
|
|
674
|
+
for (const key of ['file_path', 'path', 'target_file', 'filename', 'notebook_path', 'glob']) {
|
|
675
|
+
const value = stringValue(input[key]);
|
|
676
|
+
if (value)
|
|
677
|
+
paths.push(value);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
for (const leaf of stringLeaves(event.tool_input)) {
|
|
681
|
+
if (!leaf.includes('*** Begin Patch'))
|
|
682
|
+
continue;
|
|
683
|
+
for (const match of leaf.matchAll(/^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$/gm))
|
|
684
|
+
paths.push(match[1].trim());
|
|
685
|
+
}
|
|
686
|
+
return [...new Set(paths)];
|
|
687
|
+
}
|
|
688
|
+
// The shell text exactly as the host will run it; hookToolCommand trims, and a
|
|
689
|
+
// trimmed control or Unicode space character must still reach the parser check.
|
|
690
|
+
function shellCommandText(event) {
|
|
691
|
+
if (typeof event.tool_input === 'string')
|
|
692
|
+
return event.tool_input;
|
|
693
|
+
const input = asRecord(event.tool_input);
|
|
694
|
+
for (const key of ['command', 'cmd']) {
|
|
695
|
+
if (typeof input?.[key] === 'string' && String(input[key]).trim())
|
|
696
|
+
return String(input[key]);
|
|
697
|
+
}
|
|
698
|
+
return hookToolCommand(event);
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* True when a tool reads secret or credential material, or writes a secret file
|
|
702
|
+
* or Marrow's local control state. Unparseable shell falls back to a token scan.
|
|
703
|
+
*/
|
|
704
|
+
function isSecretMaterialAccess(event) {
|
|
705
|
+
const tool = normalizeHookToolName(event.tool_name);
|
|
706
|
+
if (CONTENT_READ_TOOLS.has(tool))
|
|
707
|
+
return toolTargetPaths(event).some((path) => isSecretPath(path));
|
|
708
|
+
if (FILE_EDIT_TOOLS.has(tool))
|
|
709
|
+
return toolTargetPaths(event).some((path) => isProtectedWriteTarget(path));
|
|
710
|
+
if (!SHELL_TOOLS.has(tool))
|
|
711
|
+
return false;
|
|
712
|
+
const command = shellCommandText(event);
|
|
713
|
+
const analysis = shellAnalysis(command);
|
|
714
|
+
if (analysis)
|
|
715
|
+
return analysis.verdicts.includes('secret');
|
|
716
|
+
return command.split(/[\s'"`;&|<>(){}]+/).some((token) => isProtectedWriteTarget(token));
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* The text whose words decide a tool's action type, surfaces and keyword
|
|
720
|
+
* protection. Read-only shell segments, task bookkeeping and the content an
|
|
721
|
+
* editing tool writes are excluded: naming a word is not performing it.
|
|
722
|
+
*/
|
|
723
|
+
function toolClassificationText(event) {
|
|
724
|
+
const tool = normalizeHookToolName(event.tool_name);
|
|
725
|
+
if (LOCAL_TASK_TOOLS.has(tool))
|
|
726
|
+
return tool;
|
|
727
|
+
if (FILE_EDIT_TOOLS.has(tool))
|
|
728
|
+
return [tool, ...toolTargetPaths(event)].join(' ');
|
|
729
|
+
const command = hookToolCommand(event);
|
|
730
|
+
if (SHELL_TOOLS.has(tool)) {
|
|
731
|
+
const analysis = shellAnalysis(shellCommandText(event));
|
|
732
|
+
if (analysis) {
|
|
733
|
+
return [tool, ...analysis.segments
|
|
734
|
+
.filter((_, index) => analysis.verdicts[index] !== 'read')
|
|
735
|
+
.map((segment) => [...segment.words, ...segment.outputs].join(' '))].join(' ; ');
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
let serialized = '';
|
|
739
|
+
try {
|
|
740
|
+
serialized = JSON.stringify(event.tool_input || {});
|
|
741
|
+
}
|
|
742
|
+
catch {
|
|
743
|
+
serialized = '';
|
|
744
|
+
}
|
|
745
|
+
return `${tool} ${command} ${serialized}`;
|
|
746
|
+
}
|
|
747
|
+
/** Editing and task tools are judged by their target, not by shell-like text in their input. */
|
|
748
|
+
function isShellGovernedTool(event) {
|
|
749
|
+
const tool = normalizeHookToolName(event.tool_name);
|
|
750
|
+
return !FILE_EDIT_TOOLS.has(tool) && !LOCAL_TASK_TOOLS.has(tool);
|
|
150
751
|
}
|
|
151
752
|
function pathOnlyInput(value) {
|
|
152
753
|
const input = asRecord(value);
|
|
@@ -161,28 +762,24 @@ function isReadOnlyToolEvent(event) {
|
|
|
161
762
|
const tool = normalizeHookToolName(event.tool_name);
|
|
162
763
|
if (!tool)
|
|
163
764
|
return false;
|
|
164
|
-
if (
|
|
765
|
+
if (LOCAL_TASK_TOOLS.has(tool))
|
|
165
766
|
return true;
|
|
767
|
+
if (READ_ONLY_TOOLS.has(tool))
|
|
768
|
+
return !isSecretMaterialAccess(event);
|
|
166
769
|
if (['edit', 'write', 'multiedit', 'search_replace', 'run_terminal_command', 'spawn_subagent'].includes(tool))
|
|
167
770
|
return false;
|
|
168
771
|
if (MUTATION_TOOL_VERB.test(tool))
|
|
169
772
|
return false;
|
|
170
773
|
if (READ_ONLY_TOOL_VERB.test(tool))
|
|
171
774
|
return true;
|
|
172
|
-
const command = hookToolCommand(event)
|
|
173
|
-
if (
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
return true;
|
|
178
|
-
if (/^(?:node\s+--test|npx\s+(?:vitest|tsc\s+--noemit)|pytest|python(?:3)?\s+-m\s+(?:pytest|unittest)|cargo\s+(?:test|check)|go\s+test)(?:\s|$)/i.test(command))
|
|
179
|
-
return true;
|
|
180
|
-
if (/^git\s+(?:status|diff|show|log|branch|rev-parse|ls-files|ls-remote)(?:\s|$)/i.test(command))
|
|
181
|
-
return true;
|
|
182
|
-
const firstToken = command.split(/[\s|;&]+/, 1)[0]?.toLowerCase();
|
|
183
|
-
if (firstToken && READ_ONLY_BASH_COMMANDS.has(firstToken))
|
|
184
|
-
return true;
|
|
775
|
+
const command = hookToolCommand(event);
|
|
776
|
+
if (tool === 'bash' && command) {
|
|
777
|
+
// A compound command is read-only only when every simple command in it is.
|
|
778
|
+
const analysis = shellAnalysis(shellCommandText(event));
|
|
779
|
+
return Boolean(analysis?.segments.length && analysis.verdicts.every((verdict) => verdict === 'read'));
|
|
185
780
|
}
|
|
186
|
-
return !['edit', 'write', 'multiedit', 'search_replace'].includes(tool)
|
|
781
|
+
return !['edit', 'write', 'multiedit', 'search_replace'].includes(tool)
|
|
782
|
+
&& pathOnlyInput(event.tool_input)
|
|
783
|
+
&& !toolTargetPaths(event).some((path) => isSecretPath(path));
|
|
187
784
|
}
|
|
188
785
|
//# sourceMappingURL=hook-tool-policy.js.map
|