This project clones and reads arbitrary user-supplied repositories. That's a real attack surface even though parsing itself only needs read access to source text.
- Parsing never requires execution. Tree-sitter reads file contents and produces a syntax tree — it does not need
npm install,pip install, build scripts, or any other code from the target repo to run. The clone/parse pipeline should never invoke the repo's own install/build/test scripts. - Isolate the clone + parse step. Even without deliberately executing the repo's code, git hooks and certain tooling can trigger unexpected behavior on clone/checkout. Run cloning and parsing inside a container at minimum; a microVM (e.g. Firecracker-style isolation) is a stronger option if handling many untrusted third-party repos at scale.
- Mount only what's needed. The parser process should only have access to the cloned repo's directory — never the host filesystem, credentials, or other users' cloned repos.
- No network access from the parse step. The parser doesn't need outbound network access once the clone is done; blocking it removes a whole class of potential exfiltration or supply-chain risk from a malicious repo. This is a goal, not something the running product currently enforces — see "Where the isolation actually applies" below before relying on it.
- Auth. GitHub OAuth for user login. The scope is
read:user, notrepo-- GitHub OAuth apps have no read-only repository scope, andrepogrants write access to every private repository the user can reach (R26). Private repositories are therefore out of scope until a phase needs them. Webhook payloads must be signature-verified (GitHub's HMAC signature) before being trusted and enqueued. - Tenant isolation. If this ever serves more than one user, cloned repos and parse jobs must not share state, disk, or memory across users/repos — treat each clone as its own isolated workspace, cleaned up after use.
Read this before quoting anything above as a guarantee. The container constraints are real and tested, and the running product does not use them.
There are two ways the parser runs, and only one is sandboxed:
| Path | How it runs | Sandboxed? |
|---|---|---|
make parser-isolated |
docker compose run --rm --no-deps parser against the parser service, which sets network_mode: "none", read_only: true, cap_drop: [ALL] and a non-root user |
Yes. This is the harness the constraints were written for. |
| The product, on every path | apps/api/src/repos/register.ts calls execFile(env.PARSER_BIN, [...]) from the queue worker |
No. A plain child process of the worker, with the worker's network, filesystem and user. |
So --network none, the read-only rootfs and the dropped capabilities apply when you deliberately
invoke the harness, and at no other time. make start does not use them, and neither would a
composed stack — docker-compose.yml's parser service is the harness, which is why it can carry
network_mode: "none" and a depends_on on Postgres without anyone noticing the contradiction.
What is genuinely enforced on every path, because it lives in the parser rather than around it
(services/parser/internal/security, covered by security package tests):
- Symlinks hard-fail the run; every path is bounded to the clone root.
- Files over
PARSER_MAX_FILE_BYTES(1 MB) are skipped, andPARSER_MAX_FILES/PARSER_MAX_DEPTH/PARSER_SKIP_PATHScap the walk. - The clone is
git clone --depth 1withGIT_TERMINAL_PROMPT=0and emptyGIT_ASKPASS/SSH_ASKPASS, so a private or missing repository fails immediately instead of blocking on a credential prompt (R32). - The repo's own install, build and test scripts are never invoked. Parsing does not execute code.
- The clone is removed afterwards, on the failure path too.
Closing the gap is R38. The two routes both cost something: having the worker shell out to
docker run means mounting the Docker socket into it, which grants root-equivalent control of the
host and trades one security property for a worse one; doing it with namespaces, seccomp or
bubblewrap avoids that but is Linux-only and a project in its own right.
- Symlink / path-traversal guard. Never follow symlinks. Resolve every file path and reject it unless it is strictly inside the clone root (rejects
../../etc/passwdand symlinks pointing outside the repo). This applies to both parsing and to serving raw source. - Repo/clone bombs. Cap total file count, per-file size (skip files >1MB), and directory depth. Skip binary/non-text files and anything under
node_modules/.git/build output. A 100k-file repo or a 50MB minified file must not OOM the parser. Respecting.gitignoreis deferred post-MVP — the skip-list already covers where the volume is. - Webhook DoS / replay. HMAC verification alone is insufficient: enforce a timestamp/replay window, per-repo job throttling, and a max-concurrent-parse cap so a flood of webhooks can't exhaust resources.
- API authorization. Every graph-serving endpoint is session-gated; anonymous requests are rejected even in single-user mode.
- SQL / CTE safety. All queries are parameterized; the recursive N-hop traversal CTE is depth-bounded to prevent runaway queries.
- Full code-execution sandboxing (Firecracker/gVisor-grade isolation for running arbitrary code) — not required, since parsing is read-only. Revisit only if a future feature actually executes repo code (e.g. running tests).
- Complex per-request sandbox lifecycle management — relevant for AI agents that run many arbitrary commands; this project's parse step is a single, bounded, non-executing operation per job.
- Clone/parse runs in an isolated container, not the host running the API — the container exists and is tested; the product does not use it.
make parser-isolatedruns the parser undernetwork_mode: "none", a read-only rootfs,cap_drop: ALLand a non-root user. The queue worker runs the same binary throughexecFile, in its own process space. Ticked from Phase 1 until the landing-page branch, on the strength of the harness rather than of the running path. R38. - No install/build scripts from the target repo are ever invoked — parsing reads file contents into a syntax tree and nothing else. This one holds on every path, container or not, because it is a property of tree-sitter rather than of the sandbox.
- Parser process has no outbound network access (
--network none, read-only mount, dropped caps) — only undermake parser-isolated. Same gap as the box above, and note the parser needs network forgit clonein the first place, so a real sandbox has to admit a clone stage before it drops the interface. - Symlink / path-traversal escapes are checked — path validation done; descriptor-based TOCTOU protection deferred again in Phase 4.
Walkhard-fails on any symlink andContainsRootbounds every path, so an escape needs a directory swapped between the check and the open. That is a real race and a narrow one: the parser reads a checkout it made itself, seconds earlier, from a--depth 1clone of a public repository. The original wording justified this partly by "in a container with no network and no capabilities", which R38 shows is not true of the running path — the deferral still stands on the rest, but it stands on less than it appeared to. Revisit when it parses a tree someone else can write to. - File-count, per-file size (>1MB), and depth caps are enforced; binary/
node_modules/.gitskipped - Webhook signatures are verified, replay-protected, and per-repo throttled — HMAC-SHA256 over the raw bytes (
routes/webhook.ts), delivery ids held in Redis withSET NX, and a per-hook rate limit. Replay protection is by delivery id rather than a timestamp window: GitHub sends no timestamp, and the id is what a retry repeats. - All graph endpoints are session-gated — one
preHandleron one encapsulated scope (routes/index.ts), asserted route by route inroutes/graph.test.ts. Shipped in Phase 3a; the box was never ticked. The webhook is deliberately outside that gate and is authenticated by its signature instead. - Recursive N-hop CTE is depth-bounded and parameterized — capped at
TRAVERSAL_MAX_DEPTHby the Zod schema, and the only identifiers reachingsql.rawcome from a closed map keyed by an already-narrowed union. Shipped in Phase 2; the box was never ticked. - OAuth tokens are minimally scoped and refreshed — scope done, refresh deferred.
read:userand nothing more (R26): the only scope that reads private repositories isrepo, which also grants write to every one of them. Refresh is deferred because nothing uses the token: the parser clones over public HTTPS and never sees a session. It becomes real work the moment a phase needs a private repository. - Secrets come from env vars via
.env.example, never committed — every one is a required key inenv.ts, andenv.test.tsreads that file as text to assert each non-defaulted key also appears in.env.exampleand in CI. Shipped in Phase 3a; the box was never ticked. - Cloned repo data is cleaned up after parsing —
Preparereturns a cleanup func the parser defers, covering the failure path too.mainwas restructured to return an error rather than calllogger.Fatal, which callsos.Exitand skips every defer.