Security
Quarterdeck can start agents and run commands on your machine, so please report security problems privately.
Reporting
Use GitHub's private vulnerability reporting: the Security tab of this repository, then Report a vulnerability. Please do not open a public issue for a vulnerability.
We aim to acknowledge a report within a few days and to say what we will do about it.
What is protected
- The dashboard binds to
127.0.0.1only. Every change request carries the session token and the browser's word that it came from the dashboard's own page (anOriginequal to the dashboard's address, orSec-Fetch-Site: same-origin); theHostheader is checked. The token is handed to the paired tab once and kept in that tab'ssessionStorage; the server never gives it out again, so a reload keeps working and a new tab needsqtdeck dashboard link. The session cookie is not per port: another web server on127.0.0.1that you browse to receives it and can read the dashboard with it (not write) until the session ends. A run is an argument list, never a shell string, validated against the registry. Each duty has a fixed list of the flags it takes (DUTY_FLAGSinbin/dashboard.py); a flag outside that list is refused with a 400 naming the duty and the flag, before anything starts,--applyis never accepted among the arguments (writing is asked for only by the separateapplyfield), and every value must still match a safe-character pattern (no leading dash, no.., no absolute path). A paired browser stays paired for 24 hours; reloading its own tab, once past half that, renews it, so a tab that is never reloaded stops working 24 hours after pairing.qtdeck dashboard sessions revokesigns every browser out, in a running dashboard too, and so does every start of the dashboard: after a restart runqtdeck dashboard linkonce. A start also throws away every pairing link not yet used (until audit 7 a link whose code a process holding the port had read stayed good for five minutes after the real dashboard came back). That way a process that held the port while the dashboard was down gets nothing that still works. While the dashboard is down, do not keep the paired tab open on it: any other program that binds the port in that time can read the tab's cookie and token. The command-line token instate/dashboard.tokenopens reads and Helm's chat only, never a run, merge, attach or connection change, and no model or project command can read the file. - Add-ons in your data folder are code that runs as you. Besides
bin/ext_*.py, the dashboard importsaddons/ext_*.pyfrom the data folder when it starts, and an add-on can do whatever you can. So the folder is guarded: it must be a real directory (not a link) owned by you with mode 0700, and each file must be a regular file owned by you and not writable by group or others; anything else is skipped and logged with the reason. A file is read through one checked descriptor and run from memory, so a swapped link or a cached bytecode file is not trusted. No model and no project command can write there: the folder is on the model's deny-write rules and in the sandbox'sdenyWrite(one list inbin/lib.sh,protected_write_paths), and in the sandbox profile for project commands;bin/selftest-security.shfails if the two renderings differ and proves a model's shell cannot createaddons/ext_x.py. Add-ons from there are labelled "local add-on". To load none of them, start the dashboard withQUARTERDECK_ADDONS=off. Only put a file there that you wrote or read;qtdeck addon listshows what loaded and what was skipped. /api/healthanswers without pairing (liveness: uptime, commit, whether the loops and helper models run, free disk; no path, no content). Its git answer is cached for 30 seconds.- A model thinks, the harness acts. A model call gets
Read,GrepandGloblimited to its working folder, and no shell, nogit, nogh. The harness fetches what the model reviews (diffs, comments, CI text) and puts it in the prompt as marked untrusted data. The harness alone commits, pushes and posts, and only with an explicit apply. - Onboarding (survey, scribe, ready). The survey (
bin/survey.py) reads files and the git state throughbin/safegit.pyand runs nothing of the project's; it is kept in your data folder, never in the project. Every name in it came from the project's files, so the facts block that starts each prompt is marked as untrusted data, made of plain characters only, and capped at 3000 characters.qtdeck ready <project> --proveruns the verify command once in the same sandbox as the shipwright's checks, on a throwaway copy of the default branch, and only on request (the page asks first). While it runs the page polls one small JSON of the command's last lines (text only, control characters removed, passed through the same credential scan as every log); the last 60 lines are kept with the result inverify.json(private, redacted). A command run in the sandbox is told so withQUARTERDECK_IN_SANDBOX=1, the onlyQUARTERDECK_word it receives, so a test can skip what the sandbox forbids on purpose, such as listing other processes. The scribe hasRead,GrepandGlobonly, no shell, nogh, no write; the harness decides which paths may be written (exactlyREADME.mdanddocs/ARCHITECTURE.md, only when missing), scans the text for credentials before it is shown or kept, and writes only a new branch: never the default branch, never over a file that exists, and a pull request only through the credential-scanning post helper. - The builder duties (shipwright, caulker, assayer, trials) also get
EditandWriteinside a throwaway worktree and a few shell commands (npm run,npx tsc,node,python3,pytest). On macOS those run in the Claude Code CLI's sandbox: the private list unreadable, writes only inside the worktree (its.gitfile closed even to the sandbox), no unsandboxed escape, and the call fails instead of running unconfined if the sandbox cannot be built (failIfUnavailable).QUARTERDECK_SANDBOX=offremoves it; do not. What network that sandbox allows is the CLI's own business: this project sets only the file rules. - A model's process gets a minimal environment: no token from
~/.config/quarterdeck/env, noGH_TOKEN. Its prompt is passed on standard input, not in the process arguments. - Models cannot read: your data folder's private files (connectors, capture, agenda, attention, intent, rhythm tasks, the dashboard token, the session file and the pairing folder),
~/.config(all of it),~/.ssh,~/.aws,~/.azure,~/.oci,~/.gnupg,~/.kube,~/.docker,~/.npmrc,~/.netrc,~/.pgpass,~/.my.cnf,~/.git-credentials,~/.pypirc,~/.cargo/credentials*,~/.gem/credentials, the Terraform, Vault, Supabase, Netlify, Vercel, Railway, Fly, Wrangler, Stripe, Hugging Face and Kaggle credential folders, shell and REPL history files,~/Library/Keychains, and any.env*,*.pem,*.key,*.p12file (in a working folder and, for the model's shell, anywhere under your home folder). The credential list builds both the Read rules and the sandbox rules, and a test fails if they differ. The model's shell sandbox also gets the personal folders (Documents, Desktop, Downloads, Pictures, Movies, Music, Public and, in Library, Application Support, Cookies, Safari, Messages, Mail, Group Containers, Containers, Mobile Documents, Accounts, Calendars, Reminders, HomeKit, Passes); a folder that holds the working folder or the data folder is left out, since the model must read where it works. - Text the harness sends out is scanned for credentials first and refused on a hit: every argument of a
ghpost (title, body,--body=..., labels, branch names), a body file, a commit message, a pushed branch name, and the lines a commit adds. The scan reads the text as a person would (compatibility forms folded, zero-width and soft-hyphen characters and non-ASCII hyphens dropped), joins a key split over lines or string pieces, and decodes base64 runs. It is pattern based (see below). - The project's own verify, test, build and install commands run with a minimal environment and a scratch
HOME. On macOS they run insidesandbox-exec: the private list and the whole of the data folder'sstate/andlogs/unreadable, writes only in the worktree, a scratch folder and the temp folders, network confined to this machine (loopback; a check cannot reach the internet, IPv4 or IPv6; an install may). Round 3 closed what a command could still do from inside that: no unix socket at all, so no ssh-agent, no docker and not the system resolver either (a hostname lookup went through it and carried data out in the name;localhoststill resolves, from/etc/hosts, and an address by number needs no resolver; any other name does not resolve in a check, by design; an install, which has the network, resolves names as before); no clipboard (the pasteboard service cannot be looked up,pbcopy/pbpasteare not started); no other processes (a command cannot list, inspect by path or signal anything but itself and its own children, and cannot talk to launchd: nolaunchctl print, no job started outside the sandbox); nothing opened or driven (open,osascript,securityare not started, and Launch Services and Apple events cannot be reached, so a command cannot open a URL in your browser or tell another app to run something); and your credentials and personal folders unreadable: the list above,.env*,*.pem,*.key,*.p12,*.pfx,id_rsa*,id_ed25519*,id_ecdsa*anywhere under your home folder, and the personal folders (only the contents: a folder still shows up tostat). Let back in, because a build needs them: the worktree (with its own.env), the scratch folder, thenode_modulesit borrows from the real checkout (only the link the harness itself made and recorded; anode_moduleslink the checkout carries is not let in, wherever it points),.env.example/.sample/.template/.dist, a toolchain whose folder is onPATH(also when that is inside a denied folder), and, for an install only,~/.npmrcand the package managers' own settings under~/.config. A Mac where the sandbox cannot be applied runs nothing. Pull requests are different only in one way: a pull request from a fork is never built, installed or started at all (pilot, lookout, inspector and quartermaster refuse it; until round 5 the quartermaster did not, and installed a fork's head with the network on). The browser the pilot and lookout drive reaches only the app's own address, and Chromium runs with its renderer sandbox on. Until round 7 that was enforced only by Playwright's request routing, which sees the page's own requests but not those of a worker, not WebRTC, not the browser's own name lookups, and only the first address of a redirect: a worker's fetch and WebSocket, WebRTC to a STUN or TURN server, and a 302 from the app to another port all reached a decoy on a second loopback port. Now the whole browser is launched behind a dead proxy that the driver itself holds (it drops every connection), with only the app'shost:portbypassing it (<-loopback>removes Chromium's implicit bypass of every loopback port), every name mapped to not-found except the app's host, and WebRTC limited to UDP through the proxy, so to none; service workers are blocked. A real run on macOS with Google Chrome 155 shows the decoy (TCP and UDP) receives nothing from fetch, img, preconnect, a redirect, WebSocket, a dedicated, shared or service worker, or WebRTC, and Chromium's own network log shows no name lookup at all.--force-webrtc-ip-handling-policyand--dns-prefetch-disableare not in that Chrome and are not used. - Before any project command runs on a model-made change, the change is refused if it touches CI files, dependency manifests and lockfiles, tool and test-runner configs (jest, pytest, babel, eslint and the like),
.env*, Cargo and Yarn project settings,.pnpmfile.cjs,vercel.json,netlify.toml,CODEOWNERS, Terraform files, a Makefile or a make include file (*.mk), scripts or Dockerfiles, the lockfiles and task manifests of uv, PDM, Bundler, Composer, Deno and Mix, editor tasks and settings (.vscode,.idea), dev containers and Gitpod, the files that steer the next agent (CLAUDE.md,AGENTS.md,GEMINI.md,.cursor,.cursorrulesand the like), the pipelines of Bitbucket, Buildkite, Drone, AppVeyor and Cloud Build (round 8), or anypackage.jsonscripts block. A plan can never unlock the lockfiles, CI, agent files, deploy-platform config or what runs when a person opens the branch; only the project'sprotected_allowcan. The check reads paths exactly as they are on disk (a non-ASCII name is not C-quoted past the pattern), compares them the way a Mac's volume does (letter case folded, an accent spelled either way equal), and also examines files that.gitignorehides. A protected folder also covers its bare name, and so does a registry forbidden pathX/: git records a folder replaced by a symlink (.claude -> docs/agent) as one entry named.claude, and until audit 7 that link passed both. A symlink in the change is refused when it leads out of the worktree, to the worktree itself, into.git, or to a protected place, unlessprotected_allownames it. A path is unlocked only by the project'sprotected_allowor by the Where field of an item in the plan (a dev panel's ruling), and only when that field is exactly one path in backticks and the item has no second Where line; a path mentioned in a plan's prose, even to say not to touch it, unlocks nothing, and neither does prose, a list or an unquoted value inside the field (until round 5 it did). The gate runs again after the checks, because the checks run project code that may write in the worktree: it compares the same baseline, refuses if a check created or changed a protected path or changed any file that passed the first gate (until round 5 such a change was committed), throws away any other file the checks made, and only the files that passed the first gate are staged (nevergit add -A). They are staged first and the staged blobs are compared with what passed; the commit takes the index (until round 6 the files on disk were compared and then staged, and a process a check left running could change one in between). Each project command runs as its own process group, which is killed when the command ends; a command that leaves a process running fails. A process that moves itself to another session (setsid) is not reached by that kill, so since audit 7 a check never runs in the worktree at all: it runs in a throwaway clone of it (an APFS clone,cp -cR) inside its scratch folder, the sandbox lets it write only that clone and the scratch folder, and both are removed when it ends. Until then such a process could edit a file in the worktree after the check had ended and before the first gate, and its words were committed. An install (the quartermaster's) still runs in the worktree, because the checks after it need what it installed; the quartermaster never commits. The pilot and lookout also build and start the app in the worktree (the app must serve what was built); they review a pull request and never commit. A plan never unlocks CI files, lockfiles, package-manager settings (.npmrc,.yarnrc*,.pnpmfile.*,pnpm-workspace.yaml,bunfig.toml,.yarn,.cargo), hooks,CODEOWNERS, agent settings or.env*, whatever the registry's forbidden paths say; onlyprotected_allowdoes (until round 6 a Where line naming.github/workflows/ci.ymlunlocked it). - Terraform written by a model (experimental, first slice). A plan item whose Where field is exactly one backticked folder (
infra/net/) unlocks the*.tffiles under it and the*.tfvarsfiles under it with no secret-looking value, nothing else; the folder is the one spelled in the plan, also on a case-sensitive volume (review r9). Terraform state (*.tfstate,*.tfstate.backup),.terraform/at any depth,.terraformrc,terraform.rc,credentials.tfrc.json,.terraform.lock.hcl, the JSON variable files (*.tfvars.json,*.auto.tfvars.json) and backend files (*.tfbackend) are protected and never unlocked by a plan. The harness runs onlyterraform fmt -checkand, when the project names a folder of prefetched provider plugins, an offlineinit -backend=false -get=false -plugin-dirandterraform validate, through the same project-command sandbox, with no cloud credential and no network; without the plugins, validate is reported as not run. It never runsplan,apply,destroy,import,stateor anything that talks to a backend. The builder has no shell for such an item. The infra duty reviews the change, and the pull request is opened as a draft for a person to apply. - A ticket is text, and repo creation is a yes. A ticket comes from the owner's own request or from the planner's reply, and is stored and filed only as plain text in fixed fields (
bin/tickets.py): bounded sizes, no control or invisible characters, no HTML comment that could forge one of Quarterdeck's marks, no@mention, and no path, command or flag the harness would act on (extra keys in a reply are dropped). The planner hasRead,GrepandGlobin the project folder and the request reaches it only inside an untrusted block; it never getsghor a credential. A draft is kept in the data folder and files nothing; a ticket becomes a GitHub issue only with--apply, through the samegh_postcredential scan as every other post (a secret in any ticket refuses the whole batch, exit 3, nothing posted). A local ticket never callsgh: its build ends in one commit on a local branch (the commit-message and added-line scans apply), with no push and no pull request.qtdeck repo createrunsgh repo createonly after a typedyeson a terminal (or--yes), only whengh auth statussays gh is signed in, with the owner taken fromgh api userand checked and the name from the project name; a public repository needs--i-mean-public; the one push it can make is the default branch, after a second yes (or--push). It never creates an account, never reads or prints a token or password, and passes no secret on a command line. Tested with a stand-inghand the fake provider only; not run against real GitHub. - Guardrails are the project's rules, and three cannot be removed. A project's guardrails are text given to the model as its rules, in every prompt. Never merging, never pushing to the default branch and never touching secrets are locked: the project page,
qtdeck guardrailand the API (POST /api/project/guardrails,attach.editunderneath) all refuse a list that drops or changes one, and name it with its reason. The owner's own rules are one line of at most 300 characters, at most 20, with no control character, no comment marker and no text that forges a prompt marker, and each is scanned for credential shapes before it is accepted. The change goes through the same pairing, origin, token and confirm checks as the other writes, the registry stays 0600, and every change is a line inlogs/guardrails.log. Like all scanning this is a net for accidents: a rule is the owner's own text. - Mission actions are guarded like the other writes. Dismiss, Undo dismiss, Close the issue, Try again and Mark as merged are one
POST /api/mission/action: pairing, same origin, this tab's token, a plain 400 in Demo mode, and a line inlogs/missions.logthat names the action, the project and the mission id and never any text from an issue. Dismiss writes onlystate/dismissed-missions.jsonin your data folder (0600, atomic). Close the issue and Try again need an explicit yes (confirm: true, after a dialog that names the repository and the issue); the close isgh issue closewith a fixed comment (a sentence and a commit id, no model text) and runs once. Try again goes through the same start paths, budget and lock rules as the buttons that first started the work. Mark as merged writes only after GitHub itself says the pull request merged. Pull request states are read withgh pr view <url>for a URL that matcheshttps://github.com/<owner>/<repo>/pull/<n>exactly. Tested with a stand-inghonly; not run against real GitHub. - Where connection credentials are kept (Connections page). By default a private file,
~/.config/quarterdeck/integrations.env, mode 0600 in a 0700 folder. On macOS you can choose the macOS Keychain instead (securitycommand; one item per credential, servicequarterdeck.integrations). Moving between the two is explicit: you confirm, every saved credential is copied and read back, the choice is switched, and only then are the old copies removed; if a copy fails nothing moves. A secret reachessecurityon its standard input as hex, never as a process argument (arguments of your processes can be read by pid, see below), and comes out on standard output only inside the feed that needs it. The page, the API and the log show only that a credential is present and which store holds it, never a value; the log names the key, not the value. Model calls and project commands run with a minimal environment that never contains a stored credential (a self-test builds both environments with a credential in the store and checks), a model call is deniedBash(security*)and a tool list that names it is refused, and the project-command sandbox does not start/usr/bin/security. On Linux, or a Mac without/usr/bin/security, the Keychain is reported as not available and the file stays in use. The self-tests use a stubsecurityand never touch your keychain. With several accounts of one service a credential is stored per account, asKEYfor the default account andKEY@labelfor another, in the same file or Keychain with the same rules (0600, write-only, an account never reads another account's keys); removing an account deletes its keys and its sign-in file. - A registered folder's own
.claude/settings.json, hooks and.mcp.jsonare never loaded. The harness runs git in it through one wrapper (safe_git, andbin/safegit.pyfor the Python tools): hooks, fsmonitor, the repository's ssh command, theext::transport, the pager, commit and tag signing and the repository's credential helpers are switched off. A folder whose own git config defines something git would run that no flag reaches (clean/smudge/process filters, textconv, external diff, merge drivers, any gpg program,url.<x>.insteadOf, includes, a URL credential helper, a git proxy, an attributes file, an uploadpack) is not touched at all, by any duty or tool;"trusted_git_config": trueon a registry entry is the owner's explicit opt-out for a folder they wrote. A default branch name that is not a plain branch name (a leading dash would pass for a git option) is refused. - Live work (the Running now strip and each project's Live work card) is read from the events the optional sessions hook writes into your data folder (
state/fleet-events/) and from the live locks. Those event files are untrusted text: every field is length-limited on the server, shown only through escaping, and never run, followed or used as a path. The hook records no prompt, reply, file name or transcript, and the page says "no detail reported" instead of guessing./api/statecarries only that summary (names of sessions' worktrees, branches and agents), on the same loopback and token rules as the rest; none of it goes to a model. - A linked worktree's
.gitis a file inside the folder a model works in. It is pinned when the harness makes it: every later git command compares it with what the harness wrote and runs withGIT_DIRandGIT_WORK_TREEset, so git never reads it. The model's sandbox and edit rules also close that file to writes. - The sandbox settings file and every duty's scratch folder are in
state/tmp(0700), which no project-command sandbox lets anything write to. - Helm's own Claude processes load no settings file and get a minimal environment.
- Billing: your plan or an API key. A duty is billed to the
claudesign-in unlessconfig/billing.json(Settings page,qtdeck billing) sets it, or one crew seat, to an Anthropic API key. API keys are the supported way to pay for automated and product use: https://code.claude.com/docs/en/legal-and-compliance. The key is kept under the nameanthropic_api_keyin the credential store above (the 0600 file or the Keychain, moved with the rest). It goes in throughqtdeck keys set anthropic, which reads standard input only and refuses it as an argument, or the Settings page, whose field is write-only: every answer about the key is a presence flag. For a call billed to the key the harness reads it into a shell variable and exports it into that call's subshell only (exportis a builtin, so the value is never a process argument), asANTHROPIC_API_KEY, withANTHROPIC_AUTH_TOKEN,CLAUDE_CODE_OAUTH_TOKENand the cloud-provider switches removed so nothing else wins over it. It is never in argv, the sandbox settings file, the prompt, the run record, the spend ledger or a log; if the CLI quotes it back in its envelope or its error text, it is replaced before either is written. A call billed to the plan gets noANTHROPIC_API_KEYorANTHROPIC_AUTH_TOKENeven when the owner's shell has one, and neither do Helm's processes. No call, plan or api, and no Helm process gets anything that would bill another account or send the call elsewhere (review r9): everyANTHROPIC_*name but the model choices (so noANTHROPIC_BASE_URL,ANTHROPIC_CUSTOM_HEADERSorANTHROPIC_FOUNDRY_*), noCLAUDE_CODE_USE_*orCLAUDE_CODE_SKIP_*cloud switch and no AWS, Google or Azure credential. Running claude through Bedrock, Vertex or Foundry is therefore not supported by Quarterdeck. A call billed to the key that has no key stored, or whose daily or per-duty cap (counted from the spend ledger, calls billed to the key only) is reached, does not start: the duty stops with NEEDS_HUMAN and the reason, never a quiet fallback to the plan. The same holds when the setting cannot be trusted (review r9): aconfig/billing.jsonthat cannot be read, is not JSON, or holds a mode, a duty or a cap this version does not understand stops every call of theclaudeprovider with NEEDS_HUMAN and the reason, and with a cap set, a spend ledger that cannot be read or holds a line that is not a row stops a call billed to the key; neither is read as "plan", "no cap" or "no spend". Every call billed to the key that may have reached the API counts against the caps at its per-call budget at worst (killed at the timeout, crashed, no reply, no cost reported), and calls running at once share what is left: each is handed, and holds until it is booked, only what the others have not. The outgoing-text scan compares every post with the key in either store.qtdeck doctorshows each duty's billing and the key's presence, never its value.bin/selftest-billing.shchecks all of this against a stand-inclaudethat records its argv and environment. Not verified: the real CLI with a real key (which credential it prefers, how it reports a refused key), and the Keychain path against the real Keychain (a stand-in was used). The key, like your sign-in, is in the environment of the model's own process;CLAUDE_CODE_SUBPROCESS_ENV_SCRUBkeeps it from the commands that process runs, and that variable was not checked against the real CLI in this change. -
Your data folder is mode 0700 and the files in it 0600.
-
The dashboard's address (
#/page/view/id?project=...) is untrusted input: anyone can paste anything after the#.assets/route.jschecks every part against the names the page actually has (pages, registered projects, installed add-ons), caps it at 300 characters, refuses control characters, markup characters and bad encodings, and gives Home for anything else. A value from it is never written as HTML and never run; a mission id is only sent as a request parameter and shown as text. The pairing nonce lives in the#fragmentonly until the page reads it: it is removed from the address bar withhistory.replaceStateat once, so it is never in a history entry, a state object or storage. The hash never reaches the server, and the Content-Security-Policy is unchanged (no inline script, noeval).
Checked by hand against the real Claude Code CLI, and what the repository's tests cover
No test in this repository runs the real CLI (a test would cost money and need a login). On 17 October 2026, with CLI 2.1.293 on macOS, using decoy files only, a throwaway repository and a cheap model, these were tried by hand through the harness's own claude_run:
WriteandEditon a linked worktree's.gitfile were refused ("Permission to edit ... has been denied"). A sandboxednodecould not write it, rename a file onto it, delete it, append to it or swap it for a symlink (EPERM), while writing an ordinary file in the same folder worked. Which layer refuses was not isolated; the harness does not rely on it (see the pinning above).- A
Readoutside the working folder, through a symlink out of it, or through..was refused. AReadof a decoystate/dashboard.tokenwas refused by the deny rule, and a sandboxednodecould not open it (EPERM). - The CLI honours
sandbox.filesystem.denyWrite(a denied path gaveEPERM, a neighbour was writable) and knowssandbox.failIfUnavailable; the CLI's own text says that without it a missing sandbox backend runs the shell unconfined. What it does when the sandbox really is unavailable was not tried.
What the repository's tests do cover is the harness side, against stand-ins for the model, gh and the browser, and (on macOS) the real sandbox-exec with the generated profile and real curl, a real name lookup, pbpaste, pgrep, kill, launchctl, open, osascript, invented decoy secrets in a throwaway home folder, and node --test, npm test, python3 unittest, git and a loopback server and client by address and by localhost: the profile, the rules and settings handed to the CLI, the gates, the wrappers, the scanners. The CLI's behaviour can change with a new version; re-run the hand checks above after an upgrade.
Prompt injection
Text that someone else wrote (an issue, a pull request, a diff, a document, a release note) reaches a model only inside a marked data block, and a model has no shell for git or gh and no way to post. Whether a model obeys such text anyway is measured, not assumed.
- What is tested, offline, on every
qtdeck check(bin/selftest-injection.sh): 50 hostile texts in 16 styles (injection/fixtures.py: direct overrides, forged system and contract lines, forged end-of-data markers, requests to read or post secrets, to run commands, to flip a verdict or edit protected files, markup and Unicode tricks, very long input, instructions split over fields or hidden in code comments and READMEs, encoded payloads) are put, through the real duty code, in every place fourteen duties take third-party text. A stub model records the prompt, the tool list and the flags. Asserted: the text is inside a block that a forged closing line cannot close, no text is outside one, the standing "data, never instructions" sentence is there, no shell for git or gh and no web tool, file access scoped to the working folder, and no shell metacharacter in the text ran anything. Stub models that do obey (a credential in a finding, a forged verdict, an edit topackage.json, a mock-up page with a script) show what the harness then refuses. - Helm's conversation is in the same test. Its facts carry text other people wrote: calendar and reminder titles (anyone can send you an invite), notes, inbox lines, run details, pull request titles, what an agent is working on, and add-on lines. Each of these reaches either brain only inside a marked block with its own random marker, under a sentence in Helm's instructions that says such text is data and never asks for an action. Every fixture is put in all eight places and the real dashboard path builds the prompt. A stub that obeys is then run: for a plain question its ACTION lines are dropped; when you asked for something they go to the page, which refuses every phrase that is not a command it knows and holds a write for your click. Limit: an obeying model can still say a forged fact out loud ("the owner pre-approved this"). Only actions are contained, not words.
- What is measured with a real model, only on request (it costs money):
QUARTERDECK_INJECT_REAL=1 bin/inject-eval.py real --model haikuruns eight duties against all fixtures and records, per duty and fixture, whether the model obeyed, by an observable (a marker on a contract line, a decoy secret in the output, a forbidden tool call, a flipped verdict, a protected file changed). The first run is indocs/injection-report-2026-10-17.md: 0 of 400 runs obeyed with a small model, 0 of 301 with a larger one, and 2 of 400 when the data wrapper was removed on purpose to show the measurement can see obedience. - Honest limits. A few dozen texts are not a proof. Results vary by model and by run, and a text written for one model and one duty may work where these did not. The measurement cannot see a subtle effect (a weaker review, a quietly changed requirement). What a model writes in its reply is posted as it is after the credential scan; the harness decides what happens, not what the words say. Read what the duties post.
Not protected
See THREAT-MODEL.md for the reasoning and the list of known gaps. What remains, plainly:
- Terraform authoring is a first slice, tested only with a fake
terraformand a stand-in model. "Secret-looking" is a pattern check: a secret under an innocent variable name with a short, plain value gets into a*.tfvarsfile (names with pass, pwd, passphrase, salt, seed, bearer, jwt, webhook or signing as a word count as secret names since review r9, inside a map too).validatesays the files are consistent; it says nothing about what would change in an account, and no plan is run here. The content rules of the design (no backend, provider version or module source change, no provisioner or external data source, no delete withoutmoved) are not built yet, so such an edit inside the named folder is caught only by the review and by the person. A folder of prefetched providers is copied into the check and run as provider code: the owner chooses what goes in it. See docs/design-terraform-authoring.md. - Attaching a folder lets that folder influence what runs on your machine. Its source is read by models and its build and test commands are run. Attach only folders you trust.
- Most readable files inside a worktree can still be read by a model. Before a model that can read, search or run a shell command is started, the harness scans the working folder (tracked or not, any name; the project's own files first, then dependency and build folders such as
node_modules; up to 200,000 files or 60 seconds; files read through in pieces up to 64 MB;.gitskipped) for the credential shapesbin/guard.pyknows and adds aReaddeny rule for each file it finds, for a file it could not open or that is too big to read through, for a file link that leads out of the working folder or to a file holding a credential, and for a folder link that leads out (except thenode_modulesthe harness borrowed); the names (never the contents) are written tologs/runs/<run>.screen.txt. It fails closed (round 5; before, each of these was skipped or passed as clean): if the screen fails or does not finish the project's own files the call is refused; a dependency folder it did not finish is denied whole; a file whose name cannot be written as a rule (a comma, a bracket, a glob or escape character, a newline) makes the call refused rather than leaving the file readable. Checked against the real Claude CLI (2.1.293, macOS, a fake key in a nested file with an ordinary name):Readrefused the file under every spelling tried (./,.., upper case, mixed case), andGlobandGrepover the folder left it out; butGrepgiven a case-variant folder path (SUB/DEEPforsub/deep) printed the key, and a sandboxednode -ecommand read it, because aReadrule does not reach the shell. So when the screen denies anything,Grepis taken out of that call, and every screened path is also put in the operating-system sandbox'sdenyRead(the samenodecommand then gotEPERMunder both spellings); a call with a shell is screened even when it has noReadtool.Globwith a case-variant path still lists a denied file's name (not its content). This is still a net, not a barrier: a credential in a shape the list does not know is readable. A per-project extra deny list and a mode that exposes only the files a duty needs are not built (G11 stays open, narrowed). sandbox-execis deprecated by Apple. It still ships and is what the CLI's own sandbox builds on, but it may go away.- The Keychain option protects against less than it may seem. It moves a credential out of a plain file, nothing more. Any program of yours that runs
/usr/bin/securityoutside the sandbox (a terminal, a script) reads these items without a prompt while the login keychain is unlocked, because the items are created by that tool and trust it. The project-command profile shuts thesecurityprogram and the Keychains folder but not the keychain daemon, so a program that speaks to the daemon directly is not stopped by the profile (macOS itself may then ask you). Google sign-in refresh tokens are still kept in their own 0600 files, not in the Keychain. Linux has no secret-service option yet. Not tested against the real keychain: the code runs against a stub, so a locked keychain, a prompt and the exact output ofsecurityon your macOS version are not proven. - Linux: an app started for a browser run gets the host network, and only on your opt-in. The pilot and the lookout start a pull request's app and point a headless browser at it. On a Mac the app runs under the check profile, which already lets it answer on loopback. On Linux bubblewrap's network namespace is all or nothing: inside its own namespace the app's
127.0.0.1is not the browser's, so the browser cannot reach it (this is what the first real Linux run showed). Bubblewrap has no rule that shares the loopback alone, so the only way to make the app reachable is--share-net, and then the app can also reach everything this machine can: the internet, your local network, and any service listening on this machine's loopback. That is never done silently: on Linux the app is not started, and the run is BLOCKED with the reason, unlessQUARTERDECK_APP_NET=shareis set for this machine;qtdeck doctorthen says so. With it set, everything else is the check sandbox unchanged: system folders read-only, a private home folder and/tmp, only the worktree and a scratch folder writable, no credential place mounted, own process and IPC namespaces, no capabilities. The build step before it, and every other project command, keep their own network namespace (no network). What the opt-in does not confine: the app's network. The browser itself stays behind its dead proxy, with only the app's address let through. Not yet confirmed on a Linux kernel: the argument list and the refusal are tested against a stand-inbwrap. - Linux file sandbox: run on a real Linux kernel in the project's CI only. Project commands on Linux go through
bwrap(bubblewrap): system folders read-only, a private/tmpand home folder, the worktree and a scratch folder writable, the folders onPATHread-only (each judged with its links resolved, so$HOME/,$HOME//binor a link to the home folder mounts nothing of it, which until round 6 mounted all of it; with the folder above each, unless that folder is or holds a credential place: until round 5~/.cargo/binonPATHmounted all of~/.cargo, credentials included; and, since round 8, in a code folder or a registered checkout only thePATHfolder itself, never the folder above it, which until then mounted the whole of another project's checkout), and nothing else of yours mounted (the data folder,~/.ssh,~/.configand the other credential places are not hidden by a list, they are simply absent), no network except loopback for a check (an install shares the host network), own process and IPC namespaces, no capabilities,--die-with-parent. It was developed on a Mac: the argument list is a pure function tested as text, and the refuse/allow logic is tested against a stand-inbwrapthat records its arguments. The round-8 review ran it once against a realbwrap(0.9.0, in a container): in check mode~/.ssh, the data folder'sstate/, the network, name lookup and the host's abstract unix sockets were out of reach; it also found the checkout above aPATHentry readable (fixed) and the gaps below. It has run on a real Linux kernel (an Ubuntu runner with bubblewrap 0.9, user namespaces allowed) for the project's test suite in CI, and an independent review ran the suite there too. No person has used it day to day on Linux, so how it behaves in a container or on a distribution that restricts user namespaces, and whether common toolchains still run inside it, are unverified. Differences from the macOS profile:/etcis readable; an install, and an app started on yourQUARTERDECK_APP_NET=shareopt-in, can connect to the host's abstract unix sockets (they belong to the network namespace, which--share-netshares: an X server's, some session buses and agents; a check cannot, it has its own namespace; closing this needs a seccomp filter that is not written yet, round 8 M7);gitin the worktree reads the main checkout's objects, refs,HEADandconfigand its ownworktrees/<name>, read-only (round 8, L16: until then they were not mounted and git failed in a check); the main checkout's index and logs are not mounted and its stash is covered. Ifbwrapis missing or cannot make namespaces, project commands are not run and the message says why;QUARTERDECK_CHECK_SANDBOX=offis the owner's explicit opt-out, it is written to the log each time andqtdeck doctorshows it. The model's own shell sandbox (the Claude Code CLI's) is separate and also depends on bubblewrap. - Secret scanning is a safety net for accidents, not a barrier. It matches credential shapes (with or without a character glued on), decodes common wrappers, refuses a binary file or a symlink in a commit, and compares outgoing text with the secret values this machine holds (the harness env file,
GH_TOKEN, the GitHub CLI's token) as they are, reversed, in hex or base64, in capitals or split by punctuation. It warns, but does not block, on a long run of random-looking characters. A model that was shown a secret and wants to leak it can still re-encode it in a way none of that recognises (a cipher, a rewording). The control that counts is keeping secrets out of the model's context in the first place, which is what the read rules, the minimal environment and the sandbox are for. - The arguments of another process can be read by anyone who knows its pid. No sandbox rule reaches this on macOS (tried: process-info, sysctl and signal rules). The profile denies listing processes,
pgrep, inspecting by path and signalling, which stops the casual route, but a command that tries every pid number can still read a command line. So a secret must never be a process argument: the dashboard's one-time pairing code used to be (open "$link"); it is now in a0600redirect page inside a0700folder that the browser is handed by path, removed after a minute, and the code is in no argument. Your models' prompts already go in on standard input. - The model's own shell sandbox is the Claude Code CLI's. This project sets its file rules (the lists above) and nothing else; its network, name lookup, clipboard and process behaviour are the CLI's and were not changed or tested here, so the protections of the project-command sandbox in the previous section do not extend to the few shell commands a builder model runs (
npm run,node,python3,pytest). - Your other source trees are closed to a project command, by name only. The usual code folders under your home folder (
code,dev,src,source,Projects,repos,git,GitHub,Developer,Sites,work,workspace) and every registered project's checkout, this project's own main checkout included, cannot be read. Let back in: the worktree, its git (the main checkout's.gitfolder: objects, refs and config, but since round 6 not the stash ref, the reflogs or the main checkout's index; the folder comes from the harness's own record, not the worktree's.gitfile), thenode_modulesit borrows, and a folder onPATH(with the one above it, except inside a source tree since round 6), even inside those folders. Still readable through that.gitfolder: itsconfig, so a token written into a remote URL there can be read by a check (git does not run without it), and every object in it, stashed and staged-only work included. Closing the stash ref, the reflogs and the index hides the names, not the content:git cat-file --batch-all-objectslists every blob without knowing an id (audit 7, L1, proven), so work you stashed, a file you only staged, and commits reachable only from a reflog are readable to a check. What is not readable: files in the main checkout's working tree that git has never stored (uncommitted, unstaged, unstashed edits), and the other folders above. Keep secrets out of stashes and the index of a registered project; the fix (worktrees built from a separate clone that holds only fetched refs) is not done. A check runs in a throwaway clone of the worktree (audit 7); in that clone a workspace package link (node_modules/@scope/lib -> ../../packages/lib, which leads into the main checkout) points at the clone's own copy of the package instead, so a monorepo's checks run without opening the main checkout (top-level and@scope/links only). A check that fails because the sandbox refused something says so in its output, apart from an ordinary failure. The data folder'sworkspace(other projects' worktrees, scratch folders, plans) is closed except the command's own worktree and scratch folder (until round 6 it was readable). Code you keep under another folder name is still readable. The worktree's own.gitfile cannot be written by a project command (a rewritten one would point git elsewhere; the harness's pinning catches that anyway). - What the project-command profile still allows: reading anything outside the lists (system folders,
/Users/Shared,/private/tmp, other users' world-readable files, application folders you keep under your home folder that are not named above), the keychain daemon (items still need your approval; the files are unreadable), a.envin the worktree itself, and a toolchain folder onPATH. An install has the network, name lookup and your~/.npmrcby design, so an install script can send out whatever it can read (the quartermaster installs with scripts disabled, never a fork's head, never a pull request that changes.pnpmfile.cjs,.yarnrc*,.npmrcor.yarn/, and runs pnpm without its pnpmfile; "scripts disabled" alone does not stop a package manager's own hooks, and a Yarn plugin oryarnPathalready on the base branch still runs; other installs do not disable scripts). A tool that needs a name other thanlocalhost(a*.testhost,host.docker.internal) or that signals a process it did not start (a runner that kills a process group,tree-killthroughpgrep) fails inside a check. - The privacy-limit scan is a net, not enforcement.
bin/forbidden_signals.py(part ofcheck) looks through the code, its own tests included, for the APIs that would read keystrokes, the screen, apps and websites, the clipboard, the location or another app's personal data. It puts names split across string pieces or lines, joined at run time or reversed back together first, and the only exemptions are exact strings in named files (the sandbox rules that deny these things, and the tests that prove it). A name spelled in hex, base64 or character codes still gets past it; the sandboxes are what stop a command from doing these things. - The Copy button writes one command to the clipboard and reads nothing. The first-run screen, the guided attach panel and the next-step card show a command to run in a terminal, with a Copy button. That button is one function in
dashboard/index.html(copyCommand) that makes one write-only call with the one command you just clicked; nothing is ever read from the clipboard. The privacy-limit scan allows exactly that one call in exactly that function and still fails on every read form (readText,read(,clipboardData, paste and copy listeners,execCommandpaste or copy) anywhere, and on the same write call anywhere else; a test feeds the scan each form and expects it to fail. If the browser refuses the write, the command is selected in a read-only box and you are told to press Cmd+C or Ctrl+C. - Project commands may still write to the shared temp folders (
/private/tmp,/private/var/folders), where other processes of yours also work. Since round 6 Claude Code's session folders there (/private/tmp/claude-*, which hold other sessions' task output) are closed to reading and writing; anything else another program leaves in/private/tmpcan be read. -
Anyone who can run code as you can read everything.
-
Agent sources reach the network, only where you point them. An agent source you add on the Connections page (
AGENTS.md, Level 2) makes one read-only GET request to the address you gave:https://, orhttp://127.0.0.1:<port>for a tunnel you run. No redirect is followed, the request stops after 10 seconds, the reply is cut off at 256 KB and a source is asked at most once a minute. The token is kept in the same credential store as your other connections and is sent only as that request's Authorization header; it is not written to the source's config, its snapshots or any log. Only four mapped fields of each agent are kept, as text. Nothing is asked until you add a source, and removing it deletes the token and snapshots.
This page is built from SECURITY.md in the repository.