Threat analysis: sandbox, deterministic scanners, Issue→Draft PR and acceptance criteria ​
Status: signed off (2026-10-12). Extends threat-model.md. Required before any Issue→PR implementation. FIX_FEATURE_SIGNED_OFF is true: repositories may opt in to fix.mode other than off through the policy API; the default stays off for every repository. The same review covers the sandbox (zone Z3), the deterministic scanners and the acceptance-criteria check.
Reviewed and signed off by Leopold (maintainer), 2026-10-12; fix limits to be revisited with real usage.
Review fixes (2026-10-15): the scope row "Review fixes", FX13 to FX17, §7b and the new residual risks in §8 await the maintainer's sign-off. Until then REVIEW_FIX_SIGNED_OFF is false: the API refuses review fix requests, the worker records them as ineligible (not_signed_off) and the publisher treats them as off.
1. Scope and capabilities ​
| Feature | Trigger | What it may do | What it may never do |
|---|---|---|---|
| Sandbox (Z3) | A signed job from the worker | Run one allowlisted image (by digest) with a fixed command over a copy of a repository snapshot; return exit code, capped output and named artefacts | Hold tokens, secrets or credentials; reach the network (default); reach the control plane, Valkey, Postgres, the Docker socket, the host filesystem or other jobs |
| Deterministic scanners | Review of a PR (features.sast.mode not off) | ast-grep with the Quavon Apache-2.0 rule pack, gitleaks, bandit, gosec, Ruff, ESLint with eslint-plugin-security (and bring-your-own images an instance admin allowlisted), inside the sandbox, on the changed files of the head commit; findings on changed lines (± context) join the review with a "deterministic" badge; optional SARIF upload to GitHub code scanning | Execute repository-provided scanner configuration outside the sandbox; post anything except through the publisher; ship Semgrep/Opengrep or GPL scanners in the default images |
| Fix agent | Label from fix.triggerLabels or the issue comment @<app> fix, by a user with write or admin permission on the repository | Plan, read and search the base commit, edit files in an in-memory overlay, run the policy's setup/test/lint commands in the sandbox, produce a patch and a summary | Choose commands, run code outside the sandbox, see tokens, touch off-limits paths, push, open pull requests, merge, comment |
| Fix publication (Z2) | Approval in the dashboard (approval) or directly (auto, private repositories only) | Through the Fixer app: create one new bot branch quavon/fix-<issue>-<short> from the base commit with exactly the validated patch, open one draft PR against the default branch | Push to existing non-bot branches or the default branch, force-push, merge, approve, mark ready for review, change settings, touch workflows |
| Review fixes | Apply fixes or Fix on a review run in the web interface, by a repo:manage user whose code-host identity (same host, login and id) currently has write or admin permission; fix.reviewFixes not off | The fix agent works on the pull request head with the review findings as input. Delivery stacked_pr (default): one bot branch quavon/fix-<pr>-<short> and one draft PR into the pull request's head branch. Delivery commit (only with fix.reviewFixes: 'commit'): one fast-forward commit on the head branch of a same-repository pull request, created with GraphQL createCommitOnBranch and expectedHeadOid | Touch forks, the default branch or protected branches, force-push, push when the head moved since the review, merge, approve, comment |
| Acceptance criteria (AC) check | Review of a PR that links an issue (features.acceptance.mode not off) | Extract criteria from the linked issue, judge each against the diff, report met/unmet/unclear with evidence in the review summary and check run (never failing) | Block merges, comment separately, follow links, read other repositories |
The fix agent and the AC check run in Z1 (model calls through @quavon/llm, read-only tools, no write token). Repository code runs only in Z3. Every external write goes through the publisher (Z2).
2. Actors and assets ​
- Issue authors and commenters (anyone on public repositories): fully untrusted. They control the issue text the fix agent and the AC check read.
- Repository contents (code, tests, build scripts, configs,
AGENTS.md): untrusted, even on the base commit. Tests and build scripts are arbitrary code. - Users who trigger fixes: must hold
writeoradminpermission (getCollaboratorPermission, decided only onpermission ∈ {admin, write}as CH5), checked when the job runs and again before publishing. - Maintainers approving patches in the dashboard:
repo:review, audited. - The model: treated as an attacker-controlled component once it has read untrusted text.
- Operators: instance admins configure the sandbox backend, image allowlist, registries allowlist and BYO scanner images.
- Assets: installation tokens (review and Fixer apps),
QUAVON_ENCRYPTION_KEY,QUAVON_SANDBOX_SIGNING_KEY, database and Valkey credentials, LLM keys, other tenants' code and results, the customer's CI secrets (reachable through workflows that run on the bot branch), the host running the sandbox.
3. Sandbox design (fixed by this analysis) ​
Separate service.
apps/sandbox-runneris the only component with container-runtime access (Docker Engine socket, or/dev/kvm+ jailer for Firecracker). It has no database connection, no installation tokens, no LLM keys and no encryption key. It runs as its own container/service with its own Valkey user.Internal authenticated queue. Worker → runner jobs travel on the BullMQ queue
sandboxunder the prefixquavon-sandbox(attempts 1, no stalled-job redelivery). Every job is aSignedSandboxJob: HMAC-SHA256 overquavon-sandbox-job:v1+\n+ the RFC 8785 canonical JSON of the job, keyQUAVON_SANDBOX_SIGNING_KEY(*_FILE, ≥ 32 bytes, rotation via a previous-keys list, key id = first 16 hex of SHA-256 of the key), constant-time comparison. Results are signed the same way withquavon-sandbox-result:v1and carry the runner id. The runner rejects unsigned, expired (notAfterpast or more than 15 min ahead, 30 s clock skew), replayed (SET NXon the job id until expiry) or schema-invalid jobs before touching the runtime; a rejected replay is a failed run, never a retry. The runner authenticates to Valkey as the ACL usersandboxlimited to~quavon-sandbox:*and&quavon-sandbox:*and the commands BullMQ needs. Why both: the ACL limits what a compromised runner can read (no tenant job payloads, no sealed token cache); the signature makes sure only the worker can start containers even if another component or a misconfigured ACL can write to the sandbox keys. Pools: the key is per runner pool; all hosted runners form the pooldefault. A runner can forge jobs and read snapshots of its own pool only. A future customer-run runner gets its own pool (queue prefixquavon-sandbox-<pool>, key, ACL user), so it never sees other pools.Images by digest only. A job names an image class (
scanners,node,python,go, orbyo:<name>). The runner resolves it from its read-only lockfile (images.lock.json, baked into the runner image, BYO entries added by the instance admin) torepository@sha256:<digest>and refuses anything else. Tags are never pulled. The resolved digest is returned and stored insandbox_runs.image_digest.Runtimes.
docker-runsc(gVisor) is required in production; on hosts without nested virtualisation (the netcup k3s host has novmx/svmand no/dev/kvm) runsc uses its defaultsystrapplatform.firecracker(jailer-launched microVM per job) is for bare-metal/KVM hosts.docker-runc-insecureis for development and tests only: it is accepted only whenNODE_ENVis notproductionandQUAVON_SANDBOX_ALLOW_INSECURE=true;quavon doctorreports it as a failure in production and as a warning elsewhere.firecrackerwithout a usable/dev/kvmfails closed at startup and indoctor; there is never a silent fallback to another backend.k3s. The runner drives a Docker Engine, so on k3s (containerd, no dockerd) it runs on a dedicated, tainted sandbox node that has Docker Engine with
runscregistered (/etc/docker/daemon.jsonruntimes.runsc, platformsystrap); the runner pod tolerates the taint, mounts that node's/var/run/docker.sockand is the only workload with it. Sandbox containers are Docker containers on that node, not Kubernetes pods. A Kubernetes-native backend (pods withruntimeClassName: gvisorvia a containerdrunschandler) is part of the Helm chart.Container settings (Docker backends).
HostConfig.Runtime=runsc(orrunconly in insecure mode; the runsc platform is set by the daemon'sruntimeArgs, and the runner'sQUAVON_SANDBOX_RUNSC_PLATFORMis only checked againstdocker info),NetworkMode: none(egressnone) or the internal egress network,ReadonlyRootfs: true,CapDrop: [ALL],SecurityOpt: [no-new-privileges:true](+ default seccomp),User: 65532:65532,PidsLimit,Memory=MemorySwap,NanoCpus,Tmpfsfor/work(exec,nosuid,nodev,size=),/outand/tmp(noexec,nosuid,nodev,size=),HOMEunder/work/.home,Ulimits(nofile,fsize),IpcMode: privatewith a smallShmSize, no bind mounts, no volumes, no devices, noPrivileged, no host init (the entry program is PID 1 and kills every other process between steps and at the end), removed after the result is collected, labels with job id only.Snapshot transport (no shared filesystem). The runner never shares a filesystem or volume with the worker. The worker builds the snapshot itself: a gzip tar of the work tree at the commit (from its hardened checkout, I6), always without
.git, symlinks, devices, hard links or paths outside the root, capped (MAX_SNAPSHOT_BYTEScompressed, entry count); the fix agent's edits travel as a second snapshot (overlay) applied after the setup step. Each snapshot is encrypted with AES-256-GCM under a per-blob key (HKDF-SHA256 of the pool key, salt = blob id, infoquavon-sandbox-blob:v1), 1 MiB chunks with a fresh 12-byte IV each and AADquavon-sandbox-blob:v1:<tenantId>:<blobId>:<chunk>/<count>(no reordering or truncation), stored underquavon-sandbox:blob:<blobId>:<n>in Valkey with a TTL ofMAX_JOB_TTL_MS; the signed job carries{tenantId, blobId, keyId, sha256, bytes}of the compressed plaintext. The runner fetches, decrypts, checks the SHA-256 and the tenant, deletes the chunks after the job and streams the tar into the sandbox; the entry program extracts it into the/worktmpfs. Artefacts come back inside the signed result on the queue (capped). No.gitdirectory, credential helper, token or remote URL ever enters the sandbox. The runner pulls jobs from the queue (BullMQ worker); nothing connects into the runner, so a customer-run runner can be added later without changing the contract.Artefacts out. The entry program streams a tar with
exit.json,stdout.log,stderr.logand the requested artefact names to stdout. The runner treats it as untrusted: regular files only, names only from the request, per-file and total caps (outputBytes), UTF-8 text only, everything else dropped and the job markedoutputTruncated.Egress.
noneby default (only loopback; Firecracker: no network device).registriesis an explicit opt-in per repository (fix.egress) that also requires the instance-level allowlist (QUAVON_SANDBOX_REGISTRIES), and it applies only to the setup step (setupCommand, run on the unmodified base tree before the agent's overlay is applied). For that step the container joins the internal networkquavon-sandbox-egress, whose only member is thesandbox-egressproxy (separate container, no Docker socket, no secrets, read-only root). The runner issues a random per-job proxy credential (HTTPS_PROXY=http://job:<token>@sandbox-egress:<port>) and registers{hosts = job registries ∩ instance list, notAfter}underquavon-sandbox:egress:<token>; the proxy (Valkey useregress, read-only on that pattern) acceptsCONNECT host:443only with a valid token and an exact hostname from that list, resolves DNS itself and refuses loopback, private, link-local, CGNAT and metadata addresses. When the entry program reports the end of the setup step (or the setup budget runs out) the runner deletes the token, so the run step (with the agent's changes) has no egress; the run step's environment never contains the proxy variables. Plain HTTP and other ports are refused. Firecracker: tap device in a per-VM network namespace with nftables allowing only the proxy address and port, removed after setup.Placement. Single-server self-hosting may run the runner with gVisor on the same host as the control plane; residual risk: a sandbox escape lands next to Postgres, Valkey and the keys. The hosted (multi-tenant) edition must run the runner on a dedicated sandbox node that holds no database, no encryption key and no provider tokens, connects to Valkey over WireGuard and uses the
sandboxACL user (sandbox queue, results and blobs only). gVisor is acceptable there at the start; Firecracker on bare metal is preferred.quavon doctorin the enterprise edition fails when the runner reports the same host as the API/worker.Limits. CPU, memory, pids, wall clock, tmpfs/disk size and output bytes per job, clamped by the runner to
SANDBOX_LIMIT_CEILING; runner concurrency limit; per-repository and per-tenant caps on fix runs (§5); SAST runs at most once per review run.
4. Fix agent design (fixed by this analysis) ​
- Actions. The agent loop runs in the worker and has exactly these tools:
list_files(dir),read_file(path, startLine?, endLine?),search(pattern, pathGlob?),edit_file(path, oldText, newText),create_file(path, content),delete_file(path),run_tests(),run_linter(),finish(summary). Read and search work on the worker's checkout of the base commit plus the overlay (bounded, path-checked, as the review tools). Edits change only an in-memory overlay; the checkout on disk is never modified.run_tests/run_lintertake no arguments: they run one sandbox job with the base snapshot and an overlay snapshot of the edits: the policy'ssetupCommand(optional, the only step that may have registry egress) runs on the unmodified base tree, then the entry program applies the overlay and runstestCommand/lintCommandwithout egress (each as/bin/sh -c <command>). The model can never choose a command, image, egress or limit. Commands come from the repository policy (set by a maintainer in the dashboard), never from the repository or the issue. - Patch source. The patch is computed in the worker from the overlay against the base blobs (
FixFileChange[]with the base blob SHA of each changed file) and rendered as a unified diff. Nothing produced by code running in the sandbox (generated files, snapshots, formatter output) becomes part of the patch; sandbox output is only reported as test results. - Limits.
maxSteps(default 30),maxSandboxRuns(default 6), wall clock (default 20 min), model budget (BudgetExceededError→ runfailedwith reason, not retried),maxFiles(default 10) andmaxChangedLines(default 300), file size ≤ 256 KiB, UTF-8 text only.
5. Threats and controls ​
| # | Threat | Control | Test |
|---|---|---|---|
| SB1 | Sandbox escape to the host via kernel or runtime bugs | gVisor runsc (systrap on hosts without KVM) or Firecracker microVM; read-only root, no capabilities, no-new-privileges, seccomp, non-root uid 65532, no devices, no bind mounts; dedicated node for the runner in k3s/Kubernetes | escape smoke suite (runc locally, runsc and Firecracker in CI): mount, ptrace of other pids, write outside /work//tmp, /proc/1/root, raw sockets, /dev enumeration all fail |
| SB2 | Secrets or tokens reachable in the sandbox | The runner never forwards its own environment; container env = the job's allowlisted keys + fixed PATH/HOME; job env keys from SandboxEnvKey only, values checked against the secret patterns of findSecrets; no .git, no credential helper, no mounted secrets; the runner itself holds only its Valkey password and the signing key, neither of which is mounted into jobs | escape suite enumerates /proc/self/environ, /proc/1/environ, env, /run/secrets, ~/.ssh, ~/.gitconfig, /work/.git, the Docker socket path and cloud metadata (169.254.169.254); all absent or unreachable; the canary secrets placed in the runner's and worker's env never appear in any artefact |
| SB3 | Network exfiltration from repository code | Egress none by default; registries only for the setup step on the unmodified base tree, through the allowlisting proxy with a per-job token revoked before the run step (CONNECT 443, exact hostnames, private ranges refused); no DNS in the sandbox. Residual: setup runs the repository's own install scripts with registry access (same exposure as the repository's CI); registries accept uploads, but the sandbox holds no credentials | escape suite: curl, raw TCP/UDP, DNS lookups fail with none; with registries, only allowlisted hosts connect, metadata and private IPs are refused, plain HTTP refused |
| SB4 | Forged jobs (start arbitrary images or commands through Valkey) | HMAC-signed jobs with expiry and replay check; image allowlist by digest; runner ACL user; schema validation | runner unit tests: unsigned, wrong key, expired, replayed, unknown image, tampered field → rejected without a runtime call |
| SB5 | Compromised runner pivots into the control plane | Runner has no DB access and no tokens; Valkey ACL limited to the sandbox prefix; results signed and re-validated by the worker; runner only on the sandbox-control network (Valkey only), never on backend | compose test: runner cannot reach Postgres or the API; ACL test: GET of a non-sandbox key is denied |
| SB6 | Container-runtime access of the runner (Docker socket = root on the host) | Runner is the only service with the socket; it exposes no network port; it accepts only signed jobs; on k3s it runs on a dedicated tainted node; the socket path is not configurable from jobs | doctor check: only the runner has the socket mounted (compose lint test) |
| SB7 | Resource abuse (fork bombs, memory, disk, endless output, long runs) | PidsLimit, memory = swap limit, CPU quota, tmpfs size=, fsize ulimit, wall-clock kill by the runner (not by the guest), output cap with truncation, runner concurrency limit, sandbox runs per fix run, fix runs per issue/repository/tenant per day | escape suite: fork bomb, dd to fill tmpfs, 1 GiB stdout, sleep beyond wall clock → limited, killed, timed_out |
| SB8 | Malicious artefacts (huge, binary, path traversal, symlinks in the output tar) | Runner parses the output tar with caps, accepts only regular files named in the request, UTF-8 only; the worker re-validates SandboxResult with zod | runner tar parser tests with crafted archives |
| SB9 | Cross-tenant leakage of snapshots or results | Snapshot blobs are encrypted per blob, bound to the tenant in the signed job and verified by SHA-256; chunks expire (TTL) and are deleted after the job; one container/VM per job, nothing reused (Firecracker snapshot restore reuses only the read-only base image memory); sandbox_runs, sast_*, fix_runs, ac_checks under forced RLS | runner test: snapshot ref of another tenant rejected; RLS tests |
| SB10 | Logs leak repository output or secrets | Runner logs job id, purpose, image class, digest, status, duration, byte counts only; never commands' output or env; stdout/stderr tails stored only in fix_runs.test_results (capped, secret-scanned and redacted) | telemetry assertions in runner tests |
| SB11 | Insecure fallback used in production | docker-runc-insecure refused when NODE_ENV=production or QUAVON_EDITION=enterprise; firecracker without /dev/kvm fails closed; doctor shows the effective backend, runtime and platform | config and doctor tests |
| FC1 | KVM exposure of the Firecracker host | Firecracker only on bare-metal/KVM hosts operated for this purpose; jailer with a unique uid/gid per VM, chroot, new PID and mount namespaces, cgroup v2 limits (cpu.max, memory.max, pids.max), fsize/no-file rlimits; Firecracker's built-in seccomp filters left enabled; Firecracker version pinned and updated | Firecracker integration suite (KVM CI runner): jailer uid differs per VM, chroot contains only the expected files, seccomp level reported as default |
| FC2 | Guest agent protocol abused (guest is untrusted) | The host only reads the guest's output tar (same parser and caps as SB8); no host-side command is ever derived from guest data; per-connection byte and time limits; unknown or malformed data ends the VM | protocol fuzz tests with a fake guest |
| FC3 | Shared host directories or images leak data between VMs | No shared writable host directories; rootfs and kernel read-only and digest-pinned; per-job scratch drive and chroot deleted after the VM exits; snapshot memory files are read-only base images without tenant data | cleanup test: no files left in the chroot base after a run, including after a killed VM |
| FC4 | Identical state after snapshot restore (same RNG/ASLR across clones) | Snapshot restore optional; the host sends fresh random bytes with each run and the guest agent reseeds; restored VMs never hold tenant data from the base image; residual ASLR similarity accepted (nothing secret inside) | integration test: two VMs restored from the same snapshot read different bytes from /dev/urandom after reseeding |
| SC1 | Repository config files or inline suppressions steer scanners (exec in ESLint configs, ruff.toml, .gitleaks.toml/.gitleaksignore, # nosec, # noqa, eslint-disable, gitleaks:allow) | Default scanners run with Quavon-owned configs only and ignore inline suppressions: eslint --no-config-lookup --no-inline-config -c /opt/quavon/eslint/security.config.js, ruff check --isolated --ignore-noqa, bandit --ignore-nosec, gosec -nosec (with GOTOOLCHAIN=local, GOFLAGS=-mod=mod), gitleaks --config /opt/quavon/gitleaks.toml --gitleaks-ignore-path /opt/quavon/empty --ignore-gitleaks-allow, ast-grep scan -c /opt/quavon/ast-grep/sgconfig.yml (Quavon rules do not honour ast-grep-ignore comments: findings carrying them are still reported); all scanners run only inside the sandbox | scanner wrapper tests with a hostile eslint.config.js and .gitleaks.toml in the repository |
| SC2 | Forged or noisy deterministic findings | Findings are filtered to changed lines (± contextLines), deduplicated by fingerprint, capped (maxFindings), shown with the tool and rule id; their messages are untrusted text (sanitised by the publisher, never instructions to the model; passed to the model only inside untrusted()); SAST findings never change review mode, thresholds or other findings | SAST normalisation fixtures; review eval shows no precision regression |
| SC3 | SARIF upload writes outside the intended scope | Opt-in (sast.uploadSarif), separate provider feature code_scanning (security_events: write, review app; disabled until the installation approves it), only through the publisher, SARIF regenerated from stored normalised findings (not the raw tool output), size capped, one upload per review run (idempotency key) | publisher tests |
| FX1 | Unauthorised trigger (issue author without write access labels or comments; labels applied at creation by issue templates) | Only labelAdded events and @<app> fix comments (mention = the app's own slug) by a non-bot actor whose current permission is admin or write; issue authors get no special rights; GitHub also sends issues.labeled for labels set at creation (including template labels) with the creator as sender, which the permission check rejects for outsiders; permission checked when the job runs and again by the publisher | eligibility tests; permission matrix (nock) |
| FX2 | Prompt injection in the issue or repository makes the agent exfiltrate data (curl $TOKEN, read ~/.ssh, print env into the PR) | No secrets anywhere the agent can read: the worker checkout has no credentials on disk (I6), the sandbox has none (SB2); the agent cannot run arbitrary commands; network denied (SB3); test output in the PR body is truncated, secret-scanned and shown as a code block; the publisher secret-scans the patch and the body and blocks on a hit | exfiltration suite (fix-exfil-smoke-v1 + escape suite) at 0 violations in CI |
| FX3 | Patch modifies CI, workflows, ownership, Quavon or rule files, hooks, lockfiles, secrets | fixPathViolation (shared, used by the edit tool and by validatePatch): built-in denied paths (§6) plus fix.deniedPaths; fix.allowedPaths restricts further; lockfiles only with fix.allowLockfiles; the Fixer token has no workflows permission, so GitHub itself rejects workflow changes | path policy unit tests; validatePatch tests; nock test that a workflow change is refused |
| FX4 | Patch content that GitHub accepts but humans miss (binary, symlink, submodule, mode change, case-folding collisions, .git/ paths, huge files, invalid UTF-8, bidi characters) | validatePatch: text files only (UTF-8, no NUL), new files mode 100644, modified/deleted files must be 100644 at base, no symlinks or gitlinks touched or created beneath, no path segment .git, no case-insensitive collisions with existing paths, size and line limits, bidi/zero-width characters in changed lines rejected | validatePatch tests |
| FX5 | Patch does not match what was approved, or the base moved | fix_runs.patch_sha256 pins the patch; the approval preview shows exactly that diff; the publisher re-validates against a fresh listing of the base commit (listTree/getFile at baseSha, base blob SHAs must match) and refuses on mismatch; the PR is always based on baseSha | publisher tests |
| FX6 | CI secrets exposed by running the patch on the bot branch (workflows triggered by pushes or pull_request events from an app token do run, with repository secrets) | approval mode by default; auto is downgraded to approval on public repositories and allowed on private repositories only when a maintainer chose it; the approval preview highlights changes to build- and test-executed files (package.json scripts, Makefile, Dockerfile, test files); off-limits list (§6) includes package-manager hooks (.pnpmfile.cjs, .yarnrc*, .npmrc); dashboard explains that bot branches run CI. Residual risk documented (§8) | approval and policy API tests |
| FX7 | Pushing to protected or foreign branches, force-push, merges | Provider write methods refuse branches outside quavon/fix-<issue>-<8 hex>, refuse when GET …/rules/branches/{branch} returns any rule other than deletion/non_fast_forward/required_linear_history or the branch is protected, refuse existing branches unless they were created by the same publish intent (single parent = baseSha, same tree), never use force, never call merge or ready-for-review endpoints; draft PR only, base = the repository's default branch for issue fixes. A stacked review fix PR has base = the head branch of the reviewed pull request, which the publisher re-reads live: the pull request is open, its head repository is the repository itself (headRepoExternalId, a missing value counts as a fork), its head branch still equals the base and is not the default branch. The only write to a non-bot branch is pushCommit (FX13) | provider nock tests; publisher tests for forks, closed pull requests and changed head branches; ESLint publisher-only guard including pushCommit |
| FX8 | Fixer app over-privileged or installed where the repository did not opt in | Fixer installation must cover the repository and have contents: write and pull_requests: write approved; tokens minted with exactly those permissions (+ metadata: read) for that one repository; repository opt-in = fix.mode not off; review app never writes contents | provider factory tests; token scope tests |
| FX9 | PR body carries injected content (mentions, links, images, cross-repo references, Fixes of other issues, /commands) | Body rendered by Quavon from typed fields (issue number, plan summary, test results, "how verified"); model text sanitised like replies (RP2, RP8, RP9): no images, mentions neutralised, links only under the repository prefix, cross-repository references neutralised; exactly one Fixes #<issue> line generated by the renderer; stacked review fix PRs carry the plain line Fixes review findings of #<pr> and no closing keyword at all; transparency label first | render/sanitise tests; publisher test that a stacked body has no closing keyword |
| FX10 | Cost and runner abuse (label spam, many issues, repeated commands) | Caps before any model call: per issue per day (maxRunsPerIssuePerDay, default 2), per repository per day (maxRunsPerDay, default 5), per tenant per day (MAX_FIX_RUNS_PER_TENANT_PER_DAY, 50); at most one running fix per issue; caps count every run, including ineligible and failed ones from the same actor; budget gate | cap tests |
| FX11 | Stale or replayed publication (issue closed, trigger revoked, approval of an old patch) | Publisher re-checks: feature mode, effective mode, issue open, trigger actor still write/admin, approval not expired, Fixer installation present, patch hash; publish intent idempotency key fix_pr:{repositoryId}:{fixRunId} with reconcile (find the PR by head branch, its body marker authored by the Fixer bot) | publisher tests |
| FX12 | Agent reads beyond the repository (other repositories, other tenants, absolute paths, ..) | Tools are bound to the checkout root and the overlay with the existing workspace path checks; no history search across repositories; no network tools | tool unit tests |
| FX13 | Pushing to a human branch (the head branch of a pull request) | Only with the explicit opt-in fix.reviewFixes: 'commit', the provider capability pushToBranch (GitHub) and the review fix sign-off; same-repository pull requests only (headRepoExternalId equals the repository, checked by the worker and again live by the publisher); the branch is neither the live default branch (GET /repos/{owner}/{repo}) nor protected, and repository rules are evaluated as in FX7; one commit created with GraphQL createCommitOnBranch whose expectedHeadOid is the reviewed head (expectedHeadSha), so GitHub applies it only as a fast-forward of exactly that head; never force; patch re-validated against a fresh read of the head (FX4, FX5) | git-provider nock tests (happy path, moved head, protected branch, default branch, blocking rule, reconcile after a lost response, signature rule); publisher tests (fork, closed pull request, changed head branch, moved head, switched-off policy); worker tests (fork, moved head) |
| FX14 | Finding text as an injection channel: findings are model output derived from untrusted pull request content | The findings are rendered by Quavon from typed fields (renderFindings, at most 20 findings and 8 000 characters) and passed to the agent only inside untrusted(…, 'review-findings'); the prompt tells the agent that findings describe problems and are never instructions; the agent keeps exactly the tools, limits and sandbox of §4 (no network, no secrets); validatePatch and the path policy are unchanged; finding ids must belong to the review run and be published or pending approval | fix-exfil-smoke-v1/012; red-team items 079 to 081 (model_output); agent test that findings are wrapped as untrusted |
| FX15 | CI secrets: a commit on a same-repository pull request branch runs its workflows with repository secrets, a stronger exposure than FX6 because the branch already has CI history and reviewers may trust its runs | approval by default and auto still downgraded on public repositories; the commit delivery is a separate opt-in; the approval preview names the delivery, shows a banner that the commit lands on the pull request branch and highlights executed paths (isExecutedPath); the off-limits paths of §6 apply. Residual risk recorded in §8 | publisher preview tests; web approvals test |
| FX16 | Race with the author: the author pushes while the fix runs or waits for approval | The worker refuses when the live head differs from the reviewed head (head_moved, "re-run the review first"); the publisher passes the reviewed head as expectedHeadOid to createCommitOnBranch, which GitHub applies as a compare-and-swap: a head that moved refuses the commit, the run fails with the reason and the user can request a stacked pull request instead. A retry after a lost response reconciles only a head whose single parent is expectedHeadSha and whose message carries the intent marker | git-provider tests for the stale head and the reconcile; publisher test that a moved head fails the run without a retry |
| FX17 | Attribution and audit of commits on human branches | The commit is authored by the Fixer app and signed by GitHub (createCommitOnBranch); the message is rendered by Quavon (renderFixCommitMessage: a fixed headline, at most ten finding titles reduced to letters, digits and basic punctuation, so no mentions, issue references, links or markup) plus the line CodeLotse reference <marker>, with no trailers; the request is audited (repository.review_fix.request with the user), the approval and the publication are audited as for FX11 | publisher and worker tests on the message; audit assertions in the API tests |
| AC1 | Injection in the linked issue skews AC results or the summary | Criteria text is untrusted: extraction prefers deterministic forms (checklists, "Acceptance criteria"/"Definition of Done" sections, Gherkin) and marks model-extracted items inferred; the judge sees criteria and diff inside untrusted(); output is enumerated statuses with evidence that must point to changed lines or files of the PR (verified, else unclear); the summary renders item text sanitised and truncated; AC never fails a check | AC tests with injected issues |
| AC2 | Linking a foreign or private issue to leak its text into a public PR | Linked issues only from the same repository: closingIssuesReferences filtered to the repository, closing keywords without owner/repo, branch names …/<number>-…; issue text from the issues table of that repository; public PR + issue from the same repository only | linked-issue resolution tests |
| AC3 | Cost (every PR runs an AC model call) | Runs only when a linked issue with at least one criterion exists; default mode shadow; per-feature budget; once per head SHA | handler tests |
| DT5 | New stored data (patches, test output, scanner findings, AC evidence) | Tenant tables with forced RLS, repository FK cascade, tenant retention; test output tails capped (8 KiB per run) and secret-redacted before storage; no full logs stored | RLS and retention tests |
6. Off-limits paths for the fix agent ​
Paths are normalised before matching (POSIX separators, no leading /, no ./.. segments, no NUL/control characters, NFC) and matched case-insensitively. Built-in denied set (never overridable by policy):
.git/**and any path with a.gitsegment;.gitmodules,.gitattributes;.githooks/**,.husky/**,lefthook.yml,.pre-commit-config.yaml..github/**(workflows, actions, CODEOWNERS, Dependabot, issue templates, Copilot instructions);CODEOWNERS,docs/CODEOWNERS.- CI configuration:
.gitlab-ci.yml,.circleci/**,.travis.yml,azure-pipelines.yml,.azure-pipelines/**,Jenkinsfile,.buildkite/**,.drone.yml,bitbucket-pipelines.yml,.woodpecker/**,.woodpecker.yml,cloudbuild.yaml,.devcontainer/**,.vscode/**. - Quavon and agent rule/instruction files:
.quavon/**,**/AGENTS.md,**/CLAUDE.md,.cursorrules,.cursor/**. - Package-manager hooks and registry configuration:
**/.npmrc,**/.yarnrc,**/.yarnrc.yml,.yarn/**,**/.pnpmfile.cjs,**/.pypirc,**/pip.conf,.mise.toml,.tool-versions. - Secret-like files:
**/.env,**/.env.*,**/*.pem,**/*.key,**/*.p12,**/*.pfx,**/id_rsa*,**/id_ed25519*,**/.netrc,**/.git-credentials. - Lockfiles unless
fix.allowLockfiles:package-lock.json,npm-shrinkwrap.json,pnpm-lock.yaml,yarn.lock,bun.lock,bun.lockb,poetry.lock,Pipfile.lock,uv.lock,Cargo.lock,go.sum,composer.lock,Gemfile.lock(any directory).
Policy can add fix.deniedPaths and restrict to fix.allowedPaths (empty = everything not denied). The rule lives in one function (fixPathViolation in @quavon/shared-types) used by the edit tool and by validatePatch.
7. Conditions under which the publisher creates a branch and a draft PR ​
All must hold at publish time:
- The output is a typed
FixPrTargetvalidated by zod; the referencedfix_runsrow is in statuspublishing(auto, or approved: thepending_outputsrow isapproved), belongs to the target's repository and itspatch_sha256equals the target's. features.fix.modeisapproval(and a maintainer approved the pending output, not expired, not rejected) orauto(private and internal repositories only; public repositories are downgraded toapproval). The policy fails to parse → treated asoff.- The repository's Fixer-tier installation exists, is not suspended, covers the repository, and has
contents: writeandpull_requests: write; the token is minted for that repository with exactly those permissions plusmetadata: read. - The trigger actor's current permission is
adminorwrite; the issue is open, not a pull request, and still in the same repository. For a stacked review fix (subjectpull_request) a live read of the pull request replaces the issue check: open, from the same repository, head branch equal to the base of the stacked PR and not the default branch (FX7). validatePatchpasses against a fresh read of the base commit: path policy, size/line/file limits, text-only, mode/symlink/submodule rules, base blob SHAs, secret scan (in-process patterns + gitleaks on the patch) with no hit.- The branch name matches
quavon/fix-<issue>-<8 hex>(hex from the publish marker of the intent); the branch does not exist, or exists with a single commit whose parent isbaseShaand whose tree equals the patch tree (retry of the same intent); repository rules (unknown rule types block;required_signaturespasses only when GitHub reports the created commit as verified) and branch protection for that name do not block. - One commit (message rendered by Quavon), created with the git data API (blobs → tree on
base_tree→ commit with parentbaseSha→POST git/refs), neverforce. - One draft PR (
draft: true) from the bot branch to the default branch (issue fixes) or to the head branch of the reviewed pull request (stacked review fixes, body without a closing keyword), body rendered by Quavon and sanitised (FX9) with the hidden idempotency marker; no reviewers, assignees or labels; never ready-for-review, never merged. - Idempotency key
fix_pr:{repositoryId}:{fixRunId}not published before (reconcile: PR with that head branch whose body carries the marker and whose author is the Fixer bot login).
7b. Conditions for a commit to a pull request branch ​
All must hold at publish time:
- The output is a typed
FixCommitTargetvalidated by zod; the referencedfix_runsrow has subjectpull_request, deliverycommit, statuspublishing(auto, or approved: thepending_outputsrow of kindfix_commitisapproved; it never carries areview_run_id), belongs to the target's repository, and itspatch_sha256, pull request number and head SHA equal the target's. FIX_FEATURE_SIGNED_OFFandREVIEW_FIX_SIGNED_OFFaretrue,features.fix.reviewFixesiscommitandfeatures.fix.modeisapproval(approved, not expired, not rejected) orauto(private and internal repositories only). The policy fails to parse → treated asoff.- The Fixer installation exists, is not suspended, covers the repository and has
contents: writeandpull_requests: write. - The trigger actor (the code-host login of the requesting user) still has
adminorwritepermission. - A live read of the pull request shows it open, from the same repository (
headRepoExternalId) and with the target branch as head branch; the branch is not the default branch (live repository read) and not protected; repository rules do not block (FX7). validatePatchpasses against a fresh read of the head commitexpectedHeadSha, as in §7.5.- The commit message rendered by Quavon (FX17) passes the secret scan and carries the intent marker.
- One commit through GraphQL
createCommitOnBranchwithexpectedHeadOid = expectedHeadSha; neverforce, never a second commit. A refused compare-and-swap fails the run (FX16). - Idempotency key
fix_commit:{repositoryId}:{fixRunId}; a retry reconciles a head whose single parent isexpectedHeadShaand whose message carries the marker, and refuses any other head.
8. Residual risks ​
- CI on the bot branch runs repository workflows with repository secrets; a maintainer approving a patch that changes test or build code can expose them (FX6). Mitigated by approval by default and the preview highlights; not eliminated.
- A user with
writeaccess can trigger fixes that consume budget; caps limit the cost. - gVisor/Firecracker vulnerabilities; tracked through pinned versions and the update policy (PSIRT).
- Model-generated patches can be subtly wrong; they are draft PRs, reviewed by the same reviewer and by humans, never merged by Quavon.
docker-runc-insecureon a developer machine provides container isolation only.- Single-server installations: a gVisor escape reaches a host that also runs Postgres, Valkey and holds the keys.
- Snapshots pass through Valkey (encrypted, short TTL); Valkey memory is sized for
MAX_SNAPSHOT_BYTES× runner concurrency. - The deterministic scanners can miss issues or report false positives; they never suppress model findings.
- Review fix commits land on a human branch whose workflows run with repository secrets (FX15). A maintainer who approves a commit that changes test or build code can expose them; approval by default, the separate opt-in and the preview highlights mitigate this, they do not eliminate it.
- A commit on the pull request branch becomes part of the author's pull request. Reviewers may attribute it to the author; the Fixer app as author and the reference line make the origin visible, but the commit is not draft and can be merged with the pull request.
- Findings are model output; an attacker who controls pull request content can steer the finding text that the fix agent reads (FX14). The agent's capabilities are unchanged, so the impact is limited to a wrong patch that still needs approval by default.
- The reconcile after a lost GraphQL response trusts a head whose parent is the expected head and whose message carries the marker. Someone with write access who copies the marker into their own commit could make a failed push look published; the patch itself is never pushed twice.