Threat model
What Quarterdeck protects, from whom, and how. Written from the code in bin/dashboard.py, bin/fleetview.py, bin/scan.py, bin/attach.py, bin/guard.py, bin/outscan.py, bin/protect.py, bin/safegit.py, bin/mockup.py, bin/lib.sh, bin/claudeproc.py and bin/fleet-hook.sh. It has been attacked three times by reading the code and running proofs of concept; each finding became a test that failed before its fix. Where something is not done, it says so. To report a problem, see SECURITY.md.
This is a single-user tool for your own machine. It is not designed to be exposed to a network or shared between users.
What runs where
- Dashboard.
bin/dashboard.py, one Python process, bound to127.0.0.1only (default port 7878). It serves the page, reads state, and starts runs. Under launchd on macOS it is restarted if it exits. - Runs. The dashboard (or you, or the scheduler) starts
bin/qtdeck run <project> <duty>as an argument list, never a shell string. A duty calls the model provider's command line tool throughclaude_runinbin/lib.sh, which decides what that model can do (below). The harness script, not the model, fetches GitHub text, commits, pushes and posts, and only with--apply. - Project commands. The harness runs a project's own verify, test, build and install commands (from the registry), in a worktree, with a minimal environment and, on macOS, inside
sandbox-exec. The pilot and lookout run a pull request's build and start commands the same way, and only for a branch of the repository itself, never a fork. - Data folder. Your registry, state, logs and connector files live in
~/.quarterdeck(mode 0700, files 0600), apart from the program. - Local voice and chat model. Started on first use by the dashboard, listening on
127.0.0.1on its own port with a random per-process key, stopped when idle. - Optional hook.
bin/fleet-hook.shcan be installed into the provider tool's settings. It appends one line per session event to a file in the data folder.
Assets
- Credentials: provider login, GitHub login, connector tokens in
~/.config/quarterdeck/andstate/connectors/, SSH and cloud keys. - Your source code and the registered project folders.
- Private notes: capture, agenda, intent, rhythm tasks, attention data in
state/. - The ability to run agents and spend model usage.
- The integrity of what gets pushed, commented or merged on your behalf.
Trust boundaries
- Browser to dashboard: any web page you have open can send requests to your loopback port. Loopback is not authorisation.
- Dashboard to child processes: only registry-validated projects and duties, a fixed list of flags per duty (
--applynever among them; a flag the duty does not take is refused with a 400 before a run starts), values limited to a safe-character pattern; numbers (--pr,--issue) are digits only. - Model to harness: model output is untrusted text. It becomes an action only by parsing as a contract, and only with an explicit apply.
- Project folder, issue and pull request text to model and to harness: written by other people. Attaching a folder lets it influence what runs on your machine: models read its files and the harness runs its build and test commands. Attach only folders you trust.
- Agent to data folder: private files must not be readable by an agent working on a project.
What a model call can do (claude_run)
| Control | What it does |
|---|---|
| Tools | --tools names the built-in tools that exist: Read, Grep, Glob for review duties (no shell at all); builders add Edit, Write and a few named commands. A tool list with git, gh, curl, ssh, sudo, WebFetch or an open shell is refused before any model runs. git diff --output= overwrites any file, which is why no model gets git. |
| Paths | Read, Edit and Write are allowed only in the working folder; Grep and Glob get no allow rule that would lift them off it. Tried by hand against the real CLI (2.1.293, 17 October 2026; see SECURITY.md): a read of a file outside the folder, through a symlink out of it, or through .. is refused. No repository test runs the CLI. |
| Deny list | Data folder private files (under the data folder and the program folder: connectors, capture, agenda, attention, intent, rhythm tasks, project status, 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, .env*, *.pem, *.key, *.p12, and writes to .git/, to a worktree's own .git file, .claude/, .mcp.json. Deny rules win over allow rules. QUARTERDECK_PRIVATE_HOME and QUARTERDECK_PRIVATE_DENY can add to it, never remove. |
| Sandbox | A call with a shell runs inside the CLI's sandbox: the same private list unreadable, writes only in the working folder and not to its .git file, no unsandboxed escape, and failIfUnavailable so a missing sandbox is an error, not an unconfined shell. This project sets only the file rules (plus, for a model's shell, .env*/*.pem/*.key/*.p12 anywhere under your home folder and the personal folders Documents, Desktop, Downloads, Application Support, Messages, Mail and the like, left out where they hold the working folder); the CLI's own network, name lookup, clipboard and process policy applies and was not checked here (Gap G18). |
| Settings | --setting-sources "" and --strict-mcp-config: a folder's .claude/settings.json, hooks, .mcp.json and your own user settings are not loaded. QUARTERDECK_SETTING_SOURCES=user loads yours. |
| Environment | env -i with PATH, HOME, TMPDIR, locale, proxy and the provider's own variables only. Nothing from ~/.config/quarterdeck/env, no GH_TOKEN, no QUARTERDECK_*. |
| Arguments | The prompt goes in on standard input and the sandbox settings in a 0600 file removed after the call, so neither appears in ps. The file lives in state/tmp (0700), a place no project-command sandbox can write, so a hostile command running in parallel cannot rewrite it. |
| Provider | Every control above is built by claude_run before the provider is called; an adapter only starts its program with those inputs (docs/design-providers.md). This version has claude (the default) and fake (tests only: it answers only from a script a test names, in an env -i environment, and records what it was handed). A provider name that is not one of these, from any setting, is refused before any model runs, and so is a call whose registry setting cannot be read (review r9: it never falls back to claude). bin/selftest-providers.sh pins the claude process's arguments and environment to a record made before the adapter seam existed (made again when billing took the shell's ANTHROPIC_API_KEY out of plan calls, and when review r9 took cloud switches and cloud credentials out of every call); the injection checks run through both. |
| Helm's own processes | The dashboard's Claude processes (the long-lived chat process and the one-shot path) load no settings file (--setting-sources ""), run with every tool off in an empty folder, and get a minimal environment: no GH_TOKEN, no QUARTERDECK_*, no API key, no cloud switch or cloud credential and no gateway URL or header (Helm is billed to the plan; review r9 A1). |
Threats
| Threat | Mitigation in the code | Residual |
|---|---|---|
A web page you visit POSTs to 127.0.0.1:7878 (CSRF) |
Every POST needs an X-Quarterdeck-Token header, a custom header that forces a CORS preflight; the server sends no CORS headers. A write from a paired browser also needs the browser to say it came from the dashboard's own page: an Origin equal to the server's host, or, with no Origin, Sec-Fetch-Site: same-origin; anything else is refused (including Origin: null, another loopback port and a request with neither header). Token compared with secrets.compare_digest. |
None known for this path. |
| DNS rebinding | Host must be 127.0.0.1 or localhost on every GET and POST. |
[::1] is refused (safe, but unsupported). |
| Another local user or local process | Binds loopback only. The page is served with no secret: a one-time link (qtdeck dashboard, nonce in the URL fragment, single use, 5 minutes) is traded for an HttpOnly, SameSite=Strict cookie and a per-session token, handed out once and kept in that tab's sessionStorage (per origin, port included). The server never gives the token out again: GET /api/session only checks a token the tab already holds and answers without it. A reload in the same tab keeps working; a new tab needs a new link (qtdeck dashboard link). Every API route but /api/health needs the cookie; writes also need the session token and the browser's word that the request came from the dashboard's own page (Origin equal to the server's host, or Sec-Fetch-Site: same-origin). A session lasts 24 hours from pairing; only its own tab (cookie and token) renews it, when that tab is reloaded past half that, so a cookie on its own never keeps a session alive and a tab that is never reloaded stops working 24 hours after pairing. It ends on logout, on qtdeck dashboard sessions revoke (a running server re-reads the file) and on every start of the dashboard; the owner's one next action after a restart is qtdeck dashboard link. Wrong nonces are slowed after ten a minute, but a valid link is always redeemed: a page that posts junk cannot lock you out. The command-line token (state/dashboard.token) opens reads and the two Helm endpoints only, and the token, the session file and the pairing folder are on the private list, so no model and no project command can read them. /api/health is unauthenticated by design (liveness, no paths or contents) and caches its one git call for 30 seconds. |
Cookies are per host, not per port: any other web server on 127.0.0.1 that you browse to (another program's, or a project's own dev server) receives the session cookie. With it, that server can READ everything the dashboard shows, until the session ends (a day after its own tab last renewed it, on logout, at qtdeck dashboard sessions revoke, or at the next start of the dashboard). It cannot write while the dashboard runs: it never sees the token, which only the paired tab holds. The exception is a program that binds the dashboard's own port while the dashboard is down: the paired tab then sends it the cookie and, on a write or a reload, the token. The sessions end when the dashboard starts again, so it keeps nothing, but do not use a paired tab while the dashboard is stopped. Do not browse to untrusted web servers on loopback while the dashboard is paired. A process running as you, outside the sandboxes, can read the token and Helm's chat is open to it. |
| Script injected into the dashboard page | No inline event handlers or code built from data: controls carry a named action and JSON arguments, dispatched through a fixed table. A content security policy with a per-response nonce (script-src 'self' 'nonce-...', object-src 'none', base-uri 'none', form-action 'none', frame-ancestors 'none'). Tests run hostile text through every path and a static check forbids handlers and dynamic code. |
style-src allows inline styles (the component library and the page style by attribute) and connect-src/img-src allow data: (the library's inline icons). These cannot run script. |
| Injection into a command | A run is argv. Project and duty must be in the registry; each flag must be one that duty takes (a fixed list per duty, from DUTY_FLAGS) and also match ^--[a-z][a-z-]{0,20}$; --apply is refused in args, writing is requested only by the apply field; values match a safe-character pattern (no leading dash, no .., no absolute path); the request body is capped. Duties take --pr and --issue as digits only. |
Values may still contain characters that matter to the receiving program. They are passed as separate arguments. |
A hostile folder is scanned (attach, scan.py) |
Only reads: no links followed, depth, file count and manifest size capped, secret-named files listed by name and never opened, no code run, no git command; the repository name from .git/config must be a plain GitHub owner/name. Registering needs apply plus confirm: true; the proposal carries a warning and lists the agent set-up found. Registry, profile and record are 0600. |
Gap G7. The folder path in /api/attach can be any directory. |
| A hostile build or test command reads your other code | A project command cannot read your usual code folders or any registered project's checkout; the worktree, its git, its borrowed node_modules and folders on PATH are let back in, and it cannot write the worktree's .git file. Proven with the real sandbox-exec in a throwaway home (bin/selftest-security.sh, A1/A2). |
Code under a folder name not on the list is readable. The main checkout's .git is readable, staged but uncommitted content included. |
| A hostile folder is registered | Models never load its settings, hooks or .mcp.json. Git runs in it through one wrapper (safe_git in the shell, bin/safegit.py in deck, adr, fleetview, publish; flags only in the fleet hook and guard): hooks, fsmonitor, the repository's ssh command, ext::, the pager, signing and the repository's credential helpers off (yours are kept). A folder whose own git config defines filters, textconv, an external diff, merge drivers, any gpg program, url.<x>.insteadOf, includes, a URL credential helper, a git proxy, an attributes file or an uploadpack is refused by every duty (acquire_lock), by safe_git and by the Python tools, with a message and the opt-out "trusted_git_config": true. The default branch is checked to be a plain branch name at attach and before every duty. New projects forbid CI, manifests, lockfiles and tool configs by default. |
Its source is still read by models and its build and test commands are still run (next rows). Git keys the list does not know are not refused (the list is bin/gitrisky.re; a new git feature that runs a program needs adding). The opt-out turns the refusal off for that folder. |
A model repoints the worktree's .git file at a repository it made |
The file is pinned when the harness makes the worktree (worktree_pin); every later git command, including the push, compares it with what was written and runs with GIT_DIR/GIT_WORK_TREE; a changed file is a refusal before any git command. Edit/Write are denied on it and the CLI's sandbox closes it to writes. The real CLI refused every write to it when tried by hand. |
The pin protects the harness's git; it does not stop a model from deleting its own worktree. |
| Prompt injection: an issue, pull request, comment or file tells the agent to do something else | The model has no shell and no git or gh in review duties; everything third-party reaches it through untrusted() with a per-call random delimiter; output must parse as a contract; the harness applies it only with --apply. Per-call dollar cap and timeout. |
An injected model can still write misleading text into a contract (a false review, a bad issue body); a person reads it. Builders edit files in a throwaway worktree; their change reaches a pull request a person reviews and merges. |
| Prompt injection through Helm: a calendar invite, note, inbox line, pull request title or add-on line tells Helm what to say or do | That text is in Helm's facts only inside a marked block with a random marker, under a standing sentence that it is data. An ACTION line is dropped unless your own words asked for something; the page runs only a phrase it knows, and every write waits for your click. Tested with every fixture in each place and a stub model that obeys (bin/selftest-injection.sh). |
An obeying model can still say a forged fact to you. Calendar and note text goes to the cloud brain only when you include them. |
A model-made change turns the next harness step into code execution (a package.json script, a test config, a CI workflow) |
bin/protect.py + model_diff_gate: a change to .github/**, manifests, lockfiles, .npmrc, *.config.*, tsconfig*, Makefile, scripts/**, Dockerfiles, .claude/**, .mcp.json, and the test-runner, linter and loader files (conftest.py, pytest.ini, .babelrc, .mocharc*, jest.setup.*, .eslintrc*, *.setup.* and more) is refused before any check runs, unless the project's protected_allow or the Where field of a plan item names it (issue text and a plan's prose cannot); .env*, .cargo, .yarn, .pnpmfile.cjs, vercel.json, netlify.toml, CODEOWNERS and Terraform files are on the list too. Paths are read unquoted (-z, core.quotepath=false), compared the way APFS compares names (casefold, and an accent spelled either way), and the files .gitignore hides are examined too (those added since the model started). Round 8 added the other ecosystems' lockfiles and task manifests, editor tasks, dev containers, agent instruction files, other CI systems, make include files and more deploy-platform config, and keeps every model-written field of a ruling on one line, so a Where line cannot be forged inside another field. A change to what the verify command is made of (package.json beyond its metadata and dependency keys, composer scripts, deno.json, a Makefile or a make include file) is refused even then. The gate runs a second time after the checks (until audit 7 they ran project code that could write in the worktree; they now run in a throwaway clone of it and can write only that clone: a model-written test made node --test write .github/workflows/pwn.yml and edit package.json after the first gate, and git add -A committed them): same baseline, refusal on a protected path, other files the checks made are discarded, and only the files that passed the first gate are staged. |
A plan is a model's ruling on issue text, so protected_allow is the safer unlock. The list is a list: a build file it does not name is not protected. |
| The harness runs a project's verify, test, build or install command | env -i with a scratch HOME; on macOS inside sandbox-exec: the private list and all of the data folder's state/ and logs/ unreadable, writes only in the worktree, a scratch folder, the temp folders and a few device files, and (check mode) the network limited to loopback: three separate rules (outbound to localhost, bind and inbound on localhost) and no unix socket at all (the system resolver's carried arbitrary hostnames out, round 3; localhost resolves from /etc/hosts, any other name does not resolve in a check). Also denied, probed with the real sandbox-exec: the clipboard, listing, inspecting by path and signalling processes other than itself and its children, launchd (launchctl), Launch Services and Apple events (open, osascript), the programs open, osascript, security, pbcopy, pbpaste, launchctl, and the credential and personal folders (~/.config, ~/.docker, cloud and package-manager credential folders, history files, .env*/*.pem/*.key anywhere under the home folder, Documents, Desktop, Downloads, Application Support, Messages, Mail ...; contents only, so stat still works), with the worktree, scratch, borrowed node_modules and PATH toolchains let back in. Installs may use the network (and keep name lookup and ~/.npmrc). A Mac whose sandbox cannot be applied runs nothing. The rule is tested with the generated profile and real curl -4, curl -6 and a loopback server. The first form of the rule, a single allow mixing local-ip and unix-socket filters, let everything out and was caught by the second audit. |
Gaps G5, G16, G17. On Linux the file sandbox is bubblewrap (bwrap), written but NOT run on real Linux; without it the command is refused unless QUARTERDECK_CHECK_SANDBOX=off. On Linux the app the pilot and the lookout start for the browser is refused unless QUARTERDECK_APP_NET=share, and with it the app has the host network (bubblewrap cannot share the loopback alone); its files stay confined as in a check. sandbox-exec is deprecated by Apple (still shipped). A command can still write the shared temp folders and read any file not on the private list. |
| A project command (or a test a model wrote) reads credentials or personal files and sends them out | The profile above: credentials and personal folders unreadable, no unix socket and no name lookup in a check (so no hostname-encoded exfiltration), no clipboard, no open/osascript to hand data to a browser or another app, loopback only. Probed for real with invented decoys in a throwaway home folder, and ordinary builds (node --test, npm test, python3 unittest, git, a loopback server by address and by localhost) are tested under the same profile. |
Gap G17. An install has the network. A file outside the lists is readable. |
| Another process reads the dashboard's one-time pairing code from a command line | The link is no longer a process argument: qtdeck dashboard hands the browser a path to a 0600 redirect page in a 0700 folder of the private state folder and removes it after a minute (QUARTERDECK_OPEN_KEEP). A test replaces open with a stand-in that records its arguments and every command line ps and pgrep show at that moment. |
Gap G16. The code also sits in that file for a minute, and in your terminal, which prints it. The link is single use and lasts 5 minutes. |
| A pull request from a stranger is built, installed or started | Pilot, lookout, inspector and quartermaster refuse a pull request whose head is on a fork, or whose origin cannot be told (isCrossRepository missing counts as a fork), before any command runs. The quartermaster also refuses a fetched head that is not the sha the pull request metadata reported, takes its changed-file list from the fetched tree (never GitHub's listing), holds without installing when a pull request changes packageManager, devEngines, pnpm-workspace.yaml, bunfig.toml or any .pnpmfile.*, and runs installs with COREPACK_ENABLE_NETWORK=0. Same-repository branches need write access to push. Their build, start and verify commands go through px_prepare/project_exec like every other project command. |
A collaborator with write access can still push code that runs sandboxed on your Mac. |
| The app under check (a pull request's code) sends data out of the browser the pilot or lookout drives | bin/browse.mjs launches Chromium with its renderer sandbox on, behind a dead proxy held by the driver (accepts and drops every connection) that only the app's host:port bypasses (<-loopback> removes the implicit loopback bypass), --host-resolver-rules mapping every name but the app's host to not-found, WebRTC limited to proxied UDP (disable_non_proxied_udp), service workers blocked, plus Playwright's routing and WebSocket routing for the page. Until round 7 only the routing existed, and a worker's fetch and WebSocket, WebRTC STUN/TURN and a redirect from the app reached another port (route() sees only a redirect's first address). bin/selftest-pilot.sh proves it with a real browser: a decoy on a second loopback port (TCP and UDP) receives nothing from fetch, img, preconnect, a 302, WebSocket, dedicated, shared and service workers or WebRTC, and Chromium's network log shows no name lookup. Skipped, and said so, where no browser is installed. |
Shown only with Google Chrome 155 on macOS (Playwright 1.64). The flags are switches of Chromium's, not a sandbox: a browser that drops one silently would lose that layer, and the live check is what notices. The app's own server process is not confined by this; it runs under the project sandbox (loopback only). |
| A mock-up page hides a meta refresh from the checker | bin/mockup.py refuses every comment, a NUL byte, and any end tag of style, title, textarea that is not exactly that (the places where html.parser and a browser disagree), and saves the parser's own re-serialisation of the page, which is what is served. Served with default-src 'none', no framing, sandbox. The bypass strings were shown to navigate in real Chromium when served as written. |
A browser feature the parser has not been taught could still differ; navigation is governed by a CSP directive no browser implements, so the defence is the parse. |
| Agent reads private files (notes, connector tokens, SSH and cloud keys) | Path-scoped Read/Grep/Glob, the deny list, and the sandbox for shell commands, all from one list built from the data folder and the program folder (a test fails if they differ). |
Any other file in the working folder is readable, including a committed credential with an unusual name. On Linux the CLI's sandbox depends on bubblewrap; Linux is untested. |
Connection credentials at rest and in use (bin/integrations/) |
Default: one 0600 file in a 0700 folder under ~/.config, which is on the deny list. Opt-in on macOS: the Keychain through security, the secret on stdin (hex) and never in argv, a confirmed, verified move between the two, presence flags only in the page and API, no value in any log. The model-call and project-command environments are built from a fixed list and never hold a stored value (tested with the credential in the store); a model call is denied Bash(security*); the project-command sandbox does not start security and cannot read ~/Library/Keychains. Tests use a stub security. |
Gap G19. The Keychain is not a barrier against a process of yours running security outside the sandbox, or against one that talks to the keychain daemon (not blocked in the sandbox); it is not tested against the real keychain. Google refresh tokens stay in 0600 files. No Linux secret service. |
| The harness posts or pushes a secret a model saw | gh_post scans every argument (title, body, --body=..., -bTEXT, labels, branch names) and the body file in any spelling; safe_git scans a commit message, pushed branch and tag names and the lines a commit adds. Text is normalised first (NFKC, zero-width and soft-hyphen characters and non-ASCII hyphens removed), a key split over lines, quotes or + is rejoined, and base64 runs are decoded. Patterns: bin/guard.py plus JWTs, passwords in URLs and labelled secrets. A hit refuses with rc 3 and nothing is sent. |
Pattern based: an unusual secret format, or a layered encoding, will not match. File contents a model adds are scanned as added lines of a commit, not as whole files. |
| Secrets in the data folder | umask 077; folder 0700; files the harness writes 0600; the hook's settings backup 0600; home migrate does not follow symlinks. Integration env file refused if group or world readable. |
The data folder is not encrypted. Anyone with your account, or a backup of it, can read it. |
| Secrets committed to the repository | bin/guard.py scans every tracked file (and history with --history), reading file names unquoted so a non-ASCII name is scanned too; the optional pre-commit hook scans the repository it is installed in. |
Pattern based. |
| Local voice and chat model | Loopback only, random Bearer key in memory. The helper gets an ephemeral port and the dashboard sends the key or any text only after checking that the listener is its own child process (and re-checks every 30 seconds); it fails closed if it cannot check. The deep-brain process has every tool off, runs in an empty temporary folder, and its prompt goes in on stdin, not in its arguments. | Needs lsof or ss to verify the listener; without one the helper is not used. |
A planted add-on: a prompt or a project command writes addons/ext_x.py in the data folder, which the dashboard would import and run as you |
Only you can write there: the folder is on the model's deny-write rules and in the sandbox's denyWrite and profile (one list, protected_write_paths in bin/lib.sh; the security self-test compares the renderings and tries to create the file from a model's shell). The loader also needs the folder to be a real 0700 directory and each file a regular file you own that others cannot write, runs the bytes it read from one checked descriptor, and skips (and logs) the rest. QUARTERDECK_ADDONS=off loads none from the data folder. |
An attacker who already runs code as you can still write there (see What is not in scope). An add-on you install yourself has your full rights: read it first. Level 1 reporting (qtdeck fleet report) is not code: it appends a fixed-shape line and cannot run anything. |
| The optional hook | Records time, event kind, session id, folder name, branch and agent name, only for registered repositories, umask 077, always exits 0. Install and uninstall edit the settings file with a 0600 backup and only touch their own entries. |
It runs on every session event on your machine. |
| Runaway spend or a loop | Per-call cap, a daily budget, an idle stop on long processes; a duty already holding its lock is skipped. | Caps are limits, not alarms. |
What is not in scope
- An attacker who already runs code as you. They can read the data folder and the token file.
- A malicious provider command line tool or a compromised
gh,gitorjqon your PATH. - Exposing the dashboard beyond loopback (a reverse proxy or a tunnel). Do not.
- Another web server on 127.0.0.1 that you browse to while a browser is paired: it receives the session cookie (cookies are per host, not per port) and can read the dashboard with it, though not write.
- What your own GitHub account is allowed to do: the harness posts and pushes as you.
Known gaps
| # | Severity | Where | What |
|---|---|---|---|
| G1 | Closed | bin/dashboard.py, GET / |
One-time link, cookie and per-session token (see the table above). |
| G2 | Closed | dashboard/index.html, Inbox |
No inline handlers; CSP with a nonce; tests with hostile text. |
| G3 | Closed | bin/dashboard.py, local model |
Ephemeral port and a check that the listener is the child process. |
| G4 | Closed (was High) | duties/pilot.sh, duties/lookout.sh, duties/inspector.sh |
A stranger's fork pull request had its build and start commands run as the owner (real HOME, ~/.ssh, ~/.config/gh and the dashboard token readable). The second audit rated this High, not Medium. Now: a fork is refused before anything runs; the commands of a branch of the repository itself run through px_prepare (scratch HOME, minimal environment, the macOS sandbox). |
| G5 | Mitigated, run on Ubuntu in CI only | bin/lib.sh, _qd_bwrap_argv, project_exec |
Linux project commands now go through bwrap (allow-list mounts, no network but loopback for a check) or are refused; QUARTERDECK_CHECK_SANDBOX=off opts out, logged and shown in doctor. Written on a Mac and tested as an argument list and against a stand-in bwrap; it has also run on a real Linux kernel (Ubuntu, bubblewrap 0.9) for the test suite in CI. Not used by a person on Linux; containers and other distributions untested. An app started for a browser run (serve) is refused on Linux unless QUARTERDECK_APP_NET=share, which gives that app alone the host network (--share-net), shown in doctor. With --share-net (install, and serve on the opt-in) the host's abstract unix sockets are reachable too, because they belong to the network namespace (round 8, M7: no seccomp filter yet); a check has its own namespace and reaches none. |
| G6 | Closed | bin/dashboard.py |
HEAD returns 405, a negative Content-Length is 400, bodies that are too large are 413, and every /api route needs the session cookie. A connection idle for 15 seconds is closed, a request must arrive whole (line, headers and body) within 20 seconds, and at most 96 are served at once, so half-open and slow-drip connections cannot use up the server's file descriptors for long. A local user can still fill the 96 slots for up to 20 seconds at a time by opening new connections. |
| G7 | Closed | bin/dashboard.py, attach and new project |
Only folders under the configured project roots (default: your home folder), never a dot folder, symlinks resolved first. |
| G8 | Closed | bin/fleetview.py |
Git runs with the same flags as safe_git and is not run at all in a folder whose own config is refused. |
| G9 | Low | log files | Names in fleet-actions.log, connections.log and attach.log are sanitised; a run's detail is still not length-capped at the write site. |
| G10 | Low, by design | bin/dashboard.py, /api/health |
Answers without pairing: uptime, commit, loop liveness, free disk, whether the helper models run. No path or content. One cached git call per 30 seconds. |
| G11 | Medium (narrowed) | project folders | A content screen now denies Read on worktree files holding a known credential shape, whatever they are called (bin/guard.py --worktree-secrets, applied in claude_run). Since round 5 it fails closed: an unfinished or failed screen, or a secret file whose name a rule cannot carry, refuses the call; unreadable and oversized files and links out are denied. Still open: unknown shapes, Grep enforcement not proven, no per-project extra list, no "mount only what the duty needs" mode. |
| G12 | Low | bin/lib.sh |
sandbox-exec is deprecated by Apple; the Linux bubblewrap backend is unverified (G5). |
| G13 | Low | bin/outscan.py, bin/guard.py |
Credential scanning is pattern based, with normalisation, percent-decoding and one level of base64 (a value this machine holds is found in base64 at any offset since review r9); other encodings and unusual formats pass. |
| G14 | Low | bin/lib.sh |
Project commands can read and write the shared temp folders (/private/tmp, /private/var/folders), except Claude Code's session folders (/private/tmp/claude-*, closed since round 6; $TMPDIR/claude-* under /private/var/folders, closed since audit 7). The sandbox settings file and the duties' scratch folders are not there any more. A project command can also read the main checkout's .git/config (a token in a remote URL) and every object in its store: git cat-file --batch-all-objects lists them without an id, so stashed work, staged-only files and commits reachable only from a reflog are readable (audit 7, L1). The stash ref, reflogs and index are closed, which hides only the names. |
| G15 | Low | bin/gitrisky.re |
The list of git settings that are refused is a list. A new git feature that runs a program is not covered until it is added; "trusted_git_config": true turns the refusal off for a folder. |
| G16 | Low | macOS | The arguments of any process of yours can be read by anyone who knows its pid; no sandbox rule reaches it (process-info, sysctl and signal rules were tried). Listing and signalling other processes are denied for project commands, which only stops the casual route. Nothing secret may be a process argument; the pairing code was and no longer is. |
| G17 | Medium | bin/lib.sh, _qd_sb_profile |
The profile is lists plus the credential rules, not an allow-list for the whole home folder (a build reads toolchains, caches and the project's own checkout from there, so default-deny broke ordinary builds). A secret in a place no list names is readable; an install has the network and name lookup; a check cannot resolve names other than localhost; a tool that signals processes it did not start cannot. |
| G18 | Medium | bin/lib.sh, claude_sandbox_json |
A builder model's shell commands run in the Claude Code CLI's sandbox, to which this project passes only file rules. Its name lookup, clipboard, process listing and launchd access are the CLI's own and were not tested; the fixes for the project-command sandbox do not extend to it. |
| G19 | Low | bin/integrations/keychain.py |
The Keychain option moves credentials out of a plain file; it does not isolate them from your own programs. Items created through security are readable by security without a prompt while the keychain is unlocked, the sandbox leaves the keychain daemon reachable, Google refresh tokens still live in 0600 files, and the backend was tested only against a stub security. |
| G20 | Low, unverified | bin/lib.sh, bin/billing.py |
A duty billed to an Anthropic API key (ticket #38) gets the key in its model process's environment (never argv, a file or a log), and a duty on the plan gets none even when the owner's shell has one. Checked against a stand-in claude only: which credential the real CLI prefers in -p mode, how it reports a refused key, and that CLAUDE_CODE_SUBPROCESS_ENV_SCRUB keeps the key from the model's own shell commands were not verified. The API spend caps read the spend ledger before a call. Since review r9 the gate also holds the budget it hands a call (state/billing-holds.jsonl, under a lock) until the call is booked, so seats running in parallel share what is left instead of each getting all of it; a call killed at the timeout, one that crashed, printed nothing or reported no cost is booked at its budget; and a hold that is never settled (the duty itself was killed) counts until midnight. A reused answer is keyed by who pays and never gets past a billing stop. What a call really spent is the CLI's own figure: a call the CLI lets run past --max-budget-usd would still overrun, which was not tested against the real CLI. |
This page is built from THREAT-MODEL.md in the repository.