@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/README.md +312 -5
- package/ai-usage.mjs +29 -0
- package/design.mjs +299 -0
- package/guard-signals.mjs +55 -9
- package/model-refs.mjs +26 -0
- package/package.json +3 -2
- package/shomra.mjs +2360 -206
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
|
|
77
|
-
//
|
|
78
|
-
|
|
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
|
-
|
|
728
|
-
|
|
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
|
-
|
|
793
|
-
|
|
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
|
|
4
|
-
"description": "Shomra — a local-first
|
|
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"
|