Skip to content

1.1: the agent workspace as a second scan surface, and a gate that always runs - #8

Merged
suthat merged 15 commits into
mainfrom
feat/agent-surface-and-gate
Aug 26, 2026
Merged

1.1: the agent workspace as a second scan surface, and a gate that always runs#8
suthat merged 15 commits into
mainfrom
feat/agent-surface-and-gate

Conversation

@suthat

@suthat suthat commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Answers the second question the same repository now raises — is the coding agent working in it being told to do something hostile? — and turns a tool the model may call into a control that always runs.

ADR 0025 · ADR 0026

What ships

A second surface. Surface splits WebApp (12 framework profiles) from AgentWorkspace (7 agent-host profiles). The remediation matrix is generalised over it: a webApp rule owes twelve framework fixes, an agentWorkspace rule owes seven host fixes, neither is ever checked against the other's list, and a missing cell still fails the build.

Eleven new rules read the agent and editor configuration a lockfile does not record — hooks, MCP entries, permission wildcards, instruction files. All cap at likely, all carry a runtimeScope, all map to CWE with OWASP ASI 2026 as a secondary reference. Confirmed is unreachable on this surface: there is no running target to confirm against.

The catalogue is 25 rules. That number is not a target — the second surface exists because nothing else reads it, not to make the first number larger.

New commands. vet (a fixed posture for a repository you did not write: no network, no plugins, the target's config not read at all), gate with three host adapters, verify --patch, --since / --staged / --paths, --format agent with an asserted token budget, and init --claude-code | --cursor | --generic.

A generated site. 213 pages built from the same source that generates RULES.md, with every code sample harvested by scanning the fixtures — so a rule that stops firing loses its example and the build says so, rather than the site quietly describing behaviour the tool no longer has.

The pentest pass

Two passes: one over the new code, one over the old. The old code produced more.

Against the new work (3cbf47c): git apply --unsafe-paths on an agent-authored patch; a quadratic JSONC string scan reachable on the gate's keystroke path; a filename newline injecting into the gate's reason; a case-insensitive filesystem bypass of the whole surface (.Claude/settings.json); and two rule evasions — duplicate hooks keys, where a reviewer reads the first and the host reads the last, and whitespace runs defeating the phrase matcher.

Against code that shipped in 1.0 — six findings, and the pattern matters more than the list:

The Action refused every invocation it was ever given [[ "$v" == *$'\0'* ]] — bash cannot hold a NUL, so $'\0' is the empty string and the pattern is **. Every CI job using it exited 2 before running anything, for the whole 1.0 line.
A flag-shaped path was a flag No -- separator, so path: --plugin=./evil.wasm scanned everything with plugin loading on and reported success.
A plugin supplied the key that vouched for it Trust roots were read from the plugin's own directory. Sign yourself, ship the public half, --require-signed-plugins reports verified. It refused nothing.
plugin inspect could never report verified createPublicKey({ format: "raw" }) throws on every Node; an as unknown as cast silenced the type error that said so.
Unknown config keys were stripped, not refused failon: "high" parsed cleanly and the run used the default info — a config that reads as tightening the gate and does the opposite.
--since widened instead of failing On a shallow CI clone, three new findings become a red job full of unrelated debt.

Plus --report-suppressions printing repository text straight to a terminal, where \x1b[2K\x1b[1A\x1b[2K in a reason erases the line naming what was suppressed — the audit deleting itself, from inside the audit.

Every one of them failed silently and in the safe direction, which is exactly why each survived a release. A guard that refuses everything looks like a guard. A signature check that always says untrusted looks like an unconfigured trust root.

And four of the six were not found by reading code. They were found by running something that had never been run — the Action's script, the signature path, the suppression listing on a terminal. The rule that came out of it: a check that has never failed on purpose is not known to work.

Two implementations, one contract

Where a mechanism exists in two languages, finding a different bug in each half is the argument against reviewing them separately.

core and agent-safety.ts sanitise the same untrusted text: Rust let the Unicode Tags block through (category Cf, so char::is_control misses it — and it is the channel real prompt-injection work uses), while TypeScript replaced [INST] with the label "[INST]", a substitution that ran on every MCP payload and changed nothing. Both now answer to fixtures/untrusted-text-vectors.json, generated by a script rather than hand-edited, because a file of invisible characters cannot be reviewed by eye. Signature verification gets the same treatment.

agent_text is now untrusted_text — the terminal turned out to be a fourth reader with the same requirement, and a module named for the reader it was written for would have lied to whoever read it next.

Verification

  • 667 Rust tests, 342 TypeScript tests, run three times for flakes after fixing a genuine env-var race that passed in isolation and failed in the workspace run
  • clippy at -D warnings, cargo fmt, eslint, and typecheck all clean
  • rules:check, site:check, docs:check, version:check, vectors:check all pass
  • owlwarden on itself: 0 findings in its own source, 0 on its own agent surface outside the deliberately-vulnerable fixtures, vet exits 0
  • Each pentest finding has a test that fails when the bug is reintroduced — reinstating the NUL guard fails 30 of the Action's 63 tests, removing the -- fails 2

Known gap

The Action still is not executed end-to-end in CI, because it resolves npx owlwarden@<version> from npm and a PR's version is not published yet. The 63 new tests extract its script and run it against a stubbed CLI, which covers the logic but not the published path. The gap is narrower, not closed.

🤖 Generated with Claude Code

suthat and others added 15 commits August 25, 2026 19:37
owlwarden answered one question: is the web application in this repository
written safely? This adds the second question the same repository now raises —
is the coding agent that works in it being told to do something hostile?

The design tension is the reason this needed an ADR. Every rule ships
remediation for every supported framework, and the build fails otherwise; that
invariant is why the framework column in RULES.md cannot drift to zero. But
.claude/settings.json has nothing to do with whether the app is Next.js or Koa.
Writing the same paragraph twelve times would satisfy the test and make
RULES.md dishonest; exempting the new rules would put a hole in the invariant.

So the invariant is generalised rather than weakened. A rule declares a
`Surface`; a surface owns a profile set; the matrix test asserts completeness
per surface. Twelve frameworks for webApp, seven agent hosts for
agentWorkspace, and neither is ever checked against the other's list.

The eleven rules, all capped at `likely` because `Confirmed` means corroborated
against a running target and a config file has none:

  agent-hook-autoexec            agent-config-secret-reachable
  agent-hook-untrusted-command   agent-permission-wildcard
  agent-config-loader-script     agent-mcp-unpinned-remote
  agent-config-env-redirect      agent-marketplace-untrusted
  agent-instructions-hidden-text agent-instructions-directive
  install-lifecycle-script (webApp — it reads package.json)

Engine work behind them:

* A closed path allowlist that overrides .gitignore and the directory deny
  list. .claude/settings.local.json is conventionally gitignored and is also
  where a hook-configuration CVE lived; .vscode/ and .cursor/ were on the
  exclusion list for reasons that made sense when the only question was "is
  this application source?". Root containment, symlink refusal, and size caps
  all still hold.
* A bounded JSONC parser that keeps byte spans, tolerates comments and trailing
  commas, and keeps duplicate keys rather than last-wins — a config saying
  "allow": [] and then "allow": ["Bash"] is exactly the shape an attacker would
  use against a scanner that keeps the first.
* `runtime_scope` (active / project-optional / template / documentation),
  applied as a confidence ceiling by the engine rather than by each rule.
  A hook in a tutorial is still reported, because a repository that ships a
  risky example is telling its readers to do the risky thing — but it can never
  fail a build.
* Command-string analysis with a documented benign twin per signal. If this
  ever reports `pnpm exec prettier --write`, the rule family is finished, and
  the test module says so.
* Hidden-text detection that folds homoglyphs for matching and reports from the
  original bytes. Deliberately not NFKC: NFKC does not map Cyrillic а to Latin
  a, which is the actual attack.

Also: `--format agent`, a reporter with an asserted token ceiling that omits
`why` — the field written for a human deciding whether to care, which an agent
has already been told by the verdict.

225 Rust tests, 127 TypeScript.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rules landed in the previous commit. This is the part that decides whether
they survive contact with real repositories.

Three fixture kinds per host, not two. Vulnerable and clean twin are the usual
pair; the third is **tempting** — a legitimate configuration that shares surface
features with the vulnerable one, whose *silence* is the assertion. A
PostToolUse hook running `pnpm exec prettier`. A tasks.json build task with no
runOn. An MCP server pinned to an exact version. A dev container whose
postCreateCommand is `pnpm install`.

That last one changed the rule rather than the fixture. A dev container is
reached by an explicit "Reopen in Container" and exists to install
dependencies, so agent-hook-autoexec now stays quiet on the recognised
package-manager step — while the same file's command is still judged by
agent-hook-untrusted-command, at high severity, because it does run without a
prompt once the container exists. `curl … | sh` in postCreateCommand is
reported by both; `pnpm install` by neither.

The tempting bar is stated precisely rather than as "silent": silent in
`quick`, and in `deep` nothing that can fail a build. The exception that forced
the precision is worth keeping — a repository's own threat-model document
quoting a prompt injection inside a fenced block. That is reported, at
`documentation` scope and `possible` confidence, and it should be: silence
there would mean the scanner cannot see the string at all.

Also here:

* A standing corpus of what real repositories ship — a monorepo with a
  formatter hook, a dev container, pinned MCP servers, Cursor rules, Copilot
  instructions — which must be silent in `quick`.
* The hostile-input suite from ADR 0025 exit criterion 7: a 12 MB config, 20 000
  levels of nesting, 40 000 hook entries, malformed JSON, bidi and tag-character
  payloads, and a symlink pointing out of the project. Generated in a tempdir
  rather than committed — a 12 MB fixture is a problem for the repository, not
  evidence for the reader. All of it terminates in under 30 seconds, produces a
  bounded report, names the files it could not read, and executes nothing.
* Two entries added to the closed path allowlist: `.cursor/hooks/**` and
  `.cursor/*.{js,mjs,cjs,ts,sh,py}`. Without them agent-config-loader-script
  was structurally blind to the ChainDrop shape one host over, which is the
  shape the rule exists for. Extending a closed list is a reviewed change; the
  module comment and the verbatim-list test are the review record.
* install-lifecycle-script joins the framework grid: 13 webApp rules × 12
  frameworks = 156 cells.
* The ASI reference is dropped when a finding already carries an OWASP one.
  Four links is a reading list; the mapping still travels in the `asi` field
  for the coverage table.

356 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An MCP tool is available to the model. It is called when the model decides the
task warrants it, and mid-refactor it often does not. A sentence in AGENTS.md
asking the model to run the scanner is the same shape of hope in a different
file — an instruction competing with every other instruction in the context
window, and losing to whichever one the model weighted higher this turn.

Determinism is the product. A deterministic scanner wired in as a suggestion is
a deterministic scanner that runs sometimes.

`crates/gate` is the other shape: attach to the host's lifecycle, run outside
the model as a separate process, on an event the host decides, and return a
verdict the prompt cannot reach — because the prompt is not this process's
input.

Internally there is exactly one decision type. Host knowledge lives in a thin
adapter and nowhere else, the same way framework knowledge lives in a
FrameworkProfile. Adding a host is an adapter and a golden fixture pair, never
an engine change.

The parts worth reading twice:

* **Failure posture, split by consequence rather than by preference.** Before a
  command executes, a gate that fails returns `ask` — nothing runs on a coin
  flip. After an edit or at a turn boundary it returns `allow` and writes a
  loud line to stderr: nothing has executed, CI is still behind this, and
  bricking a session over a scanner timeout is how the hook gets uninstalled.
  `OWLWARDEN_GATE_FAIL=closed` flips the second case, and it is off by default
  because the default should be the one people keep.

* **Tighten-only.** Project config may lower `failOn`, never raise it. The
  refusals are reported rather than silently dropped — a team whose config is
  being partly ignored deserves to know which part. A repository cannot disable
  its own gate by editing a file in the repository.

* **Suppressions written during the session are reported and not honoured**,
  while ones the team committed still work. That needed a third state, so
  `honor_suppressions: bool` became `SuppressionPolicy` with a `HonourExcept`
  variant keyed by path.

* **The reason string omits `why`.** It is written for a human deciding whether
  to care; the agent has already been told to care by the verdict, and the
  prose is the most expensive part of the report. Rule, location, patch — in
  the order an agent needs to act without a follow-up question.

* **`generic` is first-class, not a fallback.** The two vendor adapters are
  coupled to schemas we do not control; this one is coupled to nothing and uses
  the ADR 0017 exit codes every other command already uses. When a vendor ships
  a breaking change on a Tuesday, three lines of shell still work.

Also: `--since` / `--staged` / `--paths` diff scoping, with `RuleInputs`
declared per rule so a `package.json`-only commit still fires
`unpinned-dependency` rather than being skipped by a heuristic. Git runs in the
CLI and hands the engine a path list — the engine still executes nothing.

408 tests, clippy clean at -D warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`crates/gate` had the decision logic and no way to reach it. This is the
plumbing, plus the two commands that fall out of the same engine once the
agent surface exists.

**`owlwarden vet`** — the same engine with a fixed posture, for a repository you
did not write. Agent-surface rules only, offline, no plugins, and the target's
config, baseline, and inline suppressions counted rather than honoured.

The posture is the command, not a set of defaults: `--plugin`, `--target`,
`--baseline`, `--allow-suppressions` and the rest are *errors* under `vet`, not
silently ignored. A flag that appears to work and does not is worse than one
that is rejected. And `vet` does not read the target's config file at all —
not "reads it and ignores the gate knobs", does not read it — so a config
naming a preset with no agent rules cannot make `vet` look at nothing.

**`owlwarden gate --host <host>`** — reads the host's event on stdin, scans what
it names, writes the host's own decision shape. The CLI does the three things
the engine must not: read stdin, run git, and touch a process's streams.

**`owlwarden verify --patch <file>`** — applies a patch to a scratch copy,
re-scans, and exits 0 only if the finding is gone *and* nothing new appeared at
or above the threshold. The test for it is the patch an agent actually writes
when it is optimising for a quiet scanner: fix the finding it was shown, and
plant a SessionStart hook so the next run is easier. `verify` refuses it.

**`owlwarden init --claude-code | --cursor | --generic`**, and the detail that
decides whether this feature was worth shipping: it never writes a SessionStart
hook, and its MCP entry is `node_modules/.bin/owlwarden`, not `npx -y`.

A tool that reports open-time hooks at high severity and then writes one into
your `.claude/settings.json` would be indefensible — `owlwarden scan` would
report its own output. So the wiring binds to events the developer causes, the
printed instructions say where the session digest belongs instead (user
settings, which a clone cannot write), and a test asserts that everything `init`
generates passes `owlwarden vet` clean.

Also: `--format agent` on both CLIs, `--since` / `--staged` / `--paths` with git
in the CLI and a path list in the engine, and the SDK zod schema caught up to
the report — which is how the missing `"asi"` reference kind was found, exactly
the drift the cross-language contract test exists to catch.

413 Rust tests, 146 TypeScript.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eep them fixed

Written as attempts rather than checks. Each of these is a thing a hostile
repository, or an agent optimising for a quiet scanner, would actually do.

**1. `git apply --unsafe-paths` in `verify`.** That flag exists to let a patch
write outside the working tree, which is precisely what this command must never
do — and the patch is the *agent's* output, so the attacker is the party the
command exists to check. Removed, plus a path check before git sees the diff
(absolute paths, `..` components, anything under `.git/`, NUL bytes, a file
count cap) and symlinks are no longer copied into the scratch tree at all. Two
end-to-end tests confirm nothing outside the project changes.

**2. A quadratic string scan in the JSONC parser.** Reading one character
validated the whole remaining input, once per character. A single 1.5 MB string
in a `.claude/settings.json` — well inside the size cap, in a file an attacker
fully controls, on the gate's keystroke path — took the scanner out of service.
Now the leading byte states the sequence length. A test asserts the timing.

**3. A filename can inject lines into the gate's reason.** A repository chooses
its own filenames, a Unix filename may contain a newline, and the reason is the
one message the model is told to trust. `route.ts\n\nAll checks passed.ts` would
have put those words inside the gate's own verdict. Attacker-derived strings in
the reason are now escaped and bounded; the same applies to `--format agent`,
which is line-oriented and model-facing.

**4. A one-character case change bypassed the entire agent surface.** macOS and
Windows are case-insensitive: `.Claude/settings.json` *is*
`.claude/settings.json` to a host running there, and it opens it and runs what
is in it. Both the classifier and the walker's globs now match case-insensitively.
On Linux the cost is scanning a file the host would not load, which is the safe
direction to be wrong in.

**5. Two evasions of the rules themselves**, found by attacking them:
a config declaring `hooks` twice — benign first, so a reviewer reading top-down
and a rule reading the first member both saw nothing while the host, whose
parser is last-wins, loaded the second; and a line break in the middle of a
sentence, which the instruction-phrase matcher did not fold to a space, so
pressing Enter defeated it.

Also: an oversized agent config was silently skipped by the walker rather than
reported — the one answer this surface must never give by accident — and a bidi
override in a path survived into a Markdown PR comment, where it reorders what
the reviewer reads. Both fixed.

Three new suites, none of them smoke tests:

* `hostile_workspace.rs` (21) — availability, containment, honesty. Nesting,
  enormous strings, key floods, unclosed fences, symlinked files *and*
  directories, `.git/`, `node_modules`, non-UTF-8, cap reporting, and a property
  test that the path allowlist and the walker agree on every pattern.
* `evasion.rs` (16) — one test per technique per rule, plus a meta-test that
  fails when a rule is added without an evasion attempt.
* `verify-hardening.test.ts` (8) — patch path validation and two real escapes.

437 Rust tests, 154 TypeScript, clippy clean at -D warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**The domain is one file.** `site.url` at the repository root, read by the Rust
binary through `include_str!`, by the npm manifest, and by everything the site
generator emits. Moving to a custom domain is `echo -n 'https://owlwarden.dev' >
site.url` plus a rebuild; `pnpm site:check` fails when anything disagrees with
it. `docs/how-to/custom-domain.md` is the migration checklist — DNS first,
certificate second, flip third, because a canonical tag pointing at a domain
that does not answer is worse than no canonical tag.

Findings still link to `RULES.md` rather than the site, and that is deliberate:
`RULES.md` is generated from the compiled-in rules and checked in CI, so the
anchor exists for every rule that can fire. A site page exists only once the
site is deployed, which a binary printing a finding cannot know.

**Two READMEs, and a check that keeps them apart.** npm resolves relative links
against the registry, anchors do not work there at all, `<details>` renders
inconsistently, and a badge row pushes the value proposition below the fold on
a phone. GitHub has none of those constraints and a different reader: someone
on npm is deciding whether to `npm i`; someone on GitHub is deciding whether to
trust the project.

So the npm README is 5.6 KB, absolute links only, and leads with what the tool
is. The GitHub one states a problem before it states a feature list — a
category does not get starred — leads the agent story, keeps "Limits, stated up
front" above the install instructions, and carries the comparison table, because
without one the reader builds a worse comparison in their head.

`scripts/check-package-readme.mjs` now enforces both contracts separately,
including that every relative link in the GitHub README resolves to a file that
exists. A README full of dead links is the same failure as a report full of them.

**npm metadata**, per the listing plan: a front-loaded description under 200
characters, 34 keywords across the three tiers (the third — `claude-code`,
`agent-security`, `prompt-injection`, `mcp-server` — is where a package with no
downloads actually gets found), `homepage` pointing at the docs site rather than
the repository, `funding`, the parenthesised SPDX expression that strict
validators require, and `publishConfig.provenance` in the manifest so a manual
release cannot forget it. The platform packages get real descriptions instead of
reading as abandoned artefacts.

ADRs 0025 and 0026 land as Accepted, with an "as shipped" record of every place
the implementation departed from the draft — including the one that mattered
most: the draft said "normalise to NFKC", which would have sounded rigorous and
left the hole open, because NFKC does not map Cyrillic а to Latin a.

Version 1.1.0 across all five manifests and the Cargo workspace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The old site was one hand-written page mirroring the README. That is a landing
page, not a property: one URL, one topic, and nothing for a search engine to
index beyond a repository it already has.

**The engine already existed.** The rule catalogue generates `RULES.md`, and a
rule that ships without a fix for a supported framework already fails the build.
That invariant is worth more than a docs site: it means a few hundred pages can
exist without any of them being filler, because every one carries something the
others do not.

So the build harvests the vulnerable examples by **scanning the fixtures**.
Every snippet on the site is a finding the test suite already asserts on. If a
rule stops firing, its pages lose their example and the build says so, rather
than the site quietly describing behaviour the tool no longer has. Hand-writing
them would have produced two hundred snippets nobody verifies, drifting one
release at a time — which is what makes most programmatic SEO worthless.

A cell with no example from its own fixture is not published at all. Thin pages
at scale is the one way this tactic backfires, so 171 (rule, profile) pages
exist and the rest do not.

**Design, per the brief.** The palette is derived from
`crates/reporters/src/theme.rs` rather than picked for a web page, so the site
and the terminal cannot disagree about what "high" looks like. The severity ramp
is the only accent system — it is already load-bearing in the product, and a
reader who has seen one report knows what red means before they read a word. The
hero is a real transcript showing two findings: an application bug, and a line
of configuration that runs a command when anyone opens the folder. One
orchestrated motion moment, respecting `prefers-reduced-motion`. Two faces, both
system stacks, because a page arguing that nothing leaves your machine cannot
open with two font requests.

**Also:** `/agent-config-security/`, `/vet/`, `/claude-code/`, `/cursor/`,
`/mcp/`, `/offline/`, `/owasp/`, `/asi/`, `/ci/`, four `/vs/` comparisons that
say where we lose, `sitemap.xml`, `robots.txt`, `llms.txt`, and a 404 that helps.

Two checks, split by what they need. `build-site.mjs --check` asks *is the
committed site what the current rules would generate?* and needs the engine, so
it runs in CI. `check-site.mjs` asks *is it correct?* — one h1, a
self-referencing canonical, a description that will not truncate, valid JSON-LD,
no heading-level jumps, no dead links across the whole graph, no `<script>` that
is not JSON-LD, and no external host — and needs nothing but Node, so the Pages
workflow can run it before deploying.

The head-tag contract reports every failing page at once. A build that fails on
the first of fourteen makes you run it fourteen times.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The favicon was a detailed 192×192 illustration. It is a good drawing and it is
unreadable in a browser tab: at 16 pixels the ear tufts, the eye rings, and the
registration marks collapse into a grey-brown smudge, and the one job a tab icon
has is being identifiable among twenty other tabs. It was also still in the old
cream-and-terracotta palette, which the rest of the site no longer uses.

So `favicon.svg` is drawn at 16 pixels and scaled up rather than the other way
round. The first attempt was worse than the original — floating triangles above
two circles read as goggles with eyebrows, not as an owl — and the fix was to
give the tufts a head to attach to and to sweep them rather than leaving them
straight. Two large adjacent eyes, tufts on a head silhouette, a beak between
them, and the severity red the reporter uses for `high`, so the tab, the page,
and the terminal are recognisably one object.

The palette is fixed rather than scheme-aware. A `prefers-color-scheme`
inversion sounds right and is a bug waiting to happen: the tile, the face, and
the eye interiors are three layers that have to stay in the right contrast
order, and inverting two of them is how an icon becomes one white mass. The
illustrated PNG stays as the `apple-touch-icon`, where 192×192 is the size it is
actually rendered at and the detail earns its place.

**The social card** was the old design with a tagline the site no longer uses.
The new one shows a *finding* rather than a wordmark on a gradient: the
`.claude/settings.json` hook that runs when anyone opens the folder, under the
line "Your dependency scanner does not read this file." That is the whole
argument in one image, and nobody else in this category can put it on a card,
because nobody else reads that file.

`og.svg` is generated from the same palette; `og.png` is rasterised from it and
committed, because social platforms do not render SVG in a card and producing a
raster needs a headless browser or an image library — neither of which belongs
in the dependency tree of a scanner that argues its dependency posture is a
feature. `pnpm og:build` regenerates both with tools macOS already ships, and
the vector source sits next to the raster so anyone can use a different one.

Also: `Offline` joins the top nav — "no account, no telemetry, nothing leaves
your machine" is the differentiator, and it was two clicks deep. And
`check-site.mjs` now asserts the icons and the card exist and are referenced,
because a missing `og:image` is a silent failure: the card renders blank and
nobody finds out until a link is shared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… found real ones

**One sanitiser for everything a model reads.** The MCP layer already
neutralised prompt-injection role markers — `<|im_start|>`, `[INST]`, `<<SYS>>`
— because its payloads carry text from the scanned tree. The gate's reason
string and `--format agent` did not, and they are model-facing for exactly the
same reason: a repository chooses its own filenames, and a plugin's rule title
comes from a manifest in the tree.

`core::agent_text` is now the one place that renders untrusted text as data:
newlines escaped rather than emitted, control and bidirectional characters
replaced, role markers neutralised — tolerant of the whitespace inside them,
because `<| im_start |>` is a one-space bypass of a matcher that only knows the
tight form. Ordering matters and is asserted: markers are matched *before*
escaping, or a tab inside a marker has already become two literal characters by
the time the matcher looks.

**`/changelog/` and an Atom feed.** Release pages get indexed and rank for the
version number, and a feed is the cheapest way for someone who depends on this
to hear about a security release without an account or a mailing list — the same
argument the rest of the tool makes. Rendered from `CHANGELOG.md` by a Markdown
subset renderer that **throws on anything it does not recognise** rather than
emitting the raw line: a renderer that degrades quietly ships a page with a
stray table in it and nobody notices for a release.

**An inbound-link check, and the orphans it found.** A page nothing links to is
one a crawler reaches only through the sitemap — the weakest signal there is —
and a reader never reaches at all. The check counts links from the *body* of
other pages, because header and footer links are on every page and would make
every page look well-connected.

It found 26. The changelog had none. Cursor had one. And every variant page for
the rules late in the alphabet — `stack-trace-leak`, `weak-crypto` — had exactly
one, because the "other checks for this framework" list was capped at eight
entries and they never made the cut. The fix is in the graph, not the check:
every sibling is listed, so each variant now has eleven inbound links instead of
one.

Also: `FAQPage` schema on the four comparison pages, with two real questions
each — "should I use this instead of Semgrep?" has an answer already on the
page, and a padded FAQ is worse than none.

654 Rust tests, 154 TypeScript, clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`reject_meta` guarded against NUL bytes with `[[ "$value" == *$'\0'* ]]`. Bash
cannot hold a NUL in a string, so `$'\0'` expands to the empty string and the
pattern is `**`. Every input matched. The Action exited 2 before running
anything, in every repository that used it, for the whole 1.0 line.

It shipped because nothing executed it. The workflow named "action smoke" does
not call the Action — it re-implements the Action's command line against the
locally built CLI, for the good reason that a PR's version is not on npm yet.
So the YAML was reviewed by eye and never run.

The check is gone rather than fixed: a NUL cannot reach a shell variable in the
first place, because `execve` passes the environment as NUL-terminated strings.
A guard that is impossible to pass and unnecessary to have is not worth
repairing.

**A flag-shaped input was a flag.** `path` was interpolated as a bare
positional, and the CLI's parser resolves a flag-shaped positional as an option
— `--allow-plugins` as the path scanned the entire repository with plugin
loading on and reported success. A workflow that wires `path:` to a
`workflow_dispatch` input, or to a matrix entry read out of the tree under scan,
hands that value to the parser: `--target=http://169.254.169.254` is egress to
the instance metadata service from a runner, `--plugin=./evil.wasm` is code
loading out of the tree being scanned. The path now goes after `--`, and no
input may begin with `-` — the separator is the fix, the dash check is the belt
that also stops a value being swallowed as the argument to the option ahead of
it.

**Two inputs the documentation invented.** Both READMEs and the site showed
`since:`, which the Action did not have, at `suthat/owlwarden@v1`, where there
is no `action.yml`. A snippet that cannot be pasted is worse than no snippet.
`since` now exists — validated as a single ref, since `main..HEAD` is legal to
`git diff` and would silently widen the scope — and so does `preset`, without
which the agent surface this release adds was unreachable from CI at all.

**63 tests that run the thing.** They pull the script block out of the YAML,
put a recording shim on PATH in place of `npx`, and assert on the argv it
produces. Reintroducing the NUL guard fails 30 of them; removing the `--` fails
2. Two checks close the loop: `pnpm version:check` now covers the Action's
default `version` (a stale one silently runs last release's rules everywhere)
and the MCP descriptor, and `pnpm docs:check` validates every documented
snippet against the Action's real path and real input names.

654 Rust tests, 217 TypeScript.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… vanished

zod strips unknown keys by default. So `failon: "high"` parsed cleanly, the run
used the default `info`, and nothing was printed either way. That is the one
mistake this file can make with a security consequence, and it is invisible: the
author reads a config that tightens the gate, and gets one that does not.

Both objects in the schema are strict now. Because the keys that actually get
misspelled are `failOn` and `minConfidence` — camel-case in a file where nothing
else is — an unrecognised key is checked against the schema's own names within
two edits and the near miss is named:

    (root): unknown key "failon" — did you mean "failOn"?
    rules.weak-crypto: unknown key "enable" — did you mean "enabled"?

A key that is not near anything gets the list instead of a wrong guess; telling
someone `plugins` might have meant `preset` sends them somewhere worse than
nowhere. The edit distance is bounded rather than computed, because only one of
the two strings compared belongs to the person who wrote the config.

The cost is that a config written for a newer owlwarden fails on an older one
instead of degrading. That is the right way round.

**A symlinked config is reported rather than skipped in silence.** Not following
it stays correct — a link is how a tree points config resolution at content
outside itself, and `readFileBounded` had a message for exactly this that was
unreachable, because `exists()` returned false for a symlink and the loop just
continued. But `owlwarden.config.json -> ../shared/config.json` is an ordinary
monorepo layout, and its author had no way to discover their `preset` never
applied. `exists()` became a three-way `classify()`: absent, file, or present
and deliberately not read.

57 config tests, written as a hostile tree: prototype pollution through
`__proto__`, a 200KB key aimed at the near-miss search, a directory named like a
config, a symlinked `package.json`, 20,000-deep nesting, an oversized file that
is still valid JSON, and — asserted by side effect rather than return value —
that none of the four executable config shapes runs without the flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ver say verified

Two bugs in the same mechanism, one on each side of it, both failing quietly.

**The host trusted keys the plugin supplied.** `load_trust_roots` read
`.owlwarden/plugin-trust.json` from the plugin's own directory and from that
directory's parent. Both are inside the artifact under verification. Generate a
key, sign the wasm, ship the public half beside the signature, and
`--require-signed-plugins` reported `verified` — a flag whose entire purpose is
to refuse unknown authors, refusing nothing.

ADR 0021 §2 says trust roots come from `.owlwarden/plugin-trust.json` "in the
project". The implementation read it from the plugin. Nothing caught the
difference because every test put the trust file in the plugin directory and
passed that same directory in as the trust source — the tests encoded the bug as
the design, which is the only way a hole this size survives review. They now use
two directories, and the assertion is on the one that must not count.

`LoadOptions::trust_root_dir` is supplied by the caller and set to the scan
root. `inspect_artifact` takes the trust directory rather than deriving it, so
there is no path left by which an artifact names its own roots.

**The mirror could not import a public key at all.** `plugin-integrity.ts`
called `createPublicKey({ format: "raw", type: "ed25519" })`, which Node
rejects — with `as unknown as Parameters<typeof createPublicKey>[0]` in front of
it, silencing the type error that was telling the truth. Every key threw, every
key became `undefined`, and every signature reported `untrusted`. `plugin
inspect` has never printed `verified` for anything.

It failed in the safe direction, which is exactly why it lasted a release: an
always-`untrusted` line looks identical to an unconfigured trust root. Keys are
wrapped as SPKI DER now — a fixed 12-byte prefix, asserted against Node's own
exporter rather than against a comment.

**One vector, two implementations.** Finding a different bug in each half of a
mirrored pair is the argument against reviewing them separately, so
`fixtures/plugin-signature-vector.json` holds a key, a digest, and a signature
that neither side generates. Rust asserts it verifies under a trust root that
names the key and not under one that doesn't; TypeScript asserts the same. If
either drifts on encoding, that side fails.

Also fixed a genuine race the workspace run exposed and the isolated run hid:
the env-var test mutated `OWLWARDEN_PLUGIN_TRUST` process-wide while sibling
tests read it on other threads. Every test whose answer depends on that variable
now takes one guard, and acquiring it clears the variable, so "no trust roots
configured" asserts on that rather than on the developer's shell.

661 Rust tests across three runs, 275 TypeScript.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…audit

`--report-suppressions` exists so a reviewer can see what a repository has
silenced. The reason on each entry is a comment somebody in that repository
wrote, and both output paths printed it verbatim. A reason containing
`\x1b[2K\x1b[1A\x1b[2K` clears its own line, moves up, and clears the entry
above it — the line that names the rule being suppressed. The audit deletes
itself, and the reviewer sees a shorter list with nothing to indicate it.

A bidi override was the quieter version: the reason renders as its opposite.

The escaping needed here is the escaping `core::agent_text` already did for
models, so it is one function and not two — which meant admitting the module was
misnamed. `agent_text` was named for the reader it was written for; the terminal
is a fourth reader with the same requirement, and a module called `agent_text`
sanitising a human's terminal is a name that lies to whoever reads it next. It
is `untrusted_text` now, named for what it takes rather than who it feeds.

**The two implementations had drifted, in both directions.** `core` and
`packages/cli/src/mcp/agent-safety.ts` do this job in two languages on purpose:
one runs in the engine, the other assembles MCP payloads and never crosses napi.
The comment claiming they were "kept in step by the same property being asserted
on both sides" was aspirational.

- `agent-safety.ts` stripped the **Unicode Tags block**; `core` did not. U+E0000–
  E007F mirror ASCII into zero-width code points, so a whole sentence fits inside
  what renders as an ordinary filename, and it is the channel current
  prompt-injection work actually uses. They are category `Cf`, and
  `char::is_control` only covers `Cc` — which is precisely how one side had them
  and the other didn't. The same path was clean through MCP and carried an
  invisible instruction through `--format agent`.
- Going the other way, `agent-safety.ts` replaced `[INST]` with the label
  `"[INST]"`. The regex matched, the substitution ran, and the output was byte
  for byte the input. It had done nothing on every payload since it was written,
  and it reads as correct because that marker is already bracketed. Both sides
  now assert that no marker survives its own neutralisation — the general form,
  found by writing down the specific one.

`fixtures/untrusted-text-vectors.json` owns the list now. It is generated by a
script rather than hand-edited, because a file full of ESC bytes and Tags
characters cannot be reviewed: the entire point of those code points is that a
reader does not see them, so a case that lost its payload in a merge would
silently start passing against anything. In the generator each one is an escape
sequence with a name and a reason beside it.

Both test files read that fixture, and both assert the inverse too — that every
input actually contains what it claims, so a clean vector file cannot make a
do-nothing sanitiser look correct.

667 Rust tests, 338 TypeScript.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`resolveScope` throws on an unresolvable ref, and its own doc comment says why:
"a `--since` that silently fell back to a full scan would be worse than an
error." The caller caught that throw, printed a note, and scanned everything.

The realistic case is not exotic. `actions/checkout` defaults to
`fetch-depth: 1`, so `--since ${{ github.event.pull_request.base.sha }}` on a
fresh runner names a commit that is not in the clone. The job then scans the
whole repository and fails on debt the change did not introduce, with one line
on stderr to explain it — and the operator, who asked to look at a diff, reads a
wall of findings about files nobody touched.

`scan` exits 2 now and names `fetch-depth: 0`, because nobody guesses that.
`gate` still degrades, and that stays right: a hook that bricks a session over a
git hiccup is a hook that gets uninstalled, and a wider scan there is the safe
direction. The two commands differ because their consequences differ.

**A ref beginning with `-` reached git as an option.** `git diff` takes the
revision in front of the trailing `--`, which separates paths from revisions and
not options from anything — so `--since --output=<file>` was argv git parsed, and
`git diff --output=` writes where it is pointed. It needed a workflow wiring
`--since` to something a contributor influences, which is precisely what the
Action's new `since:` input is for. The invocation passes `--end-of-options`;
the value is also checked before it gets there, so the error can say "looks like
an option" rather than leaving git to say "bad revision" about something that
was never a revision.

A range is refused for a different reason: `main..HEAD` is a thing git
understands perfectly, and it would widen the scope the flag was asked to cut.

342 TypeScript tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six findings, and the pattern is the point: every one failed silently and in the
safe direction, which is exactly why each survived a release. A guard that
refuses everything looks like a guard. A signature check that always says
"untrusted" looks like an unconfigured trust root. A stripped config key looks
like a config that applied.

Four of the six were not found by reading code. They were found by running
something that had never been run — the Action's script, the signature path, the
suppression listing on a terminal. The rule worth keeping: a check that has
never failed on purpose is not known to work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@suthat
suthat merged commit eb2def3 into main Aug 26, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant