ai / security

Claude Security scans my repos and reports findings. An agent triages the findings through a CLI, and each fix lands as an ordinary change. The repo holds the threat model, one file for each open finding, and the plan that fixes it. So git log shows why each finding closed.

Findings

A finding has an ID, a severity, an attack path, and a recommendation. The first scan of sockeye reported 36 open findings: 4 critical, 15 high, 15 medium, and 2 low. Changes fixed 35 of them, and the threat model accepts the last one. The first scan of sand reported 3, and a later scan found one more.

Most findings named a real path from untrusted input to something I own. Examples are a job row that could name /etc as the repo to copy into a guest, and a model API key in the arguments of a process that any local user could read.

Threat model

Each repo has a todo/security/README.md. Its threat model section is my statement of who the adversaries are. I judge each finding against it, and I paste the section into the scanner's threat model when it changes.

sockeye lists its adversaries, most likely first:

  1. Code that a check runs. A change's code or any dependency can be hostile, whoever wrote the change.
  2. Text that an agent reads. Repo files, change text, log lines, and error events can steer an agent. An agent token is a principal to limit, not an operator.
  3. The internet, through the public routes and the error and perf intake.
  4. A leaked service credential, such as a deploy token or a runner token.

The operators are not adversaries. Each has root on every VM, so a control that only stops an operator protects nothing. A third operator changes the model, so I update the section before I add one.

The model also lists the risks I accept, each with its reason. One example: a check can write an entry into its slot's Go build cache that gives a later check a false pass. A review gates each merge, and a deploy builds from an empty cache, so I accept that risk. When a finding falls inside an accepted risk, I resolve it with that reason and change no code.

One file for each finding

todo/security/ holds one file for each open finding, named <severity>-<finding id>-<title>.md. The file holds the attack path, my assessment, and the criteria that a fix must meet. A plan in todo/planned/ names the findings it fixes (see git / todo). To find the plan for a finding, I grep todo/planned/ for its ID.

A file and a plan match a finding by its ID, not by its title. Claude Security can reword a title between two reads.

claudesec

claudesec is a small Go CLI that reads and triages findings through the Claude Security API. An agent runs it in a repo, with no browser:

claudesec findings --json          # open findings, most severe first
claudesec show find_01...          # one finding in full
claudesec resolve --reason fixed find_01...
claudesec resolve --reason accepted_risk --note "..." find_01...
claudesec reopen find_01...

It reads CLAUDE_SECURITY_TOKEN and CLAUDE_SECURITY_PROJECT from the .env file of the repo. A change worktree has no .env file, so claudesec also looks in the main checkout of the worktree. It does not guess the project from a Git remote, because a remote can name a mirror that no project scans.

findings follows every page of the list and prints one summary line on stderr: the count, the project, and the time of the read. The API pages in severity order. A finding whose severity changes during the read can appear on two pages or on none. claudesec drops a repeat by ID and warns about a lost finding.

The token acts as me. It sees every project I can see, and it can resolve a finding. So an agent runs findings and show when it needs them, and runs resolve or reopen only after I decide. The API address comes only from the process environment, never from a .env file. A file in a repo then cannot send the token to another host.

A triage pass compares the open list with todo/. The agent adds a file and a plan for each finding that no plan cites. For each plan that cites an ID that is no longer open, it runs claudesec show and trims the plan.

The fix

A fix is a change with the security: prefix. Its description ends with two trailers:

Reported-by: Claude Security at claude.ai/security
Fixes: find_01... and find_02...

Fixes: names finding IDs, not a CWE, because a CWE names a category. The change deletes the finding file with git rm when the fix criteria hold. Some fixes also need a step on a host, such as a deploy and one job run. That finding file stays until a run on the host proves the criteria, and the plan names the step.

After the change lands, I run claudesec resolve --reason fixed. Then git log holds the reason before Claude Security shows the state. git log --grep 'Fixes: find_' lists every fix.

The reason sets what a later scan does. A later scan that finds a fixed finding again opens it again. A later scan leaves a false_positive, accepted_risk, or duplicate finding closed. The API keeps the first reason, so I run claudesec reopen before I resolve a finding with a different reason.

← All articles