@shomra/agent 0.3.26 → 0.3.28
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/package.json +1 -1
- package/src/detect/local-redact.mjs +67 -0
- package/src/detect/signals/consequence.mjs +5 -3
- package/src/detect/signals/memory.mjs +43 -10
- package/src/detect/signals/prose-context.mjs +33 -6
- package/src/detect/signals/shell.mjs +54 -13
- package/src/guard/prompt-guard.mjs +16 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shomra/agent",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.28",
|
|
4
4
|
"description": "Shomra - adversarial assurance for AI agents, as a local-first CLI. Blocks dangerous tool-calls before they run, attacks your own guardrails to prove they hold, and gates AI artifacts in your editor and CI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { PII_PATTERNS, SECRET_PATTERNS, isPlaceholderSecret, luhnValid } from './signals/secrets.mjs';
|
|
2
|
+
|
|
3
|
+
const MAX_TEXT = 200_000;
|
|
4
|
+
const MAX_SPANS = 200;
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
export function redactLocally(text, opts = {}) {
|
|
8
|
+
const src = String(text ?? '');
|
|
9
|
+
if (!src || src.length > MAX_TEXT) return { text: src, masked: [], unmaskable: [], changed: false };
|
|
10
|
+
|
|
11
|
+
const categories = opts.categories ?? ['secret', 'pii'];
|
|
12
|
+
const spans = [];
|
|
13
|
+
|
|
14
|
+
const collect = (re, label, category) => {
|
|
15
|
+
const rx = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
|
|
16
|
+
let m;
|
|
17
|
+
let guard = 0;
|
|
18
|
+
while ((m = rx.exec(src)) !== null && guard++ < MAX_SPANS) {
|
|
19
|
+
if (!m[0]) { rx.lastIndex += 1; continue; }
|
|
20
|
+
if (category === 'secret' && isPlaceholderSecret(m[0])) continue;
|
|
21
|
+
if (label === 'Credit card number' && !luhnValid(m[0])) continue;
|
|
22
|
+
spans.push({ start: m.index, end: m.index + m[0].length, label, category });
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
if (categories.includes('secret')) for (const { name, re } of SECRET_PATTERNS) collect(re, name, 'secret');
|
|
27
|
+
if (categories.includes('pii')) for (const { name, re } of PII_PATTERNS) collect(re, name, 'pii');
|
|
28
|
+
|
|
29
|
+
if (!spans.length) return { text: src, masked: [], unmaskable: [], changed: false };
|
|
30
|
+
|
|
31
|
+
spans.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
32
|
+
const merged = [];
|
|
33
|
+
for (const s of spans) {
|
|
34
|
+
const last = merged[merged.length - 1];
|
|
35
|
+
if (last && s.start < last.end) {
|
|
36
|
+
last.end = Math.max(last.end, s.end);
|
|
37
|
+
if (!last.labels.includes(s.label)) last.labels.push(s.label);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
merged.push({ start: s.start, end: s.end, labels: [s.label], category: s.category });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let out = '';
|
|
44
|
+
let cursor = 0;
|
|
45
|
+
const masked = [];
|
|
46
|
+
for (const m of merged) {
|
|
47
|
+
out += src.slice(cursor, m.start);
|
|
48
|
+
out += `[shomra:redacted:${m.category}]`;
|
|
49
|
+
cursor = m.end;
|
|
50
|
+
masked.push({ label: m.labels.join(' + '), category: m.category, chars: m.end - m.start });
|
|
51
|
+
}
|
|
52
|
+
out += src.slice(cursor);
|
|
53
|
+
|
|
54
|
+
return { text: out, masked, unmaskable: [], changed: true };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
export function unmaskableFindings(findings, redaction) {
|
|
59
|
+
const maskedLabels = new Set((redaction?.masked ?? []).flatMap((m) => String(m.label).split(' + ')));
|
|
60
|
+
return (findings ?? [])
|
|
61
|
+
.filter((f) => f && (f.category === 'secret' || f.category === 'pii'))
|
|
62
|
+
.filter((f) => {
|
|
63
|
+
const label = String(f.label ?? '').replace(/^Live credential:\s*/, '');
|
|
64
|
+
return !maskedLabels.has(label);
|
|
65
|
+
})
|
|
66
|
+
.map((f) => f.label);
|
|
67
|
+
}
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* the server blocks work no server verdict would have blocked, and looser puts
|
|
16
16
|
* a hole in the floor at exactly the moment the floor is all there is.
|
|
17
17
|
*/
|
|
18
|
-
const SEVERE_VERB = /\b(delete|destroy|drop|purge|revoke|terminate|shutdown|wipe|erase|truncate|force[-_]?push
|
|
18
|
+
const SEVERE_VERB = /\b(delete|destroy|drop|purge|revoke|terminate|shutdown|wipe|erase|truncate|force[-_]?push)\b/i;
|
|
19
|
+
|
|
20
|
+
const RM_COMMAND = /(?<![-\w])rm\b/i;
|
|
19
21
|
|
|
20
22
|
const MATERIAL_VERB =
|
|
21
23
|
/\b(transfer|pay|payment|refund|charge|invoice|wire|send|email|post|publish|deploy|release|merge|approve|grant|invite|share|upload|export)\b/i;
|
|
@@ -43,10 +45,10 @@ export function classifyConsequence(input) {
|
|
|
43
45
|
const blob = raw.replace(/[_-]+/g, ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
|
44
46
|
|
|
45
47
|
if (typeof input.amount === 'number' && input.amount > 0) {
|
|
46
|
-
return SEVERE_VERB.test(blob) ? 'severe' : 'material';
|
|
48
|
+
return SEVERE_VERB.test(blob) || RM_COMMAND.test(raw) ? 'severe' : 'material';
|
|
47
49
|
}
|
|
48
50
|
if (PERSISTENCE_TARGET.test(raw)) return 'severe';
|
|
49
|
-
if (SEVERE_VERB.test(blob) || FORCE_PUSH.test(raw)) return 'severe';
|
|
51
|
+
if (SEVERE_VERB.test(blob) || RM_COMMAND.test(raw) || FORCE_PUSH.test(raw)) return 'severe';
|
|
50
52
|
if (AUTHORITY_GRANT.test(blob)) return PRIVILEGED_TARGET.test(blob) ? 'severe' : 'material';
|
|
51
53
|
if (input.isShell) return 'material';
|
|
52
54
|
if (MATERIAL_VERB.test(blob)) return 'material';
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { CLAIM_LABEL, claimSeverity, detectDurableClaims } from './durable-claims.mjs';
|
|
2
2
|
import { LOCAL_URL_RE, egressHost } from './egress.mjs';
|
|
3
3
|
import { lineOf, lineTextAt } from './lines.mjs';
|
|
4
|
-
import { citationGoverns, describesAt, isDescriptiveLine, isDocumentationLine, isRiskTableRow, prohibitsAt } from './prose-context.mjs';
|
|
4
|
+
import { citationGoverns, describesAt, insideMarkdownLinkLabel, isDescriptiveLine, isDocumentationLine, isRiskTableRow, prohibitsAt } from './prose-context.mjs';
|
|
5
5
|
import { localScan } from './scan.mjs';
|
|
6
6
|
import { NETWORK_VERBS, SENSITIVE_READ } from './sensitive.mjs';
|
|
7
7
|
import { DANGEROUS_SHELL } from './shell.mjs';
|
|
8
|
-
import { containsWord } from './text-match.mjs';
|
|
9
8
|
|
|
10
9
|
const PERSISTENCE_MARKERS = /\b(in (all|every|future) (sessions?|conversations?|chats?|projects?)|from now on|going forward|permanently|persist(ent|ed)?|across (all )?sessions|every time|each time|whenever you|forever|always remember to|never forget( to)?|for all future)\b/i;
|
|
11
10
|
|
|
@@ -27,7 +26,7 @@ const NEGATION_GUARD = /\b(never|do not|don'?t|cannot|can'?t|avoid|refuse|must n
|
|
|
27
26
|
|
|
28
27
|
const SABOTAGE_RULES = [
|
|
29
28
|
|
|
30
|
-
{ re: /\b(disabl|turn(ing)? off|deactivat|switch off|remov|drop|skip|suppress|circumvent)\w*\b[^.\n]{0,50}\b(security|safety|guard(?:rail)?s?|protection|moderation|content[- ]?filters?|safeguards?|sandbox(?:ing)?|controls?|restrictions?|policies|policy|filters?)\b/i, label: 'disable-safety', guarded: true },
|
|
29
|
+
{ re: /\b(disabl|turn(ing)? off|deactivat|switch off|remov|drop|skip|suppress|circumvent)\w*\b[^.\n]{0,50}\b(security|safety|guard(?:rail)?s?|protection|moderation|content[- ]?filters?|safeguards?|sandbox(?:ing)?|(?<!-)controls?|restrictions?|(?<!-)policies|(?<!-)policy|filters?)\b/i, label: 'disable-safety', guarded: true },
|
|
31
30
|
{ re: /\bbypass(?:ing)?\b[^.\n]{0,50}\b(human(?:[- ]in[- ]the[- ]loop)?|hitl|verification|approval|confirmation|review|guard(?:rail)?s?|safety|security|checks?|policy|policies|restrictions?|sandbox|permission)\b/i, label: 'bypass-controls', guarded: true },
|
|
32
31
|
{ re: /\bprioriti[sz]e\b[^.\n]{0,60}\b(above|over)\b[^.\n]{0,40}\b(prompt|instruction|input|request|message|command|direction)s?\b/i, label: 'priority-hijack', guarded: true },
|
|
33
32
|
|
|
@@ -42,9 +41,10 @@ export function offendingLine(sig, text) {
|
|
|
42
41
|
if (m.index == null) continue;
|
|
43
42
|
const line = lineTextAt(text, m.index);
|
|
44
43
|
if (sig.refine && !sig.refine(line)) continue;
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
if (
|
|
44
|
+
const at = line.indexOf(m[0]);
|
|
45
|
+
if (isDocumentationLine(line, at < 0 ? undefined : at)) continue;
|
|
46
|
+
if (prohibitsAt(line, at)) continue;
|
|
47
|
+
if (describesAt(line, at)) continue;
|
|
48
48
|
if (isRiskTableRow(line)) continue;
|
|
49
49
|
return line;
|
|
50
50
|
}
|
|
@@ -69,13 +69,30 @@ function lineMatchingAll(text, ...res) {
|
|
|
69
69
|
return null;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
const EXFIL_OBJECT_RE =
|
|
73
|
+
/(?:\b(?:env(?:ironment)?|secrets?|credentials?|tokens?|api[- ]?keys?|keys?|passwords?|cookies?|sessions?|data|files?|contents?|conversations?|history|context|output|transcript|prompt|clipboard|id_rsa|private[- ]?key|database|dump)|\.env(?:ironment)?|\.ssh)\b/i;
|
|
74
|
+
|
|
75
|
+
function urlIsMarkdownTarget(line, matched) {
|
|
76
|
+
const um = /https?:\/\/\S+/.exec(matched);
|
|
77
|
+
if (!um) return false;
|
|
78
|
+
const at = line.indexOf(um[0]);
|
|
79
|
+
if (at < 1) return false;
|
|
80
|
+
return line[at - 1] === '(' && line.slice(0, at - 1).trimEnd().endsWith(']');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const SEND_AS_NOUN_RE =
|
|
84
|
+
/\b(?:a|an|the|this|that|your|our|each|any|bug|security|error|crash|incident|status|progress|test|coverage|issue)\s+(?:reports?|pushes?|transfers?|uploads?|deliver(?:y|ies))\b/i;
|
|
85
|
+
|
|
86
|
+
const PASSIVE_SEND_RE =
|
|
87
|
+
/\b(?:are|is|was|were|been|being|get|gets|got)\s+(?:\w+\s+){0,2}?(?:published|posted|sent|shipped|forwarded|transferred|delivered|uploaded|transmitted|reported)\b/i;
|
|
88
|
+
|
|
72
89
|
const EXFIL_RULES = [
|
|
73
90
|
{ re: /\b(exfiltrat|smuggl)\w*/i, label: 'exfiltration', severity: 'CRITICAL', descGuard: true },
|
|
74
|
-
{ re: /\bleak\w*\b[^.\n]{0,60}(?:\b(?:content|data|secret|file|credential|key|token|password|env|private[- ]?key|id_rsa|api[- ]?key)|\.ssh)\b/i, label: 'leak-secrets', severity: 'CRITICAL', descGuard: true },
|
|
91
|
+
{ re: /\bleak\w*\b(?!\s+(?:memory|memories|resources?|handles?|descriptors?|sockets?|connections?|goroutines?|threads?|references?|buffers?))[^.\n]{0,60}(?:\b(?:content|data|secret|file|credential|key|token|password|env|private[- ]?key|id_rsa|api[- ]?key)|\.ssh)\b/i, label: 'leak-secrets', severity: 'CRITICAL', descGuard: true },
|
|
75
92
|
|
|
76
93
|
{ re: /\b(base64|hex|rot13|gzip|xor|url[- ]?encod)\w*\b[^.\n]{0,50}\b(before|then|prior to|and\s+then|and\s+immediately)\b[^.\n]{0,25}\b(send|post|upload|transmit|exfil|deliver|beacon|forward|transfer)\w*/i, label: 'obfuscate-before-send', severity: 'CRITICAL', descGuard: true },
|
|
77
94
|
{ re: /\bsilent(ly)?\b[^.\n]{0,70}\b(send|post|upload|collect|encod|transmit|copy|forward|read|leak|deliver|beacon|transfer)\w*/i, label: 'covert-action', severity: 'CRITICAL', descGuard: true },
|
|
78
|
-
{ re: /\b(send|post|upload|transmit|forward|deliver|beacon|report|ship|push|transfer)
|
|
95
|
+
{ re: /\b(send|post|upload|transmit|forward|deliver|beacon|report|ship|push|transfer)(?:s|es|ed|ing|ted|ping)?\b(?:[^.\n]|\.(?!\s)){0,80}\b(https?:\/\/\S+|attacker|c2\b|command[- ]and[- ]control|remote (server|host|endpoint)|external (server|host|endpoint|url|site|service))/i, label: 'send-to-external', severity: 'HIGH' },
|
|
79
96
|
|
|
80
97
|
{
|
|
81
98
|
re: /\b(?:read|open|cat|load|import|source|inspect|include|copy|dump|print|show)\b(?:[^.\n]|\.(?!\s)){0,50}(?:~?\/?\.ssh\/(?:id_[a-z0-9]+|config)(?!\.pub)|~?\/?\.aws\/credentials|~?\/?\.kube\/config|~?\/?\.gnupg|\bid_(?:rsa|ed25519|dsa)\b(?!\.pub)|~?\/?\.npmrc|~?\/?\.netrc|\/etc\/shadow|(?:^|[\s'"`(])\.env(?:\.[\w-]+)?\b)/i,
|
|
@@ -106,6 +123,12 @@ function scanDirectives(text) {
|
|
|
106
123
|
if (r.descGuard && citationGoverns(line, m.index)) continue;
|
|
107
124
|
if (r.descGuard && isRiskTableRow(line)) continue;
|
|
108
125
|
if (r.label === 'send-to-external' && LOCAL_URL_RE.test(line) && !/\b(attacker|c2|command[- ]and[- ]control|external|evil)\b/i.test(line)) continue;
|
|
126
|
+
if (r.label === 'send-to-external' && m.index != null && insideMarkdownLinkLabel(line, m.index)) continue;
|
|
127
|
+
if (r.label === 'send-to-external' && m.index != null) {
|
|
128
|
+
const around = line.slice(Math.max(0, m.index - 24), m.index + 24);
|
|
129
|
+
if (SEND_AS_NOUN_RE.test(around) || PASSIVE_SEND_RE.test(around)) continue;
|
|
130
|
+
if (urlIsMarkdownTarget(line, m[0]) && !EXFIL_OBJECT_RE.test(m[0])) continue;
|
|
131
|
+
}
|
|
109
132
|
const prev = exfil.get(r.label);
|
|
110
133
|
if (!prev || (prev === 'HIGH' && r.severity === 'CRITICAL')) exfil.set(r.label, r.severity);
|
|
111
134
|
}
|
|
@@ -244,12 +267,22 @@ function reportStagedPayload(text, { noun }, push) {
|
|
|
244
267
|
}
|
|
245
268
|
}
|
|
246
269
|
|
|
270
|
+
function hasWordFrom(line, words) {
|
|
271
|
+
const low = line.toLowerCase();
|
|
272
|
+
return words.some((w) => {
|
|
273
|
+
const t = w.toLowerCase().trim();
|
|
274
|
+
if (!t) return false;
|
|
275
|
+
if (/^[a-z0-9]+$/.test(t)) return new RegExp(`\\b${t}\\b`).test(low);
|
|
276
|
+
return low.includes(t);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
247
280
|
function reportToxicFlow(text, { noun }, push) {
|
|
248
281
|
if (!IMPERATIVE.test(text)) return;
|
|
249
282
|
const line = text.split(/\r?\n/).find((candidate) => IMPERATIVE.test(candidate)
|
|
250
283
|
&& !NEGATION_GUARD.test(candidate)
|
|
251
|
-
&&
|
|
252
|
-
&&
|
|
284
|
+
&& hasWordFrom(candidate, SENSITIVE_READ)
|
|
285
|
+
&& hasWordFrom(candidate, NETWORK_VERBS)
|
|
253
286
|
&& !isDescriptiveLine(candidate));
|
|
254
287
|
if (!line) return;
|
|
255
288
|
push(
|
|
@@ -4,8 +4,11 @@ import { IMPERATIVE } from './memory.mjs';
|
|
|
4
4
|
const DESCRIPTIVE_MARKERS =
|
|
5
5
|
/\b(detect|scan|flag|block|catch|prevent|guard|protect|harden|audit|benchmark|catalog|scenario|corpus|coverage|example|vector|signal|rule|technique|posture|detection|test\s*case|red[- ]?team|-style|grounded in|fixed|now green|was|were|had|used to|previously|postmortem|regression|changelog|root[- ]?cause|repro|note|see|describes?|documents?|refers?|treat(s|ed|ing)?|counts?|reads?)\w*/i;
|
|
6
6
|
|
|
7
|
+
const URL_TOKEN_RE = /\b(?:https?|ftp|file|data):\/*[^\s<>"')\]]+/gi;
|
|
8
|
+
|
|
7
9
|
export function isDescriptiveLine(line) {
|
|
8
|
-
|
|
10
|
+
const prose = line.replace(URL_TOKEN_RE, ' ');
|
|
11
|
+
return DESCRIPTIVE_MARKERS.test(prose) && !IMPERATIVE.test(line);
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
const RESEARCH_CITATION_RE =
|
|
@@ -36,7 +39,26 @@ export function citationGoverns(segment, offset) {
|
|
|
36
39
|
|
|
37
40
|
const ELLIPSIS_RE = /…|\.\.\./;
|
|
38
41
|
|
|
39
|
-
const
|
|
42
|
+
const ENUMERATION_RE = /[([][^)\]]*,[^)\]]*,[^)\]]*[)\]]|:\s*(?:[\w.-]+(?:\s+-\w+)?,\s*){2,}/;
|
|
43
|
+
|
|
44
|
+
const REGEX_PATTERN_RE = /\\[sdwbSDWB]|\\\+|\\\*|\\\(|\\\||\(\?:|\[\^?[a-z0-9]-[a-z0-9]\]|\.\*|\.\+/;
|
|
45
|
+
|
|
46
|
+
const MOOD_WINDOW = 140;
|
|
47
|
+
|
|
48
|
+
function windowAround(line, offset) {
|
|
49
|
+
if (offset == null || line.length <= MOOD_WINDOW * 2) return line;
|
|
50
|
+
return line.slice(Math.max(0, offset - MOOD_WINDOW), offset + MOOD_WINDOW);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function insideMarkdownLinkLabel(line, index) {
|
|
54
|
+
if (index < 0 || index >= line.length) return false;
|
|
55
|
+
const open = line.lastIndexOf('[', index);
|
|
56
|
+
if (open === -1) return false;
|
|
57
|
+
const close = line.indexOf(']', index);
|
|
58
|
+
if (close === -1) return false;
|
|
59
|
+
if (line.slice(open + 1, index).includes(']')) return false;
|
|
60
|
+
return line[close + 1] === '(';
|
|
61
|
+
}
|
|
40
62
|
|
|
41
63
|
const CREDENTIAL_PATH_RE =
|
|
42
64
|
/~\/\.(ssh|aws|kube|gnupg|docker|npmrc?)\b|\bid_(rsa|ed25519|dsa)\b|\.pem\b|\bcredentials\b\s*(file)?|\bAWS_SECRET|\bANTHROPIC_API_KEY\b|\bOPENAI_API_KEY\b/i;
|
|
@@ -48,15 +70,20 @@ function carriesHardEvidence(line) {
|
|
|
48
70
|
return CREDENTIAL_PATH_RE.test(line) || EXECUTABLE_FETCH_RE.test(line) || !!egressHost(line);
|
|
49
71
|
}
|
|
50
72
|
|
|
51
|
-
export function isDocumentationLine(line) {
|
|
73
|
+
export function isDocumentationLine(line, offset) {
|
|
52
74
|
if (!line) return false;
|
|
53
75
|
if (carriesHardEvidence(line)) return false;
|
|
54
|
-
|
|
55
|
-
|
|
76
|
+
|
|
77
|
+
const win = windowAround(line, offset);
|
|
78
|
+
if (REGEX_PATTERN_RE.test(win)) return true;
|
|
79
|
+
if (ELLIPSIS_RE.test(win)) return true;
|
|
80
|
+
if (offset != null && insideCodeSpan(line, offset) && isDescriptiveLine(win)) return true;
|
|
81
|
+
if (ENUMERATION_RE.test(win) && !IMPERATIVE.test(win)) return true;
|
|
82
|
+
return isDescriptiveLine(win);
|
|
56
83
|
}
|
|
57
84
|
|
|
58
85
|
const PROHIBITION_MARKER_RE =
|
|
59
|
-
/\b(?:never|do not|don'?t|cannot|can'?t|must not|mustn'?t|should not|shouldn'?t|avoid|avoids|avoiding|refuse to|refrain from|forbidden|prohibited|disallow\w*|instead of|rather than|beware of)\b[^.:;\n]{0,60}$/i;
|
|
86
|
+
/\b(?:never|do not|don'?t|cannot|can'?t|must not|mustn'?t|should not|shouldn'?t|avoid|avoids|avoiding|refuse to|refrain from|forbidden|prohibited|disallow\w*|instead of|rather than|beware of|do NOT)\b[^.:;\n]{0,60}$/i;
|
|
60
87
|
|
|
61
88
|
const DOUBLE_NEGATIVE_RE = /\b(?:hesitate|worry|be afraid|forget|fail|neglect|shy away)\b/i;
|
|
62
89
|
|
|
@@ -1,17 +1,53 @@
|
|
|
1
1
|
import { lineTextAt } from './lines.mjs';
|
|
2
2
|
|
|
3
3
|
const EPHEMERAL_RM_TARGET_RE =
|
|
4
|
-
/^(\.\/)?(node_modules|dist|build|out|coverage|target|\.next|\.nuxt|\.turbo|\.svelte-kit|\.cache|\.parcel-cache|__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|\.tox|venv|\.venv|\.eggs|[\w.-]+\.egg-info)\/?\*?$/i;
|
|
4
|
+
/^(\.\/)?(node_modules|dist|build|out|coverage|\.nyc_output|target|\.next|\.nuxt|\.turbo|\.svelte-kit|\.cache|\.parcel-cache|__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|\.tox|venv|\.venv|\.eggs|[\w.-]+\.egg-info)\/?\*?$/i;
|
|
5
|
+
|
|
6
|
+
const BENIGN_ABSOLUTE_RM_RE =
|
|
7
|
+
/^\/(var\/(lib\/apt\/lists|cache|tmp|log)|tmp|usr\/share\/(doc|man|locale|info)|root\/\.cache|home\/[\w.-]+\/\.cache|opt\/[\w.-]+\/\.cache)(\/|$|\*)/i;
|
|
8
|
+
|
|
9
|
+
const CATASTROPHIC_RM_TARGET_RE = /^(\/|~|\$|\$\{|%\w+%|[A-Za-z]:[\\/]|\.\.?$|\.\.\/|\*$)/;
|
|
10
|
+
|
|
11
|
+
const LOOPBACK_OR_PRIVATE_HOST_RE =
|
|
12
|
+
/^(localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|0\.0\.0\.0|\[::1\]|::1|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})$/i;
|
|
13
|
+
|
|
14
|
+
const CLOUD_METADATA_HOSTS = new Set(['169.254.169.254', 'metadata.google.internal']);
|
|
15
|
+
|
|
16
|
+
export function targetsExternalNetwork(line) {
|
|
17
|
+
const urls = line.match(/https?:\/\/[^\s'"`;|)&]+/gi);
|
|
18
|
+
if (!urls?.length) return true;
|
|
19
|
+
return urls.some((raw) => {
|
|
20
|
+
let host;
|
|
21
|
+
try {
|
|
22
|
+
host = new URL(raw).hostname.toLowerCase();
|
|
23
|
+
} catch {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
if (CLOUD_METADATA_HOSTS.has(host)) return true;
|
|
27
|
+
return !LOOPBACK_OR_PRIVATE_HOST_RE.test(host);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const RM_RF_RE = /\brm\b(?=[^\n;|&]*(?:-[a-zA-Z]*r|--recursive))(?=[^\n;|&]*(?:-[a-zA-Z]*f|--force))/i;
|
|
32
|
+
|
|
33
|
+
export function rmTargetClass(line) {
|
|
34
|
+
if (/\brm\s+-{1,2}[a-zA-Z][\w-]*\s*["'`,)\]}?!]\s*$/.test(line)) return 'local';
|
|
35
|
+
const m = /\brm\s+((?:--?[a-zA-Z][\w-]*\s+)+)(.*)$/.exec(line);
|
|
36
|
+
if (!m) return 'catastrophic';
|
|
5
37
|
|
|
6
|
-
function rmTargetsRealData(line) {
|
|
7
|
-
const m = /\brm\s+((?:-[a-zA-Z]+\s+)+)(.*)$/.exec(line);
|
|
8
|
-
if (!m) return true;
|
|
9
38
|
const targets = m[2]
|
|
10
39
|
.split(/&&|\|\||[;|>&]/)[0]
|
|
11
40
|
.split(/\s+/)
|
|
12
|
-
.filter((t) => t && !t.startsWith('-'))
|
|
13
|
-
|
|
14
|
-
|
|
41
|
+
.filter((t) => t && t !== '--' && !t.startsWith('-'))
|
|
42
|
+
.map((t) => t.replace(/^["']|["']$/g, ''));
|
|
43
|
+
|
|
44
|
+
if (!targets.length) {
|
|
45
|
+
const after = m[2] ?? '';
|
|
46
|
+
return /^\s*(?:[,)\]}?!"'`.]|$)/.test(after) ? 'local' : 'catastrophic';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (targets.some((t) => CATASTROPHIC_RM_TARGET_RE.test(t) && !BENIGN_ABSOLUTE_RM_RE.test(t))) return 'catastrophic';
|
|
50
|
+
return targets.every((t) => EPHEMERAL_RM_TARGET_RE.test(t) || BENIGN_ABSOLUTE_RM_RE.test(t)) ? 'ephemeral' : 'local';
|
|
15
51
|
}
|
|
16
52
|
|
|
17
53
|
export function matchesShellSignal(sig, text) {
|
|
@@ -32,10 +68,14 @@ export const DANGEROUS_SHELL = [
|
|
|
32
68
|
{ name: 'Invoke-Expression of downloaded content', re: /\b(iex|invoke-expression)\b[^\n]{0,120}(downloadstring|net\.webclient|\(\s*(iwr|irm|invoke-)|\$\()/i, severity: 'CRITICAL' },
|
|
33
69
|
{ name: 'Reverse shell via /dev/tcp', re: /\/dev\/(tcp|udp)\//i, severity: 'CRITICAL' },
|
|
34
70
|
{ name: 'Base64 blob piped to a shell', re: /base64\s+(--?d(ecode)?)?\b[^\n|]{0,200}\|\s*(ba|z)?sh\b/i, severity: 'CRITICAL' },
|
|
35
|
-
{ name: 'curl/wget posts data to the network (exfiltration)', re: /\b(curl|wget|http|https|invoke-restmethod|irm)\b[^\n]{0,220}(--data(-raw|-binary|-urlencode)?|--form\b|--upload-file\b|(^|\s)-d\s|(^|\s)-F\s|(^|\s)-T\s|-Method\s+Post)/i, severity: 'HIGH' },
|
|
36
|
-
{
|
|
37
|
-
|
|
38
|
-
|
|
71
|
+
{ name: 'curl/wget posts data to the network (exfiltration)', re: /\b(curl|wget|http|https|invoke-restmethod|irm)\b[^\n]{0,220}(--data(-raw|-binary|-urlencode)?|--form\b|--upload-file\b|(^|\s)-d\s|(^|\s)-F\s|(^|\s)-T\s|-Method\s+Post)/i, severity: 'HIGH', refine: targetsExternalNetwork },
|
|
72
|
+
{
|
|
73
|
+
name: 'Command output piped into a network call',
|
|
74
|
+
re: /\b(curl|wget|invoke-restmethod|invoke-webrequest|irm|iwr)\b[^\n]{0,220}(\$\(|<\(|`[^`\n]*(?:\b(?:cat|ls|whoami|id|env|printenv|uname|hostname|pwd|base64|echo|head|tail|find|grep|awk|sed|curl|wget|nc|python\d?|node|perl|ruby|php|git|aws|kubectl|openssl)\b|\/(?:etc|var|tmp|home|root|usr|proc)\/|\$\w|\s-{1,2}\w)[^`\n]*`)/i,
|
|
75
|
+
severity: 'HIGH',
|
|
76
|
+
},
|
|
77
|
+
{ name: 'Fetches from a raw IP address', re: /\b(curl|wget|iwr|irm|invoke-webrequest|invoke-restmethod)\b[^\n]{0,220}https?:\/\/\d{1,3}(\.\d{1,3}){3}/i, severity: 'HIGH', refine: targetsExternalNetwork },
|
|
78
|
+
{ name: 'Writes to shell profile / SSH keys / crontab', re: /(>>?\s*~?\/?\.?(bashrc|zshrc|bash_profile|profile)|(tee|echo|cat|printf)\b[^\n]{0,80}(\.bashrc|\.zshrc|\.bash_profile|\.profile|authorized_keys)|>>?\s*[^\n]{0,40}authorized_keys|crontab\s+(-|[^\n]{0,40}<)|id_rsa\b[^\n]{0,20}(>|cp|scp|curl|cat))/i, severity: 'HIGH' },
|
|
39
79
|
|
|
40
80
|
{
|
|
41
81
|
name: 'World-writable permissions on the filesystem root (chmod -R 777 /)',
|
|
@@ -47,7 +87,8 @@ export const DANGEROUS_SHELL = [
|
|
|
47
87
|
re: /\bchmod\b(?=[^\n;|&]*(?:\b0?[0-7][0-7][2367]\b|a\+rwx|a=rwx|o\+w|ugo\+rwx))(?=[^\n;|&]*(?:~(?:\s|$|\/\.)|\$HOME\b|\/etc\b|\/root\b|\/usr\b|\/var\b|\/boot\b|\.ssh\b|id_rsa\b|authorized_keys\b|\.aws\b|\.gnupg\b|\.kube\b))/i,
|
|
48
88
|
severity: 'HIGH',
|
|
49
89
|
},
|
|
50
|
-
{ name: 'Recursive force delete (rm -rf)', re:
|
|
90
|
+
{ name: 'Recursive force delete of a protected path (rm -rf)', re: RM_RF_RE, severity: 'HIGH', refine: (l) => rmTargetClass(l) === 'catastrophic' },
|
|
91
|
+
{ name: 'Recursive force delete (rm -rf)', re: RM_RF_RE, severity: 'MEDIUM', refine: (l) => rmTargetClass(l) === 'local' },
|
|
51
92
|
|
|
52
93
|
{ name: 'Inline eval / exec of a string', re: /(?<![-.\w$>:`"'])(eval|exec)\s*[("`']/i, severity: 'HIGH' },
|
|
53
94
|
{ name: 'Pipes an env dump to the network', re: /\b(env|printenv|set)\b[^\n|]{0,80}\|[^\n]{0,80}(curl|wget|nc\b|http)/i, severity: 'HIGH' },
|
|
@@ -85,7 +126,7 @@ export const DANGEROUS_SHELL = [
|
|
|
85
126
|
{ name: 'Preloads a shared library into every process (LD_PRELOAD)', re: /\b(?:LD_PRELOAD|LD_AUDIT|DYLD_INSERT_LIBRARIES)\s*=\s*\S|>>?\s*\/etc\/ld\.so\.preload\b/i, severity: 'HIGH' },
|
|
86
127
|
{ name: 'Installs a scheduled or boot-time persistence unit', re: /\bsystemd-run\b[^\n]{0,80}--on-(?:boot|calendar|active|unit)|>>?\s*\/etc\/(?:systemd\/system|cron\.(?:d|daily|hourly)|init\.d)\/\S|\bschtasks\b[^\n]{0,80}\/create\b|\blaunchctl\s+(?:load|bootstrap)\b|\b(?:echo|printf)\b[^\n]{0,120}\|\s*at\s+(?:now|\+|\d)/i, severity: 'MEDIUM' },
|
|
87
128
|
{ name: 'Opens a reverse tunnel to a remote host', re: /\bssh\b[^\n]{0,80}\s-\w*R\s*\d{1,5}:[^\n\s]{1,60}|\b(?:ngrok|cloudflared|localtunnel|frpc)\b[^\n]{0,60}\b(?:tcp|http|tunnel)\b/i, severity: 'HIGH' },
|
|
88
|
-
{ name: 'Encodes command output into DNS lookups (exfiltration channel)', re: /(?:^|[\n;&|(]\s*)(?:dig|nslookup|drill|host)\s+[^\n]{0,120}(?:\$\(
|
|
129
|
+
{ name: 'Encodes command output into DNS lookups (exfiltration channel)', re: /(?:^|[\n;&|(]\s*)(?:dig|nslookup|drill|host)\s+[^\n]{0,120}(?:\$\(|`[^`\n]+`|\$\{)[^\n]{0,80}\.[a-z]{2,}/i, severity: 'HIGH' },
|
|
89
130
|
{ name: 'Copies credentials or home directories off the machine over ssh', re: new RegExp(String.raw`\b(?:scp|rsync)\b(?=[^\n]{0,200}\s\S{0,40}@[\w.-]+:)(?=[^\n]{0,200}(?:${SENSITIVE_PATH}))` + String.raw`|\btar\b(?=[^\n]{0,160}\|\s*ssh\b)(?=[^\n]{0,160}(?:${SENSITIVE_PATH}))`, 'i'), severity: 'HIGH' },
|
|
90
131
|
{ name: 'Flushes the host firewall', re: /\b(?:iptables|ip6tables|nft)\b[^\n]{0,60}(?:-F\b|--flush\b|flush ruleset)|\bufw\s+disable\b|\bnetsh\s+advfirewall\s+set\s+\S+\s+state\s+off\b/i, severity: 'MEDIUM' },
|
|
91
132
|
{ name: 'Kills the audit / EDR agent (anti-forensics)', re: /\b(?:pkill|killall|kill)\b[^\n]{0,40}\b(?:auditd|osqueryd?|falcon-sensor|falconctl|wazuh|ossec|filebeat|splunkd|sysmon|crowdstrike|carbonblack|cbagent)\b|\bSet-MpPreference\b[^\n]{0,60}-Disable\w*\s+\$?true/i, severity: 'HIGH' },
|
|
@@ -3,6 +3,7 @@ import { gateMachine } from '../core/api-client.mjs';
|
|
|
3
3
|
import { breakerOpen, breakerReset, breakerTrip, guardTimeoutMs } from '../core/circuit-breaker.mjs';
|
|
4
4
|
import { loadConfig, resolveSettings } from '../core/config.mjs';
|
|
5
5
|
import { downrankCodeContext, localScan } from '../detect/guard-signals.mjs';
|
|
6
|
+
import { redactLocally } from '../detect/local-redact.mjs';
|
|
6
7
|
import { detectEnv } from '../gate/environment.mjs';
|
|
7
8
|
import { parentSessionFrom } from './normalize.mjs';
|
|
8
9
|
import { envFlag, resolveAgentFlag } from './options.mjs';
|
|
@@ -169,10 +170,24 @@ function promptInjectionNote(injection) {
|
|
|
169
170
|
);
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
|
|
172
174
|
function buildPromptGuardBody(norm, agent, clientDecision, clientReason) {
|
|
175
|
+
const redaction = localTierDisabled()
|
|
176
|
+
? { text: norm.prompt, masked: [], changed: false }
|
|
177
|
+
: redactLocally(norm.prompt);
|
|
178
|
+
|
|
173
179
|
return {
|
|
174
180
|
tool_name: 'UserPromptSubmit',
|
|
175
|
-
tool_input: { prompt:
|
|
181
|
+
tool_input: { prompt: redaction.text },
|
|
182
|
+
...(redaction.changed
|
|
183
|
+
? {
|
|
184
|
+
client_masked: {
|
|
185
|
+
count: redaction.masked.length,
|
|
186
|
+
labels: redaction.masked.map((m) => m.label).slice(0, 20),
|
|
187
|
+
where: 'client',
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
: {}),
|
|
176
191
|
cwd: norm.cwd,
|
|
177
192
|
session_id: norm.session_id,
|
|
178
193
|
...(norm.parent_session_id ? { parent_session_id: norm.parent_session_id } : {}),
|