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.
- package/dist/actionPolicyEngine.d.ts +22 -0
- package/dist/actionPolicyEngine.js +457 -22
- package/dist/blockExplanation.d.ts +95 -0
- package/dist/blockExplanation.js +240 -0
- package/dist/commands/agentCi.d.ts +2 -0
- package/dist/commands/agentCi.js +14 -0
- package/dist/commands/cmdGuard.js +6 -0
- package/dist/commands/discover.js +7 -2
- package/dist/commands/hook.d.ts +35 -0
- package/dist/commands/hook.js +247 -44
- package/dist/commands/mcpGateway.js +62 -17
- 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/fileWriteCanon.d.ts +27 -0
- package/dist/fileWriteCanon.js +107 -0
- package/dist/notify.d.ts +2 -0
- package/dist/notify.js +5 -0
- package/dist/version.json +1 -1
- package/package.json +6 -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=<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
|
|
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,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
|
|
658
|
+
return detectSecretKind(value) !== 'none';
|
|
460
659
|
}
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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
|
-
|
|
471
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
484
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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['
|
|
714
|
-
|
|
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';
|