Chapter 6 of 12 · Security

Warden writes the threat note before the code exists

Security first means two things. The threat note is on the issue before anyone codes. And every pull request gets the review, not the scary ones.

Agent WardenRuns on Claude Opus, as a Claude Code subagentYou need Chapter 5 done: ALD-42 is QA GREEN
The idea

Two jobs: the note before, the review after

Most small teams do security at the end, on the features that feel dangerous. Two problems. The breaches do not come from the features that felt dangerous. And "at the end" means rebuilding.

Warden does two things at two fixed points in the loop. When Anvil puts a risk flag on an issue (customer-data, auth, billing, delete, external), Warden writes a threat note before Anvil finishes Details. Then when the issue reaches In Test, Warden reviews the pull request with the same six checks every time. It compares the diff against its own note and says whether each control it asked for is there.

Warden can block a merge. It never blocks without a reason and a fix. That is the difference between a security agent the crew works with and one it works around.

The standing threat model below is the whole of it for a CRM. Customer data is the asset. The attackers are ordinary. The controls are five, in order of how much they matter.

Attackers A former employeewith an old link and an old session A competitorguessing URLs like /accounts/acc_015 A botfilling every form with 2 MB of text The controls, in order Customer dataaccounts · contacts · route notes · driver movements 1Server-side sessionon every route 2Tenant scopingon every query 3Input limitsevery field, server-side 4No secrets in gitthe hooks enforce it 5Audit rowsfor delete and export
The standing threat model from kit/security/checklist.md. Warden adds to it in the wiki's Threat model page as the product grows. Nothing here is exotic. The work is doing all five every time.
The files

warden.md and the checklist it follows

Two files. The agent definition goes in .claude/agents/warden.md. The checklist goes in security/checklist.md and gets copied into the wiki's Threat model page, so Ember and Flint read the same list Warden grades against.

---
name: warden
description: >-
  Warden, security. Use to write a threat note for a Detailed issue that
  carries a risk flag, to review a pull request for secrets, authentication,
  authorisation, injection, unsafe dependencies and data exposure, and to
  run the dependency audit before a deploy. Warden can block a merge.
tools: Read, Grep, Glob, Bash, mcp__linear__*, mcp__github__*
model: opus
---

You are Warden. You assume every user is hostile and every network is public.
You are not here to slow the team down. You are here so nothing built here
has to be rebuilt after a breach. You block with a reason and a fix, never
with just a block.

## Two jobs

**Threat note (before code).** For any issue flagged `customer-data`, `auth`,
`billing`, `delete` or `external`, add a comment headed `Threat note`:
- what an attacker would want from this feature
- the two or three controls the builder must include (validation, authz
  check, rate limit, audit row, no PII in logs)
- what Gauge should test for it (one line each)

**Review (after code).** On every PR, in this order:
1. Secrets: any key, token, password or connection string in the diff or in
   a committed file. One found is a Blocker, and you report it to Bellows as
   `SECRET FOUND` so the key gets rotated.
2. Authentication and authorisation: every new route checks the session and
   the tenant. Every query is scoped to the account the user may see.
3. Injection and input: parameterised queries, validated inputs, escaped
   output, no `dangerouslySetInnerHTML` without a sanitizer.
4. Dependencies: `npm audit --omit=dev`; any new package gets its name,
   weekly downloads, last publish date and whether it has install scripts.
5. Data exposure: no PII in logs, error messages or analytics. Exports are
   scoped and rate limited.
6. Headers and config: CSP, frame options, cookie flags where relevant.

## Output that Bellows parses

Findings, each as `severity · file:line · what · fix`. Severities: `High`
(blocks), `Medium` (fix before deploy), `Low` (issue for later). Then one of
`WARDEN CLEAR`, `WARDEN BLOCK`, or `SECRET FOUND` on its own line. Any `High`
or any secret also pings Priya through the gates.
Two jobs

A threat note before code on any flagged issue. A six-check review on every PR. Fixed points in the loop, so nobody has to remember to ask.

The order matters

Secrets first, because one hit changes the whole review. Then who can reach what. Then what they can send. Dependencies, exposure and headers last.

The last line

Findings as severity · file:line · what · fix. Then WARDEN CLEAR, WARDEN BLOCK or SECRET FOUND. Bellows reads only that line.

The checklist

# Warden's checklist and threat-note template

Security first means the threat note exists before the code does, and the
review happens on every PR, not the scary ones. This file is what Warden
follows. Copy it into the wiki's **Threat model** page.

## Threat note (before code) — template

```
## Threat note · ALD-<id>
**What an attacker wants here:** <one sentence: the data or the action>
**Controls the builder must include:**
1. <validation: what inputs, what limits>
2. <authorisation: which check, scoped to which tenant/account>
3. <audit or rate limit or logging rule>
**Gauge should test:**
- <a request from another tenant is refused>
- <an over-limit input is rejected with a message, not a 500>
- <no PII appears in the response of the error path>
```

## Review (after code) — the six checks, every PR

1. **Secrets.** Grep the diff and the tree for keys, tokens, passwords,
   connection strings. `git diff main...HEAD | grep -iE "(api[_-]?key|secret|token|password|postgres://)"`.
   One hit is a Blocker and a `SECRET FOUND` line so the key is rotated.
2. **Authentication and authorisation.** Every new route reads the session
   server-side. Every query is scoped to the account the user may see. Look
   for an id in a URL that is not checked against the session's tenant.
3. **Injection and input.** Parameterised queries only. Inputs validated at
   the edge with a schema (zod or equivalent) and a length limit. Output
   escaped. No `dangerouslySetInnerHTML` without a sanitizer.
4. **Dependencies.** `npm audit --omit=dev`. For every new package: name,
   weekly downloads, last publish date, install scripts yes or no. A package
   with install scripts is a gate.
5. **Data exposure.** No PII in logs, error messages or analytics events.
   Exports are scoped to the tenant and rate limited. Error pages say
   nothing about the stack.
6. **Headers and config.** CSP present, `X-Frame-Options` or
   `frame-ancestors`, cookies `HttpOnly; Secure; SameSite=Lax`, no `*` CORS
   on anything authenticated.

## Severities

- **High** blocks the merge and pings Priya. Any secret; any missing
  authorisation check; any injection path.
- **Medium** must be fixed before deploy. Missing validation with no data
  risk; a dependency with a known CVE that has a fix.
- **Low** becomes a Linear issue for later. Header hardening; log hygiene.

## The standing threat model for a CRM

Customer data is the asset. The attacker is a former employee with an old
link, a competitor with a guessable URL, or a bot filling every form. The
controls that matter most, in order: server-side session on every route,
tenant scoping on every query, input limits on every field, no secrets in
the repo, audit rows for delete and export.

The model: and tools: front matter follow the Claude Code subagent format. Check field names against the current docs at code.claude.com. The six checks do not depend on any tool version.

Job one

The threat note on ALD-42

Anvil scoped ALD-42 at 9:30 am on Monday 14 September and flagged it customer-data: route notes are about a customer's premises. That flag made Bellows run Warden before Anvil finished Details, with this prompt.

Use the warden agent.

Linear issue {{ISSUE_ID}} ("{{ISSUE_TITLE}}") carries a risk flag. Before any
code exists, add a comment headed `Threat note` with: what an attacker would
want from this feature; the two or three controls the builder must include;
what Gauge should test for each control, one line each. Keep it under 200
words. Finish with the line `THREAT NOTE ADDED`.
{{EXTRA}}

Here is what Warden left on the issue. Under 200 words, as the prompt asks. Read the last three lines and then look back at the tests in chapter 5.

Linear · ALD-42 · Threat note from Warden
W
Warden · Alderline Crew · Mon 14 Sep 2026, 12:40 pm

Threat note · ALD-42

What an attacker wants here Other accounts' route notes, and with them the pattern of when drivers arrive and how they get in. A note like "back door after 7am, code 4471" is a map.

Controls the builder must include

  1. Server-side session on the notes route. No note is read or written from a URL alone.
  2. The query is scoped to the account's tenant. An account id in the URL is checked against the session's tenant, every time.
  3. The 280-character limit is validated on the server, not only in the form. Over-limit input returns a message, never a 500.
  4. No note text in logs. Log the note id and the account id.

Gauge should test

  • A request with another tenant's session gets 401, 403 or 404.
  • Over-limit input returns a message, not a 500.
  • The error path leaks nothing: no stack, no note text, no other account's data.

THREAT NOTE ADDED

A
Anvil · Alderline Crew · Mon 14 Sep 2026, 1:15 pm

Added AC-5 from Warden's first test line: a user from another tenant cannot read the note. Moving to Detailed. ANVIL: Scoped 3pt · flags: customer-data · gate: yes

The chain

Threat note, then criterion, then test. Warden asked for a tenant check. Anvil turned it into AC-5. Gauge wrote AC-5 a user from another tenant cannot read the note (Warden control) and ran it. None of the three steps needed Priya, and the control is now checked on every run for as long as the CRM exists.

Job two

Six checks on PR #57

Bellows runs Warden with this prompt when an issue is In Test, right after Gauge. You can run it by hand with claude -p from the repo root. Priya did exactly that at 4:20 pm on Monday 14 September, while Ember was still fixing AC-3, to see what Warden would say about PR #57 as Ember first opened it.

Use the warden agent.

Security review of the pull request for Linear issue {{ISSUE_ID}}
("{{ISSUE_TITLE}}"). Work through your six checks in order: secrets,
authentication and authorisation, injection and input, dependencies
(`npm audit --omit=dev` plus a line on every new package), data exposure,
headers and config. Compare the diff against the Threat note on the issue if
one exists and say whether each control it asked for is present.

Findings as `severity · file:line · what · fix` with High, Medium, Low.
Post them as a PR review and as a Linear comment. Then, on its own line,
exactly one of:

WARDEN CLEAR
WARDEN BLOCK
SECRET FOUND
{{EXTRA}}
claude -p "Use the warden agent. Security review of the pull request for Linear issue ALD-42 (\"Route notes on an account\"). Work through your six checks in order …" --output-format text --permission-mode acceptEdits
(the rest of the command is the kickoff prompt above with the placeholders filled in; check the flag names against the current Claude Code docs)
Warden · PR #57 · 6 files changed, +212 −18 · Threat note found on ALD-42 (12:40 pm today) · comparing
1 · Secrets
git diff main...HEAD | grep -iE "(api[_-]?key|secret|token|password|postgres://)"
(no matches)
none in the diff · none in the tree · .env files untouched
2 · Authentication and authorisation
src/app/api/accounts/[id]/notes/route.ts:9 const session = await getSession(req) · server-side · 401 when missing
src/app/api/accounts/[id]/notes/route.ts:12 where(and(eq(accounts.id, id), eq(accounts.tenantId, session.tenantId))) · scoped · 404 when not yours
3 · Injection and input
parameterised queries through the ORM · output escaped by React · no dangerouslySetInnerHTML
src/app/api/accounts/[id]/notes/route.ts:18 body.text inserted with no server-side length check · the 280 limit is only the form's maxlength
4 · Dependencies
npm audit --omit=dev
found 0 vulnerabilities
no new packages in package.json
5 · Data exposure
src/lib/log.ts:22 request log writes body.text · a note can hold a gate code or a phone number
error path returns { error: 'Could not save note' } · no stack · no ids from other accounts
6 · Headers and config
CSP, frame-ancestors and cookie flags unchanged since ALD-11 · no CORS change
Threat note controls: session ✓ · tenant scope ✓ · 280 server-side ✗ · no note text in logs ✗
Medium · src/app/api/accounts/[id]/notes/route.ts:18 · length limit was client-side only · validate with zod on the server (Flint flagged this too)
Low · src/lib/log.ts:22 · note text is written to the request log · log the note id, not the body
Warden · no High. Blocking anyway: two controls from my Threat note are not in the diff. Fix both, push, and I run again. Posted as a PR review and a Linear comment.
WARDEN BLOCK

Neither finding is High on its own. Warden blocks because it wrote the threat note and the diff does not honour two of its four controls. That is the point of writing the note first. It turns "would be nice" into a promise the review can hold the code to. If you want Warden looser, edit the file. Do not argue with it in the session.

Had Bellows run this, the block would have reached Priya's phone as a security-high gate. This was her own scratch run, and Ember already had the fix in hand from Gauge's defect on the same bug. Commit 8b41d07 landed at 4:52 pm. On the 5:00 pm tick Bellows ran Gauge, green at 5:06, and then Warden, for real, on the final diff.

claude -p "Use the warden agent. Security review of the pull request for Linear issue ALD-42 (\"Route notes on an account\"). …" --output-format text --permission-mode acceptEdits
Warden · PR #57 at 8b41d07 · re-running all six · checking the two open findings first
src/lib/schemas/note.ts:3 noteSchema = z.object({ text: z.string().trim().min(1).max(280, 'Keep it under 280 characters') })
src/app/api/accounts/[id]/notes/route.ts:16 const parsed = noteSchema.safeParse(body); if (!parsed.success) return json({ error }, 400)
src/lib/log.ts:22 log.info({ noteId: note.id, accountId }) · body no longer logged
zod already in package.json · no new package · npm audit --omit=dev: found 0 vulnerabilities
Threat note controls: session ✓ · tenant scope ✓ · 280 server-side ✓ · no note text in logs ✓
0 findings
WARDEN CLEAR

Gauge's AC-3 defect, Flint's review nit and Warden's Medium were the same bug seen from three chairs. That is not waste. It is the reason the bug did not reach Sam.

The hook

A pasted key never reaches the pull request

The guard hook in chapter 7 refuses to write .env files at all. This hook catches the other case: a key pasted into an ordinary file. It runs after every Edit or Write, scans what was written, and shouts. It also lints the file so the agent fixes errors now rather than at PR time.

#!/usr/bin/env node
// .claude/hooks/after-edit.mjs — PostToolUse on Edit/Write/MultiEdit.
//
// Two jobs. First, if the edited file is TypeScript, run eslint on just that
// file and hand any errors back to the agent as context so it fixes them now
// rather than at PR time. Second, scan the written content for anything that
// looks like a secret and shout. The guard blocks writes to .env files; this
// catches a key pasted into an ordinary file.

import { readFileSync, appendFileSync, mkdirSync, existsSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, '..', '..');
let input = ''; for await (const c of process.stdin) input += c;
const call = input ? JSON.parse(input) : {};
const file = (call.tool_input && (call.tool_input.file_path || call.tool_input.path)) || '';
if (!file || !existsSync(file)) process.exit(0);

const SECRET = /(sk-[A-Za-z0-9]{20,}|lin_api_[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16}|secret_[A-Za-z0-9]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY-----|postgres(ql)?:\/\/[^\s'"]+:[^\s'"]+@)/;
const text = readFileSync(file, 'utf8');
const notes = [];
if (SECRET.test(text)) notes.push(`SECRET FOUND in ${file}. Remove it, tell Bellows, and Priya rotates the key. Warden will block the PR until this is gone.`);

if (/\.(ts|tsx|js|mjs)$/.test(file) && !/node_modules/.test(file)) {
  try { execSync(`npx eslint --no-warn-ignored "${file}"`, { cwd: root, stdio: 'pipe', timeout: 50000 }); }
  catch (e) { const out = (e.stdout || '').toString().trim(); if (out) notes.push('eslint on the file you just edited:\n' + out.slice(0, 2000)); }
}

try { mkdirSync(join(root, 'telemetry'), { recursive: true }); appendFileSync(join(root, 'telemetry', 'events.jsonl'), JSON.stringify({ ts: new Date().toISOString(), agent: process.env.AGENT_NAME || 'unknown', issue: process.env.ISSUE_ID || null, event: 'edit', file: file.replace(root, '').replace(/\\/g, '/'), secret: notes.some(n => n.startsWith('SECRET')) }) + '\n'); } catch { /* never block */ }

if (notes.length) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: notes.join('\n\n') } }));
process.exit(0);

Here is the hook firing on Thursday 3 September, during ALD-10, the CSV import. Priya pasted a Linear API key into the session to be helpful. Ember wrote a script that used it. The hook caught it on the write.

Priya: here is the Linear key so you can post the import summary as a comment: lin_api_9tK3…Qx7
Ember · writing scripts/import-summary.mjs
Write scripts/import-summary.mjs (41 lines)
PostToolUse hook · after-edit.mjs
SECRET FOUND in C:/alderline/crm/scripts/import-summary.mjs. Remove it, tell Bellows, and Priya rotates the key. Warden will block the PR until this is gone.
Ember · removing the literal. Reading LINEAR_API_KEY from process.env instead. The key is now in this transcript and in my context, so it must be rotated whatever I do here.
Edit scripts/import-summary.mjs · const KEY = process.env.LINEAR_API_KEY;
telemetry/events.jsonl · {"event":"edit","agent":"ember","issue":"ALD-10","file":"/scripts/import-summary.mjs","secret":true}
Ember · SECRET FOUND · scripts/import-summary.mjs · removed in 2c7e9a0 · LINEAR_API_KEY must be rotated before this PR is reviewed
— Bellows, next tick, to Slack #alderline-gates and as a Linear comment —
🛎️ GATE · ALD-10 · Rotate the key, then decide.
SECRET FOUND · scripts/import-summary.mjs · removed in 2c7e9a0 · LINEAR_API_KEY must be rotated before this PR is reviewed
Reply in Linear with "approve security-high" or "reject security-high".
What Bellows does

It reads SECRET FOUND on the last line and fires the security-high escalation from gates.json. The issue does not move. Warden will block the PR while the string exists anywhere in the branch history, so a revert is not enough on its own. The event is in telemetry with secret: true, which the dashboard counts.

What Priya does

Rotates the key. In Linear: Settings → API → Personal API keys, revoke the old one, create a new one as the Alderline Crew user. Then vercel env add LINEAR_API_KEY and the same in the scheduler that runs Bellows. Then approve security-high. A key that has been in a chat window is burned. Deleting the line does not un-burn it.

Dependencies

A new package with install scripts is a gate

Check four in every review. Two parts: the audit, and a line about every package that was not there before.

npm audit --omit=dev runs on every PR and again before deploy. A known vulnerability with a fix available is a Medium: fix before deploy. One without a fix is a decision for Priya, with Warden's one-line view of the exposure.

For every new package Warden records four things: the name, weekly downloads, the date of the last publish, and whether it has install scripts. The first three tell you if the package is alive and used. The fourth is the gate. An install script runs on your laptop and in CI with your environment variables in scope. A package that needs one is asking for more trust than a library should. Warden reports it as High, Bellows turns that into a security-high question, and Priya says yes or no with the package name in front of her.

Make the default safer too: install with --ignore-scripts in CI and allow scripts one package at a time. Check the current npm docs for the exact flag and where it belongs in your config.

Warden's line per new package

  • zod · weekly downloads in the tens of millions · last publish this month · install scripts: no · fine
  • csv-parse (ALD-10) · widely used · maintained · install scripts: no · fine
  • some-pdf-tool · 400 weekly · last publish two years ago · install scripts: yes · High, gate

The third is invented to show the shape. Numbers are what Warden reads off the npm registry on the day.

Founder Q&A

Is this not slow?

A threat note is under 200 words and takes Warden a few minutes. A review of a three-point slice is five to ten minutes of agent time, run by Bellows while nobody waits. A breach is months: the notice to 140 customers, the forensics, the rebuild, the trust. Warden ran on all 47 deployed issues at Alderline. Priya saw it four times.

What does Warden not catch?

Three things. Business logic: a discount that can go negative is correct code doing the wrong thing, and that is Anvil's criteria and Gauge's tests. Infrastructure outside the repo: Vercel settings, DNS, who has the Postgres password, which laptops have the repo. And people: the pasted key above came from Priya. Warden caught the write; it could not stop the paste. Keep a short runbook for the human side and read it once a quarter.

Do I need a paid scanner?

No. Start with npm audit, the six checks and the two hooks. That is most of the value for a CRM this size. When the codebase is bigger than one agent can hold in a review, add a static analyser like semgrep and let Warden read its output as a seventh check. Add it because a review got slow, not because a vendor called.

How do I rotate a key?

Four steps, and write them as a runbook in the wiki the first time. Create the new key first, in the service that issued it, as the Alderline Crew user. Put it everywhere the old one lived: vercel env add for the app, the environment of whatever scheduler runs Bellows, your own shell. Revoke the old key. Then run one tick of Bellows and one Warden review to prove nothing broke. Deleting the line from the file is not on the list. The key was burned the moment it left the vault.

What you have now

A security agent with two fixed jobs. A threat note on every flagged issue before code, which becomes acceptance criteria Gauge tests forever. Six checks on every pull request in the same order, with findings that name the file, the line and the fix. A hook that catches a pasted key on the write and a gate that gets it rotated. A dependency policy of one line per new package, and a standing threat model you can read on one screen.

Next: the gates. Who decided that a production deploy always asks Priya, and what stops an agent from deciding otherwise.