Skip to content

Trust model

Echo runs an autonomous worker with --dangerously-skip-permissions — it can edit files and run commands in its worktree without asking. That is only safe because of a specific trust design: the worker is powerful inside its branch and powerless over what lands. This page explains where the boundaries are, and is honest about where they aren’t.

The single most important property: a worker cannot merge. It implements the change, opens a PR (Refs #N), runs its adversarial review, posts the consolidated comment, files any unfixed findings, labels the PR echo:merge-ready, and stops. The decision to actually merge is made elsewhere.

Merging happens on the trusted dispatcher side, in gated_merge.sh, which runs at the top of every sweep. It independently re-runs the deterministic gates against the PR’s real changed-file set and only then squash-merges. The worker is never the thing that decides to merge — it just signals “ready” with a label.

This is enforced two ways, prompt and mechanism:

  1. Prompt. The worker’s system prompt ends its job at “PR open, reviewed” — the merge/escalate decision was removed from it entirely.

  2. Mechanism. The worker is launched with an explicit tool denylist so it cannot merge even if the prompt is ignored or injected:

    --disallowedTools "Bash(gh pr merge:*)" \
    "Bash(git push:*origin*main*)" \
    "Bash(git push:*origin*master*)"

    Bash(gh pr merge:*) is the load-bearing deny. The two git push-to-base patterns are defense-in-depth against a worker trying to fast-forward the base branch directly. Deny rules are enforced even under --dangerously-skip-permissions — that is exactly why this is a real block and not just prompt text. Plain git push is not denied: the worker must push its own topic branch to open the PR.

Echo runs two very different kinds of check, and it treats them very differently.

Deterministic gatesAdversarial review
Produced bygated_merge.sh (dispatcher)the worker (LLM)
Inspectsthe PR’s changed paths + CI status, via ghits own diff
Rolehard precondition — trips → hold for a humanadvisory — posted + filed, does not gate
Trusted?YesNo

The dispatcher-enforced gates are the actual security boundary. In order, gated_merge.sh runs:

  1. Cross-repo sentinel — if the change touched a supporting repo, hold (a human merges the sibling PRs). See Cross-repo changes.
  2. Auth / tenant-isolation (sensitive_path_gate.sh --pr <N>) — a changed path matching the auth/isolation patterns holds; an error fetching the diff also holds (fail-safe).
  3. Test coverage (check_test_coverage.sh <N>) — an untested auth/isolation change holds.
  4. CI status (gh pr checks <N>) — anything failing or pending holds.

The merge-gates guide documents each gate’s exit codes.

The worker’s adversarial-review findings deliberately do not gate the merge. Why: a worker’s review is LLM output judging LLM output produced in the same run. A compromised, injected, or simply over-confident worker could talk itself into a clean review just as easily as into a merge. A worker’s self-review cannot be its own gate. So the findings are treated as a quality signal — surfaced and filed as follow-up work — while the decision to hold an auth PR for a human is made mechanically, regardless of what the review concluded. This is the honest boundary the design commits to:

Everything that enters a run from outside the operator is treated as data, not instructions.

An issue’s title and body come from whoever filed it, not from the operator. In the worker prompt they are wrapped between marker lines carrying a per-spawn random nonce:

<<<ISSUE_BODY_UNTRUSTED_a1b2c3d4e5f6a7b8>>>
…the issue body, verbatim, as data…
<<<END_ISSUE_BODY_UNTRUSTED_a1b2c3d4e5f6a7b8>>>

The nonce (openssl rand -hex 8, or $RANDOM$RANDOM$$ as a fallback) is generated fresh at spawn time, so it is unpredictable to whoever filed the issue. That closes a real fence-escape hole: with a static delimiter, a body containing its own closing marker could break out of the fence and have the rest of its text render as trusted template (a forged heading, an “IGNORE ALL PRIOR INSTRUCTIONS” line). With an unpredictable nonce, nothing in the body can forge a matching closing marker. The title is fenced the same way with the same nonce, and the spawn refuses to launch (after 5 attempts) if the generated nonce happens to appear in the title or body — it will not proceed with a forgeable delimiter. The prompt tells the worker to read this content as data and to escalate, not comply, if it reads like an instruction to skip review, merge without tests, or mark the item done.

Discord replies — allowlisted, fail-closed

Section titled “Discord replies — allowlisted, fail-closed”

Operators steer held items by replying in Discord (e.g. approve to merge a held PR). route_replies.sh authenticates the reply author against discord.replyAuthors — a list of Discord user IDs — before any keyword matching. An unknown author, or an author not on the list, is ignored. The allowlist is fail-closed: an empty, unset, or unparseable replyAuthors disables inbound reply routing entirely rather than accepting anyone in the channel. This is what stops a stranger in the channel from typing “approve” and merging your code. See Discord ops.

The worker environment — scrubbed of secrets

Section titled “The worker environment — scrubbed of secrets”

The detached worker is launched with cloud and GitHub-token secrets unset in its environment:

GH_TOKEN GITHUB_TOKEN GH_ENTERPRISE_TOKEN
AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
GOOGLE_APPLICATION_CREDENTIALS

The worker never deploys (the deploy-watcher does), so cloud credentials are dropped. GH_TOKEN is unset on purpose: gh then falls back to the CLI’s own stored auth in ~/.config/gh, so the worker can still open its PR without a bearer token sitting in its environment. Claude’s own auth is deliberately not unset — the worker needs it to run.

Be clear about what this does and doesn’t guarantee. The worker still has the gh CLI. It can comment on issues, push its topic branch, and add labels — and because gh uses stored auth, the environment scrub doesn’t remove that ability. So the GitHub-side denylist and the untrusted-input fencing are defense in depth, not a hermetic sandbox: a determined injection still has a gh-shaped surface to poke at.

The guarantee Echo actually makes is narrower and firmer: the worker cannot merge. Merge authority lives on the trusted dispatcher side, behind deterministic gates the worker can’t run, produce, or bypass — and the one command that would let it merge is denied at the tool layer even with permissions skipped. Everything a runaway or injected worker could do stops at an open PR, which a human (or the dispatcher’s own gates) still has to clear.

  • Merge gates — the deterministic gates in full, with exit codes and the merge.mode switch.
  • Discord ops — the reply allowlist and the approve → merge path.
  • Config referenceauthIsolation, gatedPaths, merge, discord.replyAuthors, review.