Intro
dvarapala is the anti-DDoS gateway I build in Rust, and it runs in front of my own public apps (Part 40). It has grown faster than my reviews of it. I wanted a second pair of eyes that reads the whole repository on a schedule, looks for security, performance and scalability problems and features that never got finished, and files them in the way the project already works: an RFC for a design change, an issue for a bug, and a private advisory for a vulnerability.
Then, once I approve an item, I wanted the implementation done too, up to a green pull request, with the merge left to me.
This post is that pipeline: two n8n workflows on the n8n setup from Part 41, a GitHub App, a new runner tier that can use a shell, and the five bugs I hit while testing it.
TL;DR: Claude Opus reads dvarapala at the current
maincommit every Monday (security), Wednesday (performance and scalability) and Friday (implementation gaps). Each finding becomes a private draft security advisory, a public issue, or a draft RFC pull request. Nothing is implemented until I add the labelagent:implement. Twice a day a Sonnet runner with git,ghandgh stacktakes the oldest approved item, opens the pull requests by the project’s own rules, and n8n watches CI, asks for up to two fixes, and labels the itemagent:greenoragent:needs-human. The agent never merges. First runs: Opus reviews reported 1.92 to 3.33 USD each, the first implementation 0.26 USD, all on my Claude subscription.
The Figure
I drew the pipeline as an inspection line, and the one lit part is a finding
card riding the belt. The review camera on the left (Opus, read only) scans
the repo crate and drops a card, then the sorting gate swings one flap: a
security card slides into the locked box and the line stops there, a bug
drops into the issue tray and a feature into the RFC tray on the human desk.
Nothing leaves a tray until the stamp on that desk comes down on the card,
which stands for my agent:implement label or an accepted, merged RFC. The
implement station (Sonnet) then presses PR plates onto the card, one for a
bug and a stack of three for an RFC, and the CI bench probes them: red sends
the card back for a fix, green lights the top lamp and sends it to the out
tray for me to review and merge. Click the figure or press 1, 2 or 3 to run a
security, bug or RFC finding, and watch the readout in the corner for the
station, item, label and CI round. Built with the
anatomy skill.
The Situation
- n8n 2.45.7 in my k0s cluster, with
claude-runner: headless Claude Code on my Claude subscription, one Deployment per model tier (Part 41). - Claude Code 2.1.316, models
claude-opus-5-5andclaude-sonnet-5-5. - franzramadhan/dvarapala: public, Rust workspace, about 10 minutes of CI per pull request.
- dvarapala already has a written process, and the agents follow it instead
of inventing their own:
CLAUDE.md: an RFC first for anything that changes behaviour or config, fromrfc/0000-template.md, with a Test cases table; a row inROADMAP.md. A bug fix is one PR: a regression test commit, then the fix. Vulnerabilities are reported privately, and an unfixed one is never discussed in a public issue, PR or commit message..claude/skills/rfc-stack: an accepted RFC is built as a stack of PRs withgh stack: a tests layer first (the RFC’s Test cases as ignored tests), one layer per domain, then the CLI layer.CLAUDE.local.md(not in git): no localcargo build,check,testorclippy; only the cheap checks (cargo fmt, a 500-line file check,cargo deny, the UI build). CI verifies the rest.
Setup
The flow
flowchart LR
R["Review<br/>Opus, read-only<br/>Mon / Wed / Fri 08:00"] --> S{"kind"}
S -->|security| A["private draft<br/>advisory"]
S -->|bug| I["issue<br/>label agent"]
S -->|feature| F["draft RFC PR<br/>label agent"]
I --> H["me: label<br/>agent:implement"]
F --> H
H --> D["Implement<br/>Sonnet dev runner<br/>07:30 and 13:30"]
D --> C{"CI"}
C -->|red, max 2| D
C -->|green| G["agent:green<br/>I merge"]Security findings stop at the advisory. Fixing a vulnerability happens in a temporary private fork, and the pipeline does not touch that.
A GitHub App instead of my token
One App for all my projects, franzramadhan-agent-app, installed on
dvarapala:
- Repository permissions: contents, pull requests, issues and repository
advisories (read and write); checks, actions and metadata (read). No
workflowspermission, so a change under.github/workflowsstops with a note for me. No webhook, no ruleset bypass. - The private key lives in OpenBao and reaches n8n through
CREDENTIALS_OVERWRITE_DATA, never a runner pod. - Every run mints its own installation token, narrowed to one repository and only the permissions that step needs, valid for 1 hour:
JWT (RS256, iss = App id)
-> GET /repos/franzramadhan/dvarapala/installation
-> POST /app/installations/<id>/access_tokens
{"repositories": ["dvarapala"], "permissions": {"contents": "write", ...}}
-> ghs_... token
Commits from the runner carry the App’s bot identity, so it is always clear what an agent wrote.
A runner tier that can use a shell
The runners from Part 41 refuse the Bash tool, because the subscription
token is in the process environment. Implementing code needs git, gh and
cargo fmt, so I added a separate tier, claude-runner-dev, and accepted the
trade for it alone:
- Sonnet, on
buildpack-deps:bookworm-scm(digest pinned). Init containers install Node 24 and Claude Code, gh 2.103.3, the gh-stack 0.3.0 extension and skill, jq, cargo-deny 0.20.2 and rustup 1.29.2 without a toolchain, all with sha256 checks. The firstcargo fmtpulls the toolchain the repository pins. - One run at a time, 45 minutes, 200 turns, 2Gi memory limit, a 4Gi work directory and 3Gi for Rust.
- The runner only accepts repositories from an allowlist (one today), checks
them out itself and passes the per-run GitHub token to the child process as
GH_TOKEN, never logged. - dvarapala’s
CLAUDE.local.mdis not in git, so the runner copies it into the checkout from a ConfigMap, together with aPreToolUsehook that blockscargo build,check,testandclippy. - A CiliumNetworkPolicy limits egress to GitHub, the Rust toolchain host and the crates index, on top of the Anthropic API.
- Plugins: caveman (terse output), ponytail (minimal code) and pstack.
The Opus reviewer stays read-only: it gets the repository as a tarball at the
exact main commit and only Read, Glob and Grep.
Workflow 1: dvarapala review
At 08:00 on Monday, Wednesday and Friday:
- Pick the focus for the day: security, performance and scalability, or implementation gap.
- Mint a read token. Fetch the
maincommit, open items labelledagent, draft advisories, open pull requests and the RFC file names. Build a list of titles already raised (so nothing is reported twice) and the next RFC number. - Opus, up to 50 turns and 30 minutes. The prompt says what to look at for
the focus, lists what is already raised, and asks for at most 5 findings,
each verified in the code with
file:lineevidence, as one JSON object. For a feature it writes the whole RFC file from the template, its ROADMAP row and the PR body. - Parse, cap (3 security, 3 bugs, 1 RFC), strip long dashes, mint a write
token, and route:
- security:
POST /security-advisories, a private draft. - bug: an issue labelled
agent. - feature: branch
rfc/NNNN-<slug>, the RFC file, the ROADMAP row inserted after the last RFC row, a PR titledrfc: NNNN <topic>, labelledagent.
- security:
- One Telegram line per item, or “no new findings”.
The gate is a label
Labels on dvarapala carry the state: agent (raised by the review),
agent:implement (my approval), agent:in-progress, agent:green and
agent:needs-human. I only ever set one of them. For a bug I add
agent:implement to the issue. For an RFC I set its status to accepted in
the RFC PR, merge it, and then add agent:implement to that merged PR.
Workflow 2: dvarapala implement
At 07:30 and 13:30:
- Take the oldest item labelled
agent:implementwithout a busy label. An RFC PR must be merged and titledrfc: NNNN. - Swap
agent:implementforagent:in-progress, so it is never picked twice. - Mint a token (contents, pull requests and issues write; checks and actions
read) and call the dev runner with a prompt that follows the project’s
rules: for an RFC, confirm the status is accepted, then build the stack
with the
rfc-stackskill; for a bug, a branchfix/issue-N, the regression test commit, the fix, one PR withFixes #N. No local build, no merge, no waiting for CI. It answers{"prs": [...], "notes": "..."}. - Every 10 minutes, read the check runs of every PR head. Green when all
have finished without a failure. Red goes back to the dev runner with the
failing check names and a fresh token (
gh run view --log-failedfirst, then a fix in the layer that owns the code), at most twice. Still pending after 2 hours, or red after two fixes: a human looks at it. - Label
agent:greenoragent:needs-human, comment with the PRs and the runner’s notes, and send a Telegram message.
Issue 1: The Test Run That Never Started
Symptom
I test every workflow as a temporary copy with a webhook trigger instead of the schedule, and call that webhook from inside the n8n pod. The call answered 404, and n8n showed no execution at all. n8n’s log:
Received request for unknown webhook: This webhook is not registered for POST requests. Did you mean to make a GET request?
Cause
My webhook node had no httpMethod, and the default is GET. I had seen a 404
right after activating a workflow before (n8n registers the webhook a moment
later), so my first fix was a retry loop, which did nothing. The log line
said exactly what was wrong; I read it after the retries.
Fix
"httpMethod": "POST" on the test trigger.
Issue 2: 403 from a Token That Has the Permission
Symptom
The run created two advisories, then failed on the branch for the RFC:
403 - {"message":"Resource not accessible by integration","documentation_url":"https://docs.github.com/rest/git/refs#create-a-reference","status":"403"}
Cause
The App installation did have contents: write. I checked with an App JWT
(GET /app/installations/<id>), then minted a fresh token by hand and
created and deleted a test branch with it: 201, then 204. So the App was
fine, and the token n8n sent was not.
At that point the workflow used n8n’s built-in GitHub App credential. It
mints an installation token and stores it on the credential as an expirable
field, and renews it only when a request gets a 401. An installation token
keeps the permissions the App had when it was minted. Mine came from my
credential test, before I changed the App’s permissions, so it had no
contents scope. A missing permission is a 403, not a 401, so n8n never
renewed it, and would have kept failing until the token expired, up to an
hour later.
Fix
The review stopped using that credential. It mints its own tokens through a JWT credential and three HTTP nodes: a read token at the start, and a write token after Opus has answered, because Opus with retries can outlive a 1-hour token. The implement workflow keeps the built-in credential for its CI polling, which runs for more than an hour: there an expired token gives a 401, and the credential renews it.
Issue 3: The JSON Answer Broke on Its Own RFC
Symptom
Unterminated string in JSON at position 3941 (line 3 column 1469)
in the node that parses Opus’s answer, after a 74-turn review.
Cause
I asked for the answer in a fenced json block and cut it out with a lazy
regex, /```json\s*([\s\S]*?)```/. The answer contained a whole RFC
file as a JSON string, and that RFC has code blocks of its own, so the regex
stopped at the first inner fence, halfway through a string.
Fix
Take everything from the first ```json to the last fence in the
answer. Before running Opus again I read the stored execution through the n8n
API and parsed it with the new code: 4 findings, including the RFC. The
implement workflow’s two parsers got the same fix.
Issue 4: A Notification That Stopped the Run
Symptom
The performance review created three issues and then failed in the Telegram
node with Bad request - please check your parameters. Its fourth finding was
never processed.
Cause
n8n’s Telegram node sends parse_mode: Markdown when none is set. One issue
title contained max_clients, and an unpaired underscore is invalid
Telegram Markdown. With n8n’s sequential execution order, the failed node
ended the run before the next branch ran.
Fix
Send HTML with &, < and > escaped, and set the Telegram node to
continue on error. A lost notification is not worth a lost finding.
Issue 5: Test Copies That Would Not Go Away
My test script deletes the temporary workflow at the end. Two copies stayed:
- The public API answers 409 to
DELETE /workflows/<id>until the workflow is archived (POST /workflows/<id>/archive). - My poll loop waited for
finishedto be true, and an execution that ends in an error hasfinished: false. It now goes by the executionstatus.
Results
The reviews
| Run | Focus | Turns | Reported cost | Created |
|---|---|---|---|---|
| 1 | security | not recorded | not recorded | 2 draft advisories, then Issue 2 |
| 2 | security | 74 | 3.33 USD | nothing, Issue 3 |
| 3 | security | 40 | 1.92 USD | 3 draft advisories |
| 4 | performance and scalability | 55 | 2.19 USD | 3 issues, then Issue 4 |
The cost is the API-equivalent price Claude Code reports. On the subscription it is a share of the plan’s usage limit, not a bill.
The security findings are private draft advisories, and they stay out of this post until they are fixed and published. I will fix several of them in one temporary private fork and ship them in one release.
The performance review found three hot paths that hold a lock longer than they need to:
- #306: the ML tree model scores every request inside the rate limiter’s global lock.
- #307: the store
sync drains and sorts up to
max_clientsdeltas while holding the lock every request takes. - #308: the cluster heavy hitter check scans the whole shared list on every request under the cluster lock.
The first implementation
I labelled #308 agent:implement at 20:33 and ran the implement workflow:
- The dev runner took 8 turns and reported 0.26 USD. It opened
#309,
fix(engine): index cluster heavy hitters by prefix length, with two commits: the regression test, then the fix (+109, -10). - n8n polled CI three times (about 30 minutes), all 15 check runs passed or
were skipped, and #308 went from
agent:in-progresstoagent:greenwith a comment and a Telegram message. - The runner’s notes were worth reading. Nothing was built locally, so CI was
the first compile. The regression test is a timing check (20 000 misses
over 10 000 networks must finish in under 1 s), because the verdicts did not
change and only the speed can fail on the old code. It suggested moving
that gate to the criterion bench if it turns out flaky. It also added a
one-pass constructor, because building the index through the existing
addwould have been quadratic for 10 000 entries.
The merge is mine, from my own agent in the dvarapala checkout.
Lessons
- Give the agents the project’s own rules. dvarapala already had
CLAUDE.md, an RFC template and a stacking skill. Pointing the agents at them gave PRs that look like mine, with no new conventions to review. - Keep a human gate on what gets built. A review that files work is cheap; an implementation is a PR I have to read. One label per item keeps that decision with me.
- Route vulnerabilities away from public tracks by construction. The review cannot open a public issue for a security finding: the security branch only knows the advisory endpoint.
- A cached token keeps old permissions. After changing a GitHub App, mint a new token. Anything that renews only on 401 will serve a 403 until the token expires.
- Read the error the tool already printed. The webhook log said “not registered for POST” before I wrote a retry loop.
- Do not cut structured answers with a lazy regex. A model answer that embeds markdown will contain fences; slice to the last one, or ask for raw JSON.
- Notifications must not fail the work. The message is the least important node in the workflow; it should never be able to stop the rest.
References
- GitHub: franzramadhan/dvarapala
- GitHub Docs: Generating an installation access token for a GitHub App
- GitHub Docs: Repository security advisories
- GitHub Docs: Collaborating in a temporary private fork
- GitHub: github/gh-stack
- n8n: Public REST API
- n8n: Webhook node
- Telegram: Bot API formatting options
- Claude Code: Headless mode
- GitHub: wheresryan22/anatomy
