fullcourtdefense-cli 1.29.0 → 1.31.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=<real value>`, `"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,427 @@ 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
+ // A generic token is TOKEN-SHAPED: base64 / base64url / hex / a prefixed id.
515
+ // Source code that happens to sit after `=` or `:` — call expressions
516
+ // (`parseInput(args.files`), member chains (`process.env.GITHUB_REPOSITORY`),
517
+ // regex literals, array indexing — carries parens, brackets and operators
518
+ // that no credential format uses. Structural, so it holds for every language.
519
+ if (!/^[A-Za-z0-9._~+/=-]+$/.test(token))
520
+ return false;
521
+ // A dotted chain of identifiers (`config.auth.clientSecret`, `DEFAULT_POLICY.scope`)
522
+ // is a reference to a value, not the value.
523
+ if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(token))
524
+ return false;
525
+ if (/^[0-9a-f]+$/i.test(token))
526
+ return false; // hex digest / git sha
527
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(token))
528
+ return false; // uuid
529
+ if (/^sha(?:1|256|384|512)-/i.test(token))
530
+ return false; // sri / lockfile integrity
531
+ if (/^data:/i.test(token) || /^[A-Za-z0-9+/]{200,}={0,2}$/.test(token))
532
+ return false; // embedded binary
533
+ if (/^(?:https?|s3|gs|file):\/\//i.test(token) || /[\\/]{1}[\w.-]+[\\/]/.test(token))
534
+ return false; // url / path
535
+ if (/^[a-z]+(?:[A-Z][a-z0-9]+)+$/.test(token) || /^[a-z]+(?:_[a-z0-9]+)+$/.test(token))
536
+ return false; // camelCase / snake_case identifiers
537
+ let classes = 0;
538
+ if (/[a-z]/.test(token))
539
+ classes++;
540
+ if (/[A-Z]/.test(token))
541
+ classes++;
542
+ if (/[0-9]/.test(token))
543
+ classes++;
544
+ if (/[^A-Za-z0-9]/.test(token))
545
+ classes++;
546
+ if (classes < 3)
547
+ return false;
548
+ return shannonEntropy(token) >= 4.0;
549
+ }
550
+ 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)`;
551
+ // name, optional quotes/JSON escapes, = or : or =>, optional quotes/escapes, value (stops at whitespace, quotes, delimiters, backslash from JSON escaping).
552
+ // The name may carry a prefix (`JWT_SECRET`, `STRIPE_SECRET_KEY`, `db.password`) — it is the suffix that says "this is a credential".
553
+ const SECRET_ASSIGNMENT_RE = new RegExp(String.raw `(?:^|[^\w])[\w.-]*?${SECRET_NAME}[\\"']*\s*(?:=>|[:=])\s*[\\"']*([^\s\\"',;&|)}\]<>]{8,})`, 'gi');
554
+ // CLI flag form: --password X / --password=X / --token X / --api-key X
555
+ const SECRET_FLAG_RE = /--?(?:password|passwd|pwd|token|api-?key|secret|client-secret|access-token)(?:=|\s+)[\\"']*([^\s\\"',;&|)}\]<>]{8,})/gi;
556
+ 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;
557
+ // Code expressions are references to a secret, not the secret: req.body.password, config.get("token"), os.environ["KEY"], getSecret(), env.API_KEY
558
+ 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\(.*)$/;
559
+ function looksLikeSecretValue(raw) {
560
+ const value = raw.replace(/[\\"'`]+$/, '');
561
+ if (value.length < 8)
562
+ return false;
563
+ // `API_KEY=` (empty) followed by the next line `DATABASE_URL=…` is two assignments, not a value.
564
+ if (/^[A-Za-z_][A-Za-z0-9_.-]*\s*[:=]/.test(value))
565
+ return false;
566
+ if (SECRET_PLACEHOLDER_RE.test(value))
567
+ return false;
568
+ if (SECRET_CODE_EXPR_RE.test(value))
569
+ return false;
570
+ // A URL, path or hostname as a "password" value is configuration, not a credential value.
571
+ if (/^(?:https?:\/\/|[a-z]:\\|\.{0,2}\/|~\/)/i.test(value))
572
+ return false;
573
+ // A connection string is a secret only if the password INSIDE it is one
574
+ // (`postgres://user:password@localhost/app` in an example file is not).
575
+ const conn = value.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^:@\/\s]+:([^@\/\s]+)@)?/i);
576
+ if (conn)
577
+ return conn[1] ? looksLikeSecretValue(conn[1]) : false;
578
+ // Plain dictionary-ish word (letters only) is a description, not a secret, unless long.
579
+ if (/^[a-z]+$/i.test(value) && value.length < 16)
580
+ return false;
581
+ // Structural placeholder shapes: a run of one repeated character (xxxx, 0000,
582
+ // aaaa), a hyphen/underscore-joined phrase of plain words (unit-test-secret-not-
583
+ // real, change-me, your-token-here), or a "fake/test/example/..." prefix.
584
+ if (/(.)\1{4,}/.test(value))
585
+ return false;
586
+ if (/^[a-z]+(?:[-_.][a-z]+)+$/i.test(value))
587
+ return false;
588
+ 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))
589
+ return false;
590
+ let classes = 0;
591
+ if (/[a-z]/.test(value))
592
+ classes++;
593
+ if (/[A-Z]/.test(value))
594
+ classes++;
595
+ if (/[0-9]/.test(value))
596
+ classes++;
597
+ if (/[^A-Za-z0-9]/.test(value))
598
+ classes++;
599
+ if (classes < 2)
600
+ return false;
601
+ // Real credential values are random; prose-like values are not. 3.0 bits/char
602
+ // separates "Tr0ub4dor&3" (3.3) from "devpassword" (2.9) deterministically.
603
+ return shannonEntropy(value) >= 3.0;
604
+ }
605
+ /** Args arrive JSON-stringified: `\n` / `\t` are two characters and `\"` is an escaped quote. Undo that so line ends and quotes terminate values. */
606
+ function unescapeArgsText(text) {
607
+ return text.replace(/\\[nrt]/g, ' ').replace(/\\(["'\\/])/g, '$1');
608
+ }
609
+ function detectSecretKind(raw) {
610
+ if (!raw)
611
+ return 'none';
612
+ const value = unescapeArgsText(raw);
613
+ if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(value))
614
+ return 'private_key';
615
+ // Known token formats: the prefix says WHAT it is, the body must still LOOK
616
+ // random. `ghp_xxxxxxxxxxxxxxxxxxxx` and `AKIAXXXXXXXXXXXXXXXX` in a README
617
+ // are documentation, not credentials.
618
+ const KNOWN = [
619
+ ['aws_access_key', /\bAKIA([0-9A-Z]{16})\b/g],
620
+ ['google_api_key', /\bAIza([0-9A-Za-z_-]{30,})/g],
621
+ ['github_token', /\bgh[pousr]_([A-Za-z0-9_]{20,})\b/g],
622
+ ['openai_key', /\bsk-(?:proj-|ant-|live-|test-)?([A-Za-z0-9_-]{20,})\b/g],
623
+ ['slack_token', /\bxox[baprs]-([A-Za-z0-9-]{10,})\b/g],
624
+ ['jwt', /\beyJ[A-Za-z0-9_-]{8,}\.(eyJ[A-Za-z0-9_-]{8,})\.[A-Za-z0-9_-]{8,}\b/g],
625
+ ];
626
+ for (const [kind, re] of KNOWN) {
627
+ re.lastIndex = 0;
628
+ let m;
629
+ while ((m = re.exec(value)) !== null) {
630
+ if (plausibleTokenBody(m[1]))
631
+ return kind;
632
+ }
633
+ }
634
+ for (const re of [SECRET_ASSIGNMENT_RE, SECRET_FLAG_RE]) {
635
+ re.lastIndex = 0;
636
+ let m;
637
+ while ((m = re.exec(value)) !== null) {
638
+ if (looksLikeSecretValue(m[1]))
639
+ return 'assignment';
640
+ }
641
+ }
642
+ // Generic: ANY field / flag / auth header whose value has the statistics of
643
+ // a secret — catches vendors and field names nobody listed. Position matters
644
+ // (a value being assigned or sent), so prose and code identifiers never qualify.
645
+ for (const re of [GENERIC_VALUE_RE, AUTH_HEADER_RE]) {
646
+ re.lastIndex = 0;
647
+ let m;
648
+ while ((m = re.exec(value)) !== null) {
649
+ if (isHighEntropySecretToken(m[1].replace(/[\\"'`]+$/, '')))
650
+ return 'high_entropy';
651
+ }
652
+ }
653
+ return 'none';
654
+ }
655
+ const GENERIC_VALUE_RE = /\b[A-Za-z_][\w.-]{1,63}[\\"']*\s*(?:=>|[:=])\s*[\\"']*([^\s\\"',;&|)}\]<>]{24,})/g;
656
+ const AUTH_HEADER_RE = /\b(?:bearer|basic|token|apikey|x-api-key)\s+([A-Za-z0-9._~+/=-]{24,})/gi;
458
657
  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);
658
+ return detectSecretKind(value) !== 'none';
460
659
  }
461
- function hasPiiLikeValue(value) {
462
- return /([A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}|\+?\d[\d\s().-]{8,}\d)/i.test(value);
660
+ /**
661
+ * "Contains PII" means a real person's contact data is present. Excludes the
662
+ * addresses every codebase is full of (example.com, noreply, localhost, test
663
+ * domains) and digit runs that are not phone numbers (timestamps, IPs,
664
+ * versions, ISO dates, hashes): a phone number needs phone punctuation and
665
+ * 9-15 digits.
666
+ */
667
+ const PII_EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
668
+ 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;
669
+ const PII_PHONE_RE = /(?:^|[^\w.@/:-])(\+?\(?\d{1,4}\)?[\s-]\(?\d{2,4}\)?[\s-]\d{2,4}(?:[\s-]\d{2,4}){0,2})(?![\w.@/:-])/g;
670
+ function hasPiiLikeValue(raw) {
671
+ if (!raw)
672
+ return false;
673
+ const value = unescapeArgsText(raw);
674
+ PII_EMAIL_RE.lastIndex = 0;
675
+ let m;
676
+ while ((m = PII_EMAIL_RE.exec(value)) !== null) {
677
+ if (PII_EMAIL_IGNORE_RE.test(m[0]))
678
+ continue;
679
+ // `user:pass@host` inside a URI (`postgres://app:pw@db.internal`, `https://key@o0.ingest.sentry.io`)
680
+ // is connection userinfo, not a person's address.
681
+ const before = value.slice(Math.max(0, m.index - 64), m.index);
682
+ if (/[a-z][a-z0-9+.-]*:\/\/[^\s@]*$/i.test(before))
683
+ continue;
684
+ return true;
685
+ }
686
+ PII_PHONE_RE.lastIndex = 0;
687
+ while ((m = PII_PHONE_RE.exec(value)) !== null) {
688
+ const candidate = m[1].trim();
689
+ if (/^\d{4}-\d{2}-\d{2}/.test(candidate))
690
+ continue; // ISO date
691
+ const digits = candidate.replace(/\D/g, '');
692
+ if (/^(\d)\1+$/.test(digits) || /^(?:0123456789|1234567890|555)/.test(digits))
693
+ continue; // 000-000-0000, 555 fixtures
694
+ if (digits.length >= 9 && digits.length <= 15)
695
+ return true;
696
+ }
697
+ return false;
463
698
  }
464
- function extractUrl(args, argsText) {
465
- const direct = args.url || args.uri || args.endpoint || args.webhookUrl || args.callbackUrl || args.href;
699
+ /**
700
+ * The destination of an action is a typed URL argument, or a URL in the
701
+ * command line / request target of the call. It is NOT a link that happens to
702
+ * appear inside file content, a document body or a code edit — a README that
703
+ * mentions https://example.com is not an egress to example.com (that misread
704
+ * turned every `edit_file` into `write + destination.type=external`, which an
705
+ * egress-block policy then stopped).
706
+ */
707
+ const URL_ACTION_FIELDS = ['command', 'cmd', 'script', 'input', 'target', 'address', 'host', 'server', 'baseUrl', 'base_url', 'apiUrl', 'api_url', 'remote', 'repo', 'repository', 'source', 'destination'];
708
+ function extractUrl(args, _argsText) {
709
+ const direct = args.url || args.uri || args.endpoint || args.webhookUrl || args.callbackUrl || args.href || args.link;
466
710
  if (typeof direct === 'string' && direct.trim())
467
711
  return direct.trim();
468
- return argsText.match(/https?:\/\/[^\s"'<>]+/i)?.[0] || '';
712
+ for (const field of URL_ACTION_FIELDS) {
713
+ const value = args[field];
714
+ if (typeof value !== 'string')
715
+ continue;
716
+ // Web URLs first; storage / transfer schemes (s3://, gs://, ftp://) are
717
+ // destinations too when they appear on a command line.
718
+ const found = value.match(/https?:\/\/[^\s"'<>]+/i)?.[0] || value.match(/\b(?:s3|gs|ftp|sftp|smb):\/\/[^\s"'<>]+/i)?.[0];
719
+ if (found)
720
+ return found;
721
+ }
722
+ return '';
469
723
  }
470
- function classifyShellCommandOperation(command) {
471
- const value = command.trim().toLowerCase();
724
+ /**
725
+ * A recursive removal whose EVERY target is a plain relative path inside the
726
+ * working tree (`rm -rf node_modules dist`, `Remove-Item -Recurse -Force .\build`,
727
+ * `del /s /q coverage`) is routine developer work. Anything that could reach
728
+ * outside the repo — an absolute path, a drive, `~`/`$HOME`/`%USERPROFILE%`,
729
+ * a parent reference, a bare `*` or `.`, or a shell variable whose value we
730
+ * cannot see — is NOT workspace-scoped and stays a destructive DELETE.
731
+ * Deterministic: decided from the command's own tokens, never from a guess.
732
+ */
733
+ function isWorkspaceScopedRemoval(command) {
734
+ // Only the removal segment: `npm run build && rm -rf dist` → `rm -rf dist`.
735
+ const segments = command.split(/\s*(?:&&|\|\||;|\|)\s*/);
736
+ const removal = segments.find(seg => /\b(rm|del|remove-item|rmdir)\b/i.test(seg)) || command;
737
+ const tokens = removal.trim().split(/\s+/).slice(1);
738
+ const targets = tokens.filter(t => !/^-/.test(t) && !/^\/[fqs]$/i.test(t) && !/^-(?:recurse|force|literalpath|path|erroraction)$/i.test(t));
739
+ if (targets.length === 0)
740
+ return false;
741
+ return targets.every(raw => {
742
+ const t = raw.replace(/^["']|["']$/g, '');
743
+ if (!t || t === '*' || t === '.' || t === '..' || /^\.\/?\*$/.test(t) || /^\.\\\*$/.test(t))
744
+ return false;
745
+ if (/^[\/\\]/.test(t) || /^[A-Za-z]:/.test(t) || /^~/.test(t))
746
+ return false; // absolute / drive / home
747
+ if (/\$|%|\$\{|\$\(|`/.test(t))
748
+ return false; // variable or subshell we cannot see
749
+ if (/(^|[\/\\])\.\.([\/\\]|$)/.test(t))
750
+ return false; // climbs out of the tree
751
+ if (/^\*\*?$|^[\/\\]?\*$/.test(t))
752
+ return false;
753
+ return true;
754
+ });
755
+ }
756
+ /**
757
+ * Shell classification model — Process → Command → Arguments → Target:
758
+ *
759
+ * 1. A command line is a list of SEGMENTS (`a && b`, `a; b`, `a || b`, newlines).
760
+ * Each segment is classified on its own; the line takes the most severe
761
+ * result (DELETE > write > SHELL > read).
762
+ * 2. Inside a segment the LEADING PROGRAM decides what happens. Arguments to a
763
+ * program that does not execute them are data: `grep "DROP TABLE"`,
764
+ * `git commit -m "delete old loader"`, `echo rm -rf` never destroy anything.
765
+ * Programs that DO execute their argument (sql clients with -c/-e, `bash -c`,
766
+ * `| sh`) are classified by that argument.
767
+ * 3. Destructive scope is decided from the target tokens (see
768
+ * isWorkspaceScopedRemoval): the same `rm -rf` is a workspace write on
769
+ * `node_modules` and a DELETE on `/`, `~`, a drive or a variable.
770
+ *
771
+ * Deterministic and explainable; the Local Safety catalog (regex on the exact
772
+ * dangerous forms) remains the hard-block layer underneath this.
773
+ */
774
+ const SHELL_OP_RANK = {
775
+ DELETE: 4, DROP: 4, TRUNCATE: 4,
776
+ write: 3, INSERT: 3, UPDATE: 3, CREATE: 3, ALTER: 3, GRANT: 3, REVOKE: 3,
777
+ SHELL: 2,
778
+ read: 1, SELECT: 1, SHOW: 1, DESCRIBE: 1, EXPLAIN: 1,
779
+ };
780
+ const SHELL_READ_PROGRAMS = new Set([
781
+ 'pwd', 'ls', 'dir', 'echo', 'whoami', 'cat', 'head', 'tail', 'less', 'more', 'rg', 'grep', 'egrep', 'fgrep', 'ag', 'find', 'fd', 'which', 'where', 'type', 'tree', 'stat',
782
+ 'wc', 'env', 'printenv', 'file', 'du', 'df', 'ps', 'top', 'date', 'sort', 'uniq', 'cut', 'jq', 'yq', 'diff', 'cmp', 'md5sum', 'sha256sum',
783
+ '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',
784
+ ]);
785
+ const SHELL_SQL_CLIENTS = new Set(['psql', 'mysql', 'mariadb', 'sqlcmd', 'sqlite3', 'mongo', 'mongosh', 'clickhouse-client', 'bq', 'cockroach', 'usql', 'pgcli', 'mycli']);
786
+ 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']);
787
+ function shellSegmentLead(segment) {
788
+ const tokens = segment.trim().split(/\s+/).filter(Boolean);
789
+ let i = 0;
790
+ // `FOO=bar cmd`, `sudo cmd`, `time cmd`, `nohup cmd`, `command cmd`, `env cmd`
791
+ while (i < tokens.length && (/^[A-Za-z_][\w]*=/.test(tokens[i]) || /^(?:sudo|time|nohup|command|builtin|exec|nice|env)$/i.test(tokens[i])))
792
+ i++;
793
+ const lead = (tokens[i] || '').toLowerCase().replace(/^["']|["']$/g, '');
794
+ return lead.replace(/^.*[\\/]/, '').replace(/\.(exe|cmd|bat|ps1)$/i, '');
795
+ }
796
+ function classifyShellSegment(segment) {
797
+ const value = segment.trim().toLowerCase();
472
798
  if (!value)
799
+ return 'read';
800
+ const lead = shellSegmentLead(segment);
801
+ // Shell navigation / session builtins do nothing to the machine on their own.
802
+ if (SHELL_NEUTRAL_BUILTINS.has(lead) || /^\$env:[\w]+$/.test(lead) || /^\$\w+$/.test(lead))
803
+ 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
+ // A SQL client executes the statement it is given (`psql -c "DROP TABLE x"`,
807
+ // `mysql -e …`, `echo "TRUNCATE …" | psql`). The exact SQL verb is the
808
+ // 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);
810
+ if (sqlClient) {
811
+ const verb = segment.match(/(?<!-)\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|SHOW|DESCRIBE|EXPLAIN)\b(?!-)/i);
812
+ if (verb)
813
+ return verb[1].toUpperCase();
473
814
  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
815
  }
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)) {
816
+ if (/\b(curl|wget|invoke-restmethod|invoke-webrequest|iwr|irm)\b/.test(value)) {
817
+ // Before the read programs so `cat .env | curl -d @- https://…` is still an
818
+ // egress WRITE. The HTTP method comes from the method flag or a request-body
819
+ // flag — never from the word "delete"/"post" appearing inside a URL.
820
+ const explicitMethod = /(?:\s-x|\s--request|\s-method)[\s=]+["']?(post|put|patch|delete)\b/i.test(segment);
821
+ const hasBody = /\s(?:-d|--data(?:-raw|-binary|-urlencode)?|-F|--form|-T|--upload-file|--json|-body|-infile)\b/i.test(segment);
822
+ if (explicitMethod || hasBody)
823
+ return 'write';
824
+ return pipesIntoInterpreter ? 'SHELL' : 'read';
825
+ }
826
+ // Leading program that only reads/prints: its arguments are text, not actions
827
+ // — unless the output is redirected into a file or piped into an interpreter.
828
+ if (SHELL_READ_PROGRAMS.has(lead)) {
829
+ if (pipesIntoInterpreter)
830
+ return 'SHELL';
831
+ const redirect = value.match(/(?:^|[^>0-9])>{1,2}\s*([^\s&|;]+)/);
832
+ if (redirect && !/^\/dev\/null$|^nul$/i.test(redirect[1]))
833
+ return 'write';
478
834
  return 'read';
479
835
  }
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)) {
836
+ // Resetting or cleaning the working tree only ever touches the repo the
837
+ // developer is standing in — a workspace write, not a destructive delete.
838
+ if (lead === 'git' && /\bgit\s+(reset\s+--hard|clean\s+-[a-z]*f[a-z]*)\b/.test(value))
481
839
  return 'write';
840
+ 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))
841
+ return 'read';
842
+ if (lead === 'gh' && /\bgh\s+(?:pr|issue|repo|run|release|workflow)\s+(?:view|list|status|checks|diff|download)\b/.test(value))
843
+ return 'read';
844
+ if (lead === 'gh' && /\bgh\s+auth\s+status\b/.test(value) && !/--show-token|\s-t\b/.test(value))
845
+ return 'read';
846
+ 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))
847
+ return 'read';
848
+ 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)) {
849
+ return isWorkspaceScopedRemoval(segment) ? 'write' : 'DELETE';
482
850
  }
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';
851
+ 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)) {
852
+ return 'DELETE';
853
+ }
854
+ // File transfers: the LAST operand is where the data goes. Remote last = upload
855
+ // (egress write); remote first = download (read). `aws s3 cp x s3://b/`,
856
+ // `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) {
859
+ if (/az\s+storage\s+blob\s+upload/.test(value))
860
+ return 'write';
861
+ // Operands = tokens that are not flags and not the value of a `--flag value` pair.
862
+ const tokens = xfer[1].split(/\s+/).filter(Boolean);
863
+ const operands = [];
864
+ for (let i = 0; i < tokens.length; i++) {
865
+ if (tokens[i].startsWith('-')) {
866
+ if (!tokens[i].includes('=') && /^--?(?:profile|region|endpoint-url|sse|acl|storage-class|exclude|include|i|P|o|e|l)$/.test(tokens[i]))
867
+ i++;
868
+ continue;
869
+ }
870
+ operands.push(tokens[i]);
871
+ }
872
+ const isRemote = (t) => /^(?:s3|gs|az|ftp|sftp|smb):\/\/|^[\w.-]+@[\w.-]+:|^[\w.-]+:(?:[\/~]|$)/.test(t);
873
+ const remoteIdx = operands.findIndex(isRemote);
874
+ const localIdx = operands.findIndex(t => !isRemote(t));
875
+ if (remoteIdx < 0)
876
+ return 'write'; // local→local copy still changes files
877
+ return localIdx >= 0 && remoteIdx > localIdx ? 'write' : 'read'; // remote LAST = upload
878
+ }
879
+ 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)) {
880
+ return 'write';
485
881
  }
486
882
  return 'SHELL';
487
883
  }
884
+ function classifyShellCommandOperation(command) {
885
+ const segments = command.split(/\s*(?:&&|\|\||;|\r?\n)\s*/).map(s => s.trim()).filter(Boolean);
886
+ if (segments.length === 0)
887
+ 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;
895
+ }
488
896
  function isInternalHost(hostname) {
489
897
  const host = hostname.toLowerCase();
490
898
  if (!host || host === 'localhost' || host === '::1' || host.endsWith('.local') || host.endsWith('.internal'))
@@ -635,7 +1043,9 @@ function inferToolContext(toolName, args) {
635
1043
  const fanoutCount = Number(args.fanoutCount ?? args.recipientCount ?? args.childCalls ?? (Array.isArray(args.recipients) ? args.recipients.length : 0));
636
1044
  const burstCount = Math.max(Number.isFinite(retryCount) ? retryCount : 0, Number.isFinite(fanoutCount) ? fanoutCount : 0);
637
1045
  context['toolArgs.bytes'] = String(Buffer.byteLength(argsText, 'utf8'));
638
- context['toolArgs.containsSecret'] = hasSecretLikeValue(argsText) ? 'true' : 'false';
1046
+ const secretKind = detectSecretKind(argsText);
1047
+ context['toolArgs.containsSecret'] = secretKind !== 'none' ? 'true' : 'false';
1048
+ context['toolArgs.secretKind'] = secretKind;
639
1049
  context['toolArgs.containsPii'] = hasPiiLikeValue(argsText) ? 'true' : 'false';
640
1050
  context['retry.count'] = String(burstCount);
641
1051
  context['fanout.count'] = String(burstCount);
@@ -669,7 +1079,12 @@ function inferToolContext(toolName, args) {
669
1079
  // and CLI flags (`--delete-branch`) must not classify as SELECT/DELETE — real SQL never
670
1080
  // hyphenates these verbs. Misclassifying also poisons context.query, which would count
671
1081
  // ordinary shell pipes against database session budgets.
672
- const sqlArg = args.query || args.sql || args.statement || args.command || args.code || args.script || args.input || '';
1082
+ // Shell / code-execution tools are classified by the shell model below, never
1083
+ // by a SQL verb sniffed out of the command line — `git commit -m "delete old
1084
+ // loader"` and `grep "DROP TABLE"` are not DELETE/DROP. A SQL client invoked
1085
+ // 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);
1087
+ const sqlArg = args.query || args.sql || args.statement || (isCodeExecTool ? '' : (args.command || args.code || args.script || args.input)) || '';
673
1088
  if (!isSearchTool && typeof sqlArg === 'string' && sqlArg.length > 0) {
674
1089
  const sqlMatch = sqlArg.match(/(?<!-)\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|SHOW|DESCRIBE|EXPLAIN)\b(?!-)/i);
675
1090
  if (sqlMatch) {
@@ -681,7 +1096,9 @@ function inferToolContext(toolName, args) {
681
1096
  // but classify common command text first so safe internal reads (git status, pwd, ls)
682
1097
  // do not get blocked as high-impact execution.
683
1098
  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 || ''));
1099
+ // Only the command text is classified — never the whole args JSON, which
1100
+ // would let a file body or a comment turn a shell call into DELETE.
1101
+ operation = classifyShellCommandOperation(String(args.command || args.code || args.script || args.input || ''));
685
1102
  }
686
1103
  // File path detection
687
1104
  const pathArg = args.path || args.filepath || args.file || args.filename || '';
@@ -709,9 +1126,27 @@ function inferToolContext(toolName, args) {
709
1126
  context.url = detectedUrl;
710
1127
  try {
711
1128
  const parsed = new URL(detectedUrl);
1129
+ const hostType = isInternalHost(parsed.hostname) ? 'internal' : 'external';
1130
+ // DIRECTION is the fact an egress policy means. Data flows OUT when the
1131
+ // action writes/sends/posts; it flows IN when the action reads/fetches/
1132
+ // clones/downloads. `destination.type` is only set for outbound flows —
1133
+ // `curl https://api.github.com/…`, `git clone https://…`, an MCP `fetch`
1134
+ // are sources, not destinations (blocking them as "egress" stopped every
1135
+ // download). The host is always recorded (`destination.domain`, `url.risk`)
1136
+ // so reach and host-based rules keep working in both directions.
1137
+ const inbound = operation === 'read'
1138
+ || ['GET', 'HEAD', 'OPTIONS', 'SELECT', 'SHOW', 'DESCRIBE', 'EXPLAIN'].includes(operation)
1139
+ || (operation === toolName && /\b(fetch|get|read|search|list|lookup|browse|navigate|download|clone|pull|view|open|crawl|scrape)\b/.test(nameWords)
1140
+ && !/\b(post|put|send|upload|push|write|submit|publish|export|create|update|delete|patch)\b/.test(nameWords));
712
1141
  context['destination.domain'] = parsed.hostname;
713
- context['destination.type'] = isInternalHost(parsed.hostname) ? 'internal' : 'external';
714
- context['url.risk'] = isInternalHost(parsed.hostname) ? 'internal' : 'external';
1142
+ context['url.risk'] = hostType;
1143
+ if (inbound) {
1144
+ context['source.domain'] = parsed.hostname;
1145
+ context['source.type'] = hostType;
1146
+ }
1147
+ else {
1148
+ context['destination.type'] = hostType;
1149
+ }
715
1150
  }
716
1151
  catch {
717
1152
  context['url.risk'] = 'invalid';