fullcourtdefense-cli 1.29.0 → 1.30.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.
@@ -145,6 +145,28 @@ export declare function effectiveApprovalScope(approval: ActionPolicyApproval |
145
145
  */
146
146
  export declare function deriveToolOperations(toolName: string, declaredActions?: string[]): string[];
147
147
  export declare function toolCapabilities(value: string): Set<string>;
148
+ /**
149
+ * "Contains a secret" means a secret VALUE is present — never that the text
150
+ * talks about secrets. Source code, docs, grep patterns and commit messages in
151
+ * any security-minded codebase say "password", "credential", "api_key" and
152
+ * ".env" all day; treating those words as secrets made `toolArgs.containsSecret`
153
+ * true for ~77% of routine IDE agent actions (measured), and every org policy
154
+ * built on that fact blocked developers for editing their own code.
155
+ *
156
+ * What counts:
157
+ * - known token formats (GitHub, AWS, Google, OpenAI/Anthropic, Slack, JWT)
158
+ * - private-key PEM blocks
159
+ * - an ASSIGNMENT of a secret-named field to a REAL value:
160
+ * `password=Hunter2!2024`, `"api_key": "sk-…"`, `--token abc…` — where the
161
+ * value is not a placeholder (`<your-key>`, `xxxx`, `changeme`, `${VAR}`,
162
+ * `process.env.X`), not a code expression (`req.body.password`,
163
+ * `config.get('token')`) and has real-secret shape (>= 8 chars, >= 2
164
+ * character classes).
165
+ * The kind is exposed as `toolArgs.secretKind` so a Decision Card can say WHAT
166
+ * matched instead of just "contains secret".
167
+ */
168
+ export type SecretKind = 'github_token' | 'aws_access_key' | 'google_api_key' | 'openai_key' | 'slack_token' | 'jwt' | 'private_key' | 'assignment' | 'high_entropy' | 'none';
169
+ export declare function detectSecretKind(raw: string): SecretKind;
148
170
  /**
149
171
  * Check a tool call against action policies.
150
172
  * Returns the most restrictive matching verdict (pure — callers do their own logging).
@@ -25,6 +25,7 @@ exports.resolveApprovalOptions = resolveApprovalOptions;
25
25
  exports.effectiveApprovalScope = effectiveApprovalScope;
26
26
  exports.deriveToolOperations = deriveToolOperations;
27
27
  exports.toolCapabilities = toolCapabilities;
28
+ exports.detectSecretKind = detectSecretKind;
28
29
  exports.evaluateActionPolicies = evaluateActionPolicies;
29
30
  exports.inferToolContext = inferToolContext;
30
31
  exports.inventoryToolMatchesActionPolicy = inventoryToolMatchesActionPolicy;
@@ -416,7 +417,7 @@ function keywordAppearsAsWord(keyword, haystack) {
416
417
  * no concrete action verb was detected from the payload).
417
418
  */
418
419
  const OPERATION_MATCH_SKIP_FIELDS = new Set([
419
- 'toolArgs.bytes', 'toolArgs.containsSecret', 'toolArgs.containsPii', 'retry.count', 'fanout.count',
420
+ 'toolArgs.bytes', 'toolArgs.containsSecret', 'toolArgs.secretKind', 'toolArgs.containsPii', 'retry.count', 'fanout.count',
420
421
  'toolArgs.bytes1h', 'toolArgs.mb', 'toolArgs.mb1h', 'agent.toolArgsBytes1h', 'agent.toolPayloadMb1h',
421
422
  'tool.status', 'operation.status', 'agent.baseline',
422
423
  // Identity / metadata — NOT action signals. Excluded so the tool/agent name can never
@@ -429,6 +430,15 @@ const OPERATION_MATCH_SKIP_FIELDS = new Set([
429
430
  // The action signal for file ops is the operation (read/write/delete) + path; content
430
431
  // safety (secrets, dangerous text) is Local Safety's job, not verb matching.
431
432
  'content', 'contents', 'text', 'body', 'data', 'diff', 'patch', 'newText', 'oldText',
433
+ // IDE edit-tool field names (Cursor StrReplace / Claude Edit / MultiEdit / Write).
434
+ 'new_string', 'old_string', 'new_source', 'old_source', 'new_text', 'old_text', 'edits', 'replacement', 'code_edit', 'instructions',
435
+ // PROSE — human-readable text a tool carries along (an issue titled "Delete legacy
436
+ // loader", a commit message, a Slack post, a PR description). What the tool DOES is
437
+ // its operation/capability; the words a person typed into a title are not a verb.
438
+ 'title', 'subject', 'description', 'message', 'msg', 'comment', 'comments', 'note', 'notes', 'summary', 'reason', 'explanation', 'prompt', 'question', 'answer',
439
+ 'thought', 'thoughts', 'observations', 'caption', 'label', 'labels', 'tags', 'markdown', 'html', 'snippet', 'excerpt', 'transcript', 'log', 'logs', 'output', 'stdout', 'stderr',
440
+ // Inbound-host facts (where data comes FROM) — a host name is not an action verb.
441
+ 'source.domain', 'source.type', 'url.risk',
432
442
  // Search PATTERNS — text being looked for, not an action (see search-intent handling
433
443
  // in inferToolContext). Searching for "DROP TABLE" is a read.
434
444
  'search.pattern',
@@ -442,11 +452,18 @@ function buildOperationMatchText(_toolName, context) {
442
452
  for (const [key, value] of Object.entries(context)) {
443
453
  if (OPERATION_MATCH_SKIP_FIELDS.has(key))
444
454
  continue;
445
- if (typeof value === 'string' && value.length > 0 && value.length <= 2000)
446
- parts.push(`${key} ${value}`);
455
+ if (typeof value !== 'string' || value.length === 0 || value.length > 2000)
456
+ continue;
457
+ // On a command line, quoted text is an argument VALUE (a commit message, a grep
458
+ // pattern, a description) — never a command word. `git commit -m "delete old
459
+ // loader"` must not satisfy a DELETE rule; `systemctl restart api` still
460
+ // satisfies RESTART because the verb is an unquoted command word.
461
+ const text = COMMAND_LINE_FIELDS.has(key) ? value.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, ' ') : value;
462
+ parts.push(`${key} ${text}`);
447
463
  }
448
464
  return parts.join(' ');
449
465
  }
466
+ const COMMAND_LINE_FIELDS = new Set(['command', 'cmd', 'commandLine', 'script', 'args']);
450
467
  function stringifyArgs(args) {
451
468
  try {
452
469
  return JSON.stringify(args || {});
@@ -455,36 +472,416 @@ function stringifyArgs(args) {
455
472
  return String(args || '');
456
473
  }
457
474
  }
475
+ /**
476
+ * Shannon entropy in bits per character. Random secrets sit around 4-5.5;
477
+ * words, identifiers, paths and placeholders sit well below 3.5. Generic —
478
+ * it needs no list of vendors or field names.
479
+ */
480
+ function shannonEntropy(value) {
481
+ if (!value)
482
+ return 0;
483
+ const counts = new Map();
484
+ for (const ch of value)
485
+ counts.set(ch, (counts.get(ch) || 0) + 1);
486
+ let entropy = 0;
487
+ for (const count of counts.values()) {
488
+ const p = count / value.length;
489
+ entropy -= p * Math.log2(p);
490
+ }
491
+ return entropy;
492
+ }
493
+ /**
494
+ * A standalone token that has the STATISTICS of a secret: long, mixed
495
+ * character classes, high entropy, and not something else that is also random-
496
+ * looking (a content hash / integrity string, a UUID, a git SHA, a lockfile
497
+ * checksum, a base64 image). Those exclusions are structural, not vocabulary.
498
+ */
499
+ /** The body of a known-format token must itself look random (no `xxxx…`, `0000…`, `ABCDEF…` placeholders). */
500
+ function plausibleTokenBody(body) {
501
+ if (/(.)\1{4,}/.test(body))
502
+ return false;
503
+ if (/^(?:[A-Za-z]+|[0-9]+)$/.test(body) && /^(?:abc|xyz|0123|1234|test|example|your|fake|dummy|sample)/i.test(body))
504
+ return false;
505
+ return shannonEntropy(body) >= 3.0;
506
+ }
507
+ function isHighEntropySecretToken(token) {
508
+ if (token.length < 24 || token.length > 512)
509
+ return false;
510
+ // Any URI (postgres://, redis://, amqp://, https://…) is configuration; only
511
+ // the password inside its userinfo can be a secret — judged as a value.
512
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token))
513
+ return looksLikeSecretValue(token);
514
+ if (/^[0-9a-f]+$/i.test(token))
515
+ return false; // hex digest / git sha
516
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(token))
517
+ return false; // uuid
518
+ if (/^sha(?:1|256|384|512)-/i.test(token))
519
+ return false; // sri / lockfile integrity
520
+ if (/^data:/i.test(token) || /^[A-Za-z0-9+/]{200,}={0,2}$/.test(token))
521
+ return false; // embedded binary
522
+ if (/^(?:https?|s3|gs|file):\/\//i.test(token) || /[\\/]{1}[\w.-]+[\\/]/.test(token))
523
+ return false; // url / path
524
+ if (/^[a-z]+(?:[A-Z][a-z0-9]+)+$/.test(token) || /^[a-z]+(?:_[a-z0-9]+)+$/.test(token))
525
+ return false; // camelCase / snake_case identifiers
526
+ let classes = 0;
527
+ if (/[a-z]/.test(token))
528
+ classes++;
529
+ if (/[A-Z]/.test(token))
530
+ classes++;
531
+ if (/[0-9]/.test(token))
532
+ classes++;
533
+ if (/[^A-Za-z0-9]/.test(token))
534
+ classes++;
535
+ if (classes < 3)
536
+ return false;
537
+ return shannonEntropy(token) >= 4.0;
538
+ }
539
+ const SECRET_NAME = String.raw `(?:api[_-]?key|apikey|access[_-]?token|auth[_-]?token|refresh[_-]?token|bearer[_-]?token|client[_-]?secret|secret[_-]?key|secret|password|passwd|pwd|credentials?|private[_-]?key|token)`;
540
+ // name, optional quotes/JSON escapes, = or : or =>, optional quotes/escapes, value (stops at whitespace, quotes, delimiters, backslash from JSON escaping).
541
+ // The name may carry a prefix (`JWT_SECRET`, `STRIPE_SECRET_KEY`, `db.password`) — it is the suffix that says "this is a credential".
542
+ const SECRET_ASSIGNMENT_RE = new RegExp(String.raw `(?:^|[^\w])[\w.-]*?${SECRET_NAME}[\\"']*\s*(?:=>|[:=])\s*[\\"']*([^\s\\"',;&|)}\]<>]{8,})`, 'gi');
543
+ // CLI flag form: --password X / --password=X / --token X / --api-key X
544
+ const SECRET_FLAG_RE = /--?(?:password|passwd|pwd|token|api-?key|secret|client-secret|access-token)(?:=|\s+)[\\"']*([^\s\\"',;&|)}\]<>]{8,})/gi;
545
+ const SECRET_PLACEHOLDER_RE = /^(?:<.*>|\$\{?[A-Za-z_][\w.]*\}?|%[A-Za-z_]+%|\{\{.*\}\}|\[.*\]|\*{3,}|x{4,}|X{4,}|\.{3,}|-{3,}|_{3,}|(?:your|my|our|the|a|an|some|example|sample|dummy|fake|test|mock|placeholder|redacted|masked|hidden|changeme|change_me|replace_?me|todo|tbd|fixme|none|null|nil|undefined|true|false|required|optional|omitted|removed|unset|empty|secret|password|token|xxx+|abc+|123+)[\w.-]*)$/i;
546
+ // Code expressions are references to a secret, not the secret: req.body.password, config.get("token"), os.environ["KEY"], getSecret(), env.API_KEY
547
+ const SECRET_CODE_EXPR_RE = /^(?:[A-Za-z_$][\w$]*(?:[.\[][^\s=]*)+|[A-Za-z_$][\w$]*\(.*\)?|process\.env\.\w+|os\.environ.*|env(?:iron)?\.\w+|new\s+\w+.*|await\s+.*|require\(.*)$/;
548
+ function looksLikeSecretValue(raw) {
549
+ const value = raw.replace(/[\\"'`]+$/, '');
550
+ if (value.length < 8)
551
+ return false;
552
+ // `API_KEY=` (empty) followed by the next line `DATABASE_URL=…` is two assignments, not a value.
553
+ if (/^[A-Za-z_][A-Za-z0-9_.-]*\s*[:=]/.test(value))
554
+ return false;
555
+ if (SECRET_PLACEHOLDER_RE.test(value))
556
+ return false;
557
+ if (SECRET_CODE_EXPR_RE.test(value))
558
+ return false;
559
+ // A URL, path or hostname as a "password" value is configuration, not a credential value.
560
+ if (/^(?:https?:\/\/|[a-z]:\\|\.{0,2}\/|~\/)/i.test(value))
561
+ return false;
562
+ // A connection string is a secret only if the password INSIDE it is one
563
+ // (`postgres://user:password@localhost/app` in an example file is not).
564
+ const conn = value.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^:@\/\s]+:([^@\/\s]+)@)?/i);
565
+ if (conn)
566
+ return conn[1] ? looksLikeSecretValue(conn[1]) : false;
567
+ // Plain dictionary-ish word (letters only) is a description, not a secret, unless long.
568
+ if (/^[a-z]+$/i.test(value) && value.length < 16)
569
+ return false;
570
+ // Structural placeholder shapes: a run of one repeated character (xxxx, 0000,
571
+ // aaaa), a hyphen/underscore-joined phrase of plain words (unit-test-secret-not-
572
+ // real, change-me, your-token-here), or a "fake/test/example/..." prefix.
573
+ if (/(.)\1{4,}/.test(value))
574
+ return false;
575
+ if (/^[a-z]+(?:[-_.][a-z]+)+$/i.test(value))
576
+ return false;
577
+ 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))
578
+ return false;
579
+ let classes = 0;
580
+ if (/[a-z]/.test(value))
581
+ classes++;
582
+ if (/[A-Z]/.test(value))
583
+ classes++;
584
+ if (/[0-9]/.test(value))
585
+ classes++;
586
+ if (/[^A-Za-z0-9]/.test(value))
587
+ classes++;
588
+ if (classes < 2)
589
+ return false;
590
+ // Real credential values are random; prose-like values are not. 3.0 bits/char
591
+ // separates "Tr0ub4dor&3" (3.3) from "devpassword" (2.9) deterministically.
592
+ return shannonEntropy(value) >= 3.0;
593
+ }
594
+ /** Args arrive JSON-stringified: `\n` / `\t` are two characters and `\"` is an escaped quote. Undo that so line ends and quotes terminate values. */
595
+ function unescapeArgsText(text) {
596
+ return text.replace(/\\[nrt]/g, ' ').replace(/\\(["'\\/])/g, '$1');
597
+ }
598
+ function detectSecretKind(raw) {
599
+ if (!raw)
600
+ return 'none';
601
+ const value = unescapeArgsText(raw);
602
+ if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(value))
603
+ return 'private_key';
604
+ // Known token formats: the prefix says WHAT it is, the body must still LOOK
605
+ // random. `ghp_xxxxxxxxxxxxxxxxxxxx` and `AKIAXXXXXXXXXXXXXXXX` in a README
606
+ // are documentation, not credentials.
607
+ const KNOWN = [
608
+ ['aws_access_key', /\bAKIA([0-9A-Z]{16})\b/g],
609
+ ['google_api_key', /\bAIza([0-9A-Za-z_-]{30,})/g],
610
+ ['github_token', /\bgh[pousr]_([A-Za-z0-9_]{20,})\b/g],
611
+ ['openai_key', /\bsk-(?:proj-|ant-|live-|test-)?([A-Za-z0-9_-]{20,})\b/g],
612
+ ['slack_token', /\bxox[baprs]-([A-Za-z0-9-]{10,})\b/g],
613
+ ['jwt', /\beyJ[A-Za-z0-9_-]{8,}\.(eyJ[A-Za-z0-9_-]{8,})\.[A-Za-z0-9_-]{8,}\b/g],
614
+ ];
615
+ for (const [kind, re] of KNOWN) {
616
+ re.lastIndex = 0;
617
+ let m;
618
+ while ((m = re.exec(value)) !== null) {
619
+ if (plausibleTokenBody(m[1]))
620
+ return kind;
621
+ }
622
+ }
623
+ for (const re of [SECRET_ASSIGNMENT_RE, SECRET_FLAG_RE]) {
624
+ re.lastIndex = 0;
625
+ let m;
626
+ while ((m = re.exec(value)) !== null) {
627
+ if (looksLikeSecretValue(m[1]))
628
+ return 'assignment';
629
+ }
630
+ }
631
+ // Generic: ANY field / flag / auth header whose value has the statistics of
632
+ // a secret — catches vendors and field names nobody listed. Position matters
633
+ // (a value being assigned or sent), so prose and code identifiers never qualify.
634
+ for (const re of [GENERIC_VALUE_RE, AUTH_HEADER_RE]) {
635
+ re.lastIndex = 0;
636
+ let m;
637
+ while ((m = re.exec(value)) !== null) {
638
+ if (isHighEntropySecretToken(m[1].replace(/[\\"'`]+$/, '')))
639
+ return 'high_entropy';
640
+ }
641
+ }
642
+ return 'none';
643
+ }
644
+ const GENERIC_VALUE_RE = /\b[A-Za-z_][\w.-]{1,63}[\\"']*\s*(?:=>|[:=])\s*[\\"']*([^\s\\"',;&|)}\]<>]{24,})/g;
645
+ const AUTH_HEADER_RE = /\b(?:bearer|basic|token|apikey|x-api-key)\s+([A-Za-z0-9._~+/=-]{24,})/gi;
458
646
  function hasSecretLikeValue(value) {
459
- return /(AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{20,}|gh[pousr]_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]+|-----BEGIN [A-Z ]*PRIVATE KEY-----|api[_-]?key|access[_-]?token|secret|password|credential|\.env)/i.test(value);
647
+ return detectSecretKind(value) !== 'none';
460
648
  }
461
- function hasPiiLikeValue(value) {
462
- return /([A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}|\+?\d[\d\s().-]{8,}\d)/i.test(value);
649
+ /**
650
+ * "Contains PII" means a real person's contact data is present. Excludes the
651
+ * addresses every codebase is full of (example.com, noreply, localhost, test
652
+ * domains) and digit runs that are not phone numbers (timestamps, IPs,
653
+ * versions, ISO dates, hashes): a phone number needs phone punctuation and
654
+ * 9-15 digits.
655
+ */
656
+ const PII_EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
657
+ const PII_EMAIL_IGNORE_RE = /(?:@(?:example\.(?:com|org|net)|test\.[a-z]+|localhost(?:\.[a-z]+)?|[\w.-]*\.(?:invalid|test|local|localdomain|example)|users\.noreply\.github\.com|noreply\.[\w.-]+)$|^(?:no-?reply|noreply|do-?not-?reply|test\d*|example|user\d*|admin|root|foo|bar|baz|someone|somebody|you|me|name|first\.last|john\.doe|jane\.doe|email)@)/i;
658
+ const PII_PHONE_RE = /(?:^|[^\w.@/:-])(\+?\(?\d{1,4}\)?[\s-]\(?\d{2,4}\)?[\s-]\d{2,4}(?:[\s-]\d{2,4}){0,2})(?![\w.@/:-])/g;
659
+ function hasPiiLikeValue(raw) {
660
+ if (!raw)
661
+ return false;
662
+ const value = unescapeArgsText(raw);
663
+ PII_EMAIL_RE.lastIndex = 0;
664
+ let m;
665
+ while ((m = PII_EMAIL_RE.exec(value)) !== null) {
666
+ if (PII_EMAIL_IGNORE_RE.test(m[0]))
667
+ continue;
668
+ // `user:pass@host` inside a URI (`postgres://app:pw@db.internal`, `https://key@o0.ingest.sentry.io`)
669
+ // is connection userinfo, not a person's address.
670
+ const before = value.slice(Math.max(0, m.index - 64), m.index);
671
+ if (/[a-z][a-z0-9+.-]*:\/\/[^\s@]*$/i.test(before))
672
+ continue;
673
+ return true;
674
+ }
675
+ PII_PHONE_RE.lastIndex = 0;
676
+ while ((m = PII_PHONE_RE.exec(value)) !== null) {
677
+ const candidate = m[1].trim();
678
+ if (/^\d{4}-\d{2}-\d{2}/.test(candidate))
679
+ continue; // ISO date
680
+ const digits = candidate.replace(/\D/g, '');
681
+ if (/^(\d)\1+$/.test(digits) || /^(?:0123456789|1234567890|555)/.test(digits))
682
+ continue; // 000-000-0000, 555 fixtures
683
+ if (digits.length >= 9 && digits.length <= 15)
684
+ return true;
685
+ }
686
+ return false;
463
687
  }
464
- function extractUrl(args, argsText) {
465
- const direct = args.url || args.uri || args.endpoint || args.webhookUrl || args.callbackUrl || args.href;
688
+ /**
689
+ * The destination of an action is a typed URL argument, or a URL in the
690
+ * command line / request target of the call. It is NOT a link that happens to
691
+ * appear inside file content, a document body or a code edit — a README that
692
+ * mentions https://example.com is not an egress to example.com (that misread
693
+ * turned every `edit_file` into `write + destination.type=external`, which an
694
+ * egress-block policy then stopped).
695
+ */
696
+ const URL_ACTION_FIELDS = ['command', 'cmd', 'script', 'input', 'target', 'address', 'host', 'server', 'baseUrl', 'base_url', 'apiUrl', 'api_url', 'remote', 'repo', 'repository', 'source', 'destination'];
697
+ function extractUrl(args, _argsText) {
698
+ const direct = args.url || args.uri || args.endpoint || args.webhookUrl || args.callbackUrl || args.href || args.link;
466
699
  if (typeof direct === 'string' && direct.trim())
467
700
  return direct.trim();
468
- return argsText.match(/https?:\/\/[^\s"'<>]+/i)?.[0] || '';
701
+ for (const field of URL_ACTION_FIELDS) {
702
+ const value = args[field];
703
+ if (typeof value !== 'string')
704
+ continue;
705
+ // Web URLs first; storage / transfer schemes (s3://, gs://, ftp://) are
706
+ // destinations too when they appear on a command line.
707
+ const found = value.match(/https?:\/\/[^\s"'<>]+/i)?.[0] || value.match(/\b(?:s3|gs|ftp|sftp|smb):\/\/[^\s"'<>]+/i)?.[0];
708
+ if (found)
709
+ return found;
710
+ }
711
+ return '';
469
712
  }
470
- function classifyShellCommandOperation(command) {
471
- const value = command.trim().toLowerCase();
713
+ /**
714
+ * A recursive removal whose EVERY target is a plain relative path inside the
715
+ * working tree (`rm -rf node_modules dist`, `Remove-Item -Recurse -Force .\build`,
716
+ * `del /s /q coverage`) is routine developer work. Anything that could reach
717
+ * outside the repo — an absolute path, a drive, `~`/`$HOME`/`%USERPROFILE%`,
718
+ * a parent reference, a bare `*` or `.`, or a shell variable whose value we
719
+ * cannot see — is NOT workspace-scoped and stays a destructive DELETE.
720
+ * Deterministic: decided from the command's own tokens, never from a guess.
721
+ */
722
+ function isWorkspaceScopedRemoval(command) {
723
+ // Only the removal segment: `npm run build && rm -rf dist` → `rm -rf dist`.
724
+ const segments = command.split(/\s*(?:&&|\|\||;|\|)\s*/);
725
+ const removal = segments.find(seg => /\b(rm|del|remove-item|rmdir)\b/i.test(seg)) || command;
726
+ const tokens = removal.trim().split(/\s+/).slice(1);
727
+ const targets = tokens.filter(t => !/^-/.test(t) && !/^\/[fqs]$/i.test(t) && !/^-(?:recurse|force|literalpath|path|erroraction)$/i.test(t));
728
+ if (targets.length === 0)
729
+ return false;
730
+ return targets.every(raw => {
731
+ const t = raw.replace(/^["']|["']$/g, '');
732
+ if (!t || t === '*' || t === '.' || t === '..' || /^\.\/?\*$/.test(t) || /^\.\\\*$/.test(t))
733
+ return false;
734
+ if (/^[\/\\]/.test(t) || /^[A-Za-z]:/.test(t) || /^~/.test(t))
735
+ return false; // absolute / drive / home
736
+ if (/\$|%|\$\{|\$\(|`/.test(t))
737
+ return false; // variable or subshell we cannot see
738
+ if (/(^|[\/\\])\.\.([\/\\]|$)/.test(t))
739
+ return false; // climbs out of the tree
740
+ if (/^\*\*?$|^[\/\\]?\*$/.test(t))
741
+ return false;
742
+ return true;
743
+ });
744
+ }
745
+ /**
746
+ * Shell classification model — Process → Command → Arguments → Target:
747
+ *
748
+ * 1. A command line is a list of SEGMENTS (`a && b`, `a; b`, `a || b`, newlines).
749
+ * Each segment is classified on its own; the line takes the most severe
750
+ * result (DELETE > write > SHELL > read).
751
+ * 2. Inside a segment the LEADING PROGRAM decides what happens. Arguments to a
752
+ * program that does not execute them are data: `grep "DROP TABLE"`,
753
+ * `git commit -m "delete old loader"`, `echo rm -rf` never destroy anything.
754
+ * Programs that DO execute their argument (sql clients with -c/-e, `bash -c`,
755
+ * `| sh`) are classified by that argument.
756
+ * 3. Destructive scope is decided from the target tokens (see
757
+ * isWorkspaceScopedRemoval): the same `rm -rf` is a workspace write on
758
+ * `node_modules` and a DELETE on `/`, `~`, a drive or a variable.
759
+ *
760
+ * Deterministic and explainable; the Local Safety catalog (regex on the exact
761
+ * dangerous forms) remains the hard-block layer underneath this.
762
+ */
763
+ const SHELL_OP_RANK = {
764
+ DELETE: 4, DROP: 4, TRUNCATE: 4,
765
+ write: 3, INSERT: 3, UPDATE: 3, CREATE: 3, ALTER: 3, GRANT: 3, REVOKE: 3,
766
+ SHELL: 2,
767
+ read: 1, SELECT: 1, SHOW: 1, DESCRIBE: 1, EXPLAIN: 1,
768
+ };
769
+ const SHELL_READ_PROGRAMS = new Set([
770
+ 'pwd', 'ls', 'dir', 'echo', 'whoami', 'cat', 'head', 'tail', 'less', 'more', 'rg', 'grep', 'egrep', 'fgrep', 'ag', 'find', 'fd', 'which', 'where', 'type', 'tree', 'stat',
771
+ 'wc', 'env', 'printenv', 'file', 'du', 'df', 'ps', 'top', 'date', 'sort', 'uniq', 'cut', 'jq', 'yq', 'diff', 'cmp', 'md5sum', 'sha256sum',
772
+ 'get-childitem', 'get-content', 'get-item', 'get-location', 'select-string', 'test-path', 'get-process', 'get-date', 'measure-object', 'get-command', 'resolve-path', 'gci', 'gc', 'sls',
773
+ ]);
774
+ const SHELL_SQL_CLIENTS = new Set(['psql', 'mysql', 'mariadb', 'sqlcmd', 'sqlite3', 'mongo', 'mongosh', 'clickhouse-client', 'bq', 'cockroach', 'usql', 'pgcli', 'mycli']);
775
+ const SHELL_NEUTRAL_BUILTINS = new Set(['cd', 'pushd', 'popd', 'export', 'set', 'unset', 'setx', 'alias', 'unalias', 'clear', 'cls', 'exit', 'true', 'false', 'sleep', 'wait', 'set-location', 'sl', 'push-location', 'pop-location', 'write-host', 'write-output']);
776
+ function shellSegmentLead(segment) {
777
+ const tokens = segment.trim().split(/\s+/).filter(Boolean);
778
+ let i = 0;
779
+ // `FOO=bar cmd`, `sudo cmd`, `time cmd`, `nohup cmd`, `command cmd`, `env cmd`
780
+ while (i < tokens.length && (/^[A-Za-z_][\w]*=/.test(tokens[i]) || /^(?:sudo|time|nohup|command|builtin|exec|nice|env)$/i.test(tokens[i])))
781
+ i++;
782
+ const lead = (tokens[i] || '').toLowerCase().replace(/^["']|["']$/g, '');
783
+ return lead.replace(/^.*[\\/]/, '').replace(/\.(exe|cmd|bat|ps1)$/i, '');
784
+ }
785
+ function classifyShellSegment(segment) {
786
+ const value = segment.trim().toLowerCase();
472
787
  if (!value)
788
+ return 'read';
789
+ const lead = shellSegmentLead(segment);
790
+ // Shell navigation / session builtins do nothing to the machine on their own.
791
+ if (SHELL_NEUTRAL_BUILTINS.has(lead) || /^\$env:[\w]+$/.test(lead) || /^\$\w+$/.test(lead))
792
+ 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
+ // A SQL client executes the statement it is given (`psql -c "DROP TABLE x"`,
796
+ // `mysql -e …`, `echo "TRUNCATE …" | psql`). The exact SQL verb is the
797
+ // 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);
799
+ if (sqlClient) {
800
+ const verb = segment.match(/(?<!-)\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|SHOW|DESCRIBE|EXPLAIN)\b(?!-)/i);
801
+ if (verb)
802
+ return verb[1].toUpperCase();
473
803
  return 'SHELL';
474
- if (/\b(rm\s+-rf|del\s+\/[fq]|remove-item\b.*\b-recurse\b|drop\s+(database|table|schema)|truncate\s+table|git\s+reset\s+--hard|git\s+clean\s+-fd|kubectl\s+delete|terraform\s+destroy)\b/.test(value)) {
475
- return 'DELETE';
476
804
  }
477
- if (/\b(git\s+status|git\s+diff|git\s+log|git\s+show|pwd|ls|dir|echo|whoami|python\s+--version|python3\s+--version|node\s+--version|npm\s+--version|gcloud\s+run\s+services\s+describe|gcloud\s+run\s+services\s+logs\s+read)\b/.test(value)) {
805
+ if (/\b(curl|wget|invoke-restmethod|invoke-webrequest|iwr|irm)\b/.test(value)) {
806
+ // Before the read programs so `cat .env | curl -d @- https://…` is still an
807
+ // egress WRITE. The HTTP method comes from the method flag or a request-body
808
+ // flag — never from the word "delete"/"post" appearing inside a URL.
809
+ const explicitMethod = /(?:\s-x|\s--request|\s-method)[\s=]+["']?(post|put|patch|delete)\b/i.test(segment);
810
+ const hasBody = /\s(?:-d|--data(?:-raw|-binary|-urlencode)?|-F|--form|-T|--upload-file|--json|-body|-infile)\b/i.test(segment);
811
+ if (explicitMethod || hasBody)
812
+ return 'write';
813
+ return pipesIntoInterpreter ? 'SHELL' : 'read';
814
+ }
815
+ // Leading program that only reads/prints: its arguments are text, not actions
816
+ // — unless the output is redirected into a file or piped into an interpreter.
817
+ if (SHELL_READ_PROGRAMS.has(lead)) {
818
+ if (pipesIntoInterpreter)
819
+ return 'SHELL';
820
+ const redirect = value.match(/(?:^|[^>0-9])>{1,2}\s*([^\s&|;]+)/);
821
+ if (redirect && !/^\/dev\/null$|^nul$/i.test(redirect[1]))
822
+ return 'write';
478
823
  return 'read';
479
824
  }
480
- if (/\b(git\s+add|git\s+commit|git\s+push|npm\s+install|pnpm\s+install|yarn\s+add|pip\s+install|gcloud\s+run\s+deploy|firebase\s+deploy|terraform\s+apply|kubectl\s+apply|docker\s+push)\b/.test(value)) {
825
+ // Resetting or cleaning the working tree only ever touches the repo the
826
+ // developer is standing in — a workspace write, not a destructive delete.
827
+ if (lead === 'git' && /\bgit\s+(reset\s+--hard|clean\s+-[a-z]*f[a-z]*)\b/.test(value))
481
828
  return 'write';
829
+ if (lead === 'git' && /\bgit\s+(?:status|diff|log|show|blame|clone|fetch|pull|ls-files|remote|branch(?!\s+-[dD])|describe|rev-parse|stash\s+(?:list|show)|tag$)\b/.test(value))
830
+ return 'read';
831
+ if (lead === 'gh' && /\bgh\s+(?:pr|issue|repo|run|release|workflow)\s+(?:view|list|status|checks|diff|download)\b/.test(value))
832
+ return 'read';
833
+ if (lead === 'gh' && /\bgh\s+auth\s+status\b/.test(value) && !/--show-token|\s-t\b/.test(value))
834
+ return 'read';
835
+ if (/^(?:gcloud\s+run\s+services\s+(?:describe|logs\s+read)|kubectl\s+(?:get|describe|logs|top|config\s+(?:current-context|get-contexts|view(?!\s+--raw)))|docker\s+(?:ps|images|logs|inspect|version|info)|terraform\s+(?:plan|validate|fmt|show|state\s+list|output)|aws\s+(?:s3\s+ls|sts\s+get-caller-identity)|az\s+account\s+show|npm\s+(?:ls|list|view|outdated|--version)|node\s+--version|python3?\s+--version)\b/.test(value))
836
+ return 'read';
837
+ if (/\b(rm\s+-[a-z]*r[a-z]*f|rm\s+-[a-z]*f[a-z]*r|del\s+\/[fqs]|remove-item\b.*\b-recurse\b|rmdir\s+\/s)\b/.test(value)) {
838
+ return isWorkspaceScopedRemoval(segment) ? 'write' : 'DELETE';
482
839
  }
483
- if (/\b(curl|wget|invoke-restmethod|invoke-webrequest)\b/.test(value)) {
484
- return /\b(-x|--request)\s*(post|put|patch|delete)|\b(post|put|patch|delete)\b/i.test(command) ? 'write' : 'read';
840
+ if (/\b(drop\s+(database|table|schema)|truncate\s+table|kubectl\s+delete|terraform\s+destroy|gcloud\s+(?:sql\s+instances|compute\s+instances|projects|run\s+services)\s+delete|az\s+(?:group|vm|sql\s+server)\s+delete|aws\s+(?:s3\s+rb|rds\s+delete-db-instance|ec2\s+terminate-instances)|docker\s+(?:system\s+prune|volume\s+rm|rm\s+-f)|mkfs|dd\s+if=)\b/.test(value)) {
841
+ return 'DELETE';
842
+ }
843
+ // File transfers: the LAST operand is where the data goes. Remote last = upload
844
+ // (egress write); remote first = download (read). `aws s3 cp x s3://b/`,
845
+ // `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) {
848
+ if (/az\s+storage\s+blob\s+upload/.test(value))
849
+ return 'write';
850
+ // Operands = tokens that are not flags and not the value of a `--flag value` pair.
851
+ const tokens = xfer[1].split(/\s+/).filter(Boolean);
852
+ const operands = [];
853
+ for (let i = 0; i < tokens.length; i++) {
854
+ if (tokens[i].startsWith('-')) {
855
+ if (!tokens[i].includes('=') && /^--?(?:profile|region|endpoint-url|sse|acl|storage-class|exclude|include|i|P|o|e|l)$/.test(tokens[i]))
856
+ i++;
857
+ continue;
858
+ }
859
+ operands.push(tokens[i]);
860
+ }
861
+ const isRemote = (t) => /^(?:s3|gs|az|ftp|sftp|smb):\/\/|^[\w.-]+@[\w.-]+:|^[\w.-]+:(?:[\/~]|$)/.test(t);
862
+ const remoteIdx = operands.findIndex(isRemote);
863
+ const localIdx = operands.findIndex(t => !isRemote(t));
864
+ if (remoteIdx < 0)
865
+ return 'write'; // local→local copy still changes files
866
+ return localIdx >= 0 && remoteIdx > localIdx ? 'write' : 'read'; // remote LAST = upload
867
+ }
868
+ if (/\b(git\s+(?:add|commit|push|merge|rebase|cherry-pick|stash|checkout|switch|restore|tag\s+\S)|npm\s+(?:install|ci|i|uninstall|update|publish)|pnpm\s+(?:install|add|i)|yarn(?:\s+add|\s+install)?|pip3?\s+install|poetry\s+(?:install|add)|cargo\s+(?:build|install)|go\s+(?:build|install|get|mod)|gcloud\s+run\s+deploy|firebase\s+deploy|terraform\s+apply|kubectl\s+apply|docker\s+(?:push|build|compose\s+up)|helm\s+(?:install|upgrade))\b/.test(value)) {
869
+ return 'write';
485
870
  }
486
871
  return 'SHELL';
487
872
  }
873
+ function classifyShellCommandOperation(command) {
874
+ const segments = command.split(/\s*(?:&&|\|\||;|\r?\n)\s*/).map(s => s.trim()).filter(Boolean);
875
+ if (segments.length === 0)
876
+ 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;
884
+ }
488
885
  function isInternalHost(hostname) {
489
886
  const host = hostname.toLowerCase();
490
887
  if (!host || host === 'localhost' || host === '::1' || host.endsWith('.local') || host.endsWith('.internal'))
@@ -635,7 +1032,9 @@ function inferToolContext(toolName, args) {
635
1032
  const fanoutCount = Number(args.fanoutCount ?? args.recipientCount ?? args.childCalls ?? (Array.isArray(args.recipients) ? args.recipients.length : 0));
636
1033
  const burstCount = Math.max(Number.isFinite(retryCount) ? retryCount : 0, Number.isFinite(fanoutCount) ? fanoutCount : 0);
637
1034
  context['toolArgs.bytes'] = String(Buffer.byteLength(argsText, 'utf8'));
638
- context['toolArgs.containsSecret'] = hasSecretLikeValue(argsText) ? 'true' : 'false';
1035
+ const secretKind = detectSecretKind(argsText);
1036
+ context['toolArgs.containsSecret'] = secretKind !== 'none' ? 'true' : 'false';
1037
+ context['toolArgs.secretKind'] = secretKind;
639
1038
  context['toolArgs.containsPii'] = hasPiiLikeValue(argsText) ? 'true' : 'false';
640
1039
  context['retry.count'] = String(burstCount);
641
1040
  context['fanout.count'] = String(burstCount);
@@ -669,7 +1068,12 @@ function inferToolContext(toolName, args) {
669
1068
  // and CLI flags (`--delete-branch`) must not classify as SELECT/DELETE — real SQL never
670
1069
  // hyphenates these verbs. Misclassifying also poisons context.query, which would count
671
1070
  // ordinary shell pipes against database session budgets.
672
- const sqlArg = args.query || args.sql || args.statement || args.command || args.code || args.script || args.input || '';
1071
+ // Shell / code-execution tools are classified by the shell model below, never
1072
+ // by a SQL verb sniffed out of the command line — `git commit -m "delete old
1073
+ // loader"` and `grep "DROP TABLE"` are not DELETE/DROP. A SQL client invoked
1074
+ // 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);
1076
+ const sqlArg = args.query || args.sql || args.statement || (isCodeExecTool ? '' : (args.command || args.code || args.script || args.input)) || '';
673
1077
  if (!isSearchTool && typeof sqlArg === 'string' && sqlArg.length > 0) {
674
1078
  const sqlMatch = sqlArg.match(/(?<!-)\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|SHOW|DESCRIBE|EXPLAIN)\b(?!-)/i);
675
1079
  if (sqlMatch) {
@@ -681,7 +1085,9 @@ function inferToolContext(toolName, args) {
681
1085
  // but classify common command text first so safe internal reads (git status, pwd, ls)
682
1086
  // do not get blocked as high-impact execution.
683
1087
  if (operation === toolName && /\b(node[_-]?repl|repl|shell|exec|terminal|command|run[_-]?code)\b/i.test(toolName)) {
684
- operation = classifyShellCommandOperation(String(args.command || args.code || args.script || args.input || argsText || ''));
1088
+ // Only the command text is classified — never the whole args JSON, which
1089
+ // would let a file body or a comment turn a shell call into DELETE.
1090
+ operation = classifyShellCommandOperation(String(args.command || args.code || args.script || args.input || ''));
685
1091
  }
686
1092
  // File path detection
687
1093
  const pathArg = args.path || args.filepath || args.file || args.filename || '';
@@ -709,9 +1115,27 @@ function inferToolContext(toolName, args) {
709
1115
  context.url = detectedUrl;
710
1116
  try {
711
1117
  const parsed = new URL(detectedUrl);
1118
+ const hostType = isInternalHost(parsed.hostname) ? 'internal' : 'external';
1119
+ // DIRECTION is the fact an egress policy means. Data flows OUT when the
1120
+ // action writes/sends/posts; it flows IN when the action reads/fetches/
1121
+ // clones/downloads. `destination.type` is only set for outbound flows —
1122
+ // `curl https://api.github.com/…`, `git clone https://…`, an MCP `fetch`
1123
+ // are sources, not destinations (blocking them as "egress" stopped every
1124
+ // download). The host is always recorded (`destination.domain`, `url.risk`)
1125
+ // so reach and host-based rules keep working in both directions.
1126
+ const inbound = operation === 'read'
1127
+ || ['GET', 'HEAD', 'OPTIONS', 'SELECT', 'SHOW', 'DESCRIBE', 'EXPLAIN'].includes(operation)
1128
+ || (operation === toolName && /\b(fetch|get|read|search|list|lookup|browse|navigate|download|clone|pull|view|open|crawl|scrape)\b/.test(nameWords)
1129
+ && !/\b(post|put|send|upload|push|write|submit|publish|export|create|update|delete|patch)\b/.test(nameWords));
712
1130
  context['destination.domain'] = parsed.hostname;
713
- context['destination.type'] = isInternalHost(parsed.hostname) ? 'internal' : 'external';
714
- context['url.risk'] = isInternalHost(parsed.hostname) ? 'internal' : 'external';
1131
+ context['url.risk'] = hostType;
1132
+ if (inbound) {
1133
+ context['source.domain'] = parsed.hostname;
1134
+ context['source.type'] = hostType;
1135
+ }
1136
+ else {
1137
+ context['destination.type'] = hostType;
1138
+ }
715
1139
  }
716
1140
  catch {
717
1141
  context['url.risk'] = 'invalid';