Please report security issues privately via GitHub Security Advisories rather than a public issue.
Chronos is a developer tool. It runs on developer laptops and CI runners, and it has no server component and no user accounts. That shapes what "a vulnerability" means here.
This is the load-bearing assumption. A capsule (.chronos/failures/<seed>.json) exists to be shared — attached to a GitHub issue, produced by someone else's CI, handed to a teammate — and then opened with chronos trace, chronos stats, chronos export, chronos replay, or the Inspector.
So a capsule is treated exactly like a file downloaded from a stranger:
- Everything is validated at the read boundary.
readCapsulecaps the file size before reading (128 MB;CHRONOS_MAX_CAPSULE_BYTESraises it), rejects a__proto__key at the parse boundary, and validates the whole structure — including every trace event against theTraceEventunion — before any field reaches theSimulatoror a renderer. A malformed capsule produces a clearInvalidCapsuleerror, never a crash and never a partially-applied config. - Errors never echo capsule bytes. Node's
JSON.parseSyntaxErrorembeds the offending file contents, which on a misdirectedchronos replay /etc/passwdwould disclose that file. All capsule read errors are content-free. - Capsule paths are confined. By default, capsules and scenarios must live under the working directory,
CHRONOS_DIR, orCHRONOS_CAPSULE_DIR. SetCHRONOS_ALLOW_OUTSIDE_CAPSULES=1to opt out. Importing a scenario executes it, soreplay,shrink, andsweepall apply this. - Capsule text is escaped per output sink. Capsule strings are attacker-chosen, and each renderer feeds a sink with its own injection grammar: terminal escape sequences are stripped before printing (a trace viewer that can be made to repaint its own output can lie about what it found), CSV cells are prefixed against spreadsheet formula injection, and Markdown cells are escaped against table breakout.
- The Inspector bounds what it draws. Events that don't match the
TraceEventunion are dropped, and event count, node count, and the laid-out time span are all capped — with any truncation shown in the UI, so a partial trace is never presented as a complete one.
The Inspector server binds 127.0.0.1, requires a loopback Host header (blocking DNS rebinding), confines file serving to the built dist/, and sends a restrictive CSP plus X-Frame-Options: DENY. The ?capsule= parameter accepts only same-origin absolute paths, so a crafted link cannot make your browser fetch a cross-origin URL on your behalf.
It is the only feature that makes a network call, it is off unless you set a key, and it is absent from @sx4im/chronos-core entirely. Your API key is sent as a header (never in a URL, where proxies and access logs would retain it), only over https unless the endpoint is loopback. The capsule summary is redacted for JWTs and token=/key=/secret=/password= values before it is sent, and the model's reply is sanitized before it is printed — a capsule shapes that prompt, so the reply is the exit of a prompt-injection path.
Chronos removes every source of entropy from a simulated run. @sx4im/chronos-core has zero runtime dependencies, which keeps the supply chain for the part that runs inside your test process as small as it can be.
Note the asymmetry between the two environments:
SimEnv(tests) draws from the seeded xoshiro256** PRNG. It is deterministic by design and therefore completely predictable — never useenv.random()from a simulated run as a source of secrets.RealEnv(production) backsenv.random()with a CSPRNG. The same application code may reasonably useenv.random()for a request id or a nonce in production, so the production adapter must be safe for that; nothing in production is replayed, so this costs nothing.
- A malicious scenario module.
chronos sweep/replay/shrinkimport and run your scenario, which is your own code — the path confinement is a guard against a mistyped path, not a sandbox. - The contents of your own source tree, which
chronos checkreads. - Denial of service from a sweep you asked for. Seed counts are capped at 1,000,000 to keep a typo from exhausting memory, but a large sweep is meant to be expensive.