@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 +258 -1
- package/ai-usage.mjs +29 -0
- package/design.mjs +313 -0
- package/guard-signals.mjs +128 -21
- package/model-refs.mjs +26 -0
- package/package.json +2 -1
- package/shomra.mjs +2013 -32
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
|
+
}
|