@shomra/agent 0.3.25 → 0.3.27

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.25",
3
+ "version": "0.3.27",
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": {
@@ -9,7 +9,7 @@ export { localGate } from './signals/gate.mjs';
9
9
  export { INJECTION_PHRASES, INJECTION_REGEXES, INVISIBLE_CHARS_RE } from './signals/injection.mjs';
10
10
  export { localMemory } from './signals/memory.mjs';
11
11
  export { AGENT_ROOT_RE, isAgentAdjacentPath, localPropagation } from './signals/propagation.mjs';
12
- export { citationGoverns, isDocumentationLine, prohibitsAt } from './signals/prose-context.mjs';
12
+ export { citationGoverns, describesAt, isDocumentationLine, prohibitsAt } from './signals/prose-context.mjs';
13
13
  export { downrankCodeContext, localScan } from './signals/scan.mjs';
14
14
  export { PII_PATTERNS, SECRET_PATTERNS } from './signals/secrets.mjs';
15
15
  export { NETWORK_VERBS, SENSITIVE_READ } from './signals/sensitive.mjs';
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import { isDocumentationLine, prohibitsAt } from './prose-context.mjs';
1
+ import { describesAt, isDocumentationLine, prohibitsAt } from './prose-context.mjs';
2
2
 
3
3
  const AUTONOMY_RULES = [
4
4
  { family: 'confirmation', label: 'Acts without asking', re: /\b(?:without (?:asking|confirming|prompting|waiting for|seeking)(?:\s+(?:the\s+)?(?:user|me|anyone|permission|approval|confirmation))?|do(?:es)? not (?:ask|prompt|wait|check|confirm)[^.\n]{0,30}\b(?:for|before|first|permission|approval|confirmation)|no need to (?:ask|confirm|check with))\b/i },
@@ -36,7 +36,7 @@ export function localAutonomy(text) {
36
36
  const m = rule.re.exec(line);
37
37
  if (!m) continue;
38
38
  if (isDocumentationLine(line)) continue;
39
- if (prohibitsAt(line, m.index)) continue;
39
+ if (prohibitsAt(line, m.index) || describesAt(line, m.index)) continue;
40
40
  if (insideQuotedSpan(line, m.index)) continue;
41
41
  seen.add(rule.label);
42
42
  out.push({ family: rule.family, label: rule.label, line: i + 1 });
@@ -1,4 +1,4 @@
1
- import { isDocumentationLine, prohibitsAt } from './prose-context.mjs';
1
+ import { describesAt, isDocumentationLine, prohibitsAt } from './prose-context.mjs';
2
2
 
3
3
  const HARVEST_PROMPTS = [
4
4
  { re: /\bosascript\b[\s\S]{0,200}?\bdisplay\s+dialog\b[\s\S]{0,200}?\bhidden\s+answer\b/i, label: 'osascript password dialog (hidden answer)' },
@@ -45,14 +45,14 @@ export function detectCredentialHarvest(text) {
45
45
  for (const p of HARVEST_PROMPTS) {
46
46
  const m = p.re.exec(line);
47
47
  if (!m) continue;
48
- if (isDocumentationLine(line) || prohibitsAt(line, m.index)) continue;
48
+ if (isDocumentationLine(line) || prohibitsAt(line, m.index) || describesAt(line, m.index)) continue;
49
49
  push('interactive-prompt', p.label, 'CRITICAL', i, line);
50
50
  }
51
51
  for (const s of HARVEST_STORES) {
52
52
  const m = s.re.exec(line);
53
53
  if (!m) continue;
54
54
  if (!HARVEST_READ_VERB.test(line)) continue;
55
- if (isDocumentationLine(line) || prohibitsAt(line, m.index)) continue;
55
+ if (isDocumentationLine(line) || prohibitsAt(line, m.index) || describesAt(line, m.index)) continue;
56
56
  push(s.family, s.label, HARVEST_EXFIL_VERB.test(line) ? 'CRITICAL' : 'HIGH', i, line);
57
57
  }
58
58
  const t = HARVEST_TOKEN_PATH.exec(line);
@@ -1,7 +1,7 @@
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, isDescriptiveLine, isDocumentationLine, isRiskTableRow, prohibitsAt } from './prose-context.mjs';
4
+ import { citationGoverns, describesAt, 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';
@@ -44,6 +44,7 @@ export function offendingLine(sig, text) {
44
44
  if (sig.refine && !sig.refine(line)) continue;
45
45
  if (isDocumentationLine(line)) continue;
46
46
  if (prohibitsAt(line, line.indexOf(m[0]))) continue;
47
+ if (describesAt(line, line.indexOf(m[0]))) continue;
47
48
  if (isRiskTableRow(line)) continue;
48
49
  return line;
49
50
  }
@@ -71,6 +71,59 @@ export function prohibitsAt(line, offset) {
71
71
  return !DOUBLE_NEGATIVE_RE.test(before);
72
72
  }
73
73
 
74
+ /**
75
+ * ⚠ AN OFFSET ON THE OPENING BACKTICK IS INSIDE THE SPAN. Several rules anchor
76
+ * on the backtick itself (a markdown code span and a shell command substitution
77
+ * are the same character), so counting only what precedes the offset put every
78
+ * code-span guard one character outside the span it was meant to be inside.
79
+ */
80
+ function insideCodeSpan(line, offset) {
81
+ let ticks = 0;
82
+ for (let i = 0; i < offset && i < line.length; i++) if (line[i] === '`') ticks++;
83
+ if (ticks % 2 === 1) return true;
84
+ return line[offset] === '`' && line.indexOf('`', offset + 1) !== -1;
85
+ }
86
+
87
+ /**
88
+ * ⚠ A LINE THAT NAMES A COMMAND AS ITS SUBJECT IS DESCRIBING IT — the second
89
+ * carve-out `carriesHardEvidence` needs, for the same reason `prohibitsAt` was
90
+ * the first. Security documentation is written in exactly this mood -
91
+ * *"`curl … | sh` is a pipe-to-shell installer"*, *"`chmod 777` means the file
92
+ * is world-writable"* - so offline, where no server verdict ever arrives to
93
+ * correct it, every threat model and runbook in a careful repo blocked.
94
+ *
95
+ * ⚠ THE LEAD-IN IS NOT THE SIGNAL, THE PREDICATE IS. "For example, run
96
+ * `curl … | sh`" is an instruction wearing a documentation opener, and
97
+ * "example" is one token an attacker adds for free. What cannot be faked
98
+ * cheaply is the command sitting in SUBJECT position with a copular or
99
+ * reporting verb after it: an instruction puts the command in OBJECT position
100
+ * after an imperative.
101
+ *
102
+ * ⚠ THE MATCH MUST BE INSIDE A CODE SPAN, and ⚠ AN IMPERATIVE BEFORE IT WINS.
103
+ *
104
+ * Mirrors `describesAt` in the backend's prose-context.ts — `local-mirror-bench`
105
+ * compares the two on every mood, in both directions.
106
+ */
107
+ const DESCRIPTIVE_PREDICATE_RE =
108
+ /^\s*(?:,\s*)?(?:is|are|was|were|means?|meant|shows?|showed|demonstrates?|indicates?|signals?|denotes?|describes?|represents?|counts?\s+as|reads?\s+as|matches?|fires?|triggers?|flags?|catches?|becomes?|remains?|stays?|looks?\s+like|would\s+\w+|will\s+\w+|has|have|had)\b/i;
109
+
110
+ const IMPERATIVE_LEAD_RE =
111
+ /\b(?:run|execute|exec|invoke|call|use|paste|copy|type|enter|apply|install|download|fetch|curl|wget|pipe|add|append|write|put|send|post)\b[^.:;\n]{0,40}$/i;
112
+
113
+ export function describesAt(line, offset) {
114
+ if (!line || offset == null || offset < 0 || offset >= line.length) return false;
115
+ if (!insideCodeSpan(line, offset)) return false;
116
+
117
+ const onTick = line[offset] === '`';
118
+ const close = line.indexOf('`', onTick ? offset + 1 : offset);
119
+ if (close === -1) return false;
120
+ if (!DESCRIPTIVE_PREDICATE_RE.test(line.slice(close + 1, close + 60))) return false;
121
+
122
+ const open = onTick ? offset : line.lastIndexOf('`', offset);
123
+ const before = line.slice(Math.max(0, open - 60), open);
124
+ return !IMPERATIVE_LEAD_RE.test(before) && !IMPERATIVE.test(before);
125
+ }
126
+
74
127
  const RISK_CELL_RE = /\b(?:critical|high|medium|low|severity|risk|danger\w*|forbidden|blocked|denied|prohibited|never|do not|example|attack|threat|mitigation|why|impact)\b/i;
75
128
 
76
129
  export function isRiskTableRow(line) {
@@ -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 } : {}),