Modern Agent Engineering

Build Your Own Autonomous Bot: Copy the Controls, Not the Model Loop

Four autonomous agents differ less in the model than in where each control sits. A six-step build for one repo: shadow-mode intake, a sandbox with no credentials, reviewed actions, and checks at the sandbox and at the push.

47 min read
Build Your Own Autonomous Bot: Copy the Controls, Not the Model Loop

Between September 3 and October 31, 2026, four autonomous agents were described in public: xAI’s Grok Bot, OpenAI’s dots, Anthropic’s Claude Duet, and dsh-superagent, a research prototype from the DeepSeek Harness team. Each announcement bundles a model loop, a UI and a set of controls. If you want one on your own repo, the loop is the easy part to copy. The controls are where the four differ, and where a copy can quietly drop one.

This article reads them, plus GitHub’s Copilot cloud agent as the repo-bound reference, through one question: does each control sit inside the agent’s reach, or outside it? Then it builds the smallest version for one GitHub repo in six steps, with checks at two boundaries. We ran everything that runs on a laptop. The parts that call GitHub follow GitHub’s docs and ran only against canned responses and local stand-ins. Every statement about the products is vendor-stated as of November 2, 2026, and we tested none of them.

TL;DR

  • The build below follows one shape: events in, a decision on who may start work, isolated work, a check before actions go out. The products fit it unevenly, and they differ most in where each control sits. dots and Duet say their checks sit outside the model or its environment. The Grok Bot pages we read don’t say where Auto Review runs. dsh-superagent has no separate reviewer that we found. GitHub’s docs say Copilot cloud agent’s limits are enforced by GitHub itself: one branch, no merge, workflows held until a person approves.
  • Build order: write an attention budget, run the gate (the code that decides who may start work) in shadow mode, give each task a sandbox with no credentials, publish only checked work from outside it, check two boundaries, then switch on one boring rule.
  • The privileged call belongs to a publisher: control-plane code that pushes and opens the PR with a token only it holds. We found no way to narrow a token to a branch, and the token that pushes can also call the merge endpoint. Required reviews on the default branch are the control to rely on [INFERENCE: no sentence in the docs we read says they block that call].
  • A test from inside the sandbox can’t see the second route. Code the bot pushes later runs in CI and previews, and same-repo workflows can read the secrets they reference [INFERENCE]. GitHub documents held workflow runs for Copilot and for PRs opened with GITHUB_TOKEN. For a custom App’s token we found none, and GitHub’s own note points to an App token as the way to skip the hold. So keep branch-triggered jobs free of credentials.
  • We didn’t test the products, live GitHub calls, a hostile kernel, or whether a tired reviewer approves a plausible bad PR. A checklist near the end lists what to settle before you switch on auto.

Four agents and one repo-bound reference

Grok Bot, dots and Duet are personal agents with their own cloud computer or VM. As of November 2, 2026, dots are rolling out on Pro, Business Premium and Enterprise plans in eligible markets, and Duet is a research preview on a waitlist for US Pro, Max and Ultra subscribers, with Team and Enterprise not included. dsh-superagent is a one-repo team agent that runs on the operator’s machine, as the operator’s OS user. GitHub’s Copilot cloud agent (older docs URLs, and some current ones, still say coding-agent) is a repo agent that runs in a cloud sandbox, an Actions runner or a custom environment. For a repo-bound bot it is the closest shipping reference, so it sits beside dsh-superagent in the second table.

Pages read November 2, 2026; plan tiers and enforcement change often (Grok Bot’s approvals page already differs from its October 23 Wayback capture). Unmarked cells are vendor-stated. C means we read it in the code at commit 6337fc8; the README calls it “not an official DeepSeek product”, and we read it without running it. N means not stated in the pages we read: an absence, not proof.

Personal agents:

ControlGrok BotdotsDuet
Who may start workThe owner and, on a shared Bot, teammates in their own chats; routines on events others can cause; @bot on X, for your own Bot onlyThe owner in ChatGPT, Slack or Teams; third parties: NThe owner by message; its own checks; third parties: N
Dry run for proactive workN; a Test run does real workN; proactive research is read-onlyN
CredentialsConnector tokens stay on Cursor’s backendSecure sign-in keeps them out of the model’s contextA placeholder on the agent’s machine; a proxy outside supplies the value
Action reviewModel-based Auto Review, on by defaultA separate Auto-review; ordinary read-only steps skip itA check built on the auto-mode classifier
Where the review runsNOutside what dots can changeOutside the model; also “in your own cloud computer” (two pages)
Irreversible actionsApproval cards; routine email waits as a draft. Merge: NPurchases and permanent deletes confirmed. Merge: NApproval before send, pay, sign, delete. Merge: N
CI on the bot’s codeNNN
Network egressTeams without a policy default to allow-all; the allowlist is Enterprise-onlyNN

Repo agents:

Controldsh-superagentCopilot cloud agent
Who may start workAn allowed list per source, in code (C)Users with write access; others never reach it
Dry run for proactive workobserve mode, the default (C)N; an automation level that holds issue changes for review is rolling out
CredentialsThe agent’s login sits in the operator’s home (C)The agent can’t run git push; Agents secrets reach it as environment variables
Action reviewNo model reviewer found (C)Draft PRs; a person reviews and merges
Where the review runsNone; GitHub branch protection sits outside the shell (C)In the PR; its firewall sits inside the environment
Irreversible actionsA prompt governs auto-merge; approvals are left to branch protection, and we found no check that it is set (C)Can’t approve or merge; one branch
CI on the bot’s codeNo CI config in the snapshot (C)Held until a person approves; admins can switch it off. Cloud-sandbox sessions: not stated
Network egressNone found (C)Firewall on by default, inside the environment

What the tables say, in our reading [INFERENCE]:

  • Placement differs. dots says it keeps the controls that enforce Auto-review “outside the environments dots can change”. Duet’s safety page says its approval check runs “outside the model doing the work” and that approvals and checks run on “separate machines the model can’t access”. Its product page says they “run in your own cloud computer”, and we couldn’t tell whether those are the same place. Grok Bot’s pages describe an independent review model that “should complement, not replace, least privilege” but don’t say where it runs, and enforcing it is Enterprise-only.
  • Copilot puts its limits in GitHub’s platform: one branch, no merge, held workflows. Its firewall is the exception: GitHub’s page says it covers only processes started through the agent’s Bash tool and operates inside the environment it protects. GitHub also warns that switching off the workflow hold may expose secrets to unreviewed code.
  • dsh-superagent is a gate, not a fence. Its docs describe two paths around the gate. Messages on a subscribed Thread reach its Tickets as context, which “grants no new authority”. A person’s message can resume an ended Ticket “regardless of who sent it”, under the Ticket’s existing Mandate (docs/002-authorization.md:20,127; code at src/core/intake.ts:38-51). Every session runs as the operator’s OS user (docs/006-deployment.md:19).
  • Tiers move controls. Grok Bot’s docs say its computers run on Cursor-hosted infrastructure and that separate Bots aren’t a security boundary, and the availability notes above will change. Re-check before you rely on any cell.

The spine: what sits outside the agent’s reach

Strip the UI off all five and a shape remains, though the products fit it unevenly. Events come in, a gate decides who may start work, a worker does the task in isolation, and actions leave only after a check. The build below draws it as a control plane that holds every credential and a sandbox that holds none. The control plane is the trust root: if the loop that reads sandbox output is compromised, every credential it holds goes with it, which is why the checklist at the end puts the publisher in its own process.

flowchart LR
  GH["GitHub<br/>events"] --> CP
  subgraph CP["Control plane: holds every credential"]
    direction TB
    GATE["Gate and assess:<br/>allowed list, mode,<br/>verdict if proactive"] --> LP["Agent loop:<br/>task + grant, model calls"]
    LP --> AR["Checks:<br/>branch rules, diff policy"] --> PU["Publisher:<br/>token, push, PR"]
  end
  CP <-->|"tool calls,<br/>git bundle"| SB
  subgraph SB["Sandbox: no credentials"]
    W["Worker: shell, files,<br/>no network"]
  end
  CP --> OUT["GitHub:<br/>agent branch, PR"]
  OUT -.-> CI["CI and previews:<br/>credential-free unless<br/>you add a gate"]

Terms used below:

  • event: a webhook delivery, stored once by its delivery id.
  • gate: a pure function that decides who may start work.
  • assessment: a model judgment, used only for proactive candidates.
  • task, grant: one unit of work (the durable-task primitive from Persistent Agents) and the record of who asked, under which policy.
  • sandbox: a per-task runtime with no credential and no route out.
  • publisher: control-plane code that makes the reviewed writes.

DeepSeek’s prototype calls the event a Trigger, the gate and the assessment together Eval, the task a Ticket and the grant a Mandate.

Meta’s Agents Rule of Two (October 31, 2025), which credits Simon Willison’s lethal trifecta, says that, until research can reliably detect and refuse prompt injection, an agent should satisfy no more than two of three properties in a session: [A] it processes untrustworthy input, [B] it can reach sensitive systems or private data, [C] it can change state or communicate externally. Needing all three requires supervision, such as human approval or another reliable check. Mapped onto this build, in our reading and not Meta’s: the sandbox and the loop hold A (and B if the repo is private), and neither has a push path of its own, though until you split the publisher (checklist item 7) the loop shares a process with the credentials. The publisher holds B and C, and what it writes derives from untrusted text. What bridges them is a deterministic check before the push (Step 4) and a human review after it. No person approves the push itself, and the push can start CI. That is why Step 5 has a second boundary. The model call is also an outside channel: repo content and untrusted text go to your provider (Step 1’s data row). Meta adds that meeting the rule is not sufficient against other threats, naming spam, agent mistakes, hallucinations and excessive privileges, and that it is not a finish line.

Step 1: Write the attention budget

The first artifact is a short policy, not a prompt. Review capacity is where a bot’s cost lands: Faros reports, for the most recent 12 months of telemetry from 22,000 developers across 4,000 teams, that average PR size rose 71.8%, time in QA rose 300.6%, and PRs merged without review rose 76.3% (after a 31.3% rise six months earlier). The figures are vendor-reported, their baselines aren’t defined, and they describe teams with deep AI use rather than bots. We read them as a reason to budget attention [INFERENCE].

DecisionStart withEnforced by
Who may start workAn allowed list of logins. A stranger can’t start work, and task input comes from allowed authors onlyThe gate (step 2) enforces the first half. We didn’t build the input filter
Modeobserve: record what would be accepted, accept nothingThe gate (step 2)
Proactive rulesTwo, each named, each with a one-line instructionThe assessment validator (step 2)
NeverMerge, edit .github/, write any ref except agent/<task-id>Publisher code (step 3) for refs, the diff policy (step 4) for .github/, required reviews for merges [INFERENCE]. A ruleset confining the App to agent/* is possible and untested here
VolumeA daily PR cap, a word cap per comment, a model-call budget, a task timeoutYour outbox and launcher, in code. Not built here
DataWhat leaves (task text and code go to the model provider), retention, redaction before logsYou, before the first replay
OwnerOne named person for the allowed list, the rules, the App key and the kill switchYou
Stop conditionsA minimum number of would-accept candidates per week (step 2); your pre-bot median minutes from PR open to first review, which the bot must not push up; and two of the first ten PRs closed unmerged (step 6)You, before you look at the replay

dsh-superagent checks its 100-word cap on IM replies in a schema and again at send (src/infra/text.ts:3-4, src/app/outbox.ts:29) and leaves PR volume to the operator. Check caps in code, not in a prompt. If you spec only what must survive, as After Spec-Driven Development argues, this table is the whole spec.

Step 2: Run the gate in shadow mode

Three webhook basics from GitHub’s docs come first.

  • Verify X-Hub-Signature-256, an HMAC-SHA256 of the raw body, with a constant-time compare, and refuse to start without a webhook secret (the header is absent when none is set).
  • Answer with a 2xx within 10 seconds. GitHub doesn’t redeliver failures on its own, but you can redeliver within 3 days, and a redelivery keeps the same X-GitHub-Delivery id. Record that id in the same transaction that queues the event, then answer.
  • For some events sender is the ghost user. In production, allow-list by numeric sender.id, because logins can be renamed and reused; the code below uses logins to stay readable.

GitHub’s own agent filters hidden characters such as HTML comments out of what it passes to the model. Do the same before text reaches yours.

The gate is a pure function. Explicit requests from allowed senders work in every mode, the same rule dsh-superagent states (docs/002-authorization.md:101). Unaddressed text from a stranger is ignored in every mode, because it would become task input. Save the three blocks below as gate.ts, assessment.ts and replay.ts; they run on Node 22.19, which runs .ts files directly.

export type Mode = 'off' | 'observe' | 'auto';

export type Policy = {
  self: string; // the bot's own login
  allowed: string[]; // logins that may start work, by mention or by what they write unprompted
  mode: Mode;
};

export type BotEvent = {
  id: string; // webhook delivery id: the dedupe key
  sender: { login: string; type: 'User' | 'Bot' };
  body: string;
};

export type Gate =
  | { verdict: 'accept'; strength: 'explicit'; reason: string }
  | { verdict: 'assess'; reason: string }
  | { verdict: 'ignore'; reason: string };

// GitHub handles are letters, digits and hyphens, so they need no regex escaping.
// Matches "@bot" but not "@bot-2", "me@bot" or "@botanist", and skips fenced code, inline code and quoted lines,
// so quoting an old thread does not re-trigger the bot.
function mentions(body: string, login: string): boolean {
  const text = body.replace(/```[\s\S]*?```|`[^`\n]*`|^>.*$/gm, '');
  return new RegExp(`(^|[^\\w@])@${login}(?![\\w-])`, 'i').test(text);
}

/** Pure: no clock, no network, no model. The same event and policy always give the same verdict. */
export function decide(event: BotEvent, policy: Policy): Gate {
  const { sender, body } = event;
  const login = sender.login.toLowerCase();
  const allowed = policy.allowed.some((a) => a.toLowerCase() === login);

  // Never react to bots, including ourselves: this is what stops reply loops.
  if (sender.type === 'Bot' || login === policy.self.toLowerCase()) {
    return { verdict: 'ignore', reason: 'bot-sender' };
  }
  if (mentions(body, policy.self)) {
    return allowed
      ? { verdict: 'accept', strength: 'explicit', reason: 'allowed-sender-mention' }
      : { verdict: 'ignore', reason: 'mention-from-stranger' };
  }
  // Strangers' unaddressed text never becomes a candidate: it would become task input.
  if (!allowed) return { verdict: 'ignore', reason: 'stranger' };
  return policy.mode === 'off'
    ? { verdict: 'ignore', reason: 'mode-off' }
    : { verdict: 'assess', reason: 'unaddressed-allowed-sender' };
}

The gate checks who spoke, not whose text the task will contain. If an allowed maintainer mentions the bot on a stranger’s issue, the task reads the stranger’s text. Build task input from allowed authors only and pass anything else as quoted data. That bounds prompt injection; it doesn’t prevent it.

The model only sees what the gate passes to assess, and it runs with no tools and no credentials: its one output is a verdict the validator can reject. That answer is untrusted JSON, so the validator checks types at runtime, and the schema forces the negatives: every rule gets a verdict, and a match must quote the event. dsh-superagent makes its assessment judge every enabled rule exactly once and supply evidence to accept (src/policy.ts:38-53), and its own prompt admits the declarations are “semantic judgments, not proof supplied by the schema” (prompts/admission.md:39). This version adds one mechanical check: the quote must be at least eight characters and appear in the event, which catches a fabricated one.

import type { Mode } from './gate.ts';

export type Rule = { id: string; instruction: string };
export type Verdict = { rule_id: string; matches: boolean; evidence?: string };
export type Assessment = { rule_assessments: Verdict[]; reason: string };

export type Outcome =
  | { verdict: 'accept'; strength: 'inferred'; rules: string[] }
  | { verdict: 'ignore'; reason: string; wouldAccept?: true; rules?: string[] };

const MIN_QUOTE = 8; // an arbitrary floor on a quote's length; tune it

/** The model's answer is untrusted JSON: check its types at runtime. Throws unless every rule is judged exactly once and every match quotes the event. */
export function validateAssessment(a: Assessment, rules: Rule[], eventBody: string): void {
  const known = new Set(rules.map((r) => r.id));
  const seen = new Set<string>();
  for (const v of a.rule_assessments) {
    if (typeof v.rule_id !== 'string' || typeof v.matches !== 'boolean') throw new Error('malformed verdict');
    if (!known.has(v.rule_id)) throw new Error(`unknown rule: ${v.rule_id}`);
    if (seen.has(v.rule_id)) throw new Error(`rule judged twice: ${v.rule_id}`);
    seen.add(v.rule_id);
    const quote = typeof v.evidence === 'string' ? v.evidence.trim() : '';
    if (v.matches && !(quote.length >= MIN_QUOTE && eventBody.includes(quote))) {
      throw new Error(`rule ${v.rule_id} matched without a quote from the event`);
    }
  }
  const missing = [...known].filter((id) => !seen.has(id));
  if (missing.length > 0) throw new Error(`rules not judged: ${missing.join(', ')}`);
}

/** observe records what would have been accepted; only auto accepts. */
export function outcome(a: Assessment, rules: Rule[], mode: Mode, eventBody: string): Outcome {
  validateAssessment(a, rules, eventBody);
  const matched = a.rule_assessments.filter((v) => v.matches).map((v) => v.rule_id);
  if (matched.length === 0) return { verdict: 'ignore', reason: 'no-rule-matched' };
  return mode === 'auto'
    ? { verdict: 'accept', strength: 'inferred', rules: matched }
    : { verdict: 'ignore', reason: 'observed', wouldAccept: true, rules: matched };
}

Now replay your own history. Export your last few hundred issue and PR comment events (mind Step 1’s data row: other people’s text goes to your model provider), run each through decide, call your model for the ones that come back assess, and count. This script reads two files and uses recorded answers in place of the model, so you can run it as is and then swap in your own call. The comments at the top show the shape of each file.

import { readFileSync } from 'node:fs';
import { decide, type BotEvent, type Policy } from './gate.ts';
import { outcome, type Assessment, type Rule } from './assessment.ts';

// events.json:      [{ "id": "e5", "sender": { "login": "alice", "type": "User" }, "body": "Typo in the README: 'recieve' should be 'receive'." }]
// assessments.json: { "e5": { "reason": "...", "rule_assessments": [{ "rule_id": "typo", "matches": true, "evidence": "'recieve' should be 'receive'" }, { "rule_id": "dead-link", "matches": false }] } }
// The assessments file stands in for the model call: one recorded answer per event id.
const [eventsFile, assessmentsFile, modeArg = 'observe'] = process.argv.slice(2);
const modes = ['off', 'observe', 'auto'] as const;
const mode = modes.find((m) => m === modeArg);
if (!mode) throw new Error(`mode must be one of: ${modes.join(', ')}`);
const events: BotEvent[] = JSON.parse(readFileSync(eventsFile, 'utf8'));
const recorded: Record<string, Assessment> = JSON.parse(readFileSync(assessmentsFile, 'utf8'));

const policy: Policy = { self: 'repo-bot', allowed: ['alice', 'bob'], mode };
const rules: Rule[] = [
  { id: 'typo', instruction: 'A report of a typo that states the corrected text.' },
  { id: 'dead-link', instruction: 'A report of a broken link that states the URL.' },
];

const funnel = new Map<string, number>();
const bump = (key: string) => funnel.set(key, (funnel.get(key) ?? 0) + 1);
const seen = new Set<string>();

for (const event of events) {
  bump('received');
  if (seen.has(event.id)) {
    bump('duplicate delivery'); // a redelivery: same id, no second task
    continue;
  }
  seen.add(event.id);

  const gate = decide(event, policy);
  if (gate.verdict === 'accept') {
    bump('accepted (explicit)');
    continue;
  }
  if (gate.verdict === 'ignore') {
    bump(`ignored: ${gate.reason}`);
    continue;
  }

  bump('assessed');
  try {
    const assessment = recorded[event.id];
    if (!assessment) throw new Error('no assessment recorded');
    const result = outcome(assessment, rules, mode, event.body);
    if (result.verdict === 'accept') bump('accepted (inferred)');
    else if (result.wouldAccept) bump('would accept');
    else bump(`ignored: ${result.reason}`);
  } catch (err) {
    bump(`assessment rejected: ${err instanceof Error ? err.message : String(err)}`);
  }
}

console.log(`mode=${mode}`);
for (const [key, n] of funnel) console.log(`${String(n).padStart(3)}  ${key}`);

We ran node replay.ts events.json assessments.json observe on eleven synthetic events: one explicit request, a stranger’s mention, two bot senders, a stranger’s unprompted typo report, a redelivery, and five unprompted comments from team members, two of them with malformed assessments on purpose.

mode=observe
 11  received
  1  accepted (explicit)
  1  ignored: mention-from-stranger
  2  ignored: bot-sender
  5  assessed
  2  would accept
  1  ignored: no-rule-matched
  1  assessment rejected: rules not judged: dead-link
  1  assessment rejected: rule typo matched without a quote from the event
  1  ignored: stranger
  1  duplicate delivery

In auto the two would-accepts become accepted (inferred), and the stranger’s typo report is still ignored. In off the five assessed events become ignored: mode-off. The funnel is the point. Write your stop number from Step 1 before you read yours: if fewer candidates would be accepted per week than you would review, stop here and keep the gate as a triage log. Shadow mode doesn’t measure accuracy. It records what the bot would have done so a person can judge, and dsh-superagent has no offline eval either, as far as we read.

Step 3: One sandbox per task, no credentials inside

Run the agent loop in the control plane and treat the sandbox as a tool server: it runs shell commands and edits files in a per-task checkout, and hands back outputs and a git bundle. It holds no GitHub token, no model key and no network. The control plane fetches the bundle, applies Step 4’s checks, and only then pushes.

Here is the seam, run locally. Build the image once, with network, using docker build -t bot-sandbox:local . next to this Dockerfile, so a task never installs anything. Each task gets a clone, never the control plane’s own checkout, and a container with no network. Save the script after the Dockerfile as seam.sh.

FROM alpine:3
RUN apk add --no-cache git
#!/bin/sh
# Control-plane side of the seam: hand a clone in, take a git bundle out.
# The container has no network and no credentials; the control plane inspects the bundle before anything leaves.
set -eu
work=$(mktemp -d)
git init -q "$work/control"
cd "$work/control"
printf 'You will recieve a reply.\n' > README.md
git add . && git -c user.name=cp -c user.email=cp@example.com commit -q --signoff -m 'add README'

# The sandbox gets a clone, never the control plane's own checkout.
mkdir "$work/handoff" && git clone -q . "$work/handoff/repo"

docker run --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges \
  --user "$(id -u):$(id -g)" --pids-limit 64 --memory 256m -v "$work/handoff:/work" -w /work/repo bot-sandbox:local \
  sh -c 'sed -i "s/recieve/receive/" README.md &&
         git -c safe.directory=/work/repo -c user.name=bot -c user.email=bot@example.com commit -qam "Fix typo" --signoff &&
         git -c safe.directory=/work/repo bundle create /work/out.bundle HEAD~1..HEAD'

git fetch -q "$work/handoff/out.bundle" HEAD:refs/heads/candidate
git diff --numstat HEAD candidate

It prints one tab-separated line, 1, 1, README.md: one line added, one deleted, the typo fixed on the candidate branch. git fetch from a bundle adds objects and a ref. Treat the bundle as hostile input, and don’t run git inside a directory the sandbox wrote.

Who makes the privileged call? Shipping products use two shapes.

QuestionCredential-injecting proxyPublisher
Where the credential livesOutside the agent’s machine; the agent holds a placeholderIn the control plane; the sandbox holds nothing
What the control point seesA stream of requests; review fits only if the proxy enforces policy per requestOne artifact, a diff and a PR description; nothing is sent until the checks pass
Git pushA method-and-path allowlist can’t see which ref a push updates, because ref names travel in the POST body to /git-receive-pack; a handler would have to parse the pack protocolThe code only ever writes agent/<task-id>
Seen inDuet (placeholder, proxy outside the machine); Cloudflare Sandbox outbound handlers; Infisical’s proxiesCopilot cloud agent: the agent “cannot directly run git push”
CostLess code; per-request policy is yours to writeA service to write and run

Pick the proxy when the agent must call an API itself and you can enforce policy per request. Pick a publisher when the action is a reviewable artifact, as a diff is [INFERENCE]. For the proxy options, see Keep Secrets Out of the Agent.

GitHub’s docs give you the pieces. An App signs a JWT with RS256 that expires within 10 minutes and trades it at POST /app/installations/{id}/access_tokens for an installation token that expires after an hour. You can narrow that token to named repositories (up to 500) and to a subset of the App’s permissions; we found no option for branches. Pushing a branch and opening a PR needs Contents write and Pull requests write, and GitHub lists the merge endpoint, PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge, under Contents write. So “no merge action” is a property of your publisher code, not of the token. Step 4 is where it gets enforced.

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const run = promisify(execFile);

/** One token per task: one repository, two permissions, one hour at most. */
export async function installationToken(f: typeof fetch, jwt: string, installationId: number, repo: string): Promise<string> {
  const res = await f(`https://api.github.com/app/installations/${installationId}/access_tokens`, {
    method: 'POST',
    headers: { authorization: `Bearer ${jwt}`, accept: 'application/vnd.github+json' },
    body: JSON.stringify({
      repositories: [repo.split('/')[1]],
      permissions: { contents: 'write', pull_requests: 'write' },
    }),
  });
  if (res.status !== 201) throw new Error(`token request failed: ${res.status}`);
  const payload: unknown = await res.json();
  if (payload && typeof payload === 'object' && 'token' in payload && typeof payload.token === 'string') return payload.token;
  throw new Error('token response had no token');
}

/**
 * The only ref this code ever writes is agent/<task-id>. The token travels in an HTTP header that git reads from its
 * environment, not in argv and not in .git/config, and git gets a minimal environment of its own.
 */
export async function pushBranch(workdir: string, remote: string, token: string | null, taskId: string, sha = 'HEAD'): Promise<string> {
  if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(taskId)) throw new Error(`bad task id: ${taskId}`);
  if (!/^(HEAD|[0-9a-f]{7,40})$/.test(sha)) throw new Error(`bad sha: ${sha}`);
  const ref = `refs/heads/agent/${taskId}`;
  const env: NodeJS.ProcessEnv = { PATH: process.env.PATH, GIT_TERMINAL_PROMPT: '0' };
  if (token) {
    const basic = Buffer.from(`x-access-token:${token}`).toString('base64');
    Object.assign(env, {
      GIT_CONFIG_COUNT: '1',
      GIT_CONFIG_KEY_0: `http.${new URL(remote).origin}/.extraheader`,
      GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${basic}`,
    });
  }
  await run('git', ['push', '--porcelain', remote, `${sha}:${ref}`], { cwd: workdir, env });
  return ref;
}

Two more pieces live in the same file and aren’t shown: appJwt, eight lines of node:crypto, and openPullRequest, a POST /pulls call that has no merge in it. We covered them with 26 small node --test tests across these files. The tests check the JWT against a generated public key and the token request body against a canned reply. They push to a local bare repo to confirm that agent/t-1 is the only branch created and that task ids like ../main, a/b and Main are refused. A push to a local HTTP server that logs headers confirms the token arrives in the Authorization header and never lands in .git/config (git 2.39.5). None of it ran against GitHub. The token is still in the git child’s environment, readable by same-user processes, which is why git gets a minimal one.

Stop if the push test ever creates a ref other than agent/<task-id>. One friction we hit first: a global git hook rejected our test commit for a missing Signed-off-by line, because this site requires signoff. A human sign-off certifies the human reviewed the change, so decide whose sign-off an agent’s commit carries; a bot identity with an attribution trailer may be the honest choice [INFERENCE].

Step 4: Review the action

Review the artifact, not the prose. Deterministic checks come first and a model reviewer, if you add one, after. That is the placement rule from Agent Risk Flags Are a Setting: put hard controls where scores can’t reach.

A merge-protection check, before every publish. Because the token can merge, the control plane refuses to publish unless GitHub will stop a merge. GET /repos/{owner}/{repo}/rules/branches/{branch} returns the active rules that apply to a branch and needs only Metadata read; rulesets in evaluate mode aren’t returned, so they don’t count. The check passes only when a pull_request rule requires at least one approval, and fails closed on any other answer, including a 403 or 404. The pages we read don’t say whether rulesets work on a private repo on the Free plan, so check yours. Run it before every publish, not once at start-up: rules can change in between. Two limits. The single-ruleset endpoint returns bypass_actors only to callers with write access to the ruleset, so a read-only check can’t prove the App is off the list; confirm that once by hand. And the count says nothing about who may approve. The publisher’s token holds Pull requests write, which includes submitting reviews, so require approvals from people, and consider dismiss_stale_reviews_on_push and require_last_push_approval in the same rule so an approval can’t survive a later bot push. We didn’t wire those two. As we read it, the last-push rule excludes the pusher, which is the App, not the person who asked. GitHub lists “Prevents the user who asked Copilot cloud agent to create a pull request from approving it” as a Copilot control; for a custom App we found no equivalent, so keeping the requester from approving is a team rule here.

/** Fail closed: refuse to publish unless the default branch requires a PR with at least one approval. */
export async function assertMergeProtection(f: typeof fetch, token: string, repo: string, branch: string): Promise<void> {
  const res = await f(`https://api.github.com/repos/${repo}/rules/branches/${branch}`, {
    headers: { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json' },
  });
  if (res.status !== 200) throw new Error(`cannot read the rules for ${branch} (${res.status}): refusing to publish`);
  const rules: unknown = await res.json();
  const needsApproval =
    Array.isArray(rules) && rules.some((r) => r?.type === 'pull_request' && (r.parameters?.required_approving_review_count ?? 0) >= 1);
  if (!needsApproval) throw new Error(`${branch} does not require an approving review: refusing to publish`);
}

The docs show each rule in the response only as an object, so the field names come from the rules reference. Check them against your repo’s real answer; we ran the check against canned responses.

A diff policy. The bundle is untrusted output. Compute its diff with git diff --numstat -z --no-renames <base> candidate: without the two flags, git prints a rename as old => new and quotes unusual paths, and a path denylist misses both. Refuse the bundle when it touches files that run code where secrets live, holds a binary file, or is too big for a person to read.

export type Change = { path: string; added: number; deleted: number; binary?: true };

// An example tuned to one site: files that run code where secrets live (CI, builds, package scripts) or change what runs.
const PROTECTED = [/^\.github\//, /(^|\/)package(-lock)?\.json$/, /^pnpm-lock\.yaml$/, /^\.npmrc$/, /^wrangler\.jsonc?$/, /^astro\.config\./];

/** Deterministic checks first; a model reviewer, if you add one, comes after. */
export function diffViolations(changes: Change[], maxLines = 300): string[] {
  const out = changes.filter((c) => PROTECTED.some((p) => p.test(c.path))).map((c) => `protected path: ${c.path}`);
  out.push(...changes.filter((c) => c.binary).map((c) => `binary file: ${c.path}`));
  const lines = changes.reduce((n, c) => n + c.added + c.deleted, 0);
  if (lines > maxLines) out.push(`diff too large: ${lines} lines (limit ${maxLines})`);
  return out;
}

/** Parses `git diff --numstat -z --no-renames`: NUL-separated "<added>\t<deleted>\t<path>" records. Binary files show "-". */
export function parseNumstat(output: string): Change[] {
  return output.split('\0').filter(Boolean).map((record) => {
    const [added, deleted, ...path] = record.split('\t');
    const file = path.join('\t');
    return added === '-' || deleted === '-' ? { path: file, added: 0, deleted: 0, binary: true } : { path: file, added: Number(added), deleted: Number(deleted) };
  });
}

We tested it on real git output: a rename into .github/workflows, a non-ASCII path and a binary file are all flagged, and without the two flags the rename is missed. The list is a sample. In a repo where every source file and test runs at build time, a denylist is defence in depth, and the real control is that branch jobs hold no credentials (Step 5). For a first rule, prefer an allowlist such as docs/**/*.md.

Treat what the publisher writes as output to untrusted readers: cap its length, escape @-mentions, and drop images and links to unknown hosts. A markdown image URL can carry repo content to a server when someone opens the PR [INFERENCE: GitHub-side mitigations may apply]. We didn’t build this filter. Stop if the check passes on a repo with no required review.

Step 5: Test two boundaries

A check that can’t fail proves nothing, so each check below has a run that must fail.

Boundary 1: the sandbox. The script runs inside the container and prints one line per check:

  • the environment holds only the names the launcher chose (names only, never values)
  • the usual credential files and the Docker socket are absent
  • the capability bounding set is empty
  • no-new-privileges is set and the root filesystem is read-only
  • two outbound requests fail

Assume the worker obeys an injected instruction: the script tries what such a worker would try. Save it as contain.sh.

#!/bin/sh
# Runs INSIDE the sandbox. One line per check; exit 1 if anything leaked.
leaks=0
total=0
leak() { echo "LEAK  $1"; leaks=$((leaks + 1)); total=$((total + 1)); }
ok()   { echo "ok    $1"; total=$((total + 1)); }

# 1. The environment holds only names the launcher chose (an allowlist; names only, values are never printed).
extra=$(env | cut -d= -f1 | grep -vxE 'PATH|HOME|HOSTNAME|PWD|SHLVL|TERM|_' || true)
if [ -n "$extra" ]; then leak "unexpected environment variables: $(echo $extra | tr '\n' ' ')"; else ok "environment holds only the expected names"; fi

# 2. No credential files in the usual places (relative to HOME) and no Docker socket.
found=0
for f in "$HOME/.netrc" "$HOME/.git-credentials" "$HOME/.config/gh/hosts.yml" "$HOME/.ssh/id_ed25519" /var/run/docker.sock; do
  if [ -e "$f" ]; then leak "found $f"; found=1; fi
done
[ "$found" -eq 0 ] && ok "no credential files, no Docker socket"

# 3. No capability can be gained, no privilege escalation, no writable root filesystem.
if grep -q '^CapBnd:[[:space:]]*0000000000000000$' /proc/self/status; then ok "capability bounding set is empty"; else leak "capabilities available"; fi
if grep -q '^NoNewPrivs:[[:space:]]*1$' /proc/self/status; then ok "no-new-privileges is set"; else leak "process can gain privileges"; fi
if touch /.probe 2>/dev/null; then rm -f /.probe; leak "root filesystem is writable"; else ok "root filesystem is read-only"; fi

# 4. No route out: neither to GitHub nor to anything else.
if wget -q -T 3 -O /dev/null http://example.com 2>/dev/null; then leak "reached example.com"; else ok "cannot reach example.com"; fi
if wget -q -T 3 -O /dev/null https://api.github.com 2>/dev/null; then leak "reached api.github.com"; else ok "cannot reach api.github.com"; fi

if [ "$leaks" -eq 0 ]; then echo "NO LEAKS FOUND ($total checks)"; else echo "LEAKS FOUND: $leaks of $total checks"; exit 1; fi

Run it twice. The first run has no network, no capabilities, a read-only root and a non-root user. The second is the control: default network, default capabilities, root, a fake token in the environment, and a decoy .netrc where the script looks for one. It has to report leaks.

# A fake credential file for the control run to mount where the script looks for one.
printf 'machine github.com login decoy password not-a-real-credential\n' > decoy-netrc

docker run --rm --network none --read-only --cap-drop ALL \
  --security-opt no-new-privileges --user 10001:10001 \
  -v "$PWD/contain.sh:/contain.sh:ro" alpine:3 sh /contain.sh

docker run --rm -e GITHUB_TOKEN=ghp_fake_for_control_run \
  -v "$PWD/contain.sh:/contain.sh:ro" -v "$PWD/decoy-netrc:/root/.netrc:ro" \
  alpine:3 sh /contain.sh
ok    environment holds only the expected names
ok    no credential files, no Docker socket
ok    capability bounding set is empty
ok    no-new-privileges is set
ok    root filesystem is read-only
ok    cannot reach example.com
ok    cannot reach api.github.com
NO LEAKS FOUND (7 checks)

LEAK  unexpected environment variables: GITHUB_TOKEN 
LEAK  found /root/.netrc
LEAK  capabilities available
LEAK  process can gain privileges
LEAK  root filesystem is writable
LEAK  reached example.com
LEAK  reached api.github.com
LEAKS FOUND: 7 of 7 checks

The first run exits 0 and the control run exits 1. This shows that these flags produce these values on our Docker host (28.3.3 server, arm64). The control run changes several things at once, so it shows the script can fail, not that each flag matters alone. It doesn’t test --pids-limit, --memory, seccomp or escape. Docker’s docs say --network none creates only a loopback device and that containers on a host share its kernel, so for hostile code consider a microVM [INFERENCE]. Cloudflare describes its container sandbox as one, and Grok Bot’s security FAQ says each user gets a Firecracker microVM. With no network, apk add git fails here with a DNS error and exit 1, so a task that installs packages needs a prebuilt image, pinned by digest and built in CI with install scripts off [INFERENCE]. A registry proxy would be a route out: run this test again with the proxy as the only route.

Boundary 2: the push. The sandbox test can’t see this one: the sandbox can’t reach GitHub, but the branch it produced can. GitHub says events triggered by the repository’s GITHUB_TOKEN don’t create new workflow runs, except workflow_dispatch and repository_dispatch events, and except that a pull request it opens or updates starts runs in an approval-required state. To have such runs execute “without requiring approval”, the page says to use an App installation token or a personal access token. A publisher uses an App token, so we read this as: its pushes and PRs can start CI with no approval wait [INFERENCE: the docs describe tokens used inside workflows]. push sets GITHUB_SHA to the pushed commit and pull_request sets it to the PR’s merge commit, so, in our reading, the branch controls every script, config and test the workflow calls. Fork PRs get no secrets and a read-only token. GitHub says anyone with write access can read all repository secrets, and that a workflow reads a secret only if it references one. Together, we infer that same-repo workflows can read the secrets they reference [INFERENCE: no page says it in one sentence]. A preview build on a pushed branch is the same route: the build command runs the branch’s scripts with whatever build variables the preview environment holds.

The controls, ordered by whether the branch can edit them:

ControlWhat it stopsCan the branch edit it?Status
Branch-triggered jobs hold no credentialsBot-written scripts reading secretsNot without the workflows permissionA convention; the lint below checks it
Environments with required reviewersA job reading environment secrets before a person approvesNoDocumented; public repos only on Free, Pro and Team (private repos likely need Enterprise [INFERENCE])
No workflows permission for the AppEdits to workflow YAMLNoDocumented permission; the docs don’t show the refusal [INFERENCE]
Diff policy (Step 4)Bundles touching CI, build or package filesNo: it runs in the control planeOurs
Preview builds off for agent/*Preview builds running the branch’s scriptsNoDocumented for Cloudflare Pages branch control (docs updated 2026-04-21)
if: guards and branch filters in the workflowLittleYes, if the App can edit workflowsAdvisory

GitHub’s Copilot cloud agent has the strongest version: by default, per GitHub’s docs on November 2, 2026, its workflows don’t run until a user with write access clicks Approve and run workflows. That is a Copilot setting, and the pages we read don’t say whether it applies to cloud-sandbox sessions. For a custom App’s same-repo PRs we found no native approval gate in the pages we read. A fork-based design, where the bot pushes to a fork, takes secrets away from the PR’s workflows, but the docs we read don’t say how an App owns a fork and pushes to it.

The lint reads .github/workflows/*.yml, lists the workflows a bot’s push or PR can reach, and flags jobs that reference secrets without naming an environment. It uses one dependency, yaml. Save it as workflow-exposure.ts and run node workflow-exposure.ts <dir>; with no argument it reads .github/workflows.

import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { parse } from 'yaml';

const dir = process.argv[2] ?? '.github/workflows';

// Triggers that a bot's push or PR can set off. Branch filters are not evaluated: a workflow limited to main is reported too.
const REACHABLE = ['push', 'pull_request', 'pull_request_target', 'pull_request_review', 'pull_request_review_comment', 'issue_comment', 'create', 'workflow_run', 'check_run', 'check_suite', 'status'];

// secrets.NAME, secrets['NAME'], and a bare `secrets` (toJSON(secrets), `secrets: inherit`). Over-reports on the word.
function secretRefs(value: unknown): string[] {
  const text = JSON.stringify(value);
  const names = [...text.matchAll(/\bsecrets\b(?:\.([A-Za-z_]\w*)|\[\\?['"]([^'"\\]+)\\?['"]\])?/g)].map((m) => m[1] ?? m[2] ?? '(all)');
  return [...new Set(names)].filter((n) => n !== 'GITHUB_TOKEN');
}

let exposed = 0;
for (const file of readdirSync(dir).filter((f) => /\.ya?ml$/.test(f)).sort()) {
  const { jobs = {}, ...top } = parse(readFileSync(join(dir, file), 'utf8'));
  const on = top.on;
  const names: string[] = typeof on === 'string' ? [on] : Array.isArray(on) ? on : Object.keys(on ?? {});
  const reachable = names.filter((n) => REACHABLE.includes(n));
  console.log(`${file}: ${reachable.length ? `reachable from a bot branch via ${reachable.join(', ')}` : 'not reachable from a push or PR'}`);
  if (reachable.length === 0) continue;

  const parts: [string, unknown][] = [['(workflow-level)', top], ...Object.entries<unknown>(jobs)];
  for (const [name, part] of parts) {
    const secrets = secretRefs(part);
    const gated = typeof part === 'object' && part !== null && 'environment' in part;
    const verdict = secrets.length === 0 ? 'no secret reference found' : gated ? 'gated: check the environment has required reviewers' : 'EXPOSED';
    if (verdict === 'EXPOSED') exposed++;
    if (name !== '(workflow-level)' || secrets.length > 0) console.log(`  ${name}: ${secrets.join(', ') || 'no secrets'} -> ${verdict}`);
  }
}
process.exit(exposed > 0 ? 1 : 0);

This site has no .github/ directory, so we ran it on six fixture workflows:

  • ci.yml: tests, plus a preview deploy that uses a Cloudflare token on every push
  • cron.yml: a nightly job
  • deploy.yml: a deploy through an environment
  • lint.yml: a lint-only job
  • target.yml: a pull_request_target workflow that reads toJSON(secrets)
  • top-env.yml: a workflow-level env with a bracket-syntax secret
ci.yml: reachable from a bot branch via push, pull_request
  test: no secrets -> no secret reference found
  preview: CLOUDFLARE_API_TOKEN -> EXPOSED
cron.yml: not reachable from a push or PR
deploy.yml: reachable from a bot branch via push
  deploy: CLOUDFLARE_API_TOKEN -> gated: check the environment has required reviewers
lint.yml: reachable from a bot branch via pull_request
  lint: no secrets -> no secret reference found
target.yml: reachable from a bot branch via pull_request_target
  label: (all) -> EXPOSED
top-env.yml: reachable from a bot branch via push
  (workflow-level): PROD_TOKEN -> EXPOSED
  build: no secrets -> no secret reference found

It exits 1 because of the three exposed lines. Branch filters aren’t evaluated, so deploy.yml, which a real filter might limit to main, is listed too; check filters by hand. The lint over-reports on filters and on the word secrets, and it under-reports on what it doesn’t model: OIDC (id-token: write), a write-capable GITHUB_TOKEN, self-hosted runners, reusable workflows, secrets read by scripts the workflow calls, and default-branch workflows that put bot-written text, such as a PR title, into a run: line. “No secret reference found” is not “safe”.

The live version: on a throwaway copy of your repo, with canary secret values only and the App installed only there, push agent/probe with the publisher’s token and open the Actions tab. Every job that starts is a job the bot’s code can run. A job that holds a secret and starts without waiting for approval is a failure. We did not run this; it needs a throwaway repo and an App.

Neither check says a plausible-looking bad PR is safe. That job belongs to the reviewer, and to the caps from Step 1.

Step 6: Start with one boring rule

Switch on auto for one rule, with the daily cap from Step 1. Take the typo rule from Step 2: it is narrow, its action is one small PR limited to docs, and the funnel tells you how many candidates to expect per week. Read the review time per PR for a few weeks. Stop if the median minutes from PR open to first review rise above your pre-bot median from Step 1, or if two of the first ten PRs are closed unmerged.

If a rule needs no judgment, make it a CI check instead. This site’s CLAUDE.md says to bump updated when you edit an English article, so its Spanish translation shows as stale. We checked what a forgotten bump does, on a temp copy of one article pair: after a prose-only edit, npm run i18n:status still said current and check-translation passed; after bumping updated, it said stale. A short CI check catches that better than a bot would.

Before you switch on auto

  1. The gate applies the allowlist to proactive events, and a test proves a stranger’s matching comment is never accepted (inferred) (Step 2).
  2. The publisher writes only agent/<task-id>. Decide whether a ruleset also confines the App to that namespace, and test it on a throwaway repo; we haven’t.
  3. A pre-publish check asserts required approvals, stale-review dismissal and last-push approval; we built only the first, so extend it. Decide whether the person who asked may approve the PR. “Allow GitHub Actions to create and approve pull requests” is off [INFERENCE: whether such an approval would count isn’t documented in what we read]. The App isn’t on a bypass list: verify once on a throwaway repo.
  4. Branch-triggered CI holds no credentials. The lint passes, and you ran the live probe once.
  5. A halt flag that the gate and the publisher both check, and a rehearsed runbook. Set the flag. Suspend the App installation or delete its private key. Revoke the live token (DELETE /installation/token revokes the token used to call it). Close the PRs, delete the agent/* branches, and purge the event queue before resuming.
  6. Model-call, token, timeout and PR budgets are enforced in code.
  7. The publisher runs as a separate process or user and is the only holder of the App’s private key, kept in a secret manager.
  8. You decided what leaves to the model provider, how long logs live, and who owns the residual risk of a plausible bad PR.
  9. Text going in and out is filtered: task input comes from allowed authors only, hidden characters are stripped from it, and the publisher removes @-mentions, images and links to unknown hosts from what it writes. We built none of these filters.

Build or adopt

Hosted runners already cover part of this. Facts as of November 2, 2026 from each vendor’s docs, and all of them move.

  • Copilot cloud agent: write-access triggers, one branch, a human merge, held workflows (see the repo-agents table above). GitHub’s page says the new Copilot in the cloud experience has been available to paid plans since October 28, 2026, subject to organization and enterprise policy, while Free plans keep the older Copilot Chat experience. It calls the older one “Copilot cloud agent plus Copilot Chat” and says Copilot works only with repositories hosted on GitHub. The security pages we cite still say “cloud agent”, and they don’t say whether the workflow hold applies to cloud-sandbox sessions.
  • Claude Code Action: runs in your Actions job, with the model key and a GitHub token in the same job (the Claude GitHub App’s, unless you pass github_token). Per the action’s FAQ, the rules to push only to the branch it was invoked on and never to force push or perform destructive operations are “enforced in Claude’s system prompt”. The action is at v1, but one page in its repo (docs/usage.md, read November 2, 2026) still says “currently in beta”.
  • Codex cloud: a per-task VM; network secrets reach programs as placeholders that a proxy substitutes, for HTTPS on port 443. OpenAI lists GitLab and self-hosted GitHub Enterprise Server as not currently supported and on its roadmap, and says it plans to deprecate Codex Cloud (Legacy), with no date. A task requested by mentioning @codex on a PR starts a legacy cloud chat per its GitHub page, so we don’t know that the placeholder secrets apply to it. That page says Codex “can push a fix back to the branch when it has permission to do so” and that review rules don’t replace branch protections. We found nothing on Codex’s branch scope, who may use @codex on a PR, or workflow approval.

None of the three documents a dry run for proactive work. Adopt when the work is GitHub-only, write-access triggers are enough, and you accept the vendor’s controls. Build when you need admission rules of your own, shadow-mode evaluation of proactive work, a credential policy you control, sources beyond GitHub, or when learning is the goal. Either way, check the same two boundaries.

If you build, skip what the personal agents ship: your own panel (GitHub issues and PRs are the panel), voice, group chats, per-user memory, and a router and worker split. dsh-superagent’s main session routes and a separate worker implements (docs/004-prompts.md:77-78), and every session runs with danger-full-access (docs/006-deployment.md:19), so in our reading the split isn’t a privilege boundary. Second-hand reports say its speed numbers come from a synthetic simulation, and we couldn’t open the design article from an official page, so we don’t use them.

What we don’t know

  • The products. We tested none of the five. Where Grok Bot’s Auto Review runs isn’t stated in the pages we opened, and Duet’s two pages word where its checks run differently. Anthropic’s Managed Agents page, last modified 2026-10-30, says two things: the repo token is wired into the sandbox’s git remote, and tokens should never be reachable from the sandbox. It doesn’t say how both can hold, so we count it as neither design.
  • GitHub, for a custom App. We don’t know whether required reviews block the merge call, whether an App’s own review counts toward the approval rule, or what current_user_can_bypass returns for an installation token. We also don’t know how an App owns a fork, or what a push without the workflows permission looks like. The publisher and the check ran against canned responses and local stand-ins, not against GitHub.
  • The same-repo secrets route. We inferred it from the docs. Test it on a throwaway repo. We also didn’t test whether an environment’s deployment-branch restriction keeps agent/* away from that environment’s secrets, and the lint doesn’t model default-branch workflows that put bot-written text into a run: line.
  • A hostile kernel. Docker shares the host’s. We tested network, capabilities, files and the environment, not escape, seccomp or resource limits.
  • The reviewer. A plausible bad PR gets past both checks. Review capacity, per the Faros numbers, is the likelier limit, and prompt injection is bounded here, not prevented.
  • What we left unbuilt: the outbox and its caps, the halt flag, key custody, the input and output filters, the stale-review and last-push checks, and the model provider’s data terms. The checklist names each.

Sources

Products, all accessed 2026-11-02 unless dated:

GitHub mechanics, undated docs, accessed 2026-11-02:

Security framing, containers, hosting, data: