@shomra/agent 0.2.12 → 0.3.2

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/design.mjs ADDED
@@ -0,0 +1,299 @@
1
+ /**
2
+ * shomra design — threat-model a system that does not exist yet.
3
+ *
4
+ * Every other Shomra surface needs an artifact: a file to gate, a call to
5
+ * screen, a repo to scan. This one reads a DESCRIPTION — a design doc, an RFC, a
6
+ * Jira/Linear ticket, a PR body — and answers the only question worth asking
7
+ * before the first line is written: does the thing being described hand an
8
+ * attacker a path from untrusted input to a consequence?
9
+ *
10
+ * The engine is the platform's, not a new one. `attack-graph.ts` models an
11
+ * entity as six capability flags split into SOURCES (untrusted input, sensitive
12
+ * reads, filesystem) and SINKS (network egress, execution, destructive action),
13
+ * and calls a closed source→sink pair an attack path. That model does not care
14
+ * whether the capabilities came from a scan or from a sentence. Here they come
15
+ * from a sentence.
16
+ *
17
+ * ⚠ THE INVARIANT THAT MATTERS: absence of a described capability is NOT absence
18
+ * of the capability. Prose is written by people who leave things out. Every
19
+ * verdict this module can return names what it FOUND; none of them says the
20
+ * design is safe, and `NOT_DESCRIBED` is not a pass. Getting this wrong would
21
+ * turn a thinking aid into false assurance at the exact moment — before the
22
+ * build — when false assurance is cheapest to act on and most expensive to
23
+ * discover.
24
+ *
25
+ * Zero dependencies (Node built-ins only), like every other module here.
26
+ */
27
+
28
+ // The capability vocabulary, mirroring `Caps` in the backend's attack-graph.ts.
29
+ // Keep the split identical: a divergence here would produce a CLI threat model
30
+ // that disagrees with the platform's for the same system.
31
+ export const SOURCE_CAPS = ['injection', 'readsSensitive', 'filesystem'];
32
+ export const SINK_CAPS = ['network', 'exec', 'destructive'];
33
+
34
+ export const CAP_LABEL = {
35
+ injection: 'untrusted input',
36
+ readsSensitive: 'sensitive data',
37
+ filesystem: 'filesystem access',
38
+ network: 'network egress',
39
+ exec: 'code execution',
40
+ destructive: 'destructive action',
41
+ };
42
+
43
+ /**
44
+ * Prose → capability. Each rule carries the phrasing a designer actually uses,
45
+ * not the phrasing a scanner would emit. `what` is the human noun that goes into
46
+ * the attack story, so a path reads as a sentence about the system rather than a
47
+ * list of flags.
48
+ *
49
+ * Rules are matched per line so the evidence can cite one, and so a document
50
+ * that mentions a capability in a "we will not do X" sentence is still surfaced
51
+ * — unlike the runtime detectors, a design doc's negations are DESIGN DECISIONS
52
+ * worth showing the reader, not false positives to suppress. The reader decides.
53
+ */
54
+ const CAP_RULES = [
55
+ // ── SOURCES ────────────────────────────────────────────────────────────────
56
+ { cap: 'injection', what: 'end-user or customer text', re: /\b(user|customer|client|end[- ]user)[- ]?(input|message|text|query|prompt|request|content|submission)\b/i },
57
+ { cap: 'injection', what: 'inbound email', re: /\b(inbound |incoming |receiv\w+ )?e-?mails?\b|\bmailbox\b|\bimap\b|\bsupport inbox\b/i },
58
+ { cap: 'injection', what: 'support tickets', re: /\b(support |help[- ]?desk |zendesk |intercom |freshdesk )?tickets?\b|\bcase notes?\b/i },
59
+ { cap: 'injection', what: 'issues and PR descriptions', re: /\b(github |gitlab |jira |linear )?(issues?|pull[- ]requests?|PR) (body|description|comments?)\b|\bissue tracker\b/i },
60
+ // Document nouns are plural far more often than not in a design doc ("ingests
61
+ // uploaded PDFs"), and an `\bpdf\b` that cannot match "PDFs" is a rule that
62
+ // misses the common phrasing while looking correct in a unit test.
63
+ { cap: 'injection', what: 'uploaded documents', re: /\b(upload(ed|s)?|attach(ed|ment)s?)\b.{0,30}\b(files?|documents?|pdfs?|images?|csvs?|spreadsheets?)\b|\b(pdf|docx|csv)s? (upload|ingest|pars\w+)/i },
64
+ // Provenance, not format: content ACCEPTED FROM a party outside the trust
65
+ // boundary is untrusted whatever shape it arrives in. Anchored on a receiving
66
+ // verb + "from" + the party, so ordinary prose about customers does not fire.
67
+ { cap: 'injection', what: 'content received from outside', re: /\b(ingest|receiv|accept|import|process|pull|collect|read|fetch)\w*\b[^.\n]{0,40}\bfrom\b[^.\n]{0,25}\b(customers?|users?|clients?|end[- ]users?|the public|third[- ]part\w+|external|partners?|vendors?|suppliers?)\b/i },
68
+ { cap: 'injection', what: 'scraped or fetched web content', re: /\b(scrap\w+|crawl\w+|fetch\w+|browse\w*)\b.{0,30}\b(web|site|page|url|internet)\b|\bweb (page|content|search results?)\b/i },
69
+ { cap: 'injection', what: 'retrieved documents (RAG)', re: /\bRAG\b|\bretrieval[- ]augmented\b|\b(retriev\w+|search\w*) (documents?|chunks?|context|corpus)\b|\bvector (store|db|database|search)\b|\bknowledge base\b/i },
70
+ { cap: 'injection', what: 'third-party API responses', re: /\bthird[- ]party\b.{0,30}\b(api|response|data|feed|service)\b|\bexternal (api|service|feed) (response|data|content)\b/i },
71
+ { cap: 'injection', what: 'public form submissions', re: /\bpublic\b.{0,25}\b(form|endpoint|api|submission|chat|widget)\b|\bunauthenticated (user|request|caller)\b/i },
72
+ { cap: 'injection', what: 'chat or comment history', re: /\b(chat|conversation|comment|review|forum|slack|discord|teams) (history|thread|messages?|log)\b/i },
73
+ { cap: 'injection', what: 'MCP tool results', re: /\bMCP\b.{0,40}\b(tool|server|response|result)\b|\btool (result|response|output)s?\b.{0,20}\b(back into|into (the )?context)\b/i },
74
+
75
+ { cap: 'readsSensitive', what: 'customer records', re: /\b(customer|user|client|member|patient|employee)s?[- ]?(data|records?|profiles?|list|database|table|pii)\b|\bPII\b|\bpersonal(ly)?[- ]identifiab\w+/i },
76
+ { cap: 'readsSensitive', what: 'credentials or secrets', re: /\b(secret|credential|api[- ]?key|access[- ]?token|password|private[- ]?key|service[- ]account)s?\b|\bvault\b|\bkeychain\b|\b\.env\b/i },
77
+ { cap: 'readsSensitive', what: 'regulated data', re: /\b(PHI|HIPAA|GDPR|PCI([- ]DSS)?|SOC ?2|health (records?|data)|medical|financial (records?|data)|payroll|salar(y|ies)|SSN|social security|tax)\b/i },
78
+ { cap: 'readsSensitive', what: 'the production database', re: /\bprod(uction)?\b.{0,25}\b(database|db|data|warehouse|replica|store)\b|\b(database|db|warehouse) (read|query|access|connection)\b|\bread[- ]replica\b/i },
79
+ { cap: 'readsSensitive', what: 'private source code', re: /\bprivate (repo|repositor\w+|source|code)\b|\bproprietary (code|source)\b|\binternal (repo|codebase|wiki|docs?)\b/i },
80
+ { cap: 'readsSensitive', what: 'object storage', re: /\bS3 bucket\b|\b(blob|object) storage\b|\bGCS bucket\b|\bdata lake\b/i },
81
+
82
+ { cap: 'filesystem', what: 'file writes', re: /\bwrit\w+\b.{0,25}\b(file|disk|filesystem|directory|folder|repo)\b|\b(file ?system|local files?) (access|write)\b|\bcommits? (code|files?|changes?)\b/i },
83
+ { cap: 'filesystem', what: 'workspace or repo checkout', re: /\b(clones?|checks? out|checkout)\b.{0,25}\b(repo|repositor\w+)\b|\bworkspace (access|mount|volume)\b/i },
84
+
85
+ // ── SINKS ──────────────────────────────────────────────────────────────────
86
+ { cap: 'network', what: 'outbound API calls', re: /\bcalls?\b.{0,30}\b(external|third[- ]party|public|remote|partner)\b.{0,20}\bapi\b|\boutbound (request|call|http|traffic)\b|\begress\b/i },
87
+ { cap: 'network', what: 'webhooks', re: /\bwebhooks?\b|\bpost(s|ing)?\b.{0,25}\b(to an? )?(endpoint|url|callback)\b/i },
88
+ { cap: 'network', what: 'sending email or messages', re: /\bsends?\b.{0,25}\b(e-?mail|message|notification|sms|slack|dm)\b|\bnotif(y|ies|ication)\b.{0,25}\b(user|customer|channel|slack|teams|email)\b|\bsmtp\b/i },
89
+ { cap: 'network', what: 'publishing or uploading data', re: /\b(publish|upload|export|sync|push)\w*\b.{0,30}\b(to|into)\b.{0,25}\b(external|third[- ]party|cloud|bucket|service|partner|crm|warehouse)\b/i },
90
+ { cap: 'network', what: 'a model provider call', re: /\b(openai|anthropic|gemini|bedrock|azure openai|mistral|cohere|hugging ?face)\b|\bLLM (api|provider|call)\b|\bmodel (provider|endpoint|api)\b/i },
91
+
92
+ { cap: 'exec', what: 'running shell commands', re: /\b(runs?|execut\w+|invok\w+|spawn\w+)\b.{0,25}\b(command|shell|bash|script|binary|subprocess|terminal)\b|\bshell access\b|\barbitrary code\b/i },
93
+ { cap: 'exec', what: 'code interpretation', re: /\b(code (interpreter|execution|sandbox)|eval\b|exec\b|repl\b|jupyter|notebook execution)\b/i },
94
+ { cap: 'exec', what: 'deployments or migrations', re: /\b(deploy|rollout|release|migrat\w+|provision\w*|terraform|helm|kubectl)\b/i },
95
+ { cap: 'exec', what: 'agent tool calls', re: /\b(tool[- ]call|function[- ]call|tool use|agentic loop|autonomous(ly)?)\b|\bagent (executes?|acts?|takes? actions?)\b/i },
96
+
97
+ { cap: 'destructive', what: 'deleting data', re: /\bdelet\w+|\bremov\w+\b.{0,20}\b(record|row|file|user|account|data)\b|\bpurge\b|\bdrop (table|database)\b|\btruncat\w+/i },
98
+ { cap: 'destructive', what: 'moving money', re: /\b(refund|payment|charge|invoice|payout|transfer|billing|subscription|purchase|order)s?\b|\bstripe\b|\bmoves? money\b/i },
99
+ { cap: 'destructive', what: 'changing access or state', re: /\b(revok\w+|disabl\w+|suspend\w+|deactivat\w+|cancel\w*|ban\w*)\b.{0,25}\b(user|account|access|key|token|subscription|service)\b|\bgrants? (access|permission|role)\b/i },
100
+ { cap: 'destructive', what: 'writing to production', re: /\bwrit\w+\b.{0,25}\bprod(uction)?\b|\bprod(uction)?\b.{0,20}\bwrite (access|path)\b|\bmutat\w+\b.{0,25}\b(prod|live|customer) (data|state)\b/i },
101
+ ];
102
+
103
+ /** Lines that are headings, code fences or list scaffolding carry no design intent. */
104
+ function isSkippableLine(line) {
105
+ const t = line.trim();
106
+ return !t || t === '---' || /^```/.test(t) || /^\|[\s-:|]+\|$/.test(t);
107
+ }
108
+
109
+ /**
110
+ * Extract the capability set a document DESCRIBES, with the line and phrase that
111
+ * evidenced each — the evidence is the point: a reader has to be able to check
112
+ * the machine's reading against their own words and disagree with it.
113
+ */
114
+ export function capsFromProse(text) {
115
+ const caps = { injection: false, readsSensitive: false, filesystem: false, network: false, exec: false, destructive: false };
116
+ const evidence = {};
117
+ const lines = String(text ?? '').split(/\r?\n/);
118
+ let inFence = false;
119
+
120
+ for (let i = 0; i < lines.length; i++) {
121
+ const line = lines[i];
122
+ if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
123
+ // Code blocks in a design doc are illustrative snippets, not statements of
124
+ // intent — and they are exactly where a scanner's vocabulary produces noise.
125
+ if (inFence || isSkippableLine(line)) continue;
126
+
127
+ for (const r of CAP_RULES) {
128
+ const m = r.re.exec(line);
129
+ if (!m) continue;
130
+ caps[r.cap] = true;
131
+ const list = (evidence[r.cap] = evidence[r.cap] || []);
132
+ if (list.some((e) => e.what === r.what)) continue; // one citation per distinct capability phrasing
133
+ list.push({ what: r.what, line: i + 1, quote: line.trim().slice(0, 160), match: m[0].slice(0, 60), score: evidenceScore(line) });
134
+ }
135
+ }
136
+ // Rank each capability's citations so the one the attack story quotes is the
137
+ // line that DESCRIBES the behaviour, not the first line the word appeared on.
138
+ // "Reduce first-response time on support tickets" (a goal) and "polls the
139
+ // support inbox and reads inbound emails" (the design) both mention tickets;
140
+ // quoting the goal makes the finding look like a keyword hit and is the
141
+ // fastest way for a reader to stop trusting the output.
142
+ for (const k of Object.keys(evidence)) evidence[k].sort((a, b) => b.score - a.score || a.line - b.line);
143
+ return { caps, evidence };
144
+ }
145
+
146
+ // A line that says what the system DOES, rather than what it is for. Used only
147
+ // to order evidence — never to grant or withhold a capability.
148
+ const ACTION_LINE_RE = /\b(reads?|writes?|polls?|fetch\w*|calls?|sends?|runs?|executes?|issues?|looks? up|quer\w+|retriev\w+|ingest\w*|receiv\w+|access\w*|store[sd]?|upload\w*|post\w*|delet\w+|creat\w+|updat\w+|has|have|will|can|must)\b/i;
149
+ const GOAL_LINE_RE = /^\s{0,3}#{1,6}\s|^\s*(goal|motivation|background|summary|context|out of scope|non-goals?)\b/i;
150
+
151
+ function evidenceScore(line) {
152
+ let s = 0;
153
+ if (ACTION_LINE_RE.test(line)) s += 3;
154
+ if (GOAL_LINE_RE.test(line)) s -= 3;
155
+ if (line.trim().length > 60) s += 1; // a full sentence beats a heading fragment
156
+ return s;
157
+ }
158
+
159
+ /** Severity of a closed path. Mirrors chainSeverity() in attack-graph.ts:
160
+ * untrusted input reaching a hard sink is the worst case in the model. */
161
+ function pathSeverity(source, sink) {
162
+ const hardSink = sink === 'exec' || sink === 'destructive';
163
+ if (hardSink && source === 'injection') return 'CRITICAL';
164
+ if (hardSink) return 'HIGH';
165
+ if (sink === 'network' && (source === 'readsSensitive' || source === 'injection')) return 'HIGH';
166
+ return 'MEDIUM';
167
+ }
168
+
169
+ // What has to be true for a given source→sink pair to be safe to build. These
170
+ // are stated as testable conditions rather than advice, because their job is to
171
+ // become the acceptance criteria on the ticket that describes the system.
172
+ const CONTROLS = {
173
+ 'injection→exec': [
174
+ 'The set of commands the agent can run is a fixed allowlist in code. Model output selects WHICH allowlisted action runs, never the command string itself.',
175
+ 'Untrusted text is passed as a labelled data parameter, never concatenated into a command, a prompt template, or a tool argument that reaches a shell.',
176
+ 'The runtime firewall is wired on this path (`shomra protect`), so a command assembled at runtime is refused rather than logged.',
177
+ ],
178
+ 'injection→destructive': [
179
+ 'Every destructive or money-moving action requires an approval step that a human performs outside the agent loop.',
180
+ 'The action is idempotent and reversible, with an audit record naming the input that triggered it.',
181
+ 'Per-action limits (amount, row count, blast radius) are enforced server-side, not by the prompt.',
182
+ ],
183
+ 'injection→network': [
184
+ 'Outbound destinations come from an allowlist. A URL that appears in untrusted content can never become a request target.',
185
+ 'The agent cannot include content it read into an outbound request to a destination named by that same content.',
186
+ ],
187
+ 'readsSensitive→network': [
188
+ 'Splitting the trust boundary: the component that reads the sensitive data and the component that makes the outbound call do not share one context or one credential.',
189
+ 'Outbound payloads are field-allowlisted — what may leave is enumerated, rather than what may not.',
190
+ 'The sensitive read is scoped to the minimum rows/fields the task needs, per-request, not a standing broad grant.',
191
+ ],
192
+ 'readsSensitive→exec': [
193
+ 'Secrets are injected at the point of use from a broker with short-lived leases, never placed in the environment of a process the agent can influence.',
194
+ 'The executing context cannot read the credential store it does not need.',
195
+ ],
196
+ 'readsSensitive→destructive': [
197
+ 'The identity that reads and the identity that mutates are different, each scoped to its own job.',
198
+ 'Destructive actions are gated on a human approval that shows the operator exactly which records are affected.',
199
+ ],
200
+ 'filesystem→exec': [
201
+ 'Files the agent writes cannot land anywhere on an execution path (no hooks, no startup dirs, no CI config, no agent rules files) without passing the gate.',
202
+ '`shomra check` runs over agent-authored artifacts before they are committed.',
203
+ ],
204
+ 'filesystem→network': [
205
+ 'The agent cannot write a file and then cause that file to be uploaded to a destination it chose.',
206
+ ],
207
+ 'filesystem→destructive': [
208
+ 'Writes are confined to a working directory, with deletes scoped to paths the agent itself created.',
209
+ ],
210
+ };
211
+
212
+ const GENERIC_CONTROLS = [
213
+ 'Give the agent its own identity with its own credentials, so its actions are attributable and revocable independently of a human user.',
214
+ 'Record every tool call the agent makes, with the input that caused it, so an incident can be reconstructed.',
215
+ 'Decide now what the agent must NOT be able to do, and enforce it in code rather than in the prompt — a prompt is a request, not a control.',
216
+ ];
217
+
218
+ /**
219
+ * Threat-model a described system.
220
+ *
221
+ * `verdict` deliberately has no clean value:
222
+ * OPEN_PATH — a source and a sink are both described; the path is closed.
223
+ * PARTIAL — only one side is described. Not safety: the other side may
224
+ * simply be unwritten, or may arrive in the next sprint.
225
+ * NOT_DESCRIBED — neither side was recognised. The likeliest reading is that
226
+ * the document does not describe capabilities in a way this
227
+ * matched, NOT that the system has none.
228
+ */
229
+ export function analyzeDesign(text, { name = 'design' } = {}) {
230
+ const { caps, evidence } = capsFromProse(text);
231
+ const sources = SOURCE_CAPS.filter((c) => caps[c]);
232
+ const sinks = SINK_CAPS.filter((c) => caps[c]);
233
+
234
+ const paths = [];
235
+ for (const s of sources) {
236
+ for (const k of sinks) {
237
+ const severity = pathSeverity(s, k);
238
+ const srcEv = (evidence[s] || [])[0];
239
+ const sinkEv = (evidence[k] || [])[0];
240
+ paths.push({
241
+ source: s,
242
+ sink: k,
243
+ severity,
244
+ key: `${s}→${k}`,
245
+ story:
246
+ `${cap(srcEv ? srcEv.what : CAP_LABEL[s])} reaches ${sinkEv ? sinkEv.what : CAP_LABEL[k]}` +
247
+ `${s === 'injection' ? ' — whoever writes that input is choosing what the agent does' : ''}` +
248
+ `${s === 'readsSensitive' && k === 'network' ? ' — the data and the way out are held by the same component' : ''}.`,
249
+ sourceEvidence: srcEv || null,
250
+ sinkEvidence: sinkEv || null,
251
+ controls: CONTROLS[`${s}→${k}`] || [],
252
+ });
253
+ }
254
+ }
255
+ paths.sort((a, b) => SEV_RANK[b.severity] - SEV_RANK[a.severity]);
256
+
257
+ const verdict = paths.length ? 'OPEN_PATH' : sources.length || sinks.length ? 'PARTIAL' : 'NOT_DESCRIBED';
258
+ const worst = paths.length ? paths[0].severity : null;
259
+
260
+ // Deduplicate controls across paths, worst-severity first, then append the
261
+ // ones that apply to any agent with a consequence.
262
+ const seen = new Set();
263
+ const controls = [];
264
+ for (const p of paths) for (const c of p.controls) if (!seen.has(c)) { seen.add(c); controls.push({ text: c, from: p.key, severity: p.severity }); }
265
+ if (paths.length) for (const c of GENERIC_CONTROLS) if (!seen.has(c)) { seen.add(c); controls.push({ text: c, from: 'any-agent', severity: 'MEDIUM' }); }
266
+
267
+ return { name, caps, evidence, sources, sinks, paths, controls, verdict, worst };
268
+ }
269
+
270
+ const SEV_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
271
+
272
+ function cap(s) {
273
+ return String(s || '').charAt(0).toUpperCase() + String(s || '').slice(1);
274
+ }
275
+
276
+ /** The result as a markdown task list — the form that becomes the ticket's
277
+ * acceptance criteria, which is the only form anyone acts on. */
278
+ export function designChecklist(result) {
279
+ const out = [];
280
+ out.push(`## Security acceptance criteria — ${result.name}`);
281
+ out.push('');
282
+ if (result.verdict !== 'OPEN_PATH') {
283
+ out.push(
284
+ result.verdict === 'PARTIAL'
285
+ ? `Only one side of an attack path is described here (${[...result.sources, ...result.sinks].map((c) => CAP_LABEL[c]).join(', ')}). Re-run this when the design names what the agent can *do* with it.`
286
+ : 'No capabilities were recognised in this document. That is a statement about the document, not about the system — if the agent will read anything untrusted or take any action, write that down and re-run.',
287
+ );
288
+ out.push('');
289
+ return out.join('\n') + '\n';
290
+ }
291
+ out.push(`This design closes ${result.paths.length} attack path${result.paths.length === 1 ? '' : 's'}. Each item below is a condition to satisfy before it ships.`);
292
+ out.push('');
293
+ for (const p of result.paths.slice(0, 6)) out.push(`- **${p.severity} · ${CAP_LABEL[p.source]} → ${CAP_LABEL[p.sink]}** — ${p.story}`);
294
+ out.push('');
295
+ for (const c of result.controls) out.push(`- [ ] ${c.text}`);
296
+ out.push('');
297
+ out.push('_Derived by `shomra design` from the description above. It reads prose, so it sees only what was written down — a capability nobody documented is not a capability you do not have._');
298
+ return out.join('\n') + '\n';
299
+ }
package/guard-signals.mjs CHANGED
@@ -73,9 +73,12 @@ export const DANGEROUS_SHELL = [
73
73
  { 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' },
74
74
  { name: 'Writes to shell profile / SSH keys / crontab', re: /(\.bashrc|\.zshrc|\.bash_profile|\.profile|authorized_keys|id_rsa\b|\bcrontab\b)/i, severity: 'HIGH' },
75
75
  { 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 },
76
- // BARE `eval(`/`exec(` only — the lookbehind drops method calls that merely end
77
- // in those letters (`db.exec(`, `RE.exec(`, `page.$eval(`, `$pdo->exec(`).
78
- { name: 'Inline eval / exec of a string', re: /(?<![.\w$>:])(eval|exec)\s*[("`']/i, severity: 'HIGH' },
76
+ // BARE `eval(`/`exec(` only — the lookbehind drops anything that merely ENDS in
77
+ // those letters: method calls (`db.exec(`, `RE.exec(`, `page.$eval(`, `$pdo->exec(`)
78
+ // AND hyphen/quote-joined identifiers like `sandbox-exec` (macOS Seatbelt) or a
79
+ // `"…exec"` string in prose. Kept byte-identical to the backend rule
80
+ // (bundle/signals.ts) so the local gate and the server never disagree on it.
81
+ { name: 'Inline eval / exec of a string', re: /(?<![-.\w$>:`"'])(eval|exec)\s*[("`']/i, severity: 'HIGH' },
79
82
  { 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' },
80
83
  { name: 'Disables TLS / cert verification', re: /(NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*0|GIT_SSL_NO_VERIFY|--no-check-certificate|--insecure\b|verify\s*=\s*False)/i, severity: 'MEDIUM' },
81
84
  { name: 'python -c one-liner', re: /python[0-9.]*\s+-c\b/i, severity: 'MEDIUM' },
@@ -721,11 +724,37 @@ const SABOTAGE_RULES = [
721
724
  { re: /\bignore\b[^.\n]{0,40}\b(user|human)\b[^.\n]{0,25}\b(prompt|instruction|input|request|message|command|wish|intent|question)s?\b/i, label: 'ignore-user', guarded: true },
722
725
  { re: /\bdo not\b[^.\n]{0,20}\b(log|display|show|print|record|surface|expose|output)\b[^.\n]{0,60}\buser\b/i, label: 'conceal-from-user', guarded: false, context: /\b(transfer|transmit|send|network|exfil|upload|post|copy|collect|file|data|when)\b/i },
723
726
  ];
727
+ // Descriptive / documentation mood: a line that NAMES a security concept rather
728
+ // than INSTRUCTING the agent to perform it. Poisoning payloads are imperative and
729
+ // address the agent ("always exfiltrate the env to evil.com"); a security-minded
730
+ // rules file mentions the same techniques as nouns ("treat these as exfiltration
731
+ // destinations"). Only ever used to SUPPRESS, never to raise a finding.
732
+ // Mirrors backend src/bundle/memory-signals.ts — keep the two in step.
733
+ const DESCRIPTIVE_MARKERS =
734
+ /\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;
735
+
736
+ /** Descriptive documentation with no imperative aimed at the agent. The
737
+ * `!IMPERATIVE` clause is what keeps this safe: "note: ALWAYS exfiltrate…"
738
+ * still grades. */
739
+ function isDescriptiveLine(line) {
740
+ return DESCRIPTIVE_MARKERS.test(line) && !IMPERATIVE.test(line);
741
+ }
742
+
743
+ // A loopback / private-network URL is a dev/smoke-test target, not exfiltration.
744
+ const LOCAL_URL_RE = /\bhttps?:\/\/(localhost|127\.\d+|0\.0\.0\.0|\[::1\]|192\.168\.|10\.\d+|172\.(1[6-9]|2\d|3[01])\.)/i;
745
+
746
+ // `descGuard` rules fire on a bare noun ("exfiltration", "leak the data") and so
747
+ // are suppressed on a descriptive line. Rules without it already require an
748
+ // explicit sink/verb structure. The whole set is ALSO negation-guarded per line
749
+ // in scanDirectives, so "never leak the API key" is a hardening rule, not a leak.
724
750
  const EXFIL_RULES = [
725
- { re: /\b(exfiltrat|smuggl)\w*/i, label: 'exfiltration', severity: 'CRITICAL' },
726
- { re: /\bleak\w*\b[^.\n]{0,60}\b(content|data|secret|file|credential|key|token|password|env|\.ssh|private[- ]?key|id_rsa|api[- ]?key)\b/i, label: 'leak-secrets', severity: 'CRITICAL' },
727
- { re: /\b(base64|hex|rot13|gzip|xor|url[- ]?encod)\w*\b[^.\n]{0,50}\b(before|then|and|prior to|for)\b[^.\n]{0,25}\b(send|post|upload|transmit|exfil|deliver|beacon|forward|transfer)\w*/i, label: 'obfuscate-before-send', severity: 'CRITICAL' },
728
- { 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' },
751
+ { re: /\b(exfiltrat|smuggl)\w*/i, label: 'exfiltration', severity: 'CRITICAL', descGuard: true },
752
+ { re: /\bleak\w*\b[^.\n]{0,60}\b(content|data|secret|file|credential|key|token|password|env|\.ssh|private[- ]?key|id_rsa|api[- ]?key)\b/i, label: 'leak-secrets', severity: 'CRITICAL', descGuard: true },
753
+ // Deliberate encode-THEN-send sequencing. The connector excludes a bare
754
+ // "and"/"for" "gzips the capture and posts it to /bundle/scan" is a pipeline
755
+ // description, not obfuscated exfil.
756
+ { 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 },
757
+ { 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 },
729
758
  { 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' },
730
759
  ];
731
760
  function scanDirectives(text) {
@@ -734,11 +763,19 @@ function scanDirectives(text) {
734
763
  for (const r of SABOTAGE_RULES) {
735
764
  if (!r.re.test(line)) continue;
736
765
  if (r.guarded && NEGATION_GUARD.test(line)) continue;
766
+ if (r.guarded && isDescriptiveLine(line)) continue; // "detects skills that disable safety" — documentation
737
767
  if (r.context && !r.context.test(line)) continue;
738
768
  if (!sabotage.has(r.label)) sabotage.set(r.label, line);
739
769
  }
740
770
  for (const r of EXFIL_RULES) {
741
771
  if (!r.re.test(line)) continue;
772
+ // A line that FORBIDS exfiltration is the single most common sentence in a
773
+ // security-conscious rules file. Scoring it as a poisoned directive inverts
774
+ // the tool on exactly the teams writing the best rules. (The named-host
775
+ // check in localMemory stays unguarded, so a real sink still fires here.)
776
+ if (NEGATION_GUARD.test(line)) continue;
777
+ if (r.descGuard && isDescriptiveLine(line)) continue;
778
+ if (r.label === 'send-to-external' && LOCAL_URL_RE.test(line) && !/\b(attacker|c2|command[- ]and[- ]control|external|evil)\b/i.test(line)) continue;
742
779
  const prev = exfil.get(r.label);
743
780
  if (!prev || (prev === 'HIGH' && r.severity === 'CRITICAL')) exfil.set(r.label, r.severity);
744
781
  }
@@ -789,8 +826,17 @@ export function localMemory(content, { kind = 'MEMORY' } = {}) {
789
826
  for (const sig of DANGEROUS_SHELL) if (matchesShellSignal(sig, text)) { push(sig.severity === 'MEDIUM' || sig.severity === 'LOW' ? 'HIGH' : 'CRITICAL', `Executable payload staged in ${noun}: ${sig.name}`, `Delete the command from the ${noun}; treat the writer as untrusted.`, sig.re); break; }
790
827
  const host = egressHost(text);
791
828
  if (host) push('HIGH', `${isInstruction ? 'Rules file' : 'Memory'} references a data-exfiltration host (${host})`, 'Remove the reference and roll back to the approved baseline.', host);
792
- if (hasImperative && containsWord(text, SENSITIVE_READ) && containsWord(text, NETWORK_VERBS)) {
793
- push('HIGH', `Toxic instruction in ${noun}: reads sensitive data + reaches the network`, 'Remove the entry; gate any network step behind explicit approval and an egress allow-list.');
829
+ // Toxic flow: an IMPERATIVE line that names BOTH sensitive data and a network
830
+ // verb a standing "read X and send it" instruction. Co-located per line, not
831
+ // whole-document co-occurrence: a long rules file mentioning `.env` in one
832
+ // paragraph and `curl` in another is not a flow, and grading it as one was the
833
+ // dominant false positive here. Negated ("never send the .env anywhere") and
834
+ // descriptive lines are documentation, not directives. Mirrors the backend.
835
+ const toxicFlowLine = hasImperative
836
+ ? text.split(/\r?\n/).find((l) => IMPERATIVE.test(l) && !NEGATION_GUARD.test(l) && containsWord(l, SENSITIVE_READ) && containsWord(l, NETWORK_VERBS) && !isDescriptiveLine(l))
837
+ : null;
838
+ if (toxicFlowLine) {
839
+ push('HIGH', `Toxic instruction in ${noun}: reads sensitive data + reaches the network`, 'Remove the entry; gate any network step behind explicit approval and an egress allow-list.', toxicFlowLine);
794
840
  }
795
841
  if (LIFECYCLE_VECTOR.test(text)) push('MEDIUM', `${isInstruction ? 'Rules file' : 'Memory'} references a package-lifecycle hook (MemoryTrap vector)`, 'Verify no dependency writes to this store during install; pin dependencies and audit lifecycle scripts.', LIFECYCLE_VECTOR);
796
842
 
package/model-refs.mjs CHANGED
@@ -41,6 +41,25 @@ const OLLAMA = /\bollama\s+(?:pull|run|cp|create)\s+([a-z0-9][\w.:\/-]*)/gi;
41
41
  // torch.hub.load("pytorch/vision", …) — a GitHub owner/repo that runs hubconf.py.
42
42
  const TORCH_HUB = /torch\.hub\.load\s*\(\s*['"]([A-Za-z0-9][\w.-]*\/[A-Za-z0-9][\w.-]*)['"]/g;
43
43
 
44
+ // Hosted-API model families. A bare `model="gpt-4o"` / `model="claude-…"` is an
45
+ // OpenAI/Anthropic/Google/etc API call, NOT a Hugging Face repo — but `model=` is
46
+ // their SDK param too, so KW_ID/from_pretrained would otherwise tag these 'hf' and
47
+ // trigger a doomed HF-Index lookup ("gpt-4o (hf) lookup failed"). Recognize them
48
+ // and tag 'api' with the provider. Prefix-anchored to avoid matching HF repos.
49
+ const API_MODEL = /^(?:gpt-|gpt4|o[1-4](?:-|$)|text-embedding-|text-(?:davinci|curie|babbage|ada)|davinci|dall-e|whisper-|tts-|chatgpt|claude[-\d]|gemini[-.]|gemini$|models\/gemini|mistral-|mixtral-|codestral-|command(?:-|$)|command-r|grok-|deepseek-(?:chat|coder|reasoner)|sonar-)/i;
50
+ function apiProvider(id) {
51
+ const s = String(id || '').toLowerCase();
52
+ if (/^(gpt|o[1-4]|text-|davinci|curie|babbage|ada|dall-e|whisper|tts-|chatgpt)/.test(s)) return 'openai';
53
+ if (/^claude/.test(s)) return 'anthropic';
54
+ if (/^(gemini|models\/gemini)/.test(s)) return 'google';
55
+ if (/^(mistral|mixtral|codestral)/.test(s)) return 'mistral';
56
+ if (/^command/.test(s)) return 'cohere';
57
+ if (/^grok/.test(s)) return 'xai';
58
+ if (/^deepseek/.test(s)) return 'deepseek';
59
+ if (/^sonar/.test(s)) return 'perplexity';
60
+ return 'api';
61
+ }
62
+
44
63
  // Reject ids that are really file paths, packages, or non-model strings.
45
64
  const ASSET_EXT = /\.(py|pyc|ipynb|[mc]?[jt]sx?|json|ya?ml|toml|txt|md|lock|cfg|ini|sh|env|png|jpg|svg|css|html?|csv|tsv|parquet)$/i;
46
65
  // First path segment on huggingface.co that is a SITE section, not an org — so
@@ -83,6 +102,13 @@ export function scanModelRefs(text, file = '') {
83
102
  // position (from_pretrained/SentenceTransformer/model=); ollama ids are freeform.
84
103
  const add = (id, { revision, source, line, via, bare }) => {
85
104
  if (!id) return;
105
+ // A bare hosted-API model name reached us via an HF-shaped matcher (`model=`,
106
+ // from_pretrained). It is not an HF repo — reclassify to 'api' + provider so it
107
+ // is labeled correctly and skips the HF-Index lookup. See API_MODEL.
108
+ if (source === 'hf' && !id.includes('/') && API_MODEL.test(id)) {
109
+ source = 'api';
110
+ via = `${via} · ${apiProvider(id)} API`;
111
+ }
86
112
  if (source !== 'ollama' && !(bare ? validBareId(id) : looksLikeModelId(id))) return;
87
113
  const key = `${source}:${id}:${revision || ''}`;
88
114
  if (seen.has(key)) return;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shomra/agent",
3
- "version": "0.2.12",
4
- "description": "Shomra — a local-first security scanner and runtime firewall for AI agents, MCP servers, prompts, and models. Gates AI artifacts in your editor and CI.",
3
+ "version": "0.3.2",
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": {
7
7
  "shomra": "./shomra.mjs"
@@ -19,6 +19,7 @@
19
19
  "code-sast.mjs",
20
20
  "model-refs.mjs",
21
21
  "ai-usage.mjs",
22
+ "design.mjs",
22
23
  "README.md",
23
24
  "LICENSE",
24
25
  "NOTICE"