@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shomra/agent",
3
- "version": "0.3.26",
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|rm)\b/i;
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
- if (isDocumentationLine(line)) continue;
46
- if (prohibitsAt(line, line.indexOf(m[0]))) continue;
47
- if (describesAt(line, line.indexOf(m[0]))) continue;
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)\w*\b[^.\n]{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' },
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
- && containsWord(candidate, SENSITIVE_READ)
252
- && containsWord(candidate, NETWORK_VERBS)
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
- return DESCRIPTIVE_MARKERS.test(line) && !IMPERATIVE.test(line);
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 REGEX_PATTERN_RE = /\\[sdwbSDWB]|\\\+|\\\*|\\\(|\\\||\(\?:|\.\*|\.\+/;
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
- if (ELLIPSIS_RE.test(line) || REGEX_PATTERN_RE.test(line)) return true;
55
- return isDescriptiveLine(line);
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
- if (!targets.length) return true;
14
- return !targets.every((t) => EPHEMERAL_RM_TARGET_RE.test(t.replace(/^["']|["']$/g, '')));
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
- { name: 'Command output piped into a network call', re: /\b(curl|wget|invoke-restmethod|invoke-webrequest|irm|iwr)\b[^\n]{0,220}(\$\(|`[^`\n]+`|<\()/i, severity: 'HIGH' },
37
- { 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' },
38
- { name: 'Writes to shell profile / SSH keys / crontab', re: /(\.bashrc|\.zshrc|\.bash_profile|\.profile|authorized_keys|id_rsa\b|\bcrontab\b)/i, severity: 'HIGH' },
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: /\brm\s+-[a-z]*r[a-z]*f|\brm\s+-[a-z]*f[a-z]*r/i, severity: 'HIGH', refine: rmTargetsRealData },
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}(?:\$\(|`|\$\{)[^\n]{0,80}\.[a-z]{2,}/i, severity: 'HIGH' },
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: norm.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 } : {}),