Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,16 @@
# Optional API key for summarizing failure capsules via 'chronos explain'
NVIDIA_API_KEY=
# Optional — `chronos explain` summarizes a failure capsule with an LLM.
# The command is fully optional and degrades to a no-op when nothing is set.
# Set ONE of these; they are probed in this order.
#
# NEVER commit real values. `.env` is gitignored; this file is the template.

OPENAI_API_KEY=
ANTHROPIC_API_KEY=
GEMINI_API_KEY=

# Or point at any OpenAI-compatible endpoint (Ollama, LM Studio, vLLM,
# OpenRouter, Groq, ...). https is required for any non-loopback host — the key
# is sent as a bearer token.
LLM_BASE_URL=
LLM_API_KEY=
LLM_MODEL=
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ on:
pull_request:
branches: [main]

# CI only reads the repository. Without this, the workflow's GITHUB_TOKEN
# inherits the repo-wide default, which on many repos is write-all — meaning a
# compromised dependency running during `pnpm install` or a test could push
# commits or publish releases.
permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
Expand All @@ -26,3 +33,7 @@ jobs:
- run: pnpm lint

- run: pnpm test

# The published artifact is the build output, not the sources tsc checks.
# Without this step a tsup/vite build break only surfaces at publish time.
- run: pnpm build
24 changes: 23 additions & 1 deletion PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,28 @@ This document tracks the features, validation steps, and progress of the **Chron
- Interactive timeline scrubber to inspect system states chronologically.
- **Metrics & Link Matrix Dashboard**: Highlights total events, drop rate, duplicate rate, and fault counts. Offers an interactive node-to-node link matrix detailing traffic statistics and losses.

### 6. Security Hardening

A full audit against the [vibe-security](https://github.com/raroque/vibe-security-skill)
checklist, with every finding fixed and pinned by a regression test. The threat
model is written up in [`SECURITY.md`](./SECURITY.md); its central assumption is
that **a failure capsule is untrusted input**, since capsules exist to be shared.

- **Capsule trust boundary**: the whole capsule — trace envelope and every event
against the `TraceEvent` union — is validated at read time, so renderers can
stay simple. File size is capped before reading, `__proto__` is rejected at the
parse boundary, and writes use an unpredictable temp name with an exclusive
`0600` create.
- **Per-sink output escaping**: terminal control sequences are stripped before
printing, CSV cells are neutralized against spreadsheet formula injection, and
Markdown cells against table breakout.
- **Credential handling**: `chronos explain` sends API keys as headers only,
requires `https` off-loopback, and masks the interactive key prompt.
- **Bounded rendering**: the Inspector drops malformed events and caps event
count, node count, and laid-out time span — surfacing any truncation in the UI.
- **Deployment**: security headers on the hosted Inspector, no published source
maps, and least-privilege CI token permissions.

---

## Workspace Status
Expand All @@ -49,4 +71,4 @@ All components of the monorepo are fully operational and verified:
- **Build**: Successfully compiles ESM and TypeScript definitions (`dts`) across all packages.
- **Typecheck**: Zero TypeScript compile errors (`tsc --noEmit` is clean).
- **Lint**: Zero ESLint warnings or errors.
- **Tests**: All 191 tests are passing.
- **Tests**: All 256 tests are passing.
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
- [CLI Reference](#cli-reference)
- [Examples & Reference Implementations](#examples--reference-implementations)
- [Comparison: Chronos vs Traditional Testing vs Madsim / Turmoil](#comparison-chronos-vs-traditional-testing-vs-madsim--turmoil)
- [Security](#security)
- [Contributing](#contributing)
- [License](#license)

Expand Down Expand Up @@ -266,7 +267,7 @@ npx chronos <command> [options]
* **`chronos sweep <scenario> [seeds]`**: Run a test scenario across $N$ seeds to discover hidden race conditions.
* **`chronos shrink <capsule>`**: Shrink a complex failure trace into the minimal reproducing steps.
* **`chronos open <capsule>`**: Launch the web-based time-travel inspector preloaded with the capsule data.
* **`chronos explain <capsule>`**: Get AI-powered failure analysis and root-cause explanations (supports Ollama, OpenAI, Anthropic, Gemini, Groq, DeepSeek, and more).
* **`chronos explain <capsule>`**: Get AI-powered failure analysis and root-cause explanations. Set `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, or `GEMINI_API_KEY` — or point `LLM_BASE_URL` at any OpenAI-compatible endpoint (Ollama, LM Studio, vLLM, OpenRouter, Groq, …).
* **`chronos stats <capsule>`**: Print execution statistics, network packet metrics, and event distributions.
* **`chronos export <capsule> --format markdown|csv`**: Export trace timelines to Markdown documents or CSV reports.

Expand Down Expand Up @@ -295,6 +296,16 @@ Explore ready-to-run examples in the repository:

---

## Security

**A failure capsule is untrusted input.** Capsules exist to be shared — attached to an issue, produced by someone else's CI, handed to a teammate — so Chronos treats one exactly like a file from a stranger. Every capsule is fully validated at the read boundary, capsule paths are confined to your project, and capsule text is escaped separately for each place it is rendered (terminal, CSV, Markdown, and the Inspector's DOM).

One consequence worth stating outright: `env.random()` under `SimEnv` is a **seeded** PRNG. It is deterministic by design and therefore completely predictable — never use it as a source of secrets in a simulated run. The production `RealEnv` backs the same call with a CSPRNG, so the same application code is safe in production.

Read **[SECURITY.md](./SECURITY.md)** for the full threat model and how to report a vulnerability.

---

## Contributing

We welcome contributions from the open-source community!
Expand Down
35 changes: 32 additions & 3 deletions REMAINING.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,39 @@
This document tracks any outstanding bugs, tasks, or issues remaining in the **Chronos** deterministic simulation testing framework.

## Outstanding Issues
- **None**: There are no remaining bugs, failing tests, compilation errors, lint issues, or outstanding `TODO`/`FIXME` comments in the codebase.

- **None.** No failing tests, compilation errors, lint findings, or outstanding `TODO`/`FIXME` comments.

## Code Quality Status
- **Unit and Simulation Tests**: 191/191 passing.

- **Unit and Simulation Tests**: 256/256 passing.
- **TypeScript Typecheck**: Clean (0 compilation errors).
- **ESLint**: Clean (0 warnings or errors).
- **Build**: Successfully compiles all workspace packages (esm, commonjs/types).
- **Build**: All workspace packages compile (ESM + type declarations).

## Security Posture

A full audit of every package was completed against the categories in the
[vibe-security](https://github.com/raroque/vibe-security-skill) checklist, and
every finding is fixed with a regression test. See [`SECURITY.md`](./SECURITY.md)
for the threat model — the central assumption is that **a failure capsule is
untrusted input**, because it is designed to be shared.

Closed in that pass:

| Area | Issue |
| --- | --- |
| Core | The strict-mode `setTimeout` guard compiled string handlers via `new Function` — an eval sink Node itself does not have |
| CLI | Untrusted capsule text reached the terminal unescaped (ANSI/OSC injection could repaint the trace viewer's own output) |
| CLI | `chronos export --csv` allowed spreadsheet formula injection; Markdown export allowed table breakout |
| CLI | The Gemini API key was sent in a URL query string; the interactive key prompt echoed in the clear |
| CLI | `chronos check` followed symlinks and recursed forever on a cycle; `sweep` imported scenarios from any path |
| Capsules | No file-size limit before read; no `__proto__` rejection; predictable temp filename on write; trace events unvalidated |
| Inspector | Malformed events crashed the render; event count, node count, and time span were unbounded |
| Deployment | No security headers on the hosted Inspector; source maps published; CI ran with default (write) token permissions |

## Known Non-Goals

- `@sx4im/chronos-core` keeps **zero runtime dependencies**. Do not add any.
- The BigInt PRNG is the known hot path. It stays pure TypeScript until the
planned Rust/WASM phase, which will keep pure TS as the default.
44 changes: 44 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Security Policy

## Reporting a vulnerability

Please report security issues privately via [GitHub Security Advisories](https://github.com/sx4im/chronos/security/advisories/new) rather than a public issue.

## The threat model

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.

### A failure capsule is untrusted input

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.** `readCapsule` caps the file size before reading (128 MB; `CHRONOS_MAX_CAPSULE_BYTES` raises it), rejects a `__proto__` key at the parse boundary, and validates the whole structure — including every trace event against the `TraceEvent` union — before any field reaches the `Simulator` or a renderer. A malformed capsule produces a clear `InvalidCapsule` error, never a crash and never a partially-applied config.
- **Errors never echo capsule bytes.** Node's `JSON.parse` `SyntaxError` embeds the offending file contents, which on a misdirected `chronos replay /etc/passwd` would 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`, or `CHRONOS_CAPSULE_DIR`. Set `CHRONOS_ALLOW_OUTSIDE_CAPSULES=1` to opt out. Importing a scenario *executes* it, so `replay`, `shrink`, and `sweep` all 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 `TraceEvent` union 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.

### `chronos open` serves only to localhost

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.

### `chronos explain` is optional and isolated

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.

### Determinism is a security property

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 use `env.random()` from a simulated run as a source of secrets.
- **`RealEnv` (production)** backs `env.random()` with a CSPRNG. The same application code may reasonably use `env.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.

### Out of scope

- A malicious *scenario module*. `chronos sweep`/`replay`/`shrink` import 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 check` reads.
- 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.
11 changes: 8 additions & 3 deletions docs-site/guide/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ chronos trace <capsule> print the recorded event timeline
chronos sweep <scenario> [seeds] run a scenario across N seeds (default 1000)
chronos shrink <capsule> <scenario> reduce a failing capsule's fault config to minimum
chronos open <capsule> open the time-travel Inspector preloaded with the capsule
chronos explain <capsule> summarize the failure via NVIDIA NIM (requires NVIDIA_API_KEY)
chronos explain <capsule> summarize the failure via an LLM (OpenAI/Anthropic/Gemini/custom)
chronos stats <capsule> display simulation event and network statistics
chronos check [paths...] scan source directories for unseeded global calls
chronos export <capsule> [flags] export trace timelines to Markdown tables or CSV files
Expand Down Expand Up @@ -74,7 +74,9 @@ Starts the local Inspector UI web server preloaded with the capsule, displaying

## `chronos explain`

When `NVIDIA_API_KEY` is set in the environment, `explain` sends a structured failure summary to NVIDIA NIM and prints an explanation. If the key is missing, it logs a notice and exits cleanly.
Sends a structured failure summary to an LLM and prints the explanation. Set one of `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, or `GEMINI_API_KEY`, or point `LLM_BASE_URL` (+ `LLM_API_KEY`) at any OpenAI-compatible endpoint — Ollama, LM Studio, vLLM, OpenRouter, Groq. If no key is set, it logs a notice and exits cleanly.

The summary is redacted before it leaves your machine (JWTs and `token=`/`key=`/`secret=`/`password=` values are replaced), and the key is sent as a header, never in a URL. An API key is only sent over plaintext `http` to a loopback host; anywhere else `explain` requires `https`.

## `chronos stats`

Expand Down Expand Up @@ -117,4 +119,7 @@ Verifies Node.js version requirements (>= 20), strict mode configuration status,
| `CHRONOS_SEED` | Sets a single seed for replay and sweep commands |
| `CHRONOS_DIR` | Sets output directory for `sweep` capsules (default `.chronos`) |
| `CHRONOS_STRICT` | Configures strict guard level (`route`, `throw`, or `off`) |
| `NVIDIA_API_KEY` | Enables `chronos explain` |
| `CHRONOS_MAX_CAPSULE_BYTES` | Raises the 128 MB limit on a capsule file read |
| `CHRONOS_ALLOW_OUTSIDE_CAPSULES` | Allows reading capsules from outside cwd/`CHRONOS_DIR` |
| `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `GEMINI_API_KEY` | Enables `chronos explain` |
| `LLM_BASE_URL` + `LLM_API_KEY` | Points `chronos explain` at any OpenAI-compatible endpoint |
2 changes: 1 addition & 1 deletion docs-site/guide/replay.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ A capsule file is JSON containing the seed, network configuration, invariant fai

```jsonc
{
"chronosVersion": "0.0.0",
"chronosVersion": "0.1.5",
"seed": "42",
"nodes": ["0", "1", "2"],
"config": {
Expand Down
2 changes: 1 addition & 1 deletion docs-site/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,4 @@ See the repository README for setup steps and the determinism checklist.

## Status

This site documents Chronos v0.0.0. Start with the [15-minute quickstart](/guide/quickstart), read [the determinism model](/concepts/determinism) for execution rules, or check the [API reference](/api/) for function signatures.
This site documents Chronos v0.1.5. Start with the [15-minute quickstart](/guide/quickstart), read [the determinism model](/concepts/determinism) for execution rules, or check the [API reference](/api/) for function signatures.
24 changes: 23 additions & 1 deletion docs-site/vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,27 @@
"framework": "vitepress",
"installCommand": "npm install",
"buildCommand": "npx vitepress build",
"outputDirectory": ".vitepress/dist"
"outputDirectory": ".vitepress/dist",
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src 'self'; img-src 'self' data:; font-src 'self' data: https://fonts.gstatic.com; base-uri 'self'; form-action 'none'; frame-ancestors 'none'; object-src 'none'"
},
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
{
"key": "Strict-Transport-Security",
"value": "max-age=63072000; includeSubDomains; preload"
},
{
"key": "Permissions-Policy",
"value": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()"
}
]
}
]
}
28 changes: 21 additions & 7 deletions packages/cli/src/check.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// chronos check — static analysis tool for DST (determinism) compliance.

import { readdir, readFile, stat } from "node:fs/promises";
import { readdir, readFile, lstat, stat } from "node:fs/promises";
import { join, resolve, relative } from "node:path";
import { CHRONOS_VERSION } from "@sx4im/chronos-core";
import { C, drawBox, renderTopBanner } from "./ui.js";

export interface CheckResult {
Expand Down Expand Up @@ -100,16 +101,29 @@ const RULES: Rule[] = [
},
];

async function getFiles(dir: string): Promise<string[]> {
/** Recursion depth cap. A source tree is never this deep; a pathological one is
* either a mistake or an attempt to blow the stack. */
const MAX_DEPTH = 32;

/** Walk a directory for scannable source files.
*
* Uses `lstat`, not `stat`, and skips symlinks. `stat` follows links, so a
* single `ln -s .. loop` inside a scanned tree made the walk recurse forever —
* `chronos check` (and therefore `chronos doctor`, which calls it) would hang
* or die of stack exhaustion on a perfectly ordinary repo containing a symlink
* cycle. Skipping links also keeps the scan inside the tree the user named
* rather than following a link out to somewhere they did not ask about. */
async function getFiles(dir: string, depth = 0): Promise<string[]> {
if (depth > MAX_DEPTH) return [];
const results: string[] = [];
const list = await readdir(dir).catch(() => [] as string[]);
for (const file of list) {
if (IGNORE_DIRS.has(file)) continue;
const path = join(dir, file);
const s = await stat(path).catch(() => null);
if (!s) continue;
const s = await lstat(path).catch(() => null);
if (!s || s.isSymbolicLink()) continue;
if (s.isDirectory()) {
results.push(...(await getFiles(path)));
results.push(...(await getFiles(path, depth + 1)));
} else if (s.isFile()) {
if (
/\.(ts|tsx|js|jsx|mjs)$/.test(file) &&
Expand Down Expand Up @@ -243,11 +257,11 @@ export async function checkCommand(paths: string[] = []): Promise<CheckResult> {
lines.pop(); // remove trailing empty line
}

const title = `${C.indigo("CHRONOS STATIC DST CHECKER")} ${C.muted(`v0.1.4`)}`;
const title = `${C.indigo("CHRONOS STATIC DST CHECKER")} ${C.muted(`v${CHRONOS_VERSION}`)}`;

return {
exitCode: cleanMessage ? 0 : 1,
message: renderTopBanner("0.1.4") + drawBox(title, lines),
message: renderTopBanner() + drawBox(title, lines),
};
}

Expand Down
Loading
Loading