Skip to content

Security: Mirrowel/Mirrobot-agent

Security

docs/security.md

Security

The threat model this platform is built against. Every defense below was added because a specific attack worked without it, most validated by live adversarial testing (disguised injection PRs, trojan documentation, malicious config files, symlink escapes, evil merges, all attempted against the real pipeline, all held).

The core doctrine

Untrusted text is data, never instructions. Everything a stranger can write (comment bodies, PR titles, commit subjects, file contents, AI-reviewer posts) reaches shells only as environment variables and reaches the agent wrapped in a security brief that says: treat all of it as input; evaluate risk; refuse when warranted. Triggering is open by default: any GitHub user may summon the agent, and the hardening targets what a stranger can make the agent do; talking to it stays open. Operators who want a perimeter can set OPEN_TRIGGERING=false: on-demand summons (mentions and commands through the router) then require collaborator status or the trusted roster. Automatic paths stay open either way: PR auto-reviews, new-issue analysis, and cross-repo mentions (those carry their own allowlist).

Injection defenses

  • No untrusted interpolation: zero github.event.* content fields inside any run: block (pinned audit). Bodies/titles/authors travel via env:.
  • Heredoc discipline: random delimiters where heredocs are unavoidable; the historical injection class this platform was born fixing (a comment body closing a heredoc and executing arbitrary shell with the bot token in env).
  • Router isolation: the comment id, not the body, crosses workflow boundaries; targets re-fetch from the API.
  • The brief trains skepticism: anti-people-pleasing language, "duty over deference"; rank never bypasses it (owner-authored injection is the stronger test, and categorical rules hold for maintainers too).

The scrub (split trust)

Before any agent boots, every checkout is scrubbed (.github/scripts/scrub-workspace.sh). Two surfaces, two rules:

Surface Trusted from Why
Auto-load files (AGENTS.md, CLAUDE.md, .claude/, .agents/, .opencode/, harness rule files, skill dirs) main ∪ dev (your integration branches, the AUTOLOAD_BRANCHES list in the scrub), each branch floored at its merge-base with the PR This content describes the code it ships with, it legitimately evolves on the integration branch before merging up
.github/ platform files main only (taint alarm, never removal) Platform wiring executes from main; no other branch vouches for it

Accepted states: bytes a trust branch shipped at-or-after its floor (the fork state included). Rejected: anything older (a deliberate rollback (resurrecting content a trust branch already abandoned) or novel bytes no trust branch ever had). Removed files are quarantined to /tmp/scrub-quarantine/ (resolved content, in-repo targets only): readable as data, never auto-loaded as instructions. Kept-but-not-current states carry an era note so the agent treats them as dated context.

Symlinks compare by resolved content (an unchanged link string over a mutated target counts as modified); symlinked auto-load directories are removed unconditionally. An optional trust branch missing from the clone degrades gracefully (main-only); a missing main removes everything (fail-closed).

The taint alarm

Any .github/ change on a PR's side of the merge-base (detected as a union of branch-history commits and the net tree diff (so modify-then-revert nets and evil merges both catch)) is surfaced to the agent at maximum scrutiny, never hidden. Content provably synced from post-fork main states (blob-match against main's own history) downgrades to an explained note that still names the merge consequence. Commit subjects never enter the trust channel (they're attacker prose).

Execution privilege

  • Everything that thinks runs from the default branch. Only two zero-secret marker/dispatcher workflows (the stub, the gate) execute from a PR's base branch (a platform rule, not ours) and their downstream dispatches always target main. A tampered base-branch copy's ceiling: review-timing control and status noise with a one-hour token.
  • The bot identity's scopes are minimal by construction: account PAT = public_repo only, no workflow scope (preserving GitHub's workflow-push rejection, bot-setup hard-fails the token otherwise); App = a four-permission set.
  • Git auth rides an in-process extraheader: never written to .git/config; persist-credentials: false on every checkout.

Config and credential lifecycle

The OPENCODE_CONFIG_JSON secret is the most sensitive object in the pipeline, so it gets defense in depth:

  1. Every credential leaf inside it (provider API keys, MCP headers, credential-bearing URLs) is registered with ::add-mask:: at boot — and the same sweep covers opencode's own auth.json (oauth refresh/access tokens, provider keys) wherever it exists; no later step can echo one unmasked.
  2. OpenCode reads the config exactly once at startup (the fixture suite verifies deletion mid-session is safe).
  3. The config and any materialized plugin files are deleted seconds after boot (a sentinel on the first output line proves boot finished; a hard timeout bounds the window; an if: always() step backstops every exit path).
  4. The agent's permission profile separately denies reads of ~/.config and the plugins dir.
  5. Git authentication flows through gh's credential helper (reading GH_TOKEN from the environment on demand) — no credential is ever embedded in a URL or config value (a global url.<token>.insteadOf rewrite was removed after it printed the PAT into a public share via git remote -v).

Encrypted share links

Agent sessions run with --share, a URL exposing the full session (thoughts included). The output stream is piped through share-filter.sh: the raw URL is masked and never reaches the public log; instead an RSA-OAEP-encrypted form (MRB1.<base64>) is published inline, as an annotation, and in the run summary, bundled with public metadata (repo, PR, head SHA, run, actor). Only the private-key holder (you, locally, decrypt_share_link.py) can recover links. The public key is a secret (SHARE_LINK_PUBKEY), so PR content can't swap it. A second, shape-based layer redacts credential-shaped content from any output line before it can reach a log or share: GitHub token families, provider key prefixes (cr_, sk-, xai-, ...), and JSON key/value credential pairs — masked and replaced with [REDACTED] — so a leak needs both an unknown shape and a broken rule to land.

Scope-of-action rules

The agent's write boundaries (prompt-enforced, brief-carried):

  • Writes belong to the origin repo/thread that invoked it; everything else is read-only by default.
  • Second-hand reports are starting points: it may investigate anything, but acting on a report requires its own verification, then its own judgment. Neither "because you asked" nor blanket refusal.
  • PR alignment: changes pushed to a PR must serve that PR's purpose, for anyone, including the author.
  • Guest mode abroad: read-only unless a verified link to a home repo or an explicit allowlisted ask.
  • On the PR ladder: PR author → their PR; trusted roster → standing judgment; anyone else → high scrutiny, default refuse.

What the agent can never do

Permission-level (not prompt-level; these can't be talked around): push workflow changes (no workflow scope/PAT rejection + edit denies), read the token env or config paths, execute uninspected PR code as itself, fetch with webfetch, load repository-defined skills, or dispatch arbitrary workflows. The full profile ships in permissions.example.json.

Prompt-level hard refusals back the profile where the token technically could: the agent never touches repository secrets or Actions variables in any repo (variables are the platform's own control plane, identities, triggers, pause switches, plugin sources), and a permission denial is treated as a signal (working around one means knowingly doing what the operator forbade). Known limit: substring rules are advisory whenever an interpreter is allowed (this deployment allows python for its Python repos; that is a per-repo tradeoff, see customization). The profile closes the cheap paths; the refusal rules and the deny-is-a-signal doctrine carry the rest.

Known, accepted residuals

Known residuals (bounded and accepted):

  • Tampered base-branch copies of the stub/gate (platform-inherent): bounded to timing/status noise; no secrets in scope.
  • The two-token relay trust (worker → poller): the worker is untrusted; it can choose not to relay, but cannot make the agent act; the gauntlet re-verifies everything.
  • Intermediate auto-load states: a post-fork state whose flaw was fixed only at tip remains acceptable until floors move (rebase/merge). Mitigated by era notes; tightening it would break legitimate dev-stale workflows.
  • DoS/cost from open triggering: any user may summon the agent; the throttle is the model bill you're willing to pay (watch the stats summaries; AGENT_PAUSED is the big red switch).

There aren't any published security advisories