@shomra/agent 0.3.1 → 0.3.3

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 CHANGED
@@ -12,6 +12,15 @@ Zero dependencies — Node ≥ 18 built-ins only.
12
12
 
13
13
  ## Install
14
14
 
15
+ Also available as a [Dev Container Feature](devcontainer-feature/) (the guard
16
+ exists before the first keystroke in Codespaces / Gitpod / a local rebuild) and a
17
+ [GitHub Action](action.yml).
18
+
19
+ **There is no `curl … | sh` installer, deliberately.** Shomra's own Tier-0 guard
20
+ blocks piping a downloaded script into a shell, and the rules block it writes
21
+ tells coding agents never to do it. Shipping that one-liner would be the product
22
+ contradicting its own control in its own README.
23
+
15
24
  ```bash
16
25
  npm i -g @shomra/agent # global `shomra`
17
26
  # or run without installing:
@@ -42,6 +51,8 @@ shomra fix .mcp.json --apply # AI-fix one artifact and write it back
42
51
  shomra why .mcp.json # why each finding matters + is-it-a-false-positive
43
52
  shomra install-precommit # block risky staged AI artifacts on git commit
44
53
  shomra install-hook --agent claude # wire the runtime firewall into Claude Code
54
+ shomra rules --write # teach the agent what gets blocked, so it never writes it
55
+ shomra mcp install # let the agent gate its own content BEFORE writing it
45
56
  shomra scan # discover AI tooling on this machine
46
57
  shomra status # config + firewall health
47
58
  shomra help # full command list
@@ -68,6 +79,18 @@ shomra gate my-skill/SKILL.md # vet ONE artifact (auto-classified from
68
79
  shomra gate --all . # vet every AI artifact in the repo (the CI form)
69
80
  ```
70
81
 
82
+ ### Starting a new agent project
83
+
84
+ ```bash
85
+ shomra new agent triage-bot # a project that starts compliant
86
+ ```
87
+
88
+ Guard enforcing on every model call, an egress allowlist in code rather than in
89
+ the prompt, untrusted input kept out of the system prompt, secrets referenced
90
+ from the environment, and the gate wired into CI — from commit zero. Remediating
91
+ a project into this shape later means changing decisions that have already been
92
+ built on.
93
+
71
94
  ## `gate` in CI
72
95
 
73
96
  `gate`/`gate --all` are **local-first**: real static analysis (dangerous shell,
@@ -90,7 +113,25 @@ policy not applied). `--strict` fails closed (exit 1) because org policy can't b
90
113
  verified. Every backend call is bounded by `SHOMRA_API_TIMEOUT_MS` (default 30s),
91
114
  so a job never hangs.
92
115
 
93
- ### GitHub Actions
116
+ ### GitHub Actions — the reusable action
117
+
118
+ ```yaml
119
+ - uses: actions/checkout@v4
120
+ - uses: shomra-org/agent@v0
121
+ with:
122
+ args: check # --strict is appended unless fail-on-flag: 'false'
123
+ api-key: ${{ secrets.SHOMRA_API_KEY }} # optional — the gate is local-first
124
+ url: ${{ secrets.SHOMRA_URL }}
125
+ ```
126
+
127
+ Wire it as a **required status check** on a protected branch and it becomes the
128
+ un-bypassable control on GitHub.com, which has no server-side hooks. On
129
+ self-hosted Git and GitHub Enterprise, `shomra install-precommit --pre-receive`
130
+ (below) refuses the push itself.
131
+
132
+ The hand-written workflow below still works and shows what the action does.
133
+
134
+ ### GitHub Actions — by hand
94
135
 
95
136
  ```yaml
96
137
  name: Shomra AI-artifact gate
@@ -192,6 +233,25 @@ shomra-gate:
192
233
  SHOMRA_URL: $SHOMRA_URL
193
234
  ```
194
235
 
236
+ ### pre-receive (server-side, cannot be skipped)
237
+
238
+ ```bash
239
+ shomra install-precommit --pre-receive /srv/git/your-repo.git
240
+ ```
241
+
242
+ A pre-commit hook is a courtesy: it lives on the developer's machine, it is one
243
+ `--no-verify` away, and a machine that never ran `install-precommit` has no gate
244
+ at all. A pre-receive hook runs on the **server**, on every push, for every
245
+ developer. Same check; the difference between a reminder and a control.
246
+
247
+ It **fails closed** — the opposite of the client hook. Blocking a local commit
248
+ because a binary is missing is hostile; waving a push through for the same reason
249
+ makes deleting the binary the bypass.
250
+
251
+ Available on self-hosted Git (GitLab, Gitea, Bitbucket DC, plain bare repos) and
252
+ GitHub Enterprise. GitHub.com does not run server-side hooks — use the action as
253
+ a required status check instead.
254
+
195
255
  ### pre-commit (local, blocks risky artifacts before they land)
196
256
 
197
257
  `.git/hooks/pre-commit` (or a [pre-commit](https://pre-commit.com) `local` hook):
@@ -218,6 +278,201 @@ backend, behind a short timeout + circuit breaker — so a slow or down backend
218
278
  never freezes the agent. Fail-open by default; `SHOMRA_GUARD_STRICT=1` fails
219
279
  closed on the server tier.
220
280
 
281
+ Three channels are screened:
282
+
283
+ | Channel | Hook | What it stops |
284
+ |---|---|---|
285
+ | **Tool call** | PreToolUse / `beforeShellExecution` | the shell command, artifact write or MCP call, before it runs |
286
+ | **Tool result** | PostToolUse / `afterMCPExecution` | injection, exfil sinks and hidden payloads in what a fetch/read brings *back* |
287
+ | **Prompt** | `UserPromptSubmit` (Claude Code) / `beforeSubmitPrompt` (Cursor) | what **you** paste, before it leaves the machine |
288
+ | **Plan** | `PreToolUse` on `ExitPlanMode` (Claude Code) | nothing — it *informs*. See [`shomra plan`](#shomra-plan--threat-model-what-the-agent-is-about-to-build) |
289
+
290
+ The prompt channel is the one a person controls, and the only one where the leak
291
+ is a paste rather than a tool call. A live credential in a prompt is refused;
292
+ pasted text that reads as an instruction to an agent is passed through but
293
+ flagged **to the model** as untrusted data rather than blocked — you meant to
294
+ send it, the risk is that you did not read it. Backtick-quoted payloads are
295
+ down-ranked, so asking *why does `<pattern>` get flagged* is never blocked.
296
+ `SHOMRA_PROMPT_GUARD_OFF=1` disables just this channel. Only the two vendors with
297
+ a documented pre-submit hook that can stop a submission are wired; the rest get
298
+ nothing rather than a guessed event name that would silently never fire.
299
+
300
+ ## Prevention: get in front of the model
301
+
302
+ Everything above intercepts *after* the model has written something. These run
303
+ before it.
304
+
305
+ ```bash
306
+ shomra design docs/rfc.md # threat-model a system that does not exist yet
307
+ shomra add model owner/m # vet anything before it lands on the machine
308
+ shomra rules --write # teach the agent what gets blocked here
309
+ shomra rules --check # CI: fail when the block goes stale
310
+ shomra mcp install # let the agent gate its own content before writing it
311
+ ```
312
+
313
+ ### `shomra design` — threat-model the ticket, not the repo
314
+
315
+ Every other command needs an artifact. This one reads a **description** — an RFC,
316
+ a design doc, a Jira/Linear ticket, a PR body — and answers the only question
317
+ worth asking before anyone writes code: does the thing being described hand an
318
+ attacker a path from untrusted input to a consequence?
319
+
320
+ ```bash
321
+ shomra design docs/rfc-042.md
322
+ shomra design docs/ --strict # every design doc, fail on any closed path
323
+ gh issue view 42 --json body -q .body | shomra design -
324
+ shomra design docs/rfc-042.md --checklist | gh issue comment 42 -F -
325
+ ```
326
+
327
+ It uses the platform's own model: capabilities split into **sources** (untrusted
328
+ input, sensitive data, filesystem) and **sinks** (network egress, execution,
329
+ destructive action). A closed source→sink pair is an attack path. That model does
330
+ not care whether the capabilities came from a scan or from a sentence — here they
331
+ come from a sentence, and each one cites the line that evidenced it so you can
332
+ disagree with the machine's reading.
333
+
334
+ `--checklist` emits the conditions as a markdown task list, which is the form
335
+ anyone actually acts on: paste it into the ticket as acceptance criteria.
336
+
337
+ > **It reads prose, so it sees only what was written down.** There is deliberately
338
+ > no clean verdict. `NOT_DESCRIBED` means the document did not describe
339
+ > capabilities in a way this matched — it is **not** a statement that the system
340
+ > has none. A threat model that reads as a clean bill of health is worse than
341
+ > none, because it is consumed exactly when the design is still cheap to change.
342
+
343
+ Exit codes: `1` when untrusted input reaches execution or a destructive action
344
+ (the shape where the attacker picks the action), `2` for any other closed path
345
+ under `--strict`.
346
+
347
+ ### `shomra plan` — threat-model what the agent is about to build
348
+
349
+ `design` reads a document a human remembered to write. Coding agents produce a
350
+ **plan** before every non-trivial task, constantly and automatically, and nothing
351
+ looks at it. Same analysis, a hundred times the frequency, zero human effort.
352
+
353
+ The loop: agent proposes a plan → Shomra threat-models it → the controls land in
354
+ the agent's context **before it writes line one**. The agent builds the guarded
355
+ version first, instead of building the unguarded one and having the firewall
356
+ refuse it three tool calls later.
357
+
358
+ Three ways in, deliberately redundant, strongest first:
359
+
360
+ 1. **`shomra_review_plan`** — an MCP tool, so any MCP-capable agent can call it
361
+ mid-task with no vendor hook. Register it with `shomra mcp install`.
362
+ 2. **The rules block asks the agent to call it.** Once the MCP server is
363
+ registered, `shomra rules --write` adds a *Before you implement a plan*
364
+ section — so `mcp install` and `rules --write` compose into a closed loop.
365
+ 3. **A Claude Code `PreToolUse` hook on `ExitPlanMode`**, wired by
366
+ `install-hook`. Zero-effort, but that tool name is not in the published hook
367
+ docs, so it is the optional path and never the only one.
368
+
369
+ ```bash
370
+ shomra plan plan.md # or: … | shomra plan -
371
+ ```
372
+
373
+ **A plan is a proposal, so the default is to inform, never refuse.** Denying a
374
+ plan spends a turn and tells the model only that it was wrong, not how — the
375
+ controls are the useful payload. Only untrusted-input-reaches-a-hard-sink
376
+ escalates to *ask*, and only under `SHOMRA_GUARD_STRICT=1`.
377
+ `SHOMRA_PLAN_GUARD_OFF=1` disables just this channel.
378
+
379
+ ### `shomra corpus` — screen the index, not the retrieval
380
+
381
+ The result firewall screens what a retrieval brings *back*. Nothing screened what
382
+ went **in** — so a poisoned document sits in the vector store indefinitely,
383
+ clean-until-retrieved, and is judged for the first time at the worst possible
384
+ moment: as one chunk, stripped of its document, inside a request a user is
385
+ waiting on.
386
+
387
+ Index time wins on all three counts. The whole document is present, so a payload
388
+ split across paragraphs is visible. The cost is paid once per document instead of
389
+ once per retrieval. And a document that fails is simply never embedded — a
390
+ control rather than a detection.
391
+
392
+ ```bash
393
+ shomra corpus ./kb --manifest .shomra/corpus.json
394
+ ```
395
+
396
+ ```
397
+ ✗ QUARANTINE escalation.md
398
+ HIGH Injected instruction: "ignore all previous" (line 243 · chunk 19)
399
+ ✗ QUARANTINE hidden.md
400
+ CRITICAL Invisible / bidirectional characters
401
+ ⚠ 2 files could not be read — they are NOT covered by the result above:
402
+ 2 × binary format — no text extractor
403
+ ```
404
+
405
+ Findings carry the **chunk index**, not just the line, because retrieval returns
406
+ chunks and the chunk is what actually reaches the model. The manifest is the
407
+ point of the command: feed it to your ingestion job so a quarantined document is
408
+ never embedded.
409
+
410
+ > **Absence accounting is load-bearing.** Real corpora are mostly PDF, DOCX and
411
+ > PPTX — formats this cannot read. A screen that silently skips them and prints
412
+ > "clean" is a lie about the majority of the corpus, so every unreadable file is
413
+ > counted and reported next to the verdict, and `--strict` fails on them:
414
+ > *we could not check it* is not *it is fine*.
415
+
416
+ Fenced code blocks are down-ranked — a docs corpus is full of examples, and an
417
+ example is not a live instruction. A directive in prose is the real threat and
418
+ survives the down-rank.
419
+
420
+ ### `shomra add` — vet at acquisition, not after
421
+
422
+ `mcp add` gated one channel. An agent acquires from four, and the other three had
423
+ no gate at all: a skill copied out of a gist, a model pulled from the Hub, a
424
+ package installed because an agent suggested the name.
425
+
426
+ ```bash
427
+ shomra add mcp files npx -y @modelcontextprotocol/server-filesystem /tmp
428
+ shomra add skill ./downloaded-skill # manifest AND the scripts it bundles
429
+ shomra add model openai-community/gpt2 # against the Model Index, before any weights download
430
+ shomra add package langchian --type pypi # → BLOCK: 2 edits from langchain
431
+ ```
432
+
433
+ One verdict vocabulary (ALLOW / FLAG / BLOCK), one exit-code contract, `--force`
434
+ to override a BLOCK deliberately rather than by accident. After something lands
435
+ the question changes from *should we take this?* to *is it safe to remove?*,
436
+ which is a much worse question to be asked.
437
+
438
+ **Unknown is never clean.** An unreachable Model Index, an unscanned model, and a
439
+ package the AI catalog does not recognise all return **FLAG**, not ALLOW —
440
+ "we could not check" and "it is fine" are different answers.
441
+
442
+ **`shomra rules`** compiles what Shomra actually enforces — plus what *this repo*
443
+ already trips, plus your org's policy when enrolled — into the agent's own
444
+ context files:
445
+
446
+ | Agent | File |
447
+ |---|---|
448
+ | Claude Code | `CLAUDE.md` |
449
+ | Codex CLI (and the cross-vendor default) | `AGENTS.md` |
450
+ | Cursor | `.cursor/rules/shomra.mdc` |
451
+ | GitHub Copilot | `.github/copilot-instructions.md` |
452
+ | Gemini CLI | `GEMINI.md` |
453
+ | Windsurf | `.windsurfrules` |
454
+ | Cline | `.clinerules/shomra.md` |
455
+
456
+ It writes inside a `<!-- BEGIN SHOMRA MANAGED BLOCK -->` marker pair and **never
457
+ touches a line outside it**, so your own rules are safe and re-running is a
458
+ no-op. The block is derived, not boilerplate: sections switch on according to
459
+ what the repo holds (MCP configs, skills, model loads, agent-calling code), and
460
+ an **"Already present in this repo"** section names the findings a local gate
461
+ pass actually found, with paths. Commit the result and keep it honest with
462
+ `shomra rules --check`, which exits 1 when the block is missing or stale.
463
+
464
+ The generated block is itself an AI rules file, so `shomra rules` gates its own
465
+ output and refuses to write anything its own checker would block.
466
+
467
+ **`shomra mcp install`** registers Shomra *as* an MCP server with your agents, so
468
+ the model can call it in its own loop — most usefully `shomra_review_change`,
469
+ which takes proposed file content plus its intended path and returns a verdict
470
+ **without writing anything to disk**. A BLOCK there costs nothing; the same
471
+ content on disk costs a blocked tool call and a wasted turn. `shomra_rules`,
472
+ `shomra_check`, `shomra_explain`, `shomra_fix` and `shomra_scan_models` are
473
+ exposed too. `shomra mcp serve` runs the server directly (stdio JSON-RPC) if you
474
+ prefer to wire it by hand.
475
+
221
476
  ## Adopting Shomra on an existing repo
222
477
 
223
478
  A brand-new gate on a repo with history will flag things. Three layers make
@@ -266,6 +521,8 @@ suppressed file drops to ALLOW and never fails the build:
266
521
  | `SHOMRA_GUARD_BREAKER_MS` | Skip the server this long after a failure (default 30000; `0` disables) |
267
522
  | `SHOMRA_LLM_PROXY_BASE` | Proxy base URL `install-hook` writes for Aider (default `http://127.0.0.1:4141/openai/v1`) |
268
523
  | `SHOMRA_MODEL_GUARD` | `0` = disable the model-load screen in the PreToolUse hook |
524
+ | `SHOMRA_PROMPT_GUARD_OFF` | `1` = disable the prompt channel only (tool-call and tool-result guards stay on) |
525
+ | `SHOMRA_PLAN_GUARD_OFF` | `1` = disable the plan channel only |
269
526
  | `SHOMRA_MODEL_CACHE` | `0` = disable the on-machine model-index verdict cache |
270
527
  | `SHOMRA_MODEL_CACHE_TTL_MS` | Model-cache freshness window (default 7 days) |
271
528
 
package/ai-usage.mjs CHANGED
@@ -60,6 +60,35 @@ const PROVIDERS = [
60
60
  { id: 'llama-cpp', label: 'llama.cpp', category: 'local-runtime', npm: ['node-llama-cpp'], py: ['llama_cpp'], call: [/\bLlama\s*\(\s*model_path\s*=/] },
61
61
  ];
62
62
 
63
+ /**
64
+ * Every AI package name this catalog knows, flattened for name comparison at
65
+ * ACQUISITION time (`shomra add package`). A typosquat is only detectable
66
+ * against a list of the real names, and this catalog is already that list —
67
+ * maintaining a second copy is how the two drift and the check quietly stops
68
+ * matching the packages people actually install.
69
+ *
70
+ * `ecosystem` matters: `openai` exists on both npm and PyPI, but `crewai` is
71
+ * PyPI-only, so `npm i crewai` is a different and more suspicious event than
72
+ * `pip install crewai`.
73
+ */
74
+ export const KNOWN_AI_PACKAGES = (() => {
75
+ const out = [];
76
+ const seen = new Set();
77
+ const add = (name, ecosystem, p) => {
78
+ const key = `${ecosystem}:${name}`;
79
+ if (!name || seen.has(key)) return;
80
+ seen.add(key);
81
+ out.push({ name, ecosystem, provider: p.id, label: p.label, category: p.category });
82
+ };
83
+ for (const p of PROVIDERS) {
84
+ for (const n of p.npm ?? []) add(n, 'npm', p);
85
+ for (const n of p.npmPrefix ?? []) add(n.replace(/\/$/, ''), 'npm', p);
86
+ for (const n of p.py ?? []) add(n, 'pypi', p);
87
+ for (const n of p.pyRoot ?? []) add(n, 'pypi', p);
88
+ }
89
+ return out;
90
+ })();
91
+
63
92
  const MODEL_ON_LINE = /\bmodel(?:_?id|_?name)?\s*[=:]\s*['"]([A-Za-z0-9][\w.:\/-]{1,80})['"]/;
64
93
  const MAX_CODE_LEN = 240;
65
94
  const clipLine = (s) => (s.length > MAX_CODE_LEN ? s.slice(0, MAX_CODE_LEN) + '…' : s);
package/design.mjs ADDED
@@ -0,0 +1,313 @@
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.
50
+ *
51
+ * ⚠ A line that DISCLAIMS a capability must not grant it. This originally said
52
+ * the opposite — that a "we will not do X" sentence was a design decision worth
53
+ * surfacing — and that was wrong: granting the flag from a disclaimer invents a
54
+ * phantom attack path out of the one sentence that rules it out, which is the
55
+ * most misleading output this tool can produce. "The tool has no access to
56
+ * customer data" is the designer telling you the source does not exist.
57
+ */
58
+ const DISCLAIMER_RE = /\b(no|never|not|without|excludes?|excluding|neither|nor)\b[^.\n]{0,30}\b(access|read|write|permission|abilit|able|connection|integration)\w*|\b(does |do |will |can |must )(not|n't)\b|\bout of scope\b|\bnon-goals?\b|\bis not (able|permitted|allowed)\b/i;
59
+ const CAP_RULES = [
60
+ // ── SOURCES ────────────────────────────────────────────────────────────────
61
+ { 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 },
62
+ { cap: 'injection', what: 'inbound email', re: /\b(inbound |incoming |receiv\w+ )?e-?mails?\b|\bmailbox\b|\bimap\b|\bsupport inbox\b/i },
63
+ { cap: 'injection', what: 'support tickets', re: /\b(support |help[- ]?desk |zendesk |intercom |freshdesk )?tickets?\b|\bcase notes?\b/i },
64
+ { 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 },
65
+ // Document nouns are plural far more often than not in a design doc ("ingests
66
+ // uploaded PDFs"), and an `\bpdf\b` that cannot match "PDFs" is a rule that
67
+ // misses the common phrasing while looking correct in a unit test.
68
+ { 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 },
69
+ // Provenance, not format: content ACCEPTED FROM a party outside the trust
70
+ // boundary is untrusted whatever shape it arrives in. Anchored on a receiving
71
+ // verb + "from" + the party, so ordinary prose about customers does not fire.
72
+ { 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 },
73
+ { 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 },
74
+ { 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 },
75
+ { 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 },
76
+ { 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 },
77
+ { cap: 'injection', what: 'chat or comment history', re: /\b(chat|conversation|comment|review|forum|slack|discord|teams) (history|thread|messages?|log)\b/i },
78
+ { 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 },
79
+
80
+ { 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 },
81
+ { 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 },
82
+ { 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 },
83
+ { 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 },
84
+ { 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 },
85
+ { cap: 'readsSensitive', what: 'object storage', re: /\bS3 bucket\b|\b(blob|object) storage\b|\bGCS bucket\b|\bdata lake\b/i },
86
+
87
+ // Plurals throughout: "writes the generated files to the repo" is the normal
88
+ // phrasing, and `\bfile\b` cannot match it. Same blind spot as `\bpdf\b` vs
89
+ // "PDFs" — assume every noun here arrives plural at least half the time.
90
+ { cap: 'filesystem', what: 'file writes', re: /\bwrit\w+\b[^.\n]{0,25}\b(files?|disks?|filesystems?|director(y|ies)|folders?|repos?|repositor(y|ies))\b|\b(file ?system|local files?) (access|write)\b|\bcommits? (code|files?|changes?)\b/i },
91
+ { 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 },
92
+
93
+ // ── SINKS ──────────────────────────────────────────────────────────────────
94
+ { 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 },
95
+ { cap: 'network', what: 'webhooks', re: /\bwebhooks?\b|\bpost(s|ing|ed)?\b[^.\n]{0,30}\bto\b[^.\n]{0,25}\b(endpoints?|urls?|callbacks?|apis?|services?|partners?|systems?)\b/i },
96
+ { 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 },
97
+ { 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 },
98
+ { 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 },
99
+
100
+ { 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 },
101
+ { cap: 'exec', what: 'code interpretation', re: /\b(code (interpreter|execution|sandbox)|eval\b|exec\b|repl\b|jupyter|notebook execution)\b/i },
102
+ { cap: 'exec', what: 'deployments or migrations', re: /\b(deploy|rollout|release|migrat\w+|provision\w*|terraform|helm|kubectl)\b/i },
103
+ { 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 },
104
+
105
+ { 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 },
106
+ // A money NOUN alone is not a capability — "the dashboard displays the refund
107
+ // history" is a read. The line has to name an act that MOVES the money, so the
108
+ // verb is required and read-only verbs are not on the list.
109
+ { cap: 'destructive', what: 'moving money', re: /\b(issues?|issuing|processes|processing|triggers?|initiates?|creates?|approves?|grants?|sends?|makes?|charges?|refunds?|voids?|cancels?)\b[^.\n]{0,30}\b(refunds?|payments?|charges?|invoices?|payouts?|transfers?|subscriptions?|purchases?|orders?)\b|\bmoves? money\b|\bthrough stripe\b|\bvia stripe\b/i },
110
+ { 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 },
111
+ { 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 },
112
+ ];
113
+
114
+ /** Lines that are headings, code fences or list scaffolding carry no design intent. */
115
+ function isSkippableLine(line) {
116
+ const t = line.trim();
117
+ return !t || t === '---' || /^```/.test(t) || /^\|[\s-:|]+\|$/.test(t);
118
+ }
119
+
120
+ /**
121
+ * Extract the capability set a document DESCRIBES, with the line and phrase that
122
+ * evidenced each — the evidence is the point: a reader has to be able to check
123
+ * the machine's reading against their own words and disagree with it.
124
+ */
125
+ export function capsFromProse(text) {
126
+ const caps = { injection: false, readsSensitive: false, filesystem: false, network: false, exec: false, destructive: false };
127
+ const evidence = {};
128
+ const lines = String(text ?? '').split(/\r?\n/);
129
+ let inFence = false;
130
+
131
+ for (let i = 0; i < lines.length; i++) {
132
+ const line = lines[i];
133
+ if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
134
+ // Code blocks in a design doc are illustrative snippets, not statements of
135
+ // intent — and they are exactly where a scanner's vocabulary produces noise.
136
+ if (inFence || isSkippableLine(line)) continue;
137
+ // A disclaimer names the capability in order to rule it out. Granting the
138
+ // flag here would invent an attack path from the sentence that removes it.
139
+ if (DISCLAIMER_RE.test(line)) continue;
140
+
141
+ for (const r of CAP_RULES) {
142
+ const m = r.re.exec(line);
143
+ if (!m) continue;
144
+ caps[r.cap] = true;
145
+ const list = (evidence[r.cap] = evidence[r.cap] || []);
146
+ if (list.some((e) => e.what === r.what)) continue; // one citation per distinct capability phrasing
147
+ list.push({ what: r.what, line: i + 1, quote: line.trim().slice(0, 160), match: m[0].slice(0, 60), score: evidenceScore(line) });
148
+ }
149
+ }
150
+ // Rank each capability's citations so the one the attack story quotes is the
151
+ // line that DESCRIBES the behaviour, not the first line the word appeared on.
152
+ // "Reduce first-response time on support tickets" (a goal) and "polls the
153
+ // support inbox and reads inbound emails" (the design) both mention tickets;
154
+ // quoting the goal makes the finding look like a keyword hit and is the
155
+ // fastest way for a reader to stop trusting the output.
156
+ for (const k of Object.keys(evidence)) evidence[k].sort((a, b) => b.score - a.score || a.line - b.line);
157
+ return { caps, evidence };
158
+ }
159
+
160
+ // A line that says what the system DOES, rather than what it is for. Used only
161
+ // to order evidence — never to grant or withhold a capability.
162
+ 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;
163
+ const GOAL_LINE_RE = /^\s{0,3}#{1,6}\s|^\s*(goal|motivation|background|summary|context|out of scope|non-goals?)\b/i;
164
+
165
+ function evidenceScore(line) {
166
+ let s = 0;
167
+ if (ACTION_LINE_RE.test(line)) s += 3;
168
+ if (GOAL_LINE_RE.test(line)) s -= 3;
169
+ if (line.trim().length > 60) s += 1; // a full sentence beats a heading fragment
170
+ return s;
171
+ }
172
+
173
+ /** Severity of a closed path. Mirrors chainSeverity() in attack-graph.ts:
174
+ * untrusted input reaching a hard sink is the worst case in the model. */
175
+ function pathSeverity(source, sink) {
176
+ const hardSink = sink === 'exec' || sink === 'destructive';
177
+ if (hardSink && source === 'injection') return 'CRITICAL';
178
+ if (hardSink) return 'HIGH';
179
+ if (sink === 'network' && (source === 'readsSensitive' || source === 'injection')) return 'HIGH';
180
+ return 'MEDIUM';
181
+ }
182
+
183
+ // What has to be true for a given source→sink pair to be safe to build. These
184
+ // are stated as testable conditions rather than advice, because their job is to
185
+ // become the acceptance criteria on the ticket that describes the system.
186
+ const CONTROLS = {
187
+ 'injection→exec': [
188
+ '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.',
189
+ '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.',
190
+ 'The runtime firewall is wired on this path (`shomra protect`), so a command assembled at runtime is refused rather than logged.',
191
+ ],
192
+ 'injection→destructive': [
193
+ 'Every destructive or money-moving action requires an approval step that a human performs outside the agent loop.',
194
+ 'The action is idempotent and reversible, with an audit record naming the input that triggered it.',
195
+ 'Per-action limits (amount, row count, blast radius) are enforced server-side, not by the prompt.',
196
+ ],
197
+ 'injection→network': [
198
+ 'Outbound destinations come from an allowlist. A URL that appears in untrusted content can never become a request target.',
199
+ 'The agent cannot include content it read into an outbound request to a destination named by that same content.',
200
+ ],
201
+ 'readsSensitive→network': [
202
+ '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.',
203
+ 'Outbound payloads are field-allowlisted — what may leave is enumerated, rather than what may not.',
204
+ 'The sensitive read is scoped to the minimum rows/fields the task needs, per-request, not a standing broad grant.',
205
+ ],
206
+ 'readsSensitive→exec': [
207
+ '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.',
208
+ 'The executing context cannot read the credential store it does not need.',
209
+ ],
210
+ 'readsSensitive→destructive': [
211
+ 'The identity that reads and the identity that mutates are different, each scoped to its own job.',
212
+ 'Destructive actions are gated on a human approval that shows the operator exactly which records are affected.',
213
+ ],
214
+ 'filesystem→exec': [
215
+ '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.',
216
+ '`shomra check` runs over agent-authored artifacts before they are committed.',
217
+ ],
218
+ 'filesystem→network': [
219
+ 'The agent cannot write a file and then cause that file to be uploaded to a destination it chose.',
220
+ ],
221
+ 'filesystem→destructive': [
222
+ 'Writes are confined to a working directory, with deletes scoped to paths the agent itself created.',
223
+ ],
224
+ };
225
+
226
+ const GENERIC_CONTROLS = [
227
+ 'Give the agent its own identity with its own credentials, so its actions are attributable and revocable independently of a human user.',
228
+ 'Record every tool call the agent makes, with the input that caused it, so an incident can be reconstructed.',
229
+ '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.',
230
+ ];
231
+
232
+ /**
233
+ * Threat-model a described system.
234
+ *
235
+ * `verdict` deliberately has no clean value:
236
+ * OPEN_PATH — a source and a sink are both described; the path is closed.
237
+ * PARTIAL — only one side is described. Not safety: the other side may
238
+ * simply be unwritten, or may arrive in the next sprint.
239
+ * NOT_DESCRIBED — neither side was recognised. The likeliest reading is that
240
+ * the document does not describe capabilities in a way this
241
+ * matched, NOT that the system has none.
242
+ */
243
+ export function analyzeDesign(text, { name = 'design' } = {}) {
244
+ const { caps, evidence } = capsFromProse(text);
245
+ const sources = SOURCE_CAPS.filter((c) => caps[c]);
246
+ const sinks = SINK_CAPS.filter((c) => caps[c]);
247
+
248
+ const paths = [];
249
+ for (const s of sources) {
250
+ for (const k of sinks) {
251
+ const severity = pathSeverity(s, k);
252
+ const srcEv = (evidence[s] || [])[0];
253
+ const sinkEv = (evidence[k] || [])[0];
254
+ paths.push({
255
+ source: s,
256
+ sink: k,
257
+ severity,
258
+ key: `${s}→${k}`,
259
+ story:
260
+ `${cap(srcEv ? srcEv.what : CAP_LABEL[s])} reaches ${sinkEv ? sinkEv.what : CAP_LABEL[k]}` +
261
+ `${s === 'injection' ? ' — whoever writes that input is choosing what the agent does' : ''}` +
262
+ `${s === 'readsSensitive' && k === 'network' ? ' — the data and the way out are held by the same component' : ''}.`,
263
+ sourceEvidence: srcEv || null,
264
+ sinkEvidence: sinkEv || null,
265
+ controls: CONTROLS[`${s}→${k}`] || [],
266
+ });
267
+ }
268
+ }
269
+ paths.sort((a, b) => SEV_RANK[b.severity] - SEV_RANK[a.severity]);
270
+
271
+ const verdict = paths.length ? 'OPEN_PATH' : sources.length || sinks.length ? 'PARTIAL' : 'NOT_DESCRIBED';
272
+ const worst = paths.length ? paths[0].severity : null;
273
+
274
+ // Deduplicate controls across paths, worst-severity first, then append the
275
+ // ones that apply to any agent with a consequence.
276
+ const seen = new Set();
277
+ const controls = [];
278
+ 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 }); }
279
+ 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' }); }
280
+
281
+ return { name, caps, evidence, sources, sinks, paths, controls, verdict, worst };
282
+ }
283
+
284
+ const SEV_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
285
+
286
+ function cap(s) {
287
+ return String(s || '').charAt(0).toUpperCase() + String(s || '').slice(1);
288
+ }
289
+
290
+ /** The result as a markdown task list — the form that becomes the ticket's
291
+ * acceptance criteria, which is the only form anyone acts on. */
292
+ export function designChecklist(result) {
293
+ const out = [];
294
+ out.push(`## Security acceptance criteria — ${result.name}`);
295
+ out.push('');
296
+ if (result.verdict !== 'OPEN_PATH') {
297
+ out.push(
298
+ result.verdict === 'PARTIAL'
299
+ ? `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.`
300
+ : '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.',
301
+ );
302
+ out.push('');
303
+ return out.join('\n') + '\n';
304
+ }
305
+ 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.`);
306
+ out.push('');
307
+ for (const p of result.paths.slice(0, 6)) out.push(`- **${p.severity} · ${CAP_LABEL[p.source]} → ${CAP_LABEL[p.sink]}** — ${p.story}`);
308
+ out.push('');
309
+ for (const c of result.controls) out.push(`- [ ] ${c.text}`);
310
+ out.push('');
311
+ 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._');
312
+ return out.join('\n') + '\n';
313
+ }