Threat model
This document states, in one place, what processkit-cli treats as untrusted
input, who the trusted principal is, where the project deliberately draws its
security boundary, and which concrete threats within that boundary are closed
— and by what mechanism. It does not restate the mechanisms' own normative
text (each closed threat below links to the module or document that owns it);
treat it as the map for a security reviewer or auditor, not a substitute for
reading the cited code.
Untrusted inputs
Four input surfaces are treated as untrusted or semi-trusted and are handled accordingly (bounded parsing, validation before use, no blind trust in shape):
-
Registry bytes. Every record file under the per-user run registry (
src/registry/mod.rs) is parsed defensively — a corrupt or malformed record is skipped, never trusted to abort a scan or to smuggle a path outside the registry directory (see "Closed threats" below for thelock_filecase). -
Control-plane wire strings. The one request-verb line a client sends, and the one JSON reply line the server sends back, over the local
inspect/cancel/killtransport (src/control/mod.rs), are read as untrusted bytes from whichever local process holds the socket/pipe. -
The child's argv and output. The command line passed to
runis attacker-influenceable in the sense that it ends up in a diagnostic artifact (the JSONL stream) an operator or automated tooling later reads; the child's own stdout/stderr are unbounded, potentially adversarial or merely pathological, byte streams (src/events.rs,src/capture.rs). -
The events file read back. The JSONL lifecycle stream is this project's own output while a run writes it, and untrusted input the moment anything reads it back: it sits at an operator-chosen path (
run --jsonl), any local process can write one, and a reader will read whatever it is pointed at. Two commands read one.wait --report-outcomereads a bounded head/tail window of the file a registry record names (src/wait.rs).eventsis the larger of the two surfaces — it reads a whole stream, andevents --file <path>reads an arbitrary caller-specified path, including a file this registry never knew about, such as an adapter's own fixture;events --run-id <id>reads the locator a registry record publishes, itself untrusted deserialized data under "Registry bytes" above. Everything on that path is hand-rolled and treats the bytes as hostile:- an incremental line reader that hands out only complete lines and refuses
to buffer one past
MAX_LINE_BYTES(1 MiB), so a file with no newline in it cannot decide this process's memory use; invalid UTF-8 is replaced and reported, never fatal (src/events_cmd/mod.rs); - under
--validate, an interpreter of the embedded JSON Schema document run over each parsed line (src/events_cmd/schema.rs) together with the small anchored matcher that document'spatternkeywords need, which runs against untrusted string content (src/events_cmd/pattern.rs); - and, at the terminal boundary, every operator-facing fragment — a rendered
field, the notice about a line that would not parse, a schema violation, and
the stream's own locator — passed through
text::terminal_safe_bounded(src/text.rs) before it is printed, so neither a stream's content nor a registry-published path naming it can forge or overwrite what an operator sees.events --jsonis the one deliberate exception and not a terminal rendering: it passes the runner's own bytes through byte for byte (a line that is not JSON is reported instead of emitted), relying on JSON's own escaping exactly as this project's other machine-readable outputs do — the linesrc/text.rsdraws explicitly between the two.
See "Supply-chain compromise" below for what the fuzz tier does and does not currently exercise on this surface.
- an incremental line reader that hands out only complete lines and refuses
to buffer one past
Trusted principal and boundary
The trusted principal is the same OS user that invokes processkit-cli:
every security mechanism below defends that user's own runs against a
different OS user (or an unprivileged remote party with no local account),
never against that user's own other processes.
Explicit boundary. processkit-cli does not defend a run against a
malicious process already running as the same OS user. A same-user process
that can read the registry directory, connect to the control-plane transport,
or otherwise act with that user's own privileges is, by definition, already
inside the trust boundary this project draws — the owner-only restrictions
below exist to keep other principals out, not to isolate one same-user
process from another.
Closed threats
Each entry names the threat, the mechanism that closes it, and the exact code/docs it is implemented and described in.
- A different OS user reading or connecting to the registry/transport.
The per-user registry directory's permissions are guaranteed on every
mutating open, not merely assumed:
0o700re-applied viachmodon Unix (bypassing umask); on Windows the directory is created carrying its protected owner-only DACL and, on a subsequent open, that DACL is compared ACE for ACE against the target and rewritten whenever it does not match (src/registry/mod.rs,Registry::open/open_in,platform::create_owner_only_dir). A pre-existing directory whose permissions were widened out of band is repaired on both platforms. The Windows comparison is deliberately exact and fail-closed — an unreadable descriptor, an extra ACE, a missing protected bit, or a non-directory all route to the unconditional write — so the skip can only ever elide a write whose result is already in place; no weaker signal an attacker could forge (the directory existing, a marker file, a cached flag) is accepted in its stead. Neither platform touches ownership, then or now. The control-plane transport is deliberately not derived from that directory: each run atomically reserves its own short-lived0o700directory under/tmp(falling back to the platform temp directory) and binds the Unix socket inside it, with the socket file itself given0o600on a best-effort basis afterward (src/control/mod.rs,imp::ControlServer::bind,create_private_socket_dir); the path is kept independent of the registry directory specifically so a long registry path cannot push the socket path pastsockaddr_un::sun_pathon macOS (seedocs/control-plane.md, "Local transport"). On Windows, the control-plane's named pipe is built with its own non-inheritable owner-only DACL, sharing only the FFI-glue modulesrc/win_security.rs(SecurityDescriptor,to_wide) with the registry directory's DACL construction — that sharing is Windows-only; the Unix socket has no DACL and no relationship tosrc/win_security.rs. - The command line leaking into diagnostics.
run_started'scommandfield is redacted by default: the raw argv is not recorded, only a one-way SHA-256 fingerprint (argv_sha256) and a categorical worker-shapehintfrom a static classifier table, both derived from argv but unable to reveal it (src/events.rs,argv_sha256_hex,classify_hint/HINT_RULES). Recording the raw argv requires an explicit opt-in (--argv-raw); it is never the default. The per-user registry record publishes that same one-way pair (and only it) solistcan tell several live runs apart — the raw argv is not even an input to the registry'sregister, which takes anevents::CommandFingerprint, so no flag (--argv-rawincluded) can put a command line into a registry record. The values are shape-checked when read back, like every other record field (seedocs/registry.md, "Reading a record"). - An unbounded or malformed control-plane wire line. Both the server's
request-line read and every client's reply-line read are capped at
MAX_LINE_BYTES(64 KiB) via a shared bounded-read helper (src/control/mod.rs,read_bounded_line) — an oversized or unterminated line fails deterministically rather than growing an in-memory buffer without bound. Separately, a registry record'slock_filefield is validated as a simple file name before it is ever joined onto the registry directory path — control characters, NUL, path separators, Windows reserved device names (with or without an extension, including superscript-digit aliases), and symlink targets (rejected at open time viaO_NOFOLLOWon Unix / a reparse-point check on Windows) are all refused (src/registry/mod.rs,is_simple_lock_file_name,is_windows_reserved_device_name,platform::open_lock_file). - A record steering a deletion outside its own leftovers.
prunereaps the Unix control socket a confirmed-stale record published, which means aremovecall driven by that record'sendpoint— untrusted deserialized data likelock_fileabove. The value is refused unless it is exactly the form the control server publishes (absolute, no./../empty segment as written, final componentc.sock, parentpkc-plus an alphanumeric/-token, sitting directly inside one of the temp bases the server binds in), and even then no symlink is followed: the directory is openedO_NOFOLLOW | O_DIRECTORYand the socket is unlinked relative to that handle, only if it really is a socket, with the directory itself removed by an empty-onlyrmdir. A value failing any of that deletes nothing at all — the record and its lock are still reaped (src/registry/mod.rs,platform::control_socket_dir_to_reap,platform::reap_control_socket_dir; rationale indocs/registry.md). - Launching an incompatible or unusable runner binary uncontained. The
side-effect-free
probesubcommand is a fail-closed preflight contract: it spawns no child and touches no registry, reports this binary's version,schema_version, reserved exit-code band, and live CLI surface as one JSON line, and — given--require-*expectations — exitsPROBE_INCOMPATIBLE(110) with the concrete mismatches on any unmet expectation, rather than ever letting an adapter silently proceed with an incompatible binary (src/probe.rs; consumer walkthrough indocs/integration.md, "Fail-closed preflight:probe"). - Resource exhaustion from a pathological child output stream. The
--capture-dirtee enforces a hard per-stream byte ceiling (CAPTURE_MAX_BYTES, configurable via--capture-max-bytes) with an explicittruncatedflag rather than growing the capture file without bound, and--idle-timeouttears the run down if the child goes silent past a configured window (a sharedIdleClockre-armed by any non-empty write on either the default echo path or the--capture-dirtee), closing the case of a child that neither exits nor produces bounded output (src/capture.rs). - Supply-chain compromise of the build or release pipeline. Every
third-party GitHub Actions step in
.github/workflows/ci.ymland.github/workflows/release.ymlis pinned to a full commit SHA (not a floating tag) except the toolchain selector,dtolnay/rust-toolchain@stable/@master, which both workflows leave intentionally unpinned (each occurrence carries an explicit "intentionally unpinned" comment) so CI and releases keep tracking the rollingstable/MSRV toolchain; that one exception means trust indtolnay/rust-toolchain's owner is accepted, not eliminated (see "What is not closed" below). Everywhere else, a compromised or re-tagged action cannot silently change what CI or a release build runs.cargo deny check advisories bans licenses sourcesruns on every pull request and push tomain(deny.toml,.github/workflows/ci.yml), failing the build on a known RustSec advisory, a yanked crate, a wildcard version requirement, a disallowed dependency license, or a dependency sourced from outside crates.io. Released artifacts carry a SHA-256 checksum and a signedactions/attest-build-provenanceattestation (.github/workflows/release.yml) a consumer can verify against the exact commit and workflow that produced them. A dedicated fuzz tier (fuzz/) exercises four of the parsers that sit closest to the untrusted inputs above, undercargo-fuzz: the registry's byte-to-record parser, the control-plane's request/reply decoders, the CLI's own value parsers, andwait --report-outcome's bounded head/tail read-back of a run's JSONL events file (a path any local process can write, so its content is untrusted the same way). It does not reach theeventsreader described under "Untrusted inputs" above — the larger of this project's two events-file readers, and the only one that opens an arbitrary caller-given path: neither its incremental line reader, nor the schema interpreter and anchored pattern matcher behind--validate, is fuzzed. That stack is covered by unit and through-the-binary tests, and its--validateverdict is held line for line against a real JSON Schema engine (tests/events.rs), but it is not under coverage-guided fuzzing: a fifth target over that reader is the way to close the gap, and until one exists this document claims no fuzz coverage for it.
What is not closed
The boundary above is deliberate, not an oversight; the following are explicitly out of scope for this project's own security mechanisms:
- Confidentiality of data inside the child process. Whatever the child
program reads, writes, or holds in memory is entirely its own concern;
processkit-cliobserves only what the child writes to its own stdout/stderr (and, if requested, the process tree's membership) — it does not attempt to protect the child's internal state from anything. - Isolation from another process of the same OS user. As stated under "Trusted principal and boundary" above, a same-user malicious process is inside the trust boundary, not outside it — this project provides no mechanism against it (no additional sandboxing, no cross-process capability restriction beyond the owner-only ACLs that already keep out other users).
- Trust in the
dtolnay/rust-toolchainaction owner. Both workflows deliberately leave that one action unpinned (a floating@stable/@mastertag rather than a commit SHA) so CI and releases keep tracking the rolling stable/MSRV toolchain; a compromise of that action's owner or repository could change what CI or a release build runs, and the project accepts that residual risk rather than freezing the toolchain version. - Denial of service through the operating system itself. Beyond the
opt-in, best-effort
--max-memory/--max-processes/--cpu-quotacaps on the child's own process tree (platform-limited: real Windows Job Object or Linux cgroup v2 enforcement only, fail-fast rather than silently unenforced — seeREADME.md, "Resource limits"),processkit-clidoes not defend against exhaustion of system-wide resources (memory, file descriptors, process table slots) by other workloads on the same machine; that remains the operating system's and the operator's own concern.
See also
SECURITY.md— how to report a vulnerability, and the automated supply-chain scanning this document's "Supply-chain compromise" entry summarizes.docs/architecture.md— the module map and data flow this document's closed-threat entries point into.docs/integration.md— the consumer-facing preflight and redaction walkthrough (probe, command redaction) referenced above.docs/registry.mdanddocs/control-plane.md— the normative registry and control-plane documents the owner-only and bounded-read mechanisms above are drawn from.