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.
- package/dist/actionPolicyEngine.d.ts +22 -0
- package/dist/actionPolicyEngine.js +446 -22
- package/dist/blockExplanation.d.ts +95 -0
- package/dist/blockExplanation.js +240 -0
- package/dist/commands/cmdGuard.js +6 -0
- package/dist/commands/hook.js +116 -21
- package/dist/commands/mcpGateway.js +53 -14
- package/dist/commands/posixShellGuard.d.ts +12 -0
- package/dist/commands/posixShellGuard.js +8 -0
- package/dist/commands/shellGuard.d.ts +2 -0
- package/dist/commands/shellGuard.js +34 -6
- package/dist/notify.d.ts +2 -0
- package/dist/notify.js +5 -0
- package/dist/version.json +1 -1
- package/package.json +4 -1
|
@@ -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
|
|
446
|
-
|
|
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
|
|
647
|
+
return detectSecretKind(value) !== 'none';
|
|
460
648
|
}
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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
|
-
|
|
471
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
484
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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['
|
|
714
|
-
|
|
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';
|