Skip to content

Security audit and hardening pass across all Chronos packages - #16

Merged
cursor[bot] merged 10 commits into
mainfrom
cursor/chronos-security-hardening-6239
Aug 9, 2026
Merged

Security audit and hardening pass across all Chronos packages#16
cursor[bot] merged 10 commits into
mainfrom
cursor/chronos-security-hardening-6239

Conversation

@sx4im

@sx4im sx4im commented Aug 9, 2026

Copy link
Copy Markdown
Owner

A full security and robustness audit of Chronos, following the vibe-security agent-skill methodology, with every finding fixed and pinned by a regression test. An independent security review of these changes then found one bypass in my own CSV fix, which is also fixed here.

The threat model that organizes most of this: a failure capsule is untrusted input. It is a JSON file designed to be shared — attached to a GitHub issue, produced by someone else's CI, handed to a teammate — and then fed to chronos trace, chronos export, chronos explain, and the Inspector web UI. Validation stopped at the fields the Simulator reads, and every renderer downstream trusted the rest.

Critical / High

packages/core/src/strict.ts — code-execution sink in the strict-mode setTimeout guard. The guard compiled string handlers with new Function(String(handler)). Any string reaching a forgotten global timer — a simulated peer's message payload, a field of an untrusted capsule — became executed code. Node's own setTimeout rejects string handlers, so the guard was adding an eval sink to the harness that the runtime it emulates does not even have. Now throws the same TypeError Node throws.

packages/cli/src/export.ts — CSV formula injection. escapeCSV quoted per RFC 4180 but left formula leads intact, so a capsule summary of =cmd|'/c calc'!A1 executes when the exported CSV is opened in Excel, LibreOffice, or Google Sheets. The export exists to be shared, which is what makes this reachable. Cells are now prefixed — and the check skips leading whitespace and zero-width padding, because anchoring at the very first character alone was sidesteppable with a single space.

packages/cli/src/{trace,stats,explain}.ts — terminal escape and bidi injection. Capsule summary, detail, and node ids went straight to stdout. A crafted capsule could embed ANSI escapes to erase and repaint earlier output, or a U+202E bidi override (Trojan Source, CVE-2021-42574) to make node-1 → node-2 render reversed. A trace viewer that can be made to lie about what it found is worse than one that crashes.

packages/vitest/src/capsule.ts — validation stopped short of the trace. A capsule that merely omitted trace.nodes crashed chronos trace with a raw TypeError; an event with an unknown kind or a non-array groups white-screened the Inspector. The trace envelope and all nine event kinds are now validated once at the trust boundary, so every renderer downstream can stay simple.

Medium

Where Issue
explain.ts Gemini API key sent as ?key=, where proxies, CDNs, access logs, and Referer all retain it. Now an x-goog-api-key header, with base-URL scheme validation and a refusal to put a key on plaintext http off loopback.
ui.ts The interactive API-key prompt echoed the key in the clear, into scrollback and any session recording. Now masked.
capsule.ts No size limit on a capsule read. MAX_EVENTS is a post-parse bound, which is too late — readFile and JSON.parse both materialize the whole file first.
capsule.ts Pid-derived temp filename with a following write, so anyone able to write the capsule directory could pre-create it as a symlink. Now unpredictable, wx, mode 0600.
capsule.ts __proto__, constructor, and prototype now rejected at the parse boundary — the parsed config and trace are spread and merged downstream, where any Object.assign would trigger the setter.
check.ts Walked directories with stat(), which follows symlinks, so one symlink cycle made chronos check — and chronos doctor, which calls it — recurse until the stack blew.
sweep.ts Dynamic-imported (i.e. executed) a scenario from any path, while replay and shrink both confine scenarios to cwd/CHRONOS_DIR.
real.ts Math.random() for env.random() in the production adapter. Callers may reasonably use it for an id or a nonce, and V8's PRNG state is recoverable from observed output. Now CSPRNG-backed; nothing in production is replayed, so determinism is unaffected.
server.ts No frame-ancestors/X-Frame-Options, and the CSP intersected away the webfonts index.html loads.
Inspector Malformed events crashed the render; event count, node count, and laid-out time span were unbounded (2M events × no virtualization, and span × zoom sized the SVG from an unbounded t).
Deployment No security headers on either Vercel-deployed site; source maps published; CI ran with the repo-default (often write) GITHUB_TOKEN permissions.

Correctness bugs found along the way

  • TraceLogger.summarize marked merely shared object references as [Circular] — it tracked every object seen rather than an ancestor stack — corrupting the summaries that send/deliver pairing keys on. { a: x, b: x } is a very common message shape.
  • The scheduler's microtask barrier resolved through globalThis.setTimeout, which the strict guards replace with one that schedules into the simulator. On the fallback path that is a deadlock in the one function the scheduler awaits every step. Now bound to the real primitives at module load.
  • InvariantViolated rendered as violated at t=<detail> — the detail is not a time.
  • CHRONOS_VERSION was 0.0.0 while packages shipped 0.1.5, so every capsule claimed a version that never existed. Now pinned to package.json by a test, and the CLI/Inspector/docs all read from it instead of five hardcoded copies.
  • chronos --help and chronos doctor advertised AI providers (NVIDIA_API_KEY, OpenRouter, Groq, …) that explain.ts never reads.
  • runCandidate swallowed every throw as "did not reproduce", reporting a broken harness as "already minimal".
  • Adding pnpm build to CI immediately caught a latent break: the Inspector must import core for types only, and reading a runtime value from it pulled node:async_hooks into the browser bundle.

Verification

  • pnpm typecheck, pnpm lint (zero warnings, down from 3), pnpm test, and pnpm build all green.
  • 267 tests, up from 204 — 63 new, covering every fix above. Three of them caught my own mistakes while being written, which is the point of writing them.
  • Determinism is provably intact. The committed capsule fixture still replays bit-for-bit: replayCapsule compares the whole re-run event trace against a capsule committed before any of these changes, so nothing here perturbed scheduling, RNG draw order, or network delivery.
  • The chronos open server was exercised end to end: security headers present, non-loopback Host → 403, path traversal contained, and a deliberately hostile capsule renders through trace and export with no escape bytes and a neutralized CSV formula.
  • An independent security review of this branch found no high/critical regressions introduced by the fixes, and one medium bypass (the CSV whitespace/zero-width lead) which is fixed in the final commit.

SECURITY.md is new, and documents the threat model, the capsule trust boundary, the deliberate SimEnv/RealEnv entropy asymmetry (seeded and predictable vs. CSPRNG), and what is out of scope.

Open in Web Open in Cursor 

cursoragent and others added 3 commits August 9, 2026 08:40
The guarded setTimeout compiled string handlers via `new Function`, turning
any string that reached a forgotten global timer (a simulated peer's message
payload, an untrusted capsule field) into executed code. Node's own setTimeout
rejects string handlers outright, so the guard was adding an eval sink the
emulated runtime does not even have. Throw the same TypeError Node throws.

Also in core:
- TraceLogger.summarize marked merely SHARED object references as [Circular]
  (a WeakSet of everything seen, rather than an ancestor stack), corrupting the
  summaries that send/deliver pairing keys on.
- RealEnv.random() used Math.random(); production callers may use env.random()
  for ids or nonces, and V8's PRNG state is recoverable from observed output.
  Use a CSPRNG-backed 53-bit float instead (nothing in production is replayed).
- InvariantViolated read 'violated at t=<detail>' — the detail is not a time.
- CHRONOS_VERSION was still 0.0.0 while the packages shipped 0.1.5; pin the two
  together with a test so capsules stop claiming a version that never existed.

Co-authored-by: Saim <contact@saimshafique.com>
A capsule is untrusted shared input, but validateCapsule stopped at the fields
the Simulator reads. Everything downstream — chronos trace/stats/export and the
Inspector — treats core's TraceEvent union as a guarantee, so a capsule that
merely omitted trace.nodes crashed `chronos trace` with a raw TypeError, and an
event with an unknown kind or a non-array `groups` white-screened the Inspector.
Validate the trace envelope and every event once, here, so every renderer
downstream can stay simple.

Also hardens capsule I/O:
- readCapsule had no size limit. MAX_EVENTS is a post-parse bound, which is too
  late: readFile and JSON.parse both materialize the whole file first, so a
  multi-GB .json was an OOM crash before any bound applied. Stat first and cap
  at 128 MB (CHRONOS_MAX_CAPSULE_BYTES overrides).
- Reject a `__proto__` key at the parse boundary. JSON.parse alone is safe, but
  the parsed config and trace are spread and merged downstream, where any
  Object.assign or hand-rolled deep merge would trigger the setter.
- writeCapsuleTo used a pid-derived temp name and a following write, so anyone
  who could write the capsule directory could pre-create it as a symlink and
  have the capsule written through it. Use an unpredictable name with an
  exclusive create (wx) at mode 0600, and clean up if the rename fails.
- runCandidate swallowed every throw as 'did not reproduce', reporting a broken
  harness as 'already minimal'. Let strict-mode violations and OOM propagate.

Co-authored-by: Saim <contact@saimshafique.com>
A capsule is untrusted input that the CLI renders into three sinks with three
different injection grammars, and none of them was escaped:

- Terminal (trace, stats, explain). `summary`/`detail`/node ids went straight
  to stdout, so a crafted capsule could embed ANSI escapes to erase and repaint
  earlier output — making a run that violated an invariant appear clean. A trace
  viewer that can be made to lie about what happened is worse than one that
  crashes. Strip ESC sequences and C0/C1 controls before printing.
- CSV (export). `escapeCSV` quoted per RFC 4180 but left formula leads intact,
  so a summary of `=cmd|'/c calc'!A1` executes when the shared export is opened
  in Excel, LibreOffice, or Sheets. Prefix the standard apostrophe.
- Markdown (export). Values were wrapped in backticks with no escaping, so a
  summary containing a backtick or pipe forged cells and rows.

Also in the CLI:
- explain sent the Gemini API key as `?key=`, where proxies, CDNs, access logs,
  and Referer all retain it. Use the x-goog-api-key header, validate the base
  URL's scheme, and refuse to send a key over plaintext http off loopback.
- The interactive API-key prompt echoed the key into the scrollback. Mask it.
- The model's reply is the exit of a prompt-injection path (the capsule shapes
  the prompt) and was printed raw; sanitize it too.
- check walked directories with stat(), which follows symlinks, so one symlink
  cycle made `chronos check` — and `chronos doctor`, which calls it — recurse
  forever. Use lstat, skip links, cap depth.
- sweep dynamic-imported (i.e. executed) any path, while replay and shrink both
  confine scenarios to cwd/CHRONOS_DIR. Apply the same confinement.
- Bound `sweep <seeds>`; the seed list is materialized before the first run.
- Serve frame-ancestors/X-Frame-Options, and stop the server CSP from
  intersecting away the webfonts index.html loads.
- Drive every version string and the doctor's provider list from source rather
  than hardcoded 0.1.4/0.1.5 and NVIDIA_API_KEY, which explain never reads.

Co-authored-by: Saim <contact@saimshafique.com>
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs-site-chronos Ready Ready Preview Aug 9, 2026 9:09am

cursoragent and others added 3 commits August 9, 2026 08:50
parseCapsule checked the trace envelope but nothing inside it, so the views —
which destructure events by `kind` and index style tables with it — took the
TraceEvent union on faith. Three ways a shared capsule could take down the UI:

- An unknown `kind`, or a `partition` whose `groups` was missing or empty, threw
  mid-render and white-screened the app (spreading an empty array into
  Math.min/max also yields the ±Infinity that produces invalid SVG geometry).
- 2M events were mapped to SVG markers with no virtualization.
- Event `t` is unbounded, and both views size their SVG as `span × zoom`, so
  events at t=0 and t=1e12 asked the browser for a canvas ~4e13 px on a side.

Validate the event union at the parse boundary (dropping bad events rather than
discarding an otherwise readable trace), cap nodes at 256 to match the CLI, cap
rendered events at 50k, and clamp the laid-out span. Both caps are surfaced in
the summary strip — a silently partial timeline reads as a complete one.

Also reject an oversized file before `file.text()` materializes it, and stop
publishing source maps from the production build (they hand anyone the full
unminified tree); CHRONOS_SOURCEMAP=1 keeps them for local debugging.

Co-authored-by: Saim <contact@saimshafique.com>
- vercel.json served the hosted Inspector with no security headers at all,
  while the local `chronos open` server sent a full set. Add the matching CSP
  plus HSTS, X-Frame-Options, nosniff, Referrer-Policy, and Permissions-Policy.
- The CI workflow declared no `permissions`, so its GITHUB_TOKEN inherited the
  repo default — write-all on many repos, which turns any dependency executing
  during install or test into push access. Pin to contents: read.
- CI never ran `pnpm build`, so a tsup/vite break would only surface at publish.
- .env.example advertised NVIDIA_API_KEY, which `chronos explain` never reads.

Co-authored-by: Saim <contact@saimshafique.com>
52 new tests pinning the behavior each fix introduced, so a later refactor
cannot quietly reopen any of them:

- core: the guarded setTimeout refuses string handlers and compiles nothing;
  summarize distinguishes a shared reference from a real cycle and never throws
  on a payload JSON cannot represent; RealEnv.random stays in [0,1) and varies.
- vitest: the trace envelope and all nine event kinds are validated; oversized
  files are refused before being read; a __proto__ key is rejected and does not
  reach Object.prototype.
- cli: ANSI, OSC, CR/LF and bare-ESC payloads produce no control bytes on
  stdout and cannot forge trace lines; CSV formula leads are neutralized;
  Markdown cells cannot grow the table; a symlink cycle terminates the check
  walk; sweep refuses an out-of-tree scenario.
- inspector: malformed events are dropped without discarding the trace, and
  event/node counts are capped and reported.
- The open server sends frame-ancestors/X-Frame-Options and a CSP that does not
  intersect away the app's own webfonts.

Two of these caught my own mistakes while being written (a CR formula lead is
removed rather than prefixed; escaped pipes still split a naive column count),
which is the point of writing them.

Co-authored-by: Saim <contact@saimshafique.com>
Adds SECURITY.md, which writes down the assumption the whole hardening pass
rests on: a failure capsule is untrusted input, because it is designed to be
shared. Users need that stated to understand why capsule paths are confined and
why the CLI escapes its own output. It also records the deliberate asymmetry
between SimEnv.random (seeded, therefore predictable — never use it for a
secret) and RealEnv.random (CSPRNG), and what is out of scope.

Fixes references that had drifted from the code: the docs and Inspector chrome
still said v0.0.0, and the CLI guide and README still described `chronos
explain` as NVIDIA NIM with an NVIDIA_API_KEY, which it has not read for some
time. PROGRESS/REMAINING now reflect the current 256-test state.

Co-authored-by: Saim <contact@saimshafique.com>
…ripping

The inspector may import @sx4im/chronos-core for TYPES ONLY — those are elided,
which is why the build needs no node polyfills. Reading CHRONOS_VERSION from it
imported a runtime VALUE, pulling core's whole barrel (and env.ts's
node:async_hooks) into a browser bundle and failing `vite build`. Inject core's
package.json version via a Vite define instead: same single source of truth, no
runtime import. The `pnpm build` step added to CI in the previous commit is what
surfaced this — typecheck alone did not.

Also in sanitize.ts, replace the ad-hoc escape patterns with ECMA-48's actual
byte ranges (CSI parameter 0x30-0x3F, intermediate 0x20-0x2F, final 0x40-0x7E).
This catches sequences the old pattern missed — private-use forms like ESC[?25l
and charset selection like ESC(B — and makes every quantified class disjoint
from its neighbours, so no input can be split between two of them and the
passes are provably linear on attacker-chosen text. Tests pin the C1 single-byte
CSI (0x9B) and a pathological input's runtime.

And bind the scheduler's macrotask barrier to the real setImmediate/setTimeout
at module load. The strict guards replace globalThis.setTimeout with one that
schedules into the simulator, so a barrier resolving through the patched global
would enqueue its own resolution as a simulated event only this loop can run —
a deadlock in the one function the scheduler awaits every step. Unreachable in
Node today (setImmediate always exists), but it is the wrong thing to leave
depending on that.

Co-authored-by: Saim <contact@saimshafique.com>
… the README

The docs site deploys from its own vercel.json, which had no headers at all.
Also surfaces the capsule trust model and the SimEnv/RealEnv entropy asymmetry
in the README, since 'never use env.random() for a secret in a simulated run'
is the kind of thing a reader needs before they need it, not after.

Co-authored-by: Saim <contact@saimshafique.com>
…pace leads

A security review of the previous commits found the new escapeCsvCell was
sidesteppable. Anchoring the formula test at the very first character is not
enough, because a spreadsheet skips over things on its way to the `=`:
` =cmd|'/c calc'!A1`, or a zero-width space or BOM in front of it, all reached
the cell unprefixed and still evaluated. Skip leading whitespace/BOM when
testing for a formula lead.

The zero-width variant pointed at a gap worth closing on its own: sanitizeText
stripped C0/C1 controls but left the invisible and bidirectional-override
characters, which are not controls in that sense but attack display integrity
in exactly the same way an ANSI escape does. U+202E is Trojan Source
(CVE-2021-42574) — in a capsule summary it makes `node-1 → node-2` render
reversed. For a tool whose entire job is telling a human what happened, that is
the injury rather than a side effect, so strip them alongside the escapes.

Also from the review, two defense-in-depth items:
- sanitizeText dropped the ESC introducer of DCS/SOS/PM/APC sequences but left
  their payload behind looking like ordinary trace text. Consume both, as the
  OSC pass already did.
- The capsule reviver rejected __proto__ but not `constructor`/`prototype`;
  a legitimate capsule has no use for any of the three.
- Add object-src 'none' to the open server's CSP, matching vercel.json.

Co-authored-by: Saim <contact@saimshafique.com>
@sx4im
sx4im marked this pull request as ready for review August 9, 2026 09:13
@cursor

cursor Bot commented Aug 9, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@cursor
cursor Bot merged commit 4afe0b8 into main Aug 9, 2026
3 checks passed
@sx4im
sx4im deleted the cursor/chronos-security-hardening-6239 branch August 25, 2026 05:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants