Skip to content

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 ​

FeatureTriggerWhat it may doWhat it may never do
Sandbox (Z3)A signed job from the workerRun one allowlisted image (by digest) with a fixed command over a copy of a repository snapshot; return exit code, capped output and named artefactsHold tokens, secrets or credentials; reach the network (default); reach the control plane, Valkey, Postgres, the Docker socket, the host filesystem or other jobs
Deterministic scannersReview 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 scanningExecute repository-provided scanner configuration outside the sandbox; post anything except through the publisher; ship Semgrep/Opengrep or GPL scanners in the default images
Fix agentLabel from fix.triggerLabels or the issue comment @<app> fix, by a user with write or admin permission on the repositoryPlan, 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 summaryChoose 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 branchPush to existing non-bot branches or the default branch, force-push, merge, approve, mark ready for review, change settings, touch workflows
Review fixesApply 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 offThe 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 expectedHeadOidTouch forks, the default branch or protected branches, force-push, push when the head moved since the review, merge, approve, comment
Acceptance criteria (AC) checkReview 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 &ZeroWidthSpace;

  • 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 write or admin permission (getCollaboratorPermission, decided only on permission ∈ {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) &ZeroWidthSpace;

  • Separate service. apps/sandbox-runner is 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 sandbox under the prefix quavon-sandbox (attempts 1, no stalled-job redelivery). Every job is a SignedSandboxJob: HMAC-SHA256 over quavon-sandbox-job:v1 + \n + the RFC 8785 canonical JSON of the job, key QUAVON_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 with quavon-sandbox-result:v1 and carry the runner id. The runner rejects unsigned, expired (notAfter past or more than 15 min ahead, 30 s clock skew), replayed (SET NX on 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 user sandbox limited 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 pool default. A runner can forge jobs and read snapshots of its own pool only. A future customer-run runner gets its own pool (queue prefix quavon-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, or byo:<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) to repository@sha256:<digest> and refuses anything else. Tags are never pulled. The resolved digest is returned and stored in sandbox_runs.image_digest.

  • Runtimes. docker-runsc (gVisor) is required in production; on hosts without nested virtualisation (the netcup k3s host has no vmx/svm and no /dev/kvm) runsc uses its default systrap platform. firecracker (jailer-launched microVM per job) is for bare-metal/KVM hosts. docker-runc-insecure is for development and tests only: it is accepted only when NODE_ENV is not production and QUAVON_SANDBOX_ALLOW_INSECURE=true; quavon doctor reports it as a failure in production and as a warning elsewhere. firecracker without a usable /dev/kvm fails closed at startup and in doctor; 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 runsc registered (/etc/docker/daemon.json runtimes.runsc, platform systrap); the runner pod tolerates the taint, mounts that node's /var/run/docker.sock and is the only workload with it. Sandbox containers are Docker containers on that node, not Kubernetes pods. A Kubernetes-native backend (pods with runtimeClassName: gvisor via a containerd runsc handler) is part of the Helm chart.

  • Container settings (Docker backends). HostConfig.Runtime = runsc (or runc only in insecure mode; the runsc platform is set by the daemon's runtimeArgs, and the runner's QUAVON_SANDBOX_RUNSC_PLATFORM is only checked against docker info), NetworkMode: none (egress none) or the internal egress network, ReadonlyRootfs: true, CapDrop: [ALL], SecurityOpt: [no-new-privileges:true] (+ default seccomp), User: 65532:65532, PidsLimit, Memory = MemorySwap, NanoCpus, Tmpfs for /work (exec,nosuid,nodev,size=), /out and /tmp (noexec,nosuid,nodev,size=), HOME under /work/.home, Ulimits (nofile, fsize), IpcMode: private with a small ShmSize, no bind mounts, no volumes, no devices, no Privileged, 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_BYTES compressed, 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, info quavon-sandbox-blob:v1), 1 MiB chunks with a fresh 12-byte IV each and AAD quavon-sandbox-blob:v1:<tenantId>:<blobId>:<chunk>/<count> (no reordering or truncation), stored under quavon-sandbox:blob:<blobId>:<n> in Valkey with a TTL of MAX_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 /work tmpfs. Artefacts come back inside the signed result on the queue (capped). No .git directory, 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.log and 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 marked outputTruncated.

  • Egress. none by default (only loopback; Firecracker: no network device). registries is 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 network quavon-sandbox-egress, whose only member is the sandbox-egress proxy (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} under quavon-sandbox:egress:<token>; the proxy (Valkey user egress, read-only on that pattern) accepts CONNECT host:443 only 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 sandbox ACL user (sandbox queue, results and blobs only). gVisor is acceptable there at the start; Firecracker on bare metal is preferred. quavon doctor in 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) &ZeroWidthSpace;

  • 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_linter take no arguments: they run one sandbox job with the base snapshot and an overlay snapshot of the edits: the policy's setupCommand (optional, the only step that may have registry egress) runs on the unmodified base tree, then the entry program applies the overlay and runs testCommand/lintCommand without 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 → run failed with reason, not retried), maxFiles (default 10) and maxChangedLines (default 300), file size ≤ 256 KiB, UTF-8 text only.

5. Threats and controls &ZeroWidthSpace;

#ThreatControlTest
SB1Sandbox escape to the host via kernel or runtime bugsgVisor 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/Kubernetesescape 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
SB2Secrets or tokens reachable in the sandboxThe 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 jobsescape 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
SB3Network exfiltration from repository codeEgress 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 credentialsescape 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
SB4Forged jobs (start arbitrary images or commands through Valkey)HMAC-signed jobs with expiry and replay check; image allowlist by digest; runner ACL user; schema validationrunner unit tests: unsigned, wrong key, expired, replayed, unknown image, tampered field → rejected without a runtime call
SB5Compromised runner pivots into the control planeRunner 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 backendcompose test: runner cannot reach Postgres or the API; ACL test: GET of a non-sandbox key is denied
SB6Container-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 jobsdoctor check: only the runner has the socket mounted (compose lint test)
SB7Resource 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 dayescape suite: fork bomb, dd to fill tmpfs, 1 GiB stdout, sleep beyond wall clock → limited, killed, timed_out
SB8Malicious 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 zodrunner tar parser tests with crafted archives
SB9Cross-tenant leakage of snapshots or resultsSnapshot 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 RLSrunner test: snapshot ref of another tenant rejected; RLS tests
SB10Logs leak repository output or secretsRunner 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
SB11Insecure fallback used in productiondocker-runc-insecure refused when NODE_ENV=production or QUAVON_EDITION=enterprise; firecracker without /dev/kvm fails closed; doctor shows the effective backend, runtime and platformconfig and doctor tests
FC1KVM exposure of the Firecracker hostFirecracker 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 updatedFirecracker integration suite (KVM CI runner): jailer uid differs per VM, chroot contains only the expected files, seccomp level reported as default
FC2Guest 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 VMprotocol fuzz tests with a fake guest
FC3Shared host directories or images leak data between VMsNo 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 datacleanup test: no files left in the chroot base after a run, including after a killed VM
FC4Identical 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
SC1Repository 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 sandboxscanner wrapper tests with a hostile eslint.config.js and .gitleaks.toml in the repository
SC2Forged or noisy deterministic findingsFindings 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 findingsSAST normalisation fixtures; review eval shows no precision regression
SC3SARIF upload writes outside the intended scopeOpt-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
FX1Unauthorised 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 publishereligibility tests; permission matrix (nock)
FX2Prompt 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 hitexfiltration suite (fix-exfil-smoke-v1 + escape suite) at 0 violations in CI
FX3Patch modifies CI, workflows, ownership, Quavon or rule files, hooks, lockfiles, secretsfixPathViolation (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 changespath policy unit tests; validatePatch tests; nock test that a workflow change is refused
FX4Patch 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 rejectedvalidatePatch tests
FX5Patch does not match what was approved, or the base movedfix_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 baseShapublisher tests
FX6CI 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
FX7Pushing to protected or foreign branches, force-push, mergesProvider 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
FX8Fixer app over-privileged or installed where the repository did not opt inFixer 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 contentsprovider factory tests; token scope tests
FX9PR 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 firstrender/sanitise tests; publisher test that a stacked body has no closing keyword
FX10Cost 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 gatecap tests
FX11Stale 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
FX12Agent 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 toolstool unit tests
FX13Pushing 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)
FX14Finding text as an injection channel: findings are model output derived from untrusted pull request contentThe 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 approvalfix-exfil-smoke-v1/012; red-team items 079 to 081 (model_output); agent test that findings are wrapped as untrusted
FX15CI 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 runsapproval 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 §8publisher preview tests; web approvals test
FX16Race with the author: the author pushes while the fix runs or waits for approvalThe 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 markergit-provider tests for the stale head and the reconcile; publisher test that a moved head fails the run without a retry
FX17Attribution and audit of commits on human branchesThe 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 FX11publisher and worker tests on the message; audit assertions in the API tests
AC1Injection in the linked issue skews AC results or the summaryCriteria 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 checkAC tests with injected issues
AC2Linking a foreign or private issue to leak its text into a public PRLinked 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 onlylinked-issue resolution tests
AC3Cost (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 SHAhandler tests
DT5New 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 storedRLS and retention tests

6. Off-limits paths for the fix agent &ZeroWidthSpace;

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 .git segment; .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 &ZeroWidthSpace;

All must hold at publish time:

  1. The output is a typed FixPrTarget validated by zod; the referenced fix_runs row is in status publishing (auto, or approved: the pending_outputs row is approved), belongs to the target's repository and its patch_sha256 equals the target's.
  2. features.fix.mode is approval (and a maintainer approved the pending output, not expired, not rejected) or auto (private and internal repositories only; public repositories are downgraded to approval). The policy fails to parse → treated as off.
  3. The repository's Fixer-tier installation exists, is not suspended, covers the repository, and has contents: write and pull_requests: write; the token is minted for that repository with exactly those permissions plus metadata: read.
  4. The trigger actor's current permission is admin or write; the issue is open, not a pull request, and still in the same repository. For a stacked review fix (subject pull_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).
  5. validatePatch passes 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.
  6. 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 is baseSha and whose tree equals the patch tree (retry of the same intent); repository rules (unknown rule types block; required_signatures passes only when GitHub reports the created commit as verified) and branch protection for that name do not block.
  7. One commit (message rendered by Quavon), created with the git data API (blobs → tree on base_tree → commit with parent baseSha → POST git/refs), never force.
  8. 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.
  9. 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 &ZeroWidthSpace;

All must hold at publish time:

  1. The output is a typed FixCommitTarget validated by zod; the referenced fix_runs row has subject pull_request, delivery commit, status publishing (auto, or approved: the pending_outputs row of kind fix_commit is approved; it never carries a review_run_id), belongs to the target's repository, and its patch_sha256, pull request number and head SHA equal the target's.
  2. FIX_FEATURE_SIGNED_OFF and REVIEW_FIX_SIGNED_OFF are true, features.fix.reviewFixes is commit and features.fix.mode is approval (approved, not expired, not rejected) or auto (private and internal repositories only). The policy fails to parse → treated as off.
  3. The Fixer installation exists, is not suspended, covers the repository and has contents: write and pull_requests: write.
  4. The trigger actor (the code-host login of the requesting user) still has admin or write permission.
  5. 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).
  6. validatePatch passes against a fresh read of the head commit expectedHeadSha, as in §7.5.
  7. The commit message rendered by Quavon (FX17) passes the secret scan and carries the intent marker.
  8. One commit through GraphQL createCommitOnBranch with expectedHeadOid = expectedHeadSha; never force, never a second commit. A refused compare-and-swap fails the run (FX16).
  9. Idempotency key fix_commit:{repositoryId}:{fixRunId}; a retry reconciles a head whose single parent is expectedHeadSha and whose message carries the marker, and refuses any other head.

8. Residual risks &ZeroWidthSpace;

  • 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 write access 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-insecure on 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.

The open core is released under the Apache License 2.0.