diff --git a/.changeset/audit-log.md b/.changeset/audit-log.md new file mode 100644 index 0000000..1d06c77 --- /dev/null +++ b/.changeset/audit-log.md @@ -0,0 +1,6 @@ +--- +"@karnstack/reins": minor +"@reins/extension": minor +--- + +`reins audit` — a per-action audit trail. The extension stamps each response with the resolved host, permission tier, and tab; the daemon writes one redacted JSONL line per action (policy denials included) to `~/.reins/logs/audit-YYYY-MM-DD.jsonl`, pruned after 30 days. Value-bearing params (typed text, fill values, eval code, CDP payloads) are redacted before anything reaches disk. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7d6951f..033e0c7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,7 +1,7 @@ # reins roadmap -Date: 2026-07-08. Living document — reorder freely; phases are priority order, -not a calendar. +Date: 2026-07-11 (updated; first written 2026-07-08). Living document — +reorder freely; phases are priority order, not a calendar. ## Where reins stands @@ -23,37 +23,37 @@ not a calendar. integration absorbs the niche for Claude users. reins' durable ground is agent-agnosticism (any shell agent, any Chromium browser, several at once) and being scriptable plumbing rather than a product surface. -- **Biggest product gap vs expectations:** security. reins today is - all-or-nothing — extension connected means every tab, every origin, cookies - included. Claude in Chrome has set user expectations for per-site - permissions, action gating, and audit trails. Prompt injection in a - logged-in browser is treated as unsolvable; the answer is contained blast - radius, and reins doesn't offer containment yet. +- **Biggest product gap vs expectations:** security — now partly closed. + v0.3.0 shipped per-site permission tiers (deny/read/full), ending the + all-or-nothing era. Still missing from the containment story: a per-action + audit trail, a written threat model (SECURITY.md), and prompt-injection + guidance in the skill. Claude in Chrome set user expectations for all + three; finish them before pivoting to growth. -## Phase 1 — Trust: a permission model (v0.3) +## Phase 1 — Trust: a permission model (v0.3) — mostly shipped The skill's superpower framing ("read tokens, call APIs as the user") is also the scariest sentence in the README. Ship containment before growth. -- **Site policy.** Allow/deny list by origin, enforced in the extension (the - trust boundary the daemon can't fake), managed from the popup and - `reins policy`. Default stance configurable: everything (today's behavior), - allowlist-only, or deny-sensitive-categories. -- **Read-only mode.** A per-site or global tier that permits `tabs / text / - snapshot / screenshot / console / network` but refuses `click / type / fill / - eval / cdp`. Cheap to implement (command classification already exists in - `TOOL_COMMANDS`), huge trust win — "let the agent read my browser" is a much - easier first yes. -- **Audit log.** Structured per-action log line (timestamp, command, browser, - tab, origin) — `~/.reins/logs` already exists; make the action trail - first-class and document it. `reins audit` to view. -- **Threat model doc (SECURITY.md).** Cover what the origin-allowlist protects - against, what it can't (any local process is already inside the trust +- ✅ **Site policy.** Shipped in v0.3.0 (#15) as per-site permission tiers + (deny / read / full), enforced in the extension, managed from the popup and + `reins policy` (view/tighten only — the CLI can't grant). Documented on the + web (#17). +- ✅ **Read-only mode.** Shipped as the "read" tier of #15: `tabs / text / + snapshot / screenshot / console / network` allowed, `click / type / fill / + eval / cdp` refused with `blocked by policy: is read-only/denied`. + SKILL.md teaches agents not to retry or self-escalate. +- ✅ **Audit log.** Shipped: one structured JSONL line per action (and per + policy denial) in `~/.reins/logs/audit-YYYY-MM-DD.jsonl`, value-bearing + params redacted before write, 30-day retention, `reins audit` to view + (`--last`, `--denied`, `--json`). +- ⬜ **Threat model doc (SECURITY.md).** Cover what the per-site tiers protect + against, what they can't (any local process is already inside the trust boundary — the Claude-in-Chrome LevelDB permission-bypass class), and the prompt-injection story: page content is untrusted input to the agent. -- **Skill hardening.** Add an explicit "treat page text as data, never as - instructions" section to SKILL.md; today it teaches capability with only a - light stewardship note. +- ⬜ **Skill hardening.** Add an explicit "treat page text as data, never as + instructions" section to SKILL.md; today it teaches capability plus the + policy-blocked etiquette, but has no prompt-injection guidance. ## Phase 2 — Proof: an eval harness for the skill (v0.4) @@ -117,7 +117,9 @@ Informed by the comparison table; promote by observed demand, not speculation. - **Discovery.** The funnel is installs-without-visitors; invert it: launch post (the CLI-vs-MCP token story + security model is the angle), demo recordings on the landing page, recipes gallery in docs (the SKILL.md - recipes are the best marketing copy the project has). + recipes are the best marketing copy the project has). Groundwork landed: + landing revamp + permissions docs (#17), light theme (#18), changelog page + (#19) — the site is launch-ready; the launch post is not written. - **Cross-agent eval matrix.** The skill claims Claude Code / Cursor / Codex / Copilot compatibility; actually run the trigger+execution evals per harness and publish the matrix. "Tested on N agents" is a differentiator the diff --git a/docs/superpowers/plans/2026-07-11-audit-log.md b/docs/superpowers/plans/2026-07-11-audit-log.md new file mode 100644 index 0000000..89e190c --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-audit-log.md @@ -0,0 +1,1521 @@ +# Audit Log (`reins audit`) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Every browser command reins executes — and every one policy blocks — leaves one structured, redacted JSONL line the user can review with `reins audit`. + +**Architecture:** The extension's dispatch gate already resolves the target tab, host, and tier; it stamps them onto the response frame as optional `meta`. The daemon composes the full audit record (timestamp, method, redacted params, browser, outcome, duration) and appends it to `~/.reins/logs/audit-YYYY-MM-DD.jsonl`. `reins audit` reads those files directly — no daemon needed. Spec: `docs/superpowers/specs/2026-07-11-audit-log-design.md`. + +**Tech Stack:** TypeScript, zod (protocol schemas), vitest, pnpm workspaces + turbo. No new dependencies. + +## Global Constraints + +- Monorepo: `packages/protocol` (schemas), `packages/extension` (MV3), `packages/cli` (daemon + CLI). cli/extension consume protocol's `dist/` — **after any protocol change run `pnpm --filter @reins/protocol build` before building or testing cli/extension** or you test against stale schemas. +- Existing error code for policy denials is `policy_denied` (class `PolicyDenied`, `packages/extension/src/lib/policy.ts`). Do NOT invent a new code. +- Audit writes are best-effort: a full disk must never fail an RPC. Redaction happens before write; plaintext secrets never reach disk. +- The repo's flag parser (`packages/cli/src/args.ts`) supports `--flag` forms only; the viewer's count flag is `--last `. +- Lint/format: `pnpm lint` (biome) must pass. Typecheck: `pnpm typecheck`. +- Commit after every task. Write commit messages in normal prose, conventional-commit style, ending with `Co-Authored-By: Claude Fable 5 `. +- Run all commands from the repo root: `/Users/karn/code/karnstack/reins`. + +--- + +### Task 1: Protocol — `ResponseMeta` on `ResponseFrame` + +**Files:** +- Modify: `packages/protocol/src/bridge.ts` +- Test: `packages/protocol/src/bridge.test.ts` + +**Interfaces:** +- Consumes: `Tier` from `packages/protocol/src/policy.ts` (already exported). +- Produces: `ResponseMeta` zod schema + type `{ host?: string; tier?: Tier; tabId?: number }`; `ResponseFrame` gains optional `meta: ResponseMeta`. Both re-exported through `packages/protocol/src/index.ts` (it already does `export *` from bridge — verify, don't assume). + +- [ ] **Step 1: Write the failing test** + +Append to `packages/protocol/src/bridge.test.ts`: + +```ts +describe("ResponseMeta", () => { + it("round-trips meta on a response frame", () => { + const frame = ResponseFrame.parse({ + type: "response", + id: "r1", + ok: true, + result: { done: true }, + meta: { host: "app.example.com", tier: "full", tabId: 412 }, + }); + expect(frame.meta).toEqual({ host: "app.example.com", tier: "full", tabId: 412 }); + }); + + it("parses frames without meta (older extensions)", () => { + const frame = ResponseFrame.parse({ type: "response", id: "r2", ok: true, result: 1 }); + expect(frame.meta).toBeUndefined(); + }); + + it("allows partial meta (denial without tabId)", () => { + const frame = ResponseFrame.parse({ + type: "response", + id: "r3", + ok: false, + error: { code: "policy_denied", message: "blocked" }, + meta: { host: "bank.com", tier: "read" }, + }); + expect(frame.meta?.host).toBe("bank.com"); + expect(frame.meta?.tabId).toBeUndefined(); + }); +}); +``` + +Add `ResponseFrame` (already imported) and `describe` to the test file's imports if missing. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @reins/protocol test` +Expected: FAIL — `meta` is stripped by zod (unknown key) so `frame.meta` is `undefined` in the first test. + +- [ ] **Step 3: Implement** + +In `packages/protocol/src/bridge.ts`, add after the `FrameError` block (import `Tier` at the top: `import { Tier } from "./policy.js";`): + +```ts +/** Optional target metadata the extension stamps on a response: the + * resolved tab/host/tier the command actually hit. Consumed by the + * daemon's audit trail. Absent on daemon-side failures and on responses + * from extensions older than this field. */ +export const ResponseMeta = z.object({ + host: z.string().optional(), + tier: Tier.optional(), + tabId: z.number().optional(), +}); +export type ResponseMeta = z.infer; +``` + +In `ResponseFrame`, add the field: + +```ts +export const ResponseFrame = z.object({ + type: z.literal("response"), + id: z.string().min(1), + ok: z.boolean(), + result: z.unknown().optional(), + error: FrameError.optional(), + meta: ResponseMeta.optional(), +}); +``` + +Verify `packages/protocol/src/index.ts` re-exports bridge (`export * from "./bridge.js"` or named exports — if named, add `ResponseMeta`). + +- [ ] **Step 4: Run tests, typecheck, build** + +Run: `pnpm --filter @reins/protocol test && pnpm --filter @reins/protocol typecheck && pnpm --filter @reins/protocol build` +Expected: PASS + clean build (the `dist/` rebuild is what downstream tasks consume). + +- [ ] **Step 5: Commit** + +```bash +git add packages/protocol/src/bridge.ts packages/protocol/src/bridge.test.ts +git commit -m "feat(protocol): optional ResponseMeta (host/tier/tabId) on ResponseFrame" +``` + +--- + +### Task 2: Extension — `ensureAllowed` returns the tier; `PolicyDenied` carries meta + +**Files:** +- Modify: `packages/extension/src/lib/policy.ts` +- Test: `packages/extension/src/lib/policy.test.ts` + +**Interfaces:** +- Consumes: `ResponseMeta` from `@reins/protocol` (Task 1). +- Produces: `ensureAllowed(method, host): Promise` (was `Promise`) — returns the effective tier when allowed; `PolicyDenied` gains a mutable `meta?: ResponseMeta` property, pre-filled with `{ host, tier }` at throw time. + +- [ ] **Step 1: Write the failing test** + +Append to `packages/extension/src/lib/policy.test.ts` (inside or alongside the existing `ensureAllowed` tests, reusing that file's storage-stub setup): + +```ts +it("returns the effective tier when allowed", async () => { + // default policy is full everywhere + await expect(ensureAllowed("click", "app.example.com")).resolves.toBe("full"); + await expect(ensureAllowed("read_text", "app.example.com")).resolves.toBe("full"); +}); + +it("stamps meta on PolicyDenied", async () => { + await seedPolicy({ defaultTier: "full", rules: [{ pattern: "bank.com", tier: "read" }] }); + const err = await ensureAllowed("click", "bank.com").catch((e) => e); + expect(err).toBeInstanceOf(PolicyDenied); + expect(err.code).toBe("policy_denied"); + expect(err.meta).toEqual({ host: "bank.com", tier: "read" }); +}); +``` + +Adapt `seedPolicy` to whatever helper the existing tests use to write policy into the chrome.storage stub (the file already tests `ensureAllowed` denials — copy its arrangement; do not invent a new stub). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @reins/extension test -- policy.test` +Expected: FAIL — `resolves.toBe("full")` gets `undefined`; `err.meta` is `undefined`. + +- [ ] **Step 3: Implement** + +In `packages/extension/src/lib/policy.ts`: + +```ts +import type { ResponseMeta } from "@reins/protocol"; + +/** Refused by the policy gate. `code` survives to the ResponseFrame. */ +export class PolicyDenied extends Error { + readonly code = "policy_denied"; + /** Resolved target for the audit trail; the dispatch gate adds tabId. */ + meta?: ResponseMeta; +} +``` + +Replace `ensureAllowed`: + +```ts +/** Throw PolicyDenied unless `host`'s tier covers `method`'s required tier; + * return the effective tier so dispatch can stamp it on the response. */ +export async function ensureAllowed(method: GatedMethod, host: string | undefined): Promise { + const tier = effectiveTier(await policy(), host); + const required = METHOD_TIERS[method]; + if (tier === "full" || (tier === "read" && required === "read")) return tier; + const label = host ?? "this tab"; + const err = new PolicyDenied( + tier === "deny" + ? `blocked by policy: ${label} is denied — change its tier from the reins extension popup` + : `blocked by policy: ${label} is read-only — grant full access from the reins extension popup`, + ); + err.meta = { host, tier }; + throw err; +} +``` + +(Denial messages are unchanged — SKILL.md and web docs quote them.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter @reins/extension test -- policy.test` +Expected: PASS, including all pre-existing `ensureAllowed` tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/extension/src/lib/policy.ts packages/extension/src/lib/policy.test.ts +git commit -m "feat(extension): ensureAllowed returns tier, PolicyDenied carries meta" +``` + +--- + +### Task 3: Extension — gate builds meta; `dispatchWithMeta` + +**Files:** +- Modify: `packages/extension/src/lib/dispatch.ts` +- Test: `packages/extension/src/lib/dispatch.test.ts` + +**Interfaces:** +- Consumes: `ensureAllowed` returning `Tier`, `PolicyDenied.meta` (Task 2), `ResponseMeta` (Task 1). +- Produces: `export interface DispatchOutcome { result: unknown; meta?: ResponseMeta }`; `export async function dispatchWithMeta(method: string, params: unknown): Promise`. Existing `dispatchMethod(method, params): Promise` KEEPS its signature (thin wrapper returning `.result`) so the many existing dispatch tests stay valid. + +- [ ] **Step 1: Write the failing test** + +Append to `packages/extension/src/lib/dispatch.test.ts`, reusing the file's existing chrome/tab/policy stubs (it already fakes `chrome.tabs.get`, `resolveTabId` targets, and policy storage — follow the arrangement of the existing gate tests): + +```ts +describe("dispatchWithMeta", () => { + it("stamps host/tier/tabId on success", async () => { + // arrange: one tab { id: 7, url: "https://app.example.com/x" }, default policy full + const out = await dispatchWithMeta("read_text", { tabId: 7 }); + expect(out.meta).toEqual({ host: "app.example.com", tier: "full", tabId: 7 }); + }); + + it("stamps meta (with tabId) on a policy denial", async () => { + // arrange: tab { id: 7, url: "https://bank.com/x" }, rule bank.com → read + const err = await dispatchWithMeta("click", { tabId: 7 }).catch((e) => e); + expect(err.code).toBe("policy_denied"); + expect(err.meta).toEqual({ host: "bank.com", tier: "read", tabId: 7 }); + }); + + it("leaves meta undefined for policy_get", async () => { + const out = await dispatchWithMeta("policy_get", {}); + expect(out.meta).toBeUndefined(); + }); + + it("dispatchMethod still returns the bare result", async () => { + const result = await dispatchMethod("read_text", { tabId: 7 }); + expect(result).not.toHaveProperty("meta"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @reins/extension test -- dispatch.test` +Expected: FAIL — `dispatchWithMeta` is not exported. + +- [ ] **Step 3: Implement** + +In `packages/extension/src/lib/dispatch.ts`: + +1. Import `PolicyDenied` alongside the existing policy imports, and `type ResponseMeta` from `@reins/protocol`. +2. Change `gate` to return params + meta, and to add `tabId` to a denial's meta: + +```ts +interface Gated { + params: Record; + meta: ResponseMeta; +} + +async function gate(method: GatedMethod, params: unknown): Promise { + const p = { ...((params ?? {}) as Record) }; + if (method === "list_tabs") return { params: p, meta: {} }; + if (method === "open_tab") { + const host = hostOf(String(p.url ?? "")); + const tier = await ensureAllowed("open_tab", host); + return { params: p, meta: { host, tier } }; + } + const tabId = await resolveTabId(typeof p.tabId === "number" ? p.tabId : undefined); + try { + const tab = await chrome.tabs.get(tabId); + const host = hostOf(tab.url ?? ""); + const tier = await ensureAllowed(method, host); + if (method === "navigate") { + const to = String(p.to ?? ""); + if (!NAV_HISTORY.has(to)) { + let dest = hostOf(to); + if (dest === undefined) { + // Protocol-relative ("//bank.com/x") and path-relative targets + // resolve against the current page — check what they resolve to, + // or they would dodge the destination gate. + try { + dest = hostOf(new URL(to, tab.url).href); + } catch { + // unresolvable target — the handler will reject it + } + } + if (dest !== undefined) await ensureAllowed("navigate", dest); + } + } + return { params: { ...p, tabId }, meta: { host, tier, tabId } }; + } catch (err) { + // A denial thrown in here knows host+tier but not the tab — add it. + if (err instanceof PolicyDenied && err.meta) err.meta = { ...err.meta, tabId }; + throw err; + } +} +``` + +3. Rename the exported entry point and wrap: + +```ts +export interface DispatchOutcome { + result: unknown; + meta?: ResponseMeta; +} + +/** Route a bridge method to its handler; the outcome carries the resolved + * target (host/tier/tabId) for the daemon's audit trail. */ +export async function dispatchWithMeta(method: string, params: unknown): Promise { + if (method === "policy_get") return { result: await policy() }; + if (method === "policy_tighten") { + const { pattern, tier } = PolicyTightenParams.parse(params ?? {}); + return { result: await tightenPolicy(pattern, tier) }; + } + if (!(method in METHOD_TIERS)) throw new Error(`unknown method: ${method}`); + const gated = await gate(method as GatedMethod, params); + const result = await runHandler(method as GatedMethod, gated.params); + return { result, meta: gated.meta }; +} + +export async function dispatchMethod(method: string, params: unknown): Promise { + return (await dispatchWithMeta(method, params)).result; +} +``` + +4. Move the existing `switch (method)` body into a private `runHandler(method: GatedMethod, gated: Record): Promise` — the cases are unchanged, each `case` now reads the `gated` parameter (was the `gated` local). `list_tabs`'s redaction logic moves with it. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter @reins/extension test -- dispatch.test` +Expected: PASS — new describe block AND all pre-existing dispatch tests (they call `dispatchMethod`, whose behavior is unchanged). + +- [ ] **Step 5: Commit** + +```bash +git add packages/extension/src/lib/dispatch.ts packages/extension/src/lib/dispatch.test.ts +git commit -m "feat(extension): dispatchWithMeta stamps resolved host/tier/tabId" +``` + +--- + +### Task 4: Extension — meta over the wire (bridge-client, background, offscreen) + +**Files:** +- Modify: `packages/extension/src/lib/bridge-client.ts` +- Modify: `packages/extension/src/background.ts` +- Modify: `packages/extension/src/offscreen.ts` +- Test: `packages/extension/src/lib/bridge-client.test.ts` + +**Interfaces:** +- Consumes: `DispatchOutcome` shape (Task 3). +- Produces: `bridge-client.ts`'s `Dispatch` type becomes `(method, params) => Promise` where `export interface DispatchOutcome { result: unknown; meta?: unknown }` (kept `unknown` — this file is transport-agnostic and imports nothing from protocol). Response frames sent to the daemon include `meta` when present, on success AND error paths. + +- [ ] **Step 1: Write the failing test** + +In `packages/extension/src/lib/bridge-client.test.ts`, the existing tests stub `dispatch` — they must change from `dispatch: async () => value` to `dispatch: async () => ({ result: value })`. First add the new behavior tests (reuse the file's fake-socket helper): + +```ts +it("forwards dispatch meta on the response frame", async () => { + // dispatch resolves { result, meta } + const client = makeConnectedClient({ + dispatch: async () => ({ + result: { ok: true }, + meta: { host: "app.example.com", tier: "full", tabId: 7 }, + }), + }); + sendRequestFrame(client, { id: "r1", method: "read_text", params: {} }); + const frame = await nextSentFrame(); + expect(frame).toMatchObject({ + type: "response", id: "r1", ok: true, + meta: { host: "app.example.com", tier: "full", tabId: 7 }, + }); +}); + +it("forwards meta from a thrown dispatch error", async () => { + const err = new Error("blocked by policy: bank.com is read-only") as Error & { + code?: string; meta?: unknown; + }; + err.code = "policy_denied"; + err.meta = { host: "bank.com", tier: "read", tabId: 7 }; + const client = makeConnectedClient({ dispatch: async () => { throw err; } }); + sendRequestFrame(client, { id: "r2", method: "click", params: {} }); + const frame = await nextSentFrame(); + expect(frame).toMatchObject({ + type: "response", id: "r2", ok: false, + error: { code: "policy_denied" }, + meta: { host: "bank.com", tier: "read", tabId: 7 }, + }); +}); + +it("omits meta when dispatch returns none", async () => { + const client = makeConnectedClient({ dispatch: async () => ({ result: 1 }) }); + sendRequestFrame(client, { id: "r3", method: "policy_get", params: {} }); + const frame = await nextSentFrame(); + expect(frame).not.toHaveProperty("meta"); +}); +``` + +`makeConnectedClient` / `sendRequestFrame` / `nextSentFrame` stand for whatever the existing tests use to drive a client over the fake socket — mirror them exactly. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @reins/extension test -- bridge-client.test` +Expected: FAIL — new tests fail (no meta on frames); pre-existing tests may also fail typecheck once the Dispatch type changes (fixed in step 3). + +- [ ] **Step 3: Implement** + +`packages/extension/src/lib/bridge-client.ts`: + +```ts +export interface DispatchOutcome { + result: unknown; + /** Resolved action target (host/tier/tabId), forwarded verbatim to the daemon. */ + meta?: unknown; +} +export type Dispatch = (method: string, params: unknown) => Promise; +``` + +Rework `#handleRequest`: + +```ts +async #handleRequest(id: string, method: string, params: unknown): Promise { + const socket = this.#socket; + if (!socket) return; + let outcome: DispatchOutcome | undefined; + let dispatchError: unknown; + let threw = false; + try { + outcome = await this.#opts.dispatch(method, params); + } catch (err) { + threw = true; + dispatchError = err; + } + if (this.#socket !== socket) return; // socket replaced/closed during dispatch + try { + if (!threw && outcome) { + socket.send( + JSON.stringify({ + type: "response", + id, + ok: true, + result: outcome.result, + ...(outcome.meta !== undefined ? { meta: outcome.meta } : {}), + }), + ); + } else { + const message = + dispatchError instanceof Error ? dispatchError.message : String(dispatchError); + const code = + typeof (dispatchError as { code?: unknown })?.code === "string" + ? (dispatchError as { code: string }).code + : "HANDLER_ERROR"; + const meta = (dispatchError as { meta?: unknown })?.meta; + socket.send( + JSON.stringify({ + type: "response", + id, + ok: false, + error: { code, message }, + ...(meta !== undefined ? { meta } : {}), + }), + ); + } + } catch { + // Socket closed between dispatch and send; response cannot be delivered. + } +} +``` + +Update every pre-existing `dispatch:` stub in `bridge-client.test.ts` from `async () => X` to `async () => ({ result: X })` (mechanical; assertions on `result` unchanged). + +`packages/extension/src/background.ts` — the `reins:dispatch` case switches to `dispatchWithMeta` and relays meta both ways: + +```ts +case "reins:dispatch": { + const method = message.method as string; + const params = message.params; + dispatchWithMeta(method, params) + .then(({ result, meta }) => sendResponse({ result, ...(meta !== undefined ? { meta } : {}) })) + .catch((err) => + sendResponse({ + error: err instanceof Error ? err.message : String(err), + code: + typeof (err as { code?: unknown })?.code === "string" + ? (err as { code: string }).code + : undefined, + meta: (err as { meta?: unknown })?.meta, + }), + ); + return true; +} +``` + +Update the import: `import { dispatchWithMeta } from "./lib/dispatch.js";` (drop `dispatchMethod` if now unused there). + +`packages/extension/src/offscreen.ts` — relay meta through `offscreenDispatch`: + +```ts +import type { DispatchOutcome } from "./lib/bridge-client.js"; + +async function offscreenDispatch(method: string, params: unknown): Promise { + const res = (await chrome.runtime.sendMessage({ type: "reins:dispatch", method, params })) as + | { result: unknown; meta?: unknown; error?: undefined; code?: undefined } + | { error: string; code?: string; meta?: unknown; result?: undefined } + | undefined; + if (res?.error) { + const err = new Error(res.error) as Error & { code?: string; meta?: unknown }; + if (res.code) err.code = res.code; + if (res.meta !== undefined) err.meta = res.meta; + throw err; + } + return { result: res?.result, meta: res?.meta }; +} +``` + +- [ ] **Step 4: Run the full extension suite + typecheck** + +Run: `pnpm --filter @reins/extension test && pnpm --filter @reins/extension typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/extension/src/lib/bridge-client.ts packages/extension/src/lib/bridge-client.test.ts packages/extension/src/background.ts packages/extension/src/offscreen.ts +git commit -m "feat(extension): carry dispatch meta on response frames" +``` + +--- + +### Task 5: CLI — redaction + audit writer + prune (`audit.ts`) + +**Files:** +- Create: `packages/cli/src/audit.ts` +- Test: `packages/cli/src/audit.test.ts` + +**Interfaces:** +- Consumes: `Tier` type from `@reins/protocol`. +- Produces (all from `packages/cli/src/audit.ts`): + - `interface AuditRecord { ts: string; method: string; browserId?: string; browser?: string; tabId?: number; host?: string; tier?: Tier; params: Record; ok: boolean; denied?: boolean; error?: string; ms: number }` + - `type AuditHook = (record: AuditRecord) => void` + - `redactParams(method: string, params: Record): Record` + - `auditFilePath(dir: string, now: Date): string` → `/audit-YYYY-MM-DD.jsonl` + - `createAuditor(dir: string, opts?: { log?: (msg: string) => void; now?: () => Date }): AuditHook` + - `pruneAuditLogs(dir: string, now: Date, keepDays?: number): string[]` (returns deleted filenames) + - `AUDIT_FILE_RE` regexp for `audit-YYYY-MM-DD.jsonl` filenames + +- [ ] **Step 1: Write the failing tests** + +Create `packages/cli/src/audit.test.ts`: + +```ts +import { mkdtempSync, readFileSync, rmSync, writeFileSync, chmodSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { auditFilePath, createAuditor, pruneAuditLogs, redactParams } from "./audit.js"; +import type { AuditRecord } from "./audit.js"; + +const dirs: string[] = []; +function tempDir(): string { + const d = mkdtempSync(join(tmpdir(), "reins-audit-")); + dirs.push(d); + return d; +} +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +function record(over: Partial = {}): AuditRecord { + return { + ts: "2026-07-11T10:00:00.000Z", + method: "click", + params: { selector: "#go" }, + ok: true, + ms: 42, + ...over, + }; +} + +describe("redactParams", () => { + it.each([ + ["type", { text: "hunter2secret", selector: "#pw" }, { text: "[redacted 13 chars]", selector: "#pw" }], + ["fill", { value: "hunter2", ref: "e3" }, { value: "[redacted 7 chars]", ref: "e3" }], + ["select_option", { value: "US", ref: "e3" }, { value: "[redacted 2 chars]", ref: "e3" }], + ["eval_js", { expression: "document.cookie", awaitPromise: false }, { expression: "[redacted 15 chars]", awaitPromise: false }], + ["press_key", { key: "Meta+A" }, { key: "Meta+A" }], + ["navigate", { to: "https://x.com/a" }, { to: "https://x.com/a" }], + ])("%s", (method, input, expected) => { + expect(redactParams(method, input)).toEqual(expected); + }); + + it("keeps only basenames for upload files", () => { + expect(redactParams("upload", { files: ["/Users/me/secret-dir/tax.pdf"], ref: "e1" })) + .toEqual({ files: ["tax.pdf"], ref: "e1" }); + }); + + it("redacts cdp nested params wholesale, keeps the method name", () => { + expect(redactParams("cdp", { method: "Input.insertText", params: { text: "s3cret" } })) + .toEqual({ method: "Input.insertText", params: "[redacted]" }); + }); + + it("does not mutate its input", () => { + const input = { text: "abc" }; + redactParams("type", input); + expect(input.text).toBe("abc"); + }); +}); + +describe("createAuditor", () => { + it("appends one JSON line per record to the dated file", () => { + const dir = tempDir(); + const now = () => new Date("2026-07-11T10:00:00Z"); + const audit = createAuditor(dir, { now }); + audit(record()); + audit(record({ method: "read_text" })); + const lines = readFileSync(auditFilePath(dir, now()), "utf8").trim().split("\n"); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0] as string).method).toBe("click"); + expect(JSON.parse(lines[1] as string).method).toBe("read_text"); + }); + + it("is best-effort: write failure warns once, never throws", () => { + const dir = tempDir(); + chmodSync(dir, 0o444); // unwritable + const warnings: string[] = []; + const audit = createAuditor(dir, { log: (m) => warnings.push(m), now: () => new Date("2026-07-11T10:00:00Z") }); + expect(() => { + audit(record()); + audit(record()); + }).not.toThrow(); + chmodSync(dir, 0o755); // so afterEach can clean up + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("audit write failed"); + }); +}); + +describe("pruneAuditLogs", () => { + it("deletes audit files older than keepDays by filename date, nothing else", () => { + const dir = tempDir(); + writeFileSync(join(dir, "audit-2026-06-01.jsonl"), ""); + writeFileSync(join(dir, "audit-2026-07-10.jsonl"), ""); + writeFileSync(join(dir, "daemon-2026-06-01.log"), ""); + const deleted = pruneAuditLogs(dir, new Date("2026-07-11T00:00:00Z"), 30); + expect(deleted).toEqual(["audit-2026-06-01.jsonl"]); + const left = readdirSync(dir).sort(); + expect(left).toEqual(["audit-2026-07-10.jsonl", "daemon-2026-06-01.log"]); + }); + + it("survives a missing directory", () => { + expect(pruneAuditLogs(join(tempDir(), "nope"), new Date())).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @karnstack/reins test -- audit.test` +Expected: FAIL — `./audit.js` module not found. + +- [ ] **Step 3: Implement** + +Create `packages/cli/src/audit.ts`: + +```ts +import { appendFileSync, mkdirSync, readdirSync, unlinkSync } from "node:fs"; +import { basename, join } from "node:path"; +import type { Tier } from "@reins/protocol"; + +/** One line of the per-action audit trail (spec: 2026-07-11-audit-log-design). */ +export interface AuditRecord { + ts: string; + method: string; + browserId?: string; + browser?: string; + tabId?: number; + host?: string; + tier?: Tier; + params: Record; + ok: boolean; + denied?: boolean; + error?: string; + ms: number; +} + +export type AuditHook = (record: AuditRecord) => void; + +/** Param keys whose string values are typed/filled/evaluated content. */ +const VALUE_KEYS = new Set(["text", "value", "expression"]); + +/** + * Strip secrets from params before they reach disk. Fixed field-name table, + * not heuristics — extend VALUE_KEYS (plus a test row) when a new + * value-bearing param appears. + */ +export function redactParams( + method: string, + params: Record, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (VALUE_KEYS.has(key) && typeof value === "string") { + out[key] = `[redacted ${value.length} chars]`; + } else if (method === "upload" && key === "files" && Array.isArray(value)) { + out[key] = value.map((f) => basename(String(f))); + } else if (method === "cdp" && key === "params" && value !== undefined) { + // Arbitrary CDP payloads can carry anything (Input.insertText, …). + out[key] = "[redacted]"; + } else { + out[key] = value; + } + } + return out; +} + +export const AUDIT_FILE_RE = /^audit-(\d{4}-\d{2}-\d{2})\.jsonl$/; + +/** Audit file for a given day, e.g. /audit-2026-07-11.jsonl. */ +export function auditFilePath(dir: string, now: Date): string { + return join(dir, `audit-${now.toISOString().slice(0, 10)}.jsonl`); +} + +/** + * Appender for the audit trail. Best-effort like createLogger: a full disk + * or bad permissions must never fail the user's command — warn once and + * keep going (the trail can have gaps under disk pressure; SECURITY.md + * documents the trade-off). + */ +export function createAuditor( + dir: string, + opts: { log?: (msg: string) => void; now?: () => Date } = {}, +): AuditHook { + const now = opts.now ?? (() => new Date()); + let warned = false; + try { + mkdirSync(dir, { recursive: true }); + } catch { + // fall through — the append below will warn + } + return (record) => { + try { + appendFileSync(auditFilePath(dir, now()), `${JSON.stringify(record)}\n`); + } catch (err) { + if (warned) return; + warned = true; + const msg = err instanceof Error ? err.message : String(err); + opts.log?.(`reins: audit write failed (${msg}) — the trail will have gaps`); + } + }; +} + +/** Delete audit files whose filename date is older than keepDays. Returns + * the deleted names. Filename-based, not mtime — deterministic. */ +export function pruneAuditLogs(dir: string, now: Date, keepDays = 30): string[] { + const cutoff = new Date(now.getTime() - keepDays * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + let names: string[]; + try { + names = readdirSync(dir); + } catch { + return []; + } + const deleted: string[] = []; + for (const name of names) { + const m = AUDIT_FILE_RE.exec(name); + if (!m || (m[1] as string) >= cutoff) continue; + try { + unlinkSync(join(dir, name)); + deleted.push(name); + } catch { + // best-effort + } + } + return deleted; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter @karnstack/reins test -- audit.test` +Expected: PASS (11 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/audit.ts packages/cli/src/audit.test.ts +git commit -m "feat(cli): audit record redaction, JSONL writer, 30-day prune" +``` + +--- + +### Task 6: CLI — `BridgeHost.requestFull` (meta + browserId + error code out of the bridge) + +**Files:** +- Modify: `packages/cli/src/bridge.ts` +- Test: `packages/cli/src/bridge.test.ts` + +**Interfaces:** +- Consumes: `ResponseMeta` from `@reins/protocol` (Task 1 — protocol must be rebuilt). +- Produces: + - `export interface BridgeReply { result: unknown; meta?: ResponseMeta; browserId: string }` + - `BridgePort` gains `requestFull(method, params, opts?): Promise`; `request` keeps its `Promise` signature (now delegates). + - Rejections carry `code?: string` and `meta?: ResponseMeta` properties on the Error when the response frame had them. + +- [ ] **Step 1: Write the failing test** + +Append to `packages/cli/src/bridge.test.ts`, reusing its existing fake-extension WebSocket harness (the file already connects a `ws` client and answers request frames — mirror that arrangement): + +```ts +describe("requestFull", () => { + it("resolves result + meta + browserId", async () => { + // fake extension answers with meta + respondWith((frame) => ({ + type: "response", id: frame.id, ok: true, result: { done: true }, + meta: { host: "app.example.com", tier: "full", tabId: 7 }, + })); + const reply = await host.requestFull("read_text", {}); + expect(reply.result).toEqual({ done: true }); + expect(reply.meta).toEqual({ host: "app.example.com", tier: "full", tabId: 7 }); + expect(reply.browserId).toBe("b1"); + }); + + it("rejects with code and meta from an error frame", async () => { + respondWith((frame) => ({ + type: "response", id: frame.id, ok: false, + error: { code: "policy_denied", message: "blocked by policy: bank.com is read-only" }, + meta: { host: "bank.com", tier: "read", tabId: 7 }, + })); + const err = await host.requestFull("click", {}).catch((e) => e); + expect(err.message).toBe("policy_denied: blocked by policy: bank.com is read-only"); + expect(err.code).toBe("policy_denied"); + expect(err.meta).toEqual({ host: "bank.com", tier: "read", tabId: 7 }); + }); + + it("request() still resolves the bare result", async () => { + respondWith((frame) => ({ type: "response", id: frame.id, ok: true, result: 42 })); + await expect(host.request("read_text", {})).resolves.toBe(42); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @reins/protocol build && pnpm --filter @karnstack/reins test -- bridge.test` +Expected: FAIL — `requestFull` does not exist. + +- [ ] **Step 3: Implement** + +In `packages/cli/src/bridge.ts`: + +```ts +import type { ResponseMeta } from "@reins/protocol"; // add to the existing protocol import + +/** A settled bridge request: the result plus the extension-stamped action + * target and the browser that served it — everything the audit trail needs. */ +export interface BridgeReply { + result: unknown; + meta?: ResponseMeta; + browserId: string; +} + +export interface BridgePort { + readonly paired: boolean; + readonly browsers: BrowserInfo[]; + request(method: string, params: unknown, opts?: RequestOpts): Promise; + requestFull(method: string, params: unknown, opts?: RequestOpts): Promise; +} +``` + +`Pending.resolve` becomes `(value: BridgeReply) => void`. `#settle` becomes: + +```ts +#settle(id: string, frame: ResponseFrame): void { + const pending = this.#pending.get(id); + if (!pending) return; + clearTimeout(pending.timer); + this.#pending.delete(id); + if (frame.ok === true) { + pending.resolve({ result: frame.result, meta: frame.meta, browserId: pending.browserId }); + } else { + const err = frame.error ?? { code: "ERR", message: "request failed" }; + const e = new Error(`${err.code}: ${err.message}`) as Error & { + code?: string; + meta?: ResponseMeta; + }; + e.code = err.code; + e.meta = frame.meta; + pending.reject(e); + } +} +``` + +Rename the existing `request` body to `requestFull` (its Promise generic becomes `BridgeReply`; the timeout/rejection paths are unchanged), then: + +```ts +request(method: string, params: unknown, opts: RequestOpts = {}): Promise { + return this.requestFull(method, params, opts).then((r) => r.result); +} +``` + +- [ ] **Step 4: Run the cli suite + typecheck** + +Run: `pnpm --filter @karnstack/reins test -- bridge.test && pnpm --filter @karnstack/reins typecheck` +Expected: PASS. (Typecheck also confirms every `BridgePort` implementer — test stand-ins included — gained `requestFull`; add a delegating stub where the compiler points.) + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/bridge.ts packages/cli/src/bridge.test.ts +git commit -m "feat(cli): BridgeHost.requestFull exposes meta, browserId, error code" +``` + +--- + +### Task 7: CLI — audit hook in `handleRpc`, wired through daemon and serve + +**Files:** +- Modify: `packages/cli/src/rpc.ts` +- Modify: `packages/cli/src/daemon.ts` +- Modify: `packages/cli/src/serve.ts` +- Test: `packages/cli/src/rpc.test.ts`, `packages/cli/src/integration.test.ts` + +**Interfaces:** +- Consumes: `BridgeReply`/`requestFull` (Task 6), `AuditHook`, `AuditRecord`, `redactParams`, `createAuditor`, `pruneAuditLogs` (Task 5). +- Produces: `handleRpc(bridge, body, audit?: AuditHook)`; `startDaemon` opts gain `audit?: AuditHook`; `runDaemon` creates the auditor over `logsDir()` and prunes on startup. + +- [ ] **Step 1: Write the failing tests** + +Append to `packages/cli/src/rpc.test.ts` (reuse its existing fake `BridgePort`; extend the fake with a `requestFull` that returns a canned `BridgeReply`): + +```ts +describe("audit hook", () => { + it("records a successful action with meta, browser name, and redacted params", async () => { + const records: AuditRecord[] = []; + const bridge = fakeBridge({ + browsers: [{ id: "b1", browser: "Chromium", connectedAt: 1 }], + requestFull: async () => ({ + result: { ok: true }, + meta: { host: "app.example.com", tier: "full", tabId: 7 }, + browserId: "b1", + }), + }); + await handleRpc(bridge, { method: "type", params: { text: "hunter2", tabId: 7 } }, (r) => records.push(r)); + expect(records).toHaveLength(1); + const r = records[0] as AuditRecord; + expect(r).toMatchObject({ + method: "type", ok: true, + browserId: "b1", browser: "Chromium", + host: "app.example.com", tier: "full", tabId: 7, + params: { text: "[redacted 7 chars]", tabId: 7 }, + }); + expect(r.denied).toBeUndefined(); + expect(r.ms).toBeGreaterThanOrEqual(0); + expect(() => new Date(r.ts).toISOString()).not.toThrow(); + }); + + it("records a policy denial with denied: true", async () => { + const records: AuditRecord[] = []; + const err = new Error("policy_denied: blocked by policy: bank.com is read-only") as Error & { + code?: string; meta?: unknown; + }; + err.code = "policy_denied"; + err.meta = { host: "bank.com", tier: "read", tabId: 7 }; + const bridge = fakeBridge({ requestFull: async () => { throw err; } }); + await expect(handleRpc(bridge, { method: "click", params: {} }, (r) => records.push(r))).rejects.toThrow(); + expect(records[0]).toMatchObject({ + method: "click", ok: false, denied: true, + host: "bank.com", tier: "read", tabId: 7, + error: "policy_denied: blocked by policy: bank.com is read-only", + }); + }); + + it("records daemon-side failures without meta", async () => { + const records: AuditRecord[] = []; + const bridge = fakeBridge({ requestFull: async () => { throw new Error("extension not connected"); } }); + await expect(handleRpc(bridge, { method: "click", params: {} }, (r) => records.push(r))).rejects.toThrow(); + expect(records[0]).toMatchObject({ method: "click", ok: false, error: "extension not connected" }); + expect(records[0]?.host).toBeUndefined(); + expect(records[0]?.denied).toBeUndefined(); + }); + + it("audits list_tabs as one aggregate line without host", async () => { + const records: AuditRecord[] = []; + const bridge = fakeBridge({ /* existing list_tabs arrangement from this file */ }); + await handleRpc(bridge, { method: "list_tabs" }, (r) => records.push(r)); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ method: "list_tabs", ok: true }); + expect(records[0]?.host).toBeUndefined(); + }); + + it("does not audit malformed bodies", async () => { + const records: AuditRecord[] = []; + await expect(handleRpc(fakeBridge({}), { nope: 1 }, (r) => records.push(r))).rejects.toThrow(); + expect(records).toHaveLength(0); + }); +}); +``` + +And append an end-to-end check to `packages/cli/src/integration.test.ts` (reuse `setupHarness`/`standInExtension`; pass an in-memory audit hook into `startDaemon` — extend `setupHarness` to accept and forward `audit`): + +```ts +describe("audit over the bridge", () => { + it("writes one record per /rpc action, meta included", async () => { + const records: AuditRecord[] = []; + const port = await setupHarness({ audit: (r) => records.push(r) }); + extension = await standInExtension(port); + // stand-in extension: answer the next request with meta on the frame + respondNext((frame) => ({ + type: "response", id: frame.id, ok: true, result: { text: "hi" }, + meta: { host: "app.example.com", tier: "full", tabId: 3 }, + })); + await rpc(port, "read_text", { tabId: 3 }); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + method: "read_text", ok: true, browserId: "b1", + host: "app.example.com", tier: "full", tabId: 3, + }); + }); +}); +``` + +(`respondNext` stands for however the stand-in extension answers request frames in this file — follow the existing pattern; if it always echoes a fixed response, add the meta variant beside it.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter @karnstack/reins test -- rpc.test` +Expected: FAIL — `handleRpc` takes 2 arguments; fake bridge lacks `requestFull`. + +- [ ] **Step 3: Implement** + +`packages/cli/src/rpc.ts` — add imports and the audited flow: + +```ts +import { type AuditHook, redactParams } from "./audit.js"; +import type { BridgePort, BridgeReply } from "./bridge.js"; +import type { ResponseMeta } from "@reins/protocol"; +``` + +```ts +/** + * Execute one /rpc call: `{method, params}` → bridge → browser. `list_tabs` + * aggregates across all connected browsers; everything else routes to one + * browser. When `audit` is provided, every attempt — success, policy + * denial, or daemon-side failure — produces exactly one record. + */ +export async function handleRpc( + bridge: BridgePort, + body: unknown, + audit?: AuditHook, +): Promise { + const parsed = RpcBody.safeParse(body); + if (!parsed.success) { + throw new RpcBadRequest(`invalid rpc body: expected {method, params?}`); + } + const { method, params: raw } = parsed.data; + const { browserId, params } = route(raw ?? {}); + const started = Date.now(); + + const finish = (outcome: { + ok: boolean; + browserId?: string; + meta?: ResponseMeta; + error?: Error & { code?: string }; + }): void => { + if (!audit) return; + const browser = outcome.browserId + ? bridge.browsers.find((b) => b.id === outcome.browserId)?.browser + : undefined; + audit({ + ts: new Date(started).toISOString(), + method, + ...(outcome.browserId !== undefined ? { browserId: outcome.browserId } : {}), + ...(browser !== undefined ? { browser } : {}), + ...(outcome.meta?.tabId !== undefined ? { tabId: outcome.meta.tabId } : {}), + ...(outcome.meta?.host !== undefined ? { host: outcome.meta.host } : {}), + ...(outcome.meta?.tier !== undefined ? { tier: outcome.meta.tier } : {}), + params: redactParams(method, params), + ok: outcome.ok, + ...(outcome.error?.code === "policy_denied" ? { denied: true } : {}), + ...(outcome.error !== undefined ? { error: outcome.error.message } : {}), + ms: Date.now() - started, + }); + }; + + try { + if (method === "list_tabs") { + const tabs = await listAllTabs(bridge, browserId); + finish({ ok: true, browserId }); + return { tabs }; + } + const reply: BridgeReply = await bridge.requestFull(method, params, { browserId }); + finish({ ok: true, browserId: reply.browserId, meta: reply.meta }); + return reply.result; + } catch (err) { + const e = (err instanceof Error ? err : new Error(String(err))) as Error & { + code?: string; + meta?: ResponseMeta; + }; + finish({ ok: false, browserId, meta: e.meta, error: e }); + throw err; + } +} +``` + +`packages/cli/src/daemon.ts` — thread the hook: + +```ts +import type { AuditHook } from "./audit.js"; +// startDaemon opts: +export async function startDaemon(opts: { + port: number; + bridge: BridgeHost; + log: Log; + audit?: AuditHook; + onShutdown?: () => void; +}): Promise { +``` + +and in the `/rpc` route: `.then((body) => handleRpc(opts.bridge, body, opts.audit))`. + +`packages/cli/src/serve.ts` — construct + prune in `runDaemon`, right after `createLogger()`: + +```ts +import { createAuditor, pruneAuditLogs } from "./audit.js"; +import { createLogger, type Log, logsDir } from "./log.js"; +``` + +```ts +const log = createLogger(); +const audit = createAuditor(logsDir(), { log }); +const pruned = pruneAuditLogs(logsDir(), new Date()); +if (pruned.length > 0) log(`reins: pruned ${pruned.length} audit file(s) older than 30 days`); +``` + +and pass `audit` into the `startDaemon({ port, bridge, log, audit, onShutdown })` call. + +- [ ] **Step 4: Run the cli suite** + +Run: `pnpm --filter @karnstack/reins test && pnpm --filter @karnstack/reins typecheck` +Expected: PASS — new rpc/integration tests and all pre-existing ones (audit param is optional; existing callers unchanged). + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/rpc.ts packages/cli/src/rpc.test.ts packages/cli/src/daemon.ts packages/cli/src/serve.ts packages/cli/src/integration.test.ts +git commit -m "feat(cli): daemon writes an audit record per /rpc action" +``` + +--- + +### Task 8: CLI — `reins audit` viewer + +**Files:** +- Create: `packages/cli/src/audit-cli.ts` +- Test: `packages/cli/src/audit-cli.test.ts` +- Modify: `packages/cli/src/cli.ts` (new `case "audit"`) +- Modify: `packages/cli/src/cli-commands.ts` (help line) +- Test: `packages/cli/src/cli-commands.test.ts` (help mentions audit) + +**Interfaces:** +- Consumes: `AUDIT_FILE_RE`, `AuditRecord` (Task 5), `parseArgs`/`UsageError` (`./args.js`), `logsDir` (`./log.js`). +- Produces: `runAudit(argv: string[], deps: { dir: string; now: () => Date }): { out: string; warnings: string[] }`. + +- [ ] **Step 1: Write the failing tests** + +Create `packages/cli/src/audit-cli.test.ts`: + +```ts +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { runAudit } from "./audit-cli.js"; + +const dirs: string[] = []; +function tempDir(): string { + const d = mkdtempSync(join(tmpdir(), "reins-audit-cli-")); + dirs.push(d); + return d; +} +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +const NOW = () => new Date("2026-07-11T12:00:00Z"); + +function line(over: Record = {}): string { + return `${JSON.stringify({ + ts: "2026-07-11T10:15:02.113Z", method: "click", browserId: "b1", browser: "Chromium", + tabId: 412, host: "app.example.com", tier: "full", params: { selector: "#go" }, + ok: true, ms: 184, ...over, + })}\n`; +} + +describe("runAudit", () => { + it("renders today's records as a table", () => { + const dir = tempDir(); + writeFileSync(join(dir, "audit-2026-07-11.jsonl"), line() + line({ method: "read_text", ms: 20 })); + const view = runAudit([], { dir, now: NOW }); + expect(view.out).toContain("10:15:02"); + expect(view.out).toContain("click"); + expect(view.out).toContain("app.example.com"); + expect(view.out).toContain("412"); + expect(view.out).toContain("ok"); + expect(view.warnings).toEqual([]); + }); + + it("marks policy denials as DENIED", () => { + const dir = tempDir(); + writeFileSync( + join(dir, "audit-2026-07-11.jsonl"), + line({ ok: false, denied: true, error: "policy_denied: blocked", host: "bank.com" }), + ); + const view = runAudit([], { dir, now: NOW }); + expect(view.out).toContain("DENIED"); + expect(view.out).toContain("bank.com"); + }); + + it("--denied filters to denials only", () => { + const dir = tempDir(); + writeFileSync( + join(dir, "audit-2026-07-11.jsonl"), + line() + line({ ok: false, denied: true, error: "policy_denied: blocked" }), + ); + const view = runAudit(["--denied"], { dir, now: NOW }); + expect(view.out.match(/DENIED/g)).toHaveLength(1); + expect(view.out).not.toMatch(/\bok\b/); + }); + + it("--last N crosses day files, newest last", () => { + const dir = tempDir(); + writeFileSync(join(dir, "audit-2026-07-10.jsonl"), line({ method: "older" }) + line({ method: "old" })); + writeFileSync(join(dir, "audit-2026-07-11.jsonl"), line({ method: "newest" })); + const view = runAudit(["--last", "2"], { dir, now: NOW }); + expect(view.out).not.toContain("older"); + const oldIdx = view.out.indexOf("old"); + const newIdx = view.out.indexOf("newest"); + expect(oldIdx).toBeGreaterThan(-1); + expect(newIdx).toBeGreaterThan(oldIdx); + }); + + it("--json emits raw JSONL", () => { + const dir = tempDir(); + writeFileSync(join(dir, "audit-2026-07-11.jsonl"), line()); + const view = runAudit(["--json"], { dir, now: NOW }); + expect(JSON.parse(view.out).method).toBe("click"); + }); + + it("renders — for missing host/tab and skips corrupt lines with a warning", () => { + const dir = tempDir(); + writeFileSync( + join(dir, "audit-2026-07-11.jsonl"), + `not json\n${line({ host: undefined, tabId: undefined, browser: undefined })}`, + ); + const view = runAudit([], { dir, now: NOW }); + expect(view.out).toContain("—"); + expect(view.warnings).toEqual(["skipped 1 corrupt audit line"]); + }); + + it("says so when there is nothing to show", () => { + const view = runAudit([], { dir: tempDir(), now: NOW }); + expect(view.out).toContain("no audit records"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @karnstack/reins test -- audit-cli.test` +Expected: FAIL — `./audit-cli.js` module not found. + +- [ ] **Step 3: Implement** + +Create `packages/cli/src/audit-cli.ts`: + +```ts +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { parseArgs, UsageError } from "./args.js"; +import { AUDIT_FILE_RE, type AuditRecord } from "./audit.js"; + +const USAGE = "usage: reins audit [--last ] [--denied] [--json]"; + +export interface AuditView { + out: string; + warnings: string[]; +} + +interface Loaded { + records: AuditRecord[]; + skipped: number; +} + +function loadFiles(dir: string, files: string[]): Loaded { + const records: AuditRecord[] = []; + let skipped = 0; + for (const name of files) { + let text: string; + try { + text = readFileSync(join(dir, name), "utf8"); + } catch { + continue; + } + for (const line of text.split("\n")) { + if (line.trim() === "") continue; + try { + records.push(JSON.parse(line) as AuditRecord); + } catch { + skipped += 1; + } + } + } + return { records, skipped }; +} + +function auditFiles(dir: string): string[] { + try { + return readdirSync(dir) + .filter((n) => AUDIT_FILE_RE.test(n)) + .sort(); // filename dates sort chronologically + } catch { + return []; + } +} + +function outcome(r: AuditRecord): string { + if (r.denied === true) return "DENIED"; + return r.ok ? "ok" : "error"; +} + +function table(records: AuditRecord[]): string { + const rows = records.map((r) => [ + r.ts.slice(11, 19), + r.method, + r.browser ?? "—", + r.host ?? "—", + r.tabId !== undefined ? String(r.tabId) : "—", + outcome(r), + String(r.ms), + ]); + const header = ["TIME", "METHOD", "BROWSER", "HOST", "TAB", "OUTCOME", "MS"]; + const widths = header.map((h, i) => Math.max(h.length, ...rows.map((row) => (row[i] as string).length))); + const render = (row: string[]) => row.map((cell, i) => cell.padEnd(widths[i] as number)).join(" ").trimEnd(); + return [render(header), ...rows.map(render)].join("\n"); +} + +/** `reins audit` — render the per-action trail from ~/.reins/logs, no daemon needed. */ +export function runAudit(argv: string[], deps: { dir: string; now: () => Date }): AuditView { + const a = parseArgs(argv, { booleans: ["denied", "json"] }); + if (a.positional.length > 0) throw new UsageError(USAGE); + let last: number | undefined; + if (a.flags.last !== undefined) { + last = Number.parseInt(String(a.flags.last), 10); + if (Number.isNaN(last) || last <= 0) throw new UsageError(`--last expects a positive integer\n${USAGE}`); + } + + const all = auditFiles(deps.dir); + const today = `audit-${deps.now().toISOString().slice(0, 10)}.jsonl`; + const files = last !== undefined ? all : all.filter((n) => n === today); + const { records, skipped } = loadFiles(deps.dir, files); + + let selected = records; + if (a.flags.denied === true) selected = selected.filter((r) => r.denied === true); + if (last !== undefined) selected = selected.slice(-last); + + const warnings = + skipped > 0 ? [`skipped ${skipped} corrupt audit line${skipped === 1 ? "" : "s"}`] : []; + + if (selected.length === 0) { + return { + out: `no audit records${last !== undefined ? "" : " for today"} in ${deps.dir} (the daemon writes one line per action).`, + warnings, + }; + } + if (a.flags.json === true) { + return { out: selected.map((r) => JSON.stringify(r)).join("\n"), warnings }; + } + return { out: table(selected), warnings }; +} +``` + +(Note: with `--last`, all files are read then sliced — audit files are small, day-scoped, and pruned at 30; simplicity beats a reverse-reader here.) + +`packages/cli/src/cli.ts` — add the case next to `case "logs"`: + +```ts +case "audit": { + const { runAudit } = await import("./audit-cli.js"); + const view = runAudit(rest, { dir: logsDir(), now: () => new Date() }); + for (const w of view.warnings) console.error(w); + console.log(view.out); + break; +} +``` + +`packages/cli/src/cli-commands.ts` — in `helpText`'s Management block, after the `policy` line: + +```ts +line("audit", "per-action trail: what the agent did, what policy blocked"), +``` + +Add to `packages/cli/src/cli-commands.test.ts` wherever `helpText` is asserted: + +```ts +it("help lists the audit command", () => { + expect(helpText("0.0.0", {})).toContain("audit"); +}); +``` + +(If `helpText("0.0.0", {})` throws on the empty tools map in that file's existing tests, follow whatever fixture they already pass.) + +- [ ] **Step 4: Run the cli suite** + +Run: `pnpm --filter @karnstack/reins test && pnpm --filter @karnstack/reins typecheck` +Expected: PASS. + +- [ ] **Step 5: Smoke-test the real binary** + +```bash +pnpm --filter @reins/protocol build && pnpm --filter @reins/extension build && pnpm --filter @karnstack/reins build +node packages/cli/dist/cli.js audit +node packages/cli/dist/cli.js help | grep audit +``` + +Expected: first command prints the no-records message (or a real table if a daemon has been running); second prints the help line. + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/src/audit-cli.ts packages/cli/src/audit-cli.test.ts packages/cli/src/cli.ts packages/cli/src/cli-commands.ts packages/cli/src/cli-commands.test.ts +git commit -m "feat(cli): reins audit — view the per-action trail" +``` + +--- + +### Task 9: Docs, changeset, roadmap tick + +**Files:** +- Modify: `packages/web/src/routes/docs/security.tsx` +- Create: `.changeset/audit-log.md` +- Modify: `docs/ROADMAP.md` + +**Interfaces:** none (docs only). + +- [ ] **Step 1: Web docs — audit section** + +In `packages/web/src/routes/docs/security.tsx`, insert after the "Per-site permissions" section (before `

Data handling

`): + +```tsx +

Audit trail

+
    +
  • + Every command the daemon executes — and every one the policy blocks — appends one + structured line (timestamp, command, browser, tab, host, tier, outcome, duration) to{" "} + ~/.reins/logs/audit-YYYY-MM-DD.jsonl. reins audit renders the + trail; --denied shows only what policy blocked. +
  • +
  • + Value-bearing params — typed text, fill values, eval code, CDP payloads — + are redacted before the line is written, so the trail never stores what the agent typed, + only that it typed. +
  • +
  • Audit files are pruned after 30 days. Writes are best-effort: a full disk never blocks a command.
  • +
+``` + +Run: `pnpm --filter @reins/web typecheck` +Expected: clean. + +- [ ] **Step 2: Changeset** + +Create `.changeset/audit-log.md`: + +```md +--- +"@karnstack/reins": minor +"@reins/extension": minor +--- + +`reins audit` — a per-action audit trail. The extension stamps each response with the resolved host, permission tier, and tab; the daemon writes one redacted JSONL line per action (policy denials included) to `~/.reins/logs/audit-YYYY-MM-DD.jsonl`, pruned after 30 days. Value-bearing params (typed text, fill values, eval code, CDP payloads) are redacted before anything reaches disk. +``` + +- [ ] **Step 3: Roadmap tick** + +In `docs/ROADMAP.md`, change the audit-log bullet from `⬜` to `✅` and rewrite to past tense: + +```md +- ✅ **Audit log.** Shipped: one structured JSONL line per action (and per + policy denial) in `~/.reins/logs/audit-YYYY-MM-DD.jsonl`, value-bearing + params redacted before write, 30-day retention, `reins audit` to view + (`--last`, `--denied`, `--json`). +``` + +- [ ] **Step 4: Full verification** + +Run: `pnpm lint && pnpm typecheck && pnpm build && pnpm test` +Expected: all green across the workspace. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/routes/docs/security.tsx .changeset/audit-log.md docs/ROADMAP.md +git commit -m "docs: audit trail on security page, changeset, roadmap tick" +``` + +--- + +## Post-plan notes for the executor + +- Tasks 2–4 (extension) and 5 (cli audit.ts) are independent of each other; 6–8 depend on 1 and 5. Task order as written is safe sequentially. +- If any pre-existing test asserts the exact shape of response frames or the `Dispatch` type, the fix is always the mechanical `{ result: X }` wrap — never weaken an assertion to pass. +- Manual end-to-end check (optional, needs the sideloaded extension; see memory note "sideload bundle order"): rebuild extension → cli bundle → reload the reins extension, run `reins tabs && reins text`, then `reins audit` — expect two lines with real hosts. diff --git a/docs/superpowers/specs/2026-07-11-audit-log-design.md b/docs/superpowers/specs/2026-07-11-audit-log-design.md new file mode 100644 index 0000000..bb57e90 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-audit-log-design.md @@ -0,0 +1,149 @@ +# Audit log (`reins audit`) — design + +Date: 2026-07-11. Status: approved. Roadmap: Phase 1 (v0.3) — "Trust: a +permission model", audit-log slice. SECURITY.md and the SKILL.md hardening +pass are the remaining Phase 1 slices, out of scope here. + +## Goal + +A first-class, structured per-action trail: every browser command reins +executes — and every one the policy blocks — leaves one line the user can +review with `reins audit`. The trail answers "what did the agent touch in my +browser?" for trust review and carries enough detail (redacted params, +outcome, duration) to debug agent runs. + +## Decisions (settled during brainstorming) + +| Question | Decision | +| --- | --- | +| Audience | Trust review and debugging, equally: full JSONL on disk, `reins audit` renders the trust view | +| Sensitive params | Redact value-bearing fields before write; plaintext secrets never touch disk | +| Retention | Daily files, daemon prunes >30 days on startup | +| Origin source | Extension stamps `host` + `tier` on responses (approach B); daemon composes the record | +| Writer | Daemon only — single writer, viewer works without a live browser | + +## Record format + +One JSON line per `/rpc` call, appended to +`~/.reins/logs/audit-YYYY-MM-DD.jsonl` (same directory as daemon logs — the +roadmap's "make the action trail first-class where `~/.reins/logs` already +lives"): + +```json +{"ts":"2026-07-11T10:15:02.113Z","method":"click","browserId":"b1","browser":"Chromium","tabId":412,"host":"app.example.com","tier":"full","params":{"selector":"#submit"},"ok":true,"ms":184} +{"ts":"2026-07-11T10:15:09.442Z","method":"fill","browserId":"b1","browser":"Chromium","tabId":412,"host":"bank.com","tier":"read","params":{"selector":"#amount","value":"[redacted 7 chars]"},"ok":false,"denied":true,"error":"policy_denied: blocked by policy: bank.com is read-only — grant full access from the reins extension popup","ms":12} +``` + +Fields: + +- `ts` — ISO 8601, daemon clock, time the request was received. +- `method` — bridge method name (`click`, `read_text`, …). +- `browserId` / `browser` — routing id + browser name from the bridge + roster; absent when the request failed before reaching a browser. +- `tabId` — the tab the action actually hit: from response `meta` (the + extension gate resolves the active tab when the caller omits `--tab`), + falling back to params. +- `host` / `tier` — stamped by the extension via response `meta` (see + below); absent on daemon-side failures or with an older extension. +- `params` — redacted copy (see Redaction). +- `ok` — mirror of the response frame. +- `denied` — `true` only for policy denials (error code `policy_denied`). +- `error` — `code: message` string when `ok` is false. +- `ms` — wall-clock duration from send to settle. + +`list_tabs` (a daemon-side aggregate across browsers) audits as one line +with no `host`. Daemon-side failures — timeout, no browser connected, +disconnect mid-flight — audit with `ok: false` and the error; the trail must +show attempts, not just completions. + +## Redaction + +Redaction happens in the daemon **before** the write; plaintext never +reaches disk. A fixed field-name list, not heuristics: + +- `text`, `value`, `expression`, `promptText` → `"[redacted chars]"`. +- `upload` file paths (`files`) → basename only. +- `cdp` nested `params` → `"[redacted]"` (arbitrary CDP payloads can carry + anything, e.g. `Input.insertText`); the `Domain.method` name stays. +- Everything else — selectors, URLs, tabIds, key names (`Enter`), scroll + deltas — passes through verbatim. + +The list lives in one exported table in the CLI package with a table-driven +test, so adding a future value-bearing param means one row + one test case. + +## Data flow + +1. **Protocol.** `ResponseFrame` gains an optional + `meta: { host?: string, tier?: Tier, tabId?: number }`. Optional means + old extensions remain compatible — their records simply lack the fields. +2. **Extension.** `gate()` in `dispatch.ts` already resolves the target + tab (including the active-tab default), its host, and the effective + tier. Dispatch returns them alongside the result, and the background + stamps `meta` on the response frame for both the success path and the + policy-denial path — denials must carry the host so the trail shows + what was blocked, not just what ran. +3. **Denial classification.** Policy denials already carry the structured + error code `policy_denied` (the extension's `PolicyDenied` class; the + code survives to the ResponseFrame). The daemon auditor classifies + `denied: true` off that code — no string matching, no new code needed. +4. **Daemon auditor.** The `/rpc` handler wraps the bridge call: capture + start time, method, redacted params, resolve browser name from the + roster; on settle (success or error) append the record. The auditor is + injected into `startDaemon` like `log` is, so tests can capture records + in memory. + +## `reins audit` viewer + +Reads the JSONL files directly — no daemon required. + +- **Default:** today's records as a table: + `HH:MM:SS method browser host tab outcome ms`. Policy denials render + `DENIED`; other failures `error`. +- `--last ` — last N records, newest last, crossing day-file boundaries + (the repo's flag parser handles `--flag` forms only, so no short `-n`). +- `--denied` — denials only. +- `--json` — raw JSONL lines (composable with `--last`/`--denied`). +- Missing host/tier (old extension, daemon-side failure) renders `—`. +- Corrupt or partially-written lines are skipped; the viewer prints a + one-line skip count to stderr. +- `reins help` gains an `audit` line under Management. + +## Retention + +On daemon startup, delete `audit-*.jsonl` whose filename date is older than +30 days. Filename-based, not mtime — deterministic and testable. Daemon +`daemon-*.log` files are untouched (candidate for the same policy later, +separate change). + +## Error handling + +- Audit writes are best-effort, matching `createLogger`: a full disk or bad + permissions must never fail the user's browsing command. First write + failure logs a warning to the daemon log/stderr. The trade-off (trail can + have gaps under disk pressure) gets documented in SECURITY.md (next Phase + 1 slice). +- The auditor never throws into the RPC path; a serialization bug in the + auditor must not break `/rpc`. + +## Testing + +- **protocol:** `ResponseFrame` round-trips `meta`; absent `meta` still + parses (back-compat). +- **extension:** dispatch stamps `meta.host`/`meta.tier`/`meta.tabId` on + success and on policy denial (tabId as resolved by the gate, including + the active-tab default); denial error code is `policy_denied`. +- **cli:** table-driven redaction tests; auditor unit tests (denial + classification, duration, daemon-side failure records, browser-name + resolution); viewer tests (parse, `--last` across files, `--denied`, + corrupt lines); prune-on-startup test; end-to-end record via the stand-in WS + extension in `integration.test.ts`. +- Build note: rebuild `@reins/protocol` before running cli/extension tests + (workspace consumes `dist/`). + +## Out of scope + +- Popup/audit UI in the extension — the popup stays a status light and + permissions manager. +- SKILL.md changes — the audit trail is for the user, not the agent. +- Web docs get a short section (permissions page sibling) in the same PR; + the full threat-model treatment lands with SECURITY.md. diff --git a/packages/cli/src/audit-cli.test.ts b/packages/cli/src/audit-cli.test.ts new file mode 100644 index 0000000..ffc87c4 --- /dev/null +++ b/packages/cli/src/audit-cli.test.ts @@ -0,0 +1,110 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { runAudit } from "./audit-cli.js"; + +const dirs: string[] = []; +function tempDir(): string { + const d = mkdtempSync(join(tmpdir(), "reins-audit-cli-")); + dirs.push(d); + return d; +} +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +const NOW = () => new Date("2026-07-11T12:00:00Z"); + +function line(over: Record = {}): string { + return `${JSON.stringify({ + ts: "2026-07-11T10:15:02.113Z", + method: "click", + browserId: "b1", + browser: "Chromium", + tabId: 412, + host: "app.example.com", + tier: "full", + params: { selector: "#go" }, + ok: true, + ms: 184, + ...over, + })}\n`; +} + +describe("runAudit", () => { + it("renders today's records as a table", () => { + const dir = tempDir(); + writeFileSync( + join(dir, "audit-2026-07-11.jsonl"), + line() + line({ method: "read_text", ms: 20 }), + ); + const view = runAudit([], { dir, now: NOW }); + expect(view.out).toContain("10:15:02"); + expect(view.out).toContain("click"); + expect(view.out).toContain("app.example.com"); + expect(view.out).toContain("412"); + expect(view.out).toContain("ok"); + expect(view.warnings).toEqual([]); + }); + + it("marks policy denials as DENIED", () => { + const dir = tempDir(); + writeFileSync( + join(dir, "audit-2026-07-11.jsonl"), + line({ ok: false, denied: true, error: "policy_denied: blocked", host: "bank.com" }), + ); + const view = runAudit([], { dir, now: NOW }); + expect(view.out).toContain("DENIED"); + expect(view.out).toContain("bank.com"); + }); + + it("--denied filters to denials only", () => { + const dir = tempDir(); + writeFileSync( + join(dir, "audit-2026-07-11.jsonl"), + line() + line({ ok: false, denied: true, error: "policy_denied: blocked" }), + ); + const view = runAudit(["--denied"], { dir, now: NOW }); + expect(view.out.match(/DENIED/g)).toHaveLength(1); + expect(view.out).not.toMatch(/\bok\b/); + }); + + it("--last N crosses day files, newest last", () => { + const dir = tempDir(); + writeFileSync( + join(dir, "audit-2026-07-10.jsonl"), + line({ method: "older" }) + line({ method: "old" }), + ); + writeFileSync(join(dir, "audit-2026-07-11.jsonl"), line({ method: "newest" })); + const view = runAudit(["--last", "2"], { dir, now: NOW }); + expect(view.out).not.toContain("older"); + const oldIdx = view.out.indexOf("old"); + const newIdx = view.out.indexOf("newest"); + expect(oldIdx).toBeGreaterThan(-1); + expect(newIdx).toBeGreaterThan(oldIdx); + }); + + it("--json emits raw JSONL", () => { + const dir = tempDir(); + writeFileSync(join(dir, "audit-2026-07-11.jsonl"), line()); + const view = runAudit(["--json"], { dir, now: NOW }); + expect(JSON.parse(view.out).method).toBe("click"); + }); + + it("renders — for missing host/tab and skips corrupt lines with a warning", () => { + const dir = tempDir(); + writeFileSync( + join(dir, "audit-2026-07-11.jsonl"), + `not json\n5\n{}\n${line({ host: undefined, tabId: undefined, browser: undefined })}`, + ); + const view = runAudit([], { dir, now: NOW }); + expect(view.out).toContain("—"); + expect(view.warnings).toEqual(["skipped 3 corrupt audit lines"]); + }); + + it("says so when there is nothing to show", () => { + const view = runAudit([], { dir: tempDir(), now: NOW }); + expect(view.out).toContain("no audit records"); + }); +}); diff --git a/packages/cli/src/audit-cli.ts b/packages/cli/src/audit-cli.ts new file mode 100644 index 0000000..fe65bb8 --- /dev/null +++ b/packages/cli/src/audit-cli.ts @@ -0,0 +1,123 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { parseArgs, UsageError } from "./args.js"; +import { AUDIT_FILE_RE, type AuditRecord } from "./audit.js"; + +const USAGE = "usage: reins audit [--last ] [--denied] [--json]"; + +export interface AuditView { + out: string; + warnings: string[]; +} + +interface Loaded { + records: AuditRecord[]; + skipped: number; +} + +/** Guard against JSON-parseable but shape-invalid lines (e.g. `5`, `{}`) that + * would otherwise crash the table renderer on `r.ts.slice`. */ +function isAuditRecord(v: unknown): v is AuditRecord { + if (typeof v !== "object" || v === null) return false; + const r = v as Record; + return typeof r.ts === "string" && typeof r.method === "string"; +} + +function loadFiles(dir: string, files: string[]): Loaded { + const records: AuditRecord[] = []; + let skipped = 0; + for (const name of files) { + let text: string; + try { + text = readFileSync(join(dir, name), "utf8"); + } catch { + continue; + } + for (const line of text.split("\n")) { + if (line.trim() === "") continue; + try { + const parsed: unknown = JSON.parse(line); + if (!isAuditRecord(parsed)) { + skipped += 1; + continue; + } + records.push(parsed); + } catch { + skipped += 1; + } + } + } + return { records, skipped }; +} + +function auditFiles(dir: string): string[] { + try { + return readdirSync(dir) + .filter((n) => AUDIT_FILE_RE.test(n)) + .sort(); // filename dates sort chronologically + } catch { + return []; + } +} + +function outcome(r: AuditRecord): string { + if (r.denied === true) return "DENIED"; + return r.ok ? "ok" : "error"; +} + +function table(records: AuditRecord[]): string { + const rows = records.map((r) => [ + r.ts.slice(11, 19), + r.method, + r.browser ?? "—", + r.host ?? "—", + r.tabId !== undefined ? String(r.tabId) : "—", + outcome(r), + String(r.ms), + ]); + const header = ["TIME", "METHOD", "BROWSER", "HOST", "TAB", "OUTCOME", "MS"]; + const widths = header.map((h, i) => + Math.max(h.length, ...rows.map((row) => (row[i] as string).length)), + ); + const render = (row: string[]) => + row + .map((cell, i) => cell.padEnd(widths[i] as number)) + .join(" ") + .trimEnd(); + return [render(header), ...rows.map(render)].join("\n"); +} + +/** `reins audit` — render the per-action trail from ~/.reins/logs, no daemon needed. */ +export function runAudit(argv: string[], deps: { dir: string; now: () => Date }): AuditView { + const a = parseArgs(argv, { booleans: ["denied", "json"] }); + if (a.positional.length > 0) throw new UsageError(USAGE); + let last: number | undefined; + if (a.flags.last !== undefined) { + last = Number.parseInt(String(a.flags.last), 10); + if (Number.isNaN(last) || last <= 0) + throw new UsageError(`--last expects a positive integer\n${USAGE}`); + } + + const all = auditFiles(deps.dir); + const today = `audit-${deps.now().toISOString().slice(0, 10)}.jsonl`; + const files = last !== undefined ? all : all.filter((n) => n === today); + const { records, skipped } = loadFiles(deps.dir, files); + + let selected = records; + if (a.flags.denied === true) selected = selected.filter((r) => r.denied === true); + if (last !== undefined) selected = selected.slice(-last); + + const warnings = + skipped > 0 ? [`skipped ${skipped} corrupt audit line${skipped === 1 ? "" : "s"}`] : []; + + if (selected.length === 0) { + return { + out: `no audit records${last !== undefined ? "" : " for today"} in ${deps.dir} (the daemon writes one line per action).`, + warnings, + }; + } + if (a.flags.json === true) { + return { out: selected.map((r) => JSON.stringify(r)).join("\n"), warnings }; + } + return { out: table(selected), warnings }; +} diff --git a/packages/cli/src/audit.test.ts b/packages/cli/src/audit.test.ts new file mode 100644 index 0000000..4d8722d --- /dev/null +++ b/packages/cli/src/audit.test.ts @@ -0,0 +1,138 @@ +import { + chmodSync, + existsSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { AuditRecord } from "./audit.js"; +import { auditFilePath, createAuditor, pruneAuditLogs, redactParams } from "./audit.js"; + +const dirs: string[] = []; +function tempDir(): string { + const d = mkdtempSync(join(tmpdir(), "reins-audit-")); + dirs.push(d); + return d; +} +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +function record(over: Partial = {}): AuditRecord { + return { + ts: "2026-07-11T10:00:00.000Z", + method: "click", + params: { selector: "#go" }, + ok: true, + ms: 42, + ...over, + }; +} + +describe("redactParams", () => { + it.each([ + [ + "type", + { text: "hunter2secret", selector: "#pw" }, + { text: "[redacted 13 chars]", selector: "#pw" }, + ], + ["fill", { value: "hunter2", ref: "e3" }, { value: "[redacted 7 chars]", ref: "e3" }], + ["select_option", { value: "US", ref: "e3" }, { value: "[redacted 2 chars]", ref: "e3" }], + [ + "eval_js", + { expression: "document.cookie", awaitPromise: false }, + { expression: "[redacted 15 chars]", awaitPromise: false }, + ], + ["press_key", { key: "Meta+A" }, { key: "Meta+A" }], + ["navigate", { to: "https://x.com/a" }, { to: "https://x.com/a" }], + [ + "handle_dialog", + { accept: true, promptText: "hunter2" }, + { accept: true, promptText: "[redacted 7 chars]" }, + ], + ])("%s", (method, input, expected) => { + expect(redactParams(method, input)).toEqual(expected); + }); + + it("keeps only basenames for upload files", () => { + expect(redactParams("upload", { files: ["/Users/me/secret-dir/tax.pdf"], ref: "e1" })).toEqual({ + files: ["tax.pdf"], + ref: "e1", + }); + }); + + it("redacts cdp nested params wholesale, keeps the method name", () => { + expect(redactParams("cdp", { method: "Input.insertText", params: { text: "s3cret" } })).toEqual( + { method: "Input.insertText", params: "[redacted]" }, + ); + }); + + it("does not mutate its input", () => { + const input = { text: "abc" }; + redactParams("type", input); + expect(input.text).toBe("abc"); + }); +}); + +describe("createAuditor", () => { + it("appends one JSON line per record to the dated file", () => { + const dir = tempDir(); + const now = () => new Date("2026-07-11T10:00:00Z"); + const audit = createAuditor(dir, { now }); + audit(record()); + audit(record({ method: "read_text" })); + const lines = readFileSync(auditFilePath(dir, now()), "utf8").trim().split("\n"); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0] as string).method).toBe("click"); + expect(JSON.parse(lines[1] as string).method).toBe("read_text"); + }); + + it("is best-effort: write failure warns once, never throws", () => { + const dir = tempDir(); + chmodSync(dir, 0o444); // unwritable + const warnings: string[] = []; + const audit = createAuditor(dir, { + log: (m) => warnings.push(m), + now: () => new Date("2026-07-11T10:00:00Z"), + }); + expect(() => { + audit(record()); + audit(record()); + }).not.toThrow(); + chmodSync(dir, 0o755); // so afterEach can clean up + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("audit write failed"); + }); + + it("creates a missing nested directory before writing", () => { + const dir = join(tempDir(), "nested", "logs"); + const now = () => new Date("2026-07-11T10:00:00Z"); + const audit = createAuditor(dir, { now }); + audit(record()); + const path = auditFilePath(dir, now()); + expect(existsSync(path)).toBe(true); + expect(readFileSync(path, "utf8").trim().split("\n")).toHaveLength(1); + }); +}); + +describe("pruneAuditLogs", () => { + it("deletes audit files older than keepDays by filename date, nothing else", () => { + const dir = tempDir(); + writeFileSync(join(dir, "audit-2026-06-01.jsonl"), ""); + writeFileSync(join(dir, "audit-2026-07-10.jsonl"), ""); + writeFileSync(join(dir, "daemon-2026-06-01.log"), ""); + const deleted = pruneAuditLogs(dir, new Date("2026-07-11T00:00:00Z"), 30); + expect(deleted).toEqual(["audit-2026-06-01.jsonl"]); + const left = readdirSync(dir).sort(); + expect(left).toEqual(["audit-2026-07-10.jsonl", "daemon-2026-06-01.log"]); + }); + + it("survives a missing directory", () => { + expect(pruneAuditLogs(join(tempDir(), "nope"), new Date())).toEqual([]); + }); +}); diff --git a/packages/cli/src/audit.ts b/packages/cli/src/audit.ts new file mode 100644 index 0000000..a4be9c6 --- /dev/null +++ b/packages/cli/src/audit.ts @@ -0,0 +1,111 @@ +import { appendFileSync, mkdirSync, readdirSync, unlinkSync } from "node:fs"; +import { basename, join } from "node:path"; +import type { Tier } from "@reins/protocol"; + +/** One line of the per-action audit trail (spec: 2026-07-11-audit-log-design). */ +export interface AuditRecord { + ts: string; + method: string; + browserId?: string; + browser?: string; + tabId?: number; + host?: string; + tier?: Tier; + params: Record; + ok: boolean; + denied?: boolean; + error?: string; + ms: number; +} + +export type AuditHook = (record: AuditRecord) => void; + +/** Param keys whose string values are typed/filled/evaluated content. */ +const VALUE_KEYS = new Set(["text", "value", "expression", "promptText"]); + +/** + * Strip secrets from params before they reach disk. Fixed field-name table, + * not heuristics — extend VALUE_KEYS (plus a test row) when a new + * value-bearing param appears. + */ +export function redactParams( + method: string, + params: Record, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (VALUE_KEYS.has(key) && typeof value === "string") { + out[key] = `[redacted ${value.length} chars]`; + } else if (method === "upload" && key === "files" && Array.isArray(value)) { + out[key] = value.map((f) => basename(String(f))); + } else if (method === "cdp" && key === "params" && value !== undefined) { + // Arbitrary CDP payloads can carry anything (Input.insertText, …). + out[key] = "[redacted]"; + } else { + out[key] = value; + } + } + return out; +} + +export const AUDIT_FILE_RE = /^audit-(\d{4}-\d{2}-\d{2})\.jsonl$/; + +/** Audit file for a given day, e.g. /audit-2026-07-11.jsonl. */ +export function auditFilePath(dir: string, now: Date): string { + return join(dir, `audit-${now.toISOString().slice(0, 10)}.jsonl`); +} + +/** + * Appender for the audit trail. Best-effort like createLogger: a full disk + * or bad permissions must never fail the user's command — warn once and + * keep going (the trail can have gaps under disk pressure; SECURITY.md + * documents the trade-off). + */ +export function createAuditor( + dir: string, + opts: { log?: (msg: string) => void; now?: () => Date } = {}, +): AuditHook { + const now = opts.now ?? (() => new Date()); + let warned = false; + try { + mkdirSync(dir, { recursive: true }); + } catch { + // fall through — the append below will warn + } + return (record) => { + try { + appendFileSync(auditFilePath(dir, now()), `${JSON.stringify(record)}\n`); + } catch (err) { + if (warned) return; + warned = true; + const msg = err instanceof Error ? err.message : String(err); + opts.log?.(`reins: audit write failed (${msg}) — the trail will have gaps`); + } + }; +} + +/** Delete audit files whose filename date is older than keepDays. Returns + * the deleted names. Filename-based, not mtime — deterministic. */ +export function pruneAuditLogs(dir: string, now: Date, keepDays = 30): string[] { + const cutoff = new Date(now.getTime() - keepDays * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + let names: string[]; + try { + names = readdirSync(dir); + } catch { + return []; + } + const deleted: string[] = []; + for (const name of names) { + const m = AUDIT_FILE_RE.exec(name); + if (!m || (m[1] as string) >= cutoff) continue; + try { + unlinkSync(join(dir, name)); + deleted.push(name); + } catch { + // best-effort + } + } + return deleted; +} diff --git a/packages/cli/src/bridge.test.ts b/packages/cli/src/bridge.test.ts index 10fa583..79b6c55 100644 --- a/packages/cli/src/bridge.test.ts +++ b/packages/cli/src/bridge.test.ts @@ -248,6 +248,73 @@ describe("BridgeHost (listen mode)", () => { }); }); +describe("BridgeHost (requestFull)", () => { + it("resolves result + meta + browserId", async () => { + host = newHost(); + await host.listen(0); + const client = await connectClient(host.port); + client.on("message", (data) => { + const msg = JSON.parse(data.toString()); + if (msg.type === "request" && msg.method === "read_text") { + client.send( + JSON.stringify({ + type: "response", + id: msg.id, + ok: true, + result: { done: true }, + meta: { host: "app.example.com", tier: "full", tabId: 7 }, + }), + ); + } + }); + const reply = await host.requestFull("read_text", {}); + expect(reply.result).toEqual({ done: true }); + expect(reply.meta).toEqual({ host: "app.example.com", tier: "full", tabId: 7 }); + expect(reply.browserId).toBe("b1"); + client.close(); + }); + + it("rejects with code and meta from an error frame", async () => { + host = newHost(); + await host.listen(0); + const client = await connectClient(host.port); + client.on("message", (data) => { + const msg = JSON.parse(data.toString()); + if (msg.type === "request" && msg.method === "click") { + client.send( + JSON.stringify({ + type: "response", + id: msg.id, + ok: false, + error: { code: "policy_denied", message: "blocked by policy: bank.com is read-only" }, + meta: { host: "bank.com", tier: "read", tabId: 7 }, + }), + ); + } + }); + const err = await host.requestFull("click", {}).catch((e) => e); + expect(err.message).toBe("policy_denied: blocked by policy: bank.com is read-only"); + expect(err.code).toBe("policy_denied"); + expect(err.meta).toEqual({ host: "bank.com", tier: "read", tabId: 7 }); + expect(err.browserId).toBe("b1"); + client.close(); + }); + + it("request() still resolves the bare result", async () => { + host = newHost(); + await host.listen(0); + const client = await connectClient(host.port); + client.on("message", (data) => { + const msg = JSON.parse(data.toString()); + if (msg.type === "request" && msg.method === "read_text") { + client.send(JSON.stringify({ type: "response", id: msg.id, ok: true, result: 42 })); + } + }); + await expect(host.request("read_text", {})).resolves.toBe(42); + client.close(); + }); +}); + describe("BridgeHost (attach mode)", () => { it("serves WS upgrades on a caller-owned HTTP server and leaves it open on stop()", async () => { httpServer = createHttpServer(); diff --git a/packages/cli/src/bridge.ts b/packages/cli/src/bridge.ts index 18277b2..000f6d4 100644 --- a/packages/cli/src/bridge.ts +++ b/packages/cli/src/bridge.ts @@ -6,6 +6,7 @@ import { HelloFrame, RequestFrame, ResponseFrame, + type ResponseMeta, WelcomeFrame, } from "@reins/protocol"; import { type RawData, WebSocket, WebSocketServer } from "ws"; @@ -17,14 +18,23 @@ export interface RequestOpts { timeoutMs?: number; } +/** A settled bridge request: the result plus the extension-stamped action + * target and the browser that served it — everything the audit trail needs. */ +export interface BridgeReply { + result: unknown; + meta?: ResponseMeta; + browserId: string; +} + export interface BridgePort { readonly paired: boolean; readonly browsers: BrowserInfo[]; request(method: string, params: unknown, opts?: RequestOpts): Promise; + requestFull(method: string, params: unknown, opts?: RequestOpts): Promise; } interface Pending { - resolve: (value: unknown) => void; + resolve: (value: BridgeReply) => void; reject: (reason: Error) => void; timer: NodeJS.Timeout; browserId: string; @@ -199,10 +209,18 @@ export class BridgeHost implements BridgePort { clearTimeout(pending.timer); this.#pending.delete(id); if (frame.ok === true) { - pending.resolve(frame.result); + pending.resolve({ result: frame.result, meta: frame.meta, browserId: pending.browserId }); } else { const err = frame.error ?? { code: "ERR", message: "request failed" }; - pending.reject(new Error(`${err.code}: ${err.message}`)); + const e = new Error(`${err.code}: ${err.message}`) as Error & { + code?: string; + meta?: ResponseMeta; + browserId?: string; + }; + e.code = err.code; + e.meta = frame.meta; + e.browserId = pending.browserId; + pending.reject(e); } } @@ -228,7 +246,7 @@ export class BridgeHost implements BridgePort { return { id: only.id, ws: entry.ws }; } - request(method: string, params: unknown, opts: RequestOpts = {}): Promise { + requestFull(method: string, params: unknown, opts: RequestOpts = {}): Promise { let target: { id: string; ws: WebSocket }; try { target = this.#resolveBrowser(opts.browserId); @@ -237,10 +255,14 @@ export class BridgeHost implements BridgePort { } const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; const id = randomUUID(); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.#pending.delete(id); - reject(new Error(`request "${method}" timed out after ${timeoutMs}ms`)); + const e = new Error(`request "${method}" timed out after ${timeoutMs}ms`) as Error & { + browserId?: string; + }; + e.browserId = target.id; + reject(e); }, timeoutMs); this.#pending.set(id, { resolve, reject, timer, browserId: target.id }); try { @@ -255,6 +277,10 @@ export class BridgeHost implements BridgePort { }); } + request(method: string, params: unknown, opts: RequestOpts = {}): Promise { + return this.requestFull(method, params, opts).then((r) => r.result); + } + stop(): Promise { this.#rejectAllPending("bridge stopped"); this.#browsers.clear(); diff --git a/packages/cli/src/cli-commands.test.ts b/packages/cli/src/cli-commands.test.ts index e1fb39f..a82ab21 100644 --- a/packages/cli/src/cli-commands.test.ts +++ b/packages/cli/src/cli-commands.test.ts @@ -38,6 +38,10 @@ describe("helpText", () => { expect(text, gone).not.toContain(gone); } }); + + it("help lists the audit command", () => { + expect(helpText("1.2.3", TOOL_COMMANDS)).toContain("audit"); + }); }); describe("healthSummary", () => { diff --git a/packages/cli/src/cli-commands.ts b/packages/cli/src/cli-commands.ts index 4b8d0ae..0391826 100644 --- a/packages/cli/src/cli-commands.ts +++ b/packages/cli/src/cli-commands.ts @@ -53,6 +53,7 @@ export function helpText(version: string, tools: Record): s "Management:", line("browsers", "list browsers connected to the daemon"), line("policy", "site permissions: show, deny/readonly (grants: popup)"), + line("audit", "per-action trail: what the agent did, what policy blocked"), line("status", "daemon state, port, connected browsers"), line("extension", "install the extension without the Chrome Web Store (load unpacked)"), line("allow ", "allow an unpacked/dev extension to connect"), diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index c45f45c..3737f46 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -180,6 +180,14 @@ async function main(): Promise { break; } + case "audit": { + const { runAudit } = await import("./audit-cli.js"); + const view = runAudit(rest, { dir: logsDir(), now: () => new Date() }); + for (const w of view.warnings) console.error(w); + console.log(view.out); + break; + } + case "version": case "--version": case "-v": diff --git a/packages/cli/src/daemon.ts b/packages/cli/src/daemon.ts index fbde28b..0fb907d 100644 --- a/packages/cli/src/daemon.ts +++ b/packages/cli/src/daemon.ts @@ -3,6 +3,7 @@ import { type IncomingMessage, type ServerResponse, } from "node:http"; +import type { AuditHook } from "./audit.js"; import type { BridgeHost } from "./bridge.js"; import type { Log } from "./log.js"; import { handleRpc, RpcBadRequest } from "./rpc.js"; @@ -49,6 +50,7 @@ export async function startDaemon(opts: { port: number; bridge: BridgeHost; log: Log; + audit?: AuditHook; onShutdown?: () => void; }): Promise { function allowedHosts(): string[] { @@ -82,7 +84,7 @@ export async function startDaemon(opts: { } if (path === "/rpc" && req.method === "POST") { void readJsonBody(req) - .then((body) => handleRpc(opts.bridge, body)) + .then((body) => handleRpc(opts.bridge, body, opts.audit)) .then((result) => sendJson(res, 200, { result })) .catch((err) => { const message = err instanceof Error ? err.message : String(err); diff --git a/packages/cli/src/integration.test.ts b/packages/cli/src/integration.test.ts index 1e5ffff..2105c53 100644 --- a/packages/cli/src/integration.test.ts +++ b/packages/cli/src/integration.test.ts @@ -1,5 +1,7 @@ +import type { ResponseMeta } from "@reins/protocol"; import { afterEach, describe, expect, it } from "vitest"; import { WebSocket } from "ws"; +import type { AuditHook, AuditRecord } from "./audit.js"; import { BridgeHost } from "./bridge.js"; import { startDaemon } from "./daemon.js"; @@ -15,6 +17,7 @@ afterEach(async () => { bridge = undefined; extension = undefined; DENY_CLICKS = false; + NEXT_META = undefined; }); /** Lookup table: bridge method name → stand-in result the extension returns. */ @@ -52,6 +55,11 @@ const METHOD_RESULTS: Record = { /** When set, the stand-in refuses `click` like the real extension's policy gate. */ let DENY_CLICKS = false; +/** When set, the stand-in stamps this meta on its next successful response, + * then clears it — lets a single test attach meta to one action instead of + * the fixed METHOD_RESULTS table. */ +let NEXT_META: ResponseMeta | undefined; + /** Stand-in extension: connects, authenticates, and answers any method via the lookup table. */ function standInExtension(port: number): Promise { const ws = new WebSocket(`ws://127.0.0.1:${port}`, { headers: { origin: ORIGIN } }); @@ -77,16 +85,26 @@ function standInExtension(port: number): Promise { return; } const result = METHOD_RESULTS[msg.method ?? ""]; - ws.send(JSON.stringify({ type: "response", id: msg.id, ok: true, result })); + const meta = NEXT_META; + NEXT_META = undefined; + ws.send( + JSON.stringify({ + type: "response", + id: msg.id, + ok: true, + result, + ...(meta ? { meta } : {}), + }), + ); } }); ws.on("error", reject); }); } -async function setupHarness(): Promise { +async function setupHarness(opts: { audit?: AuditHook } = {}): Promise { bridge = new BridgeHost({ allowedOrigins: new Set([ORIGIN]), log: () => {} }); - daemon = await startDaemon({ port: 0, bridge, log: () => {} }); + daemon = await startDaemon({ port: 0, bridge, log: () => {}, audit: opts.audit }); extension = await standInExtension(daemon.port); expect(bridge.paired).toBe(true); return daemon.port; @@ -194,3 +212,22 @@ describe("policy over the bridge", () => { expect(await rpc(port, "read_text")).toEqual({ text: "page text" }); }); }); + +describe("audit over the bridge", () => { + it("writes one record per /rpc action, meta included", async () => { + const records: AuditRecord[] = []; + const port = await setupHarness({ audit: (r) => records.push(r) }); + // stand-in extension: answer the next request with meta on the frame + NEXT_META = { host: "app.example.com", tier: "full", tabId: 3 }; + await rpc(port, "read_text", { tabId: 3 }); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + method: "read_text", + ok: true, + browserId: "b1", + host: "app.example.com", + tier: "full", + tabId: 3, + }); + }); +}); diff --git a/packages/cli/src/rpc.test.ts b/packages/cli/src/rpc.test.ts index fddbacf..73a211e 100644 --- a/packages/cli/src/rpc.test.ts +++ b/packages/cli/src/rpc.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import type { AuditRecord } from "./audit.js"; import type { BridgePort } from "./bridge.js"; import { handleRpc, listAllTabs, RpcBadRequest } from "./rpc.js"; @@ -12,6 +13,13 @@ function fakeBridge(overrides: Partial = {}): BridgePort { } return { ok: true }; }), + requestFull: vi.fn(async (method: string) => ({ + result: + method === "list_tabs" + ? { tabs: [{ tabId: 1, title: "t", url: "https://x", active: true }] } + : { ok: true }, + browserId: "b1", + })), ...overrides, } as BridgePort; } @@ -24,13 +32,13 @@ describe("handleRpc", () => { params: { browserId: "b1", ref: "e1" }, }); expect(result).toEqual({ ok: true }); - expect(bridge.request).toHaveBeenCalledWith("click", { ref: "e1" }, { browserId: "b1" }); + expect(bridge.requestFull).toHaveBeenCalledWith("click", { ref: "e1" }, { browserId: "b1" }); }); it("passes params through untouched when browserId is absent", async () => { const bridge = fakeBridge(); await handleRpc(bridge, { method: "type", params: { ref: "e1", text: "hi" } }); - expect(bridge.request).toHaveBeenCalledWith( + expect(bridge.requestFull).toHaveBeenCalledWith( "type", { ref: "e1", text: "hi" }, { browserId: undefined }, @@ -40,7 +48,7 @@ describe("handleRpc", () => { it("defaults params to {}", async () => { const bridge = fakeBridge(); await handleRpc(bridge, { method: "screenshot" }); - expect(bridge.request).toHaveBeenCalledWith("screenshot", {}, { browserId: undefined }); + expect(bridge.requestFull).toHaveBeenCalledWith("screenshot", {}, { browserId: undefined }); }); it("aggregates list_tabs across browsers with tags", async () => { @@ -86,3 +94,115 @@ describe("listAllTabs", () => { ); }); }); + +describe("audit hook", () => { + it("records a successful action with meta, browser name, and redacted params", async () => { + const records: AuditRecord[] = []; + const bridge = fakeBridge({ + browsers: [{ id: "b1", browser: "Chromium", connectedAt: 1 }], + requestFull: async () => ({ + result: { ok: true }, + meta: { host: "app.example.com", tier: "full", tabId: 7 }, + browserId: "b1", + }), + }); + await handleRpc(bridge, { method: "type", params: { text: "hunter2", tabId: 7 } }, (r) => + records.push(r), + ); + expect(records).toHaveLength(1); + const r = records[0] as AuditRecord; + expect(r).toMatchObject({ + method: "type", + ok: true, + browserId: "b1", + browser: "Chromium", + host: "app.example.com", + tier: "full", + tabId: 7, + params: { text: "[redacted 7 chars]", tabId: 7 }, + }); + expect(r.denied).toBeUndefined(); + expect(r.ms).toBeGreaterThanOrEqual(0); + expect(() => new Date(r.ts).toISOString()).not.toThrow(); + }); + + it("records a policy denial with denied: true", async () => { + const records: AuditRecord[] = []; + const err = new Error("policy_denied: blocked by policy: bank.com is read-only") as Error & { + code?: string; + meta?: unknown; + browserId?: string; + }; + err.code = "policy_denied"; + err.meta = { host: "bank.com", tier: "read", tabId: 7 }; + err.browserId = "b1"; + const bridge = fakeBridge({ + browsers: [{ id: "b1", browser: "Chromium", connectedAt: 0 }], + requestFull: async () => { + throw err; + }, + }); + await expect( + handleRpc(bridge, { method: "click", params: {} }, (r) => records.push(r)), + ).rejects.toThrow(); + expect(records[0]).toMatchObject({ + method: "click", + ok: false, + denied: true, + browserId: "b1", + browser: "Chromium", + host: "bank.com", + tier: "read", + tabId: 7, + error: "policy_denied: blocked by policy: bank.com is read-only", + }); + }); + + it("records daemon-side failures without meta", async () => { + const records: AuditRecord[] = []; + const bridge = fakeBridge({ + requestFull: async () => { + throw new Error("extension not connected"); + }, + }); + await expect( + handleRpc(bridge, { method: "click", params: { tabId: 412 } }, (r) => records.push(r)), + ).rejects.toThrow(); + expect(records[0]).toMatchObject({ + method: "click", + ok: false, + error: "extension not connected", + tabId: 412, + }); + expect(records[0]?.browserId).toBeUndefined(); + expect(records[0]?.host).toBeUndefined(); + expect(records[0]?.tier).toBeUndefined(); + expect(records[0]?.denied).toBeUndefined(); + }); + + it("audits list_tabs as one aggregate line without host", async () => { + const records: AuditRecord[] = []; + const bridge = fakeBridge(); + await handleRpc(bridge, { method: "list_tabs" }, (r) => records.push(r)); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ method: "list_tabs", ok: true }); + expect(records[0]?.host).toBeUndefined(); + }); + + it("never lets a throwing hook affect the RPC result or double-record", async () => { + let calls = 0; + const bridge = fakeBridge(); + const result = await handleRpc(bridge, { method: "click", params: { ref: "e1" } }, () => { + calls++; + throw new Error("boom"); + }); + expect(result).toEqual({ ok: true }); + expect(calls).toBe(1); + }); + + it("does not audit malformed bodies", async () => { + const records: AuditRecord[] = []; + await expect(handleRpc(fakeBridge({}), { nope: 1 }, (r) => records.push(r))).rejects.toThrow(); + expect(records).toHaveLength(0); + }); +}); diff --git a/packages/cli/src/rpc.ts b/packages/cli/src/rpc.ts index e495132..29c1fb4 100644 --- a/packages/cli/src/rpc.ts +++ b/packages/cli/src/rpc.ts @@ -1,6 +1,7 @@ -import { ListTabsResult, type Tab } from "@reins/protocol"; +import { ListTabsResult, type ResponseMeta, type Tab } from "@reins/protocol"; import { z } from "zod"; -import type { BridgePort } from "./bridge.js"; +import { type AuditHook, redactParams } from "./audit.js"; +import type { BridgePort, BridgeReply } from "./bridge.js"; const RpcBody = z.object({ method: z.string().min(1), @@ -40,15 +41,72 @@ export class RpcBadRequest extends Error {} /** * Execute one /rpc call: `{method, params}` → bridge → browser. `list_tabs` * aggregates across all connected browsers; everything else routes to one - * browser (explicit browserId / the only browser / ambiguity error). + * browser. When `audit` is provided, every attempt — success, policy + * denial, or daemon-side failure — produces exactly one record. */ -export async function handleRpc(bridge: BridgePort, body: unknown): Promise { +export async function handleRpc( + bridge: BridgePort, + body: unknown, + audit?: AuditHook, +): Promise { const parsed = RpcBody.safeParse(body); if (!parsed.success) { throw new RpcBadRequest(`invalid rpc body: expected {method, params?}`); } const { method, params: raw } = parsed.data; const { browserId, params } = route(raw ?? {}); - if (method === "list_tabs") return { tabs: await listAllTabs(bridge, browserId) }; - return bridge.request(method, params, { browserId }); + const started = Date.now(); + + const finish = (outcome: { + ok: boolean; + browserId?: string; + meta?: ResponseMeta; + error?: Error & { code?: string }; + }): void => { + if (!audit) return; + try { + const browser = outcome.browserId + ? bridge.browsers.find((b) => b.id === outcome.browserId)?.browser + : undefined; + const tabId = + outcome.meta?.tabId ?? (typeof params.tabId === "number" ? params.tabId : undefined); + audit({ + ts: new Date(started).toISOString(), + method, + ...(outcome.browserId !== undefined ? { browserId: outcome.browserId } : {}), + ...(browser !== undefined ? { browser } : {}), + ...(tabId !== undefined ? { tabId } : {}), + ...(outcome.meta?.host !== undefined ? { host: outcome.meta.host } : {}), + ...(outcome.meta?.tier !== undefined ? { tier: outcome.meta.tier } : {}), + params: redactParams(method, params), + ok: outcome.ok, + ...(outcome.error?.code === "policy_denied" ? { denied: true } : {}), + ...(outcome.error !== undefined ? { error: outcome.error.message } : {}), + ms: Date.now() - started, + }); + } catch { + // An audit hook must never affect the RPC result: a throw here on the + // success path would land in handleRpc's catch — double-recording the + // attempt and rejecting a genuinely successful call. + } + }; + + try { + if (method === "list_tabs") { + const tabs = await listAllTabs(bridge, browserId); + finish({ ok: true, browserId }); + return { tabs }; + } + const reply: BridgeReply = await bridge.requestFull(method, params, { browserId }); + finish({ ok: true, browserId: reply.browserId, meta: reply.meta }); + return reply.result; + } catch (err) { + const e = (err instanceof Error ? err : new Error(String(err))) as Error & { + code?: string; + meta?: ResponseMeta; + browserId?: string; + }; + finish({ ok: false, browserId: e.browserId ?? browserId, meta: e.meta, error: e }); + throw err; + } } diff --git a/packages/cli/src/serve.ts b/packages/cli/src/serve.ts index 31b6b7b..e36ad51 100644 --- a/packages/cli/src/serve.ts +++ b/packages/cli/src/serve.ts @@ -1,9 +1,10 @@ import { loadAllowedOrigins } from "./allowlist.js"; +import { createAuditor, pruneAuditLogs } from "./audit.js"; import { BridgeHost } from "./bridge.js"; import { candidatePorts, loadOrCreateConfig, recordPort } from "./config.js"; import { type Daemon, startDaemon } from "./daemon.js"; import { type FoundDaemon, probeHealth } from "./ensure.js"; -import { createLogger, type Log } from "./log.js"; +import { createLogger, type Log, logsDir } from "./log.js"; /** First live daemon on a lower candidate port than ours, if any. Two racing * CLI spawns can bind different candidates; the lower port deterministically @@ -43,6 +44,9 @@ async function bindFirstFree( /** `reins daemon` — the foreground daemon (the CLI spawns this detached). */ export async function runDaemon(): Promise { const log = createLogger(); + const audit = createAuditor(logsDir(), { log }); + const pruned = pruneAuditLogs(logsDir(), new Date()); + if (pruned.length > 0) log(`reins: pruned ${pruned.length} audit file(s) older than 30 days`); const config = loadOrCreateConfig(); const bridge = new BridgeHost({ allowedOrigins: loadAllowedOrigins(config.dir), log }); const ports = candidatePorts(config); @@ -63,6 +67,7 @@ export async function runDaemon(): Promise { port, bridge, log, + audit, onShutdown: () => void shutdown("/shutdown", () => daemon.close()), }), ); diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index c1f744e..30711c7 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -1,4 +1,4 @@ -import { dispatchMethod } from "./lib/dispatch.js"; +import { dispatchWithMeta } from "./lib/dispatch.js"; import { applyPolicyChange, type PolicyChange } from "./lib/policy.js"; import { candidateUrls, loadSettings, saveSettings } from "./lib/settings.js"; import { normalizeStatus, type WorkerStatus } from "./lib/status.js"; @@ -170,8 +170,10 @@ chrome.runtime.onMessage.addListener( case "reins:dispatch": { const method = message.method as string; const params = message.params; - dispatchMethod(method, params) - .then((result) => sendResponse({ result })) + dispatchWithMeta(method, params) + .then(({ result, meta }) => + sendResponse({ result, ...(meta !== undefined ? { meta } : {}) }), + ) .catch((err) => sendResponse({ error: err instanceof Error ? err.message : String(err), @@ -179,6 +181,7 @@ chrome.runtime.onMessage.addListener( typeof (err as { code?: unknown })?.code === "string" ? (err as { code: string }).code : undefined, + meta: (err as { meta?: unknown })?.meta, }), ); return true; diff --git a/packages/extension/src/lib/bridge-client.test.ts b/packages/extension/src/lib/bridge-client.test.ts index eead0be..4ab91a6 100644 --- a/packages/extension/src/lib/bridge-client.test.ts +++ b/packages/extension/src/lib/bridge-client.test.ts @@ -1,7 +1,7 @@ import type { AddressInfo } from "node:net"; import { afterEach, describe, expect, it } from "vitest"; import { type RawData, WebSocket, WebSocketServer } from "ws"; -import { BridgeClient, type SocketLike } from "./bridge-client.js"; +import { BridgeClient, type DispatchOutcome, type SocketLike } from "./bridge-client.js"; interface Harness { server: WebSocketServer; @@ -98,7 +98,7 @@ function makeClient( return new BridgeClient({ urls: () => urls, browser: "test", - dispatch: async () => ({}), + dispatch: async () => ({ result: {} }), createSocket: nodeSocketFactory, // Production default. A short probe window is a flake hazard on loaded CI // runners: if the timer fires before the welcome frame is processed, the @@ -171,7 +171,9 @@ describe("BridgeClient", () => { harness = await startServer(); let status = ""; client = makeClient([`ws://127.0.0.1:${harness.port}`], { - dispatch: async (method) => (method === "list_tabs" ? { tabs: [{ tabId: 1 }] } : {}), + dispatch: async (method) => ({ + result: method === "list_tabs" ? { tabs: [{ tabId: 1 }] } : {}, + }), onStatus: (s) => { status = s; }, @@ -249,6 +251,100 @@ describe("BridgeClient", () => { expect(response.error).toEqual({ code: "policy_denied", message: "nope" }); }); + it("forwards dispatch meta on the response frame", async () => { + harness = await startServer(); + let status = ""; + client = makeClient([`ws://127.0.0.1:${harness.port}`], { + dispatch: async () => ({ + result: { ok: true }, + meta: { host: "app.example.com", tier: "full", tabId: 7 }, + }), + onStatus: (s) => { + status = s; + }, + }); + client.start(); + await waitFor(() => status === "connected"); + + const response = await new Promise>((resolve) => { + // biome-ignore lint/style/noNonNullAssertion: connected implies the server accepted hello, so current() is set + const ws = harness!.current()!; + ws.on("message", (d: RawData) => { + const m = JSON.parse(d.toString()); + if (m.type === "response") resolve(m); + }); + ws.send(JSON.stringify({ type: "request", id: "r1", method: "read_text", params: {} })); + }); + expect(response).toMatchObject({ + type: "response", + id: "r1", + ok: true, + meta: { host: "app.example.com", tier: "full", tabId: 7 }, + }); + }); + + it("forwards meta from a thrown dispatch error", async () => { + harness = await startServer(); + let status = ""; + const err = new Error("blocked by policy: bank.com is read-only") as Error & { + code?: string; + meta?: unknown; + }; + err.code = "policy_denied"; + err.meta = { host: "bank.com", tier: "read", tabId: 7 }; + client = makeClient([`ws://127.0.0.1:${harness.port}`], { + dispatch: async () => { + throw err; + }, + onStatus: (s) => { + status = s; + }, + }); + client.start(); + await waitFor(() => status === "connected"); + + const response = await new Promise>((resolve) => { + // biome-ignore lint/style/noNonNullAssertion: connected implies the server accepted hello, so current() is set + const ws = harness!.current()!; + ws.on("message", (d: RawData) => { + const m = JSON.parse(d.toString()); + if (m.type === "response") resolve(m); + }); + ws.send(JSON.stringify({ type: "request", id: "r2", method: "click", params: {} })); + }); + expect(response).toMatchObject({ + type: "response", + id: "r2", + ok: false, + error: { code: "policy_denied" }, + meta: { host: "bank.com", tier: "read", tabId: 7 }, + }); + }); + + it("omits meta when dispatch returns none", async () => { + harness = await startServer(); + let status = ""; + client = makeClient([`ws://127.0.0.1:${harness.port}`], { + dispatch: async () => ({ result: 1 }), + onStatus: (s) => { + status = s; + }, + }); + client.start(); + await waitFor(() => status === "connected"); + + const response = await new Promise>((resolve) => { + // biome-ignore lint/style/noNonNullAssertion: connected implies the server accepted hello, so current() is set + const ws = harness!.current()!; + ws.on("message", (d: RawData) => { + const m = JSON.parse(d.toString()); + if (m.type === "response") resolve(m); + }); + ws.send(JSON.stringify({ type: "request", id: "r3", method: "policy_get", params: {} })); + }); + expect(response).not.toHaveProperty("meta"); + }); + it("abandons a probe whose welcome arrives late, then adopts the retry", async () => { // Regression for a CI flake: on a stalled runner the probe timer can fire // after the server accepted hello but before welcome is processed. The @@ -271,7 +367,7 @@ describe("BridgeClient", () => { let status = ""; client = makeClient([`ws://127.0.0.1:${harness.port}`], { - dispatch: async () => ({ pong: true }), + dispatch: async () => ({ result: { pong: true } }), onStatus: (s) => { status = s; }, @@ -298,8 +394,8 @@ describe("BridgeClient", () => { harness = await startServer(); // Deferred dispatch: dispatch returns a promise we resolve manually after the socket closes. - let resolveDispatch!: (v: unknown) => void; - const dispatchInflight = new Promise((res) => { + let resolveDispatch!: (v: DispatchOutcome) => void; + const dispatchInflight = new Promise((res) => { resolveDispatch = res; }); let dispatchCalled = false; @@ -329,7 +425,7 @@ describe("BridgeClient", () => { harness.current()?.close(); await waitFor(() => sawDisconnected); - resolveDispatch({ ok: true }); + resolveDispatch({ result: { ok: true } }); await new Promise((res) => setTimeout(res, 50)); expect(sawDisconnected).toBe(true); }); diff --git a/packages/extension/src/lib/bridge-client.ts b/packages/extension/src/lib/bridge-client.ts index f998114..11f1b59 100644 --- a/packages/extension/src/lib/bridge-client.ts +++ b/packages/extension/src/lib/bridge-client.ts @@ -1,6 +1,11 @@ import { nextBackoff } from "./backoff.js"; -export type Dispatch = (method: string, params: unknown) => Promise; +export interface DispatchOutcome { + result: unknown; + /** Resolved action target (host/tier/tabId), forwarded verbatim to the daemon. */ + meta?: unknown; +} +export type Dispatch = (method: string, params: unknown) => Promise; export type ConnectionStatus = "connecting" | "connected" | "disconnected"; /** The subset of the browser WebSocket API that BridgeClient uses. */ @@ -171,19 +176,27 @@ export class BridgeClient { async #handleRequest(id: string, method: string, params: unknown): Promise { const socket = this.#socket; if (!socket) return; - let result: unknown; + let outcome: DispatchOutcome | undefined; let dispatchError: unknown; let threw = false; try { - result = await this.#opts.dispatch(method, params); + outcome = await this.#opts.dispatch(method, params); } catch (err) { threw = true; dispatchError = err; } if (this.#socket !== socket) return; // socket replaced/closed during dispatch try { - if (!threw) { - socket.send(JSON.stringify({ type: "response", id, ok: true, result })); + if (!threw && outcome) { + socket.send( + JSON.stringify({ + type: "response", + id, + ok: true, + result: outcome.result, + ...(outcome.meta !== undefined ? { meta: outcome.meta } : {}), + }), + ); } else { const message = dispatchError instanceof Error ? dispatchError.message : String(dispatchError); @@ -191,7 +204,16 @@ export class BridgeClient { typeof (dispatchError as { code?: unknown })?.code === "string" ? (dispatchError as { code: string }).code : "HANDLER_ERROR"; - socket.send(JSON.stringify({ type: "response", id, ok: false, error: { code, message } })); + const meta = (dispatchError as { meta?: unknown })?.meta; + socket.send( + JSON.stringify({ + type: "response", + id, + ok: false, + error: { code, message }, + ...(meta !== undefined ? { meta } : {}), + }), + ); } } catch { // Socket closed between dispatch and send; response cannot be delivered. diff --git a/packages/extension/src/lib/dispatch.test.ts b/packages/extension/src/lib/dispatch.test.ts index 097a69e..0e0a498 100644 --- a/packages/extension/src/lib/dispatch.test.ts +++ b/packages/extension/src/lib/dispatch.test.ts @@ -48,7 +48,7 @@ vi.mock("./policy.js", async (importOriginal) => { }); import { cdpClick } from "./cdp.js"; -import { dispatchMethod } from "./dispatch.js"; +import { dispatchMethod, dispatchWithMeta } from "./dispatch.js"; import { ensureAllowed, PolicyDenied, policy, tightenPolicy } from "./policy.js"; /** chrome stub with enough tabs API for the gate (tabs.get → host). */ @@ -285,3 +285,63 @@ describe("policy methods", () => { ).rejects.toThrow(); }); }); + +describe("dispatchWithMeta", () => { + it("stamps host/tier/tabId on success", async () => { + stubTabs("https://app.example.com/x"); + vi.mocked(ensureAllowed).mockResolvedValueOnce("full"); + const out = await dispatchWithMeta("read_text", { tabId: 7 }); + expect(out.meta).toEqual({ host: "app.example.com", tier: "full", tabId: 7 }); + }); + + it("stamps meta (with tabId) on a policy denial", async () => { + stubTabs("https://bank.com/x"); + vi.mocked(ensureAllowed).mockImplementationOnce(async () => { + const err = new PolicyDenied("blocked by policy: bank.com is read-only"); + err.meta = { host: "bank.com", tier: "read" }; + throw err; + }); + const err = await dispatchWithMeta("click", { tabId: 7 }).catch((e) => e); + expect(err.code).toBe("policy_denied"); + expect(err.meta).toEqual({ host: "bank.com", tier: "read", tabId: 7 }); + }); + + it("stamps host/tier (no tabId) on open_tab success", async () => { + vi.stubGlobal("chrome", { tabs: { create: async () => ({ id: 11 }) } }); + vi.mocked(ensureAllowed).mockResolvedValueOnce("full"); + const out = await dispatchWithMeta("open_tab", { + url: "https://app.example.com/x", + activate: true, + }); + expect(out.meta).toEqual({ host: "app.example.com", tier: "full" }); + }); + + it("stamps empty meta for list_tabs", async () => { + vi.stubGlobal("chrome", { + tabs: { query: async () => [{ id: 1, title: "t", url: "https://x.com/", active: true }] }, + }); + const out = await dispatchWithMeta("list_tabs", {}); + expect(out.meta).toEqual({}); + }); + + it("propagates a metaless denial without adding tabId", async () => { + stubTabs("https://x.com/"); + vi.mocked(ensureAllowed).mockRejectedValueOnce( + new PolicyDenied("blocked by policy: x.com is read-only"), + ); + const err = await dispatchWithMeta("click", { tabId: 7 }).catch((e) => e); + expect(err.code).toBe("policy_denied"); + expect(err.meta).toBeUndefined(); + }); + + it("leaves meta undefined for policy_get", async () => { + const out = await dispatchWithMeta("policy_get", {}); + expect(out.meta).toBeUndefined(); + }); + + it("dispatchMethod still returns the bare result", async () => { + stubTabs(); + const result = await dispatchMethod("read_text", { tabId: 7 }); + expect(result).not.toHaveProperty("meta"); + }); +}); diff --git a/packages/extension/src/lib/dispatch.ts b/packages/extension/src/lib/dispatch.ts index 7936cba..8b0a184 100644 --- a/packages/extension/src/lib/dispatch.ts +++ b/packages/extension/src/lib/dispatch.ts @@ -4,6 +4,7 @@ import { hostOf, METHOD_TIERS, PolicyTightenParams, + type ResponseMeta, } from "@reins/protocol"; import { cdpClick, @@ -27,45 +28,59 @@ import { selectOption, upload, } from "./page-actions.js"; -import { ensureAllowed, policy, tightenPolicy } from "./policy.js"; +import { ensureAllowed, PolicyDenied, policy, tightenPolicy } from "./policy.js"; import { closeTab, listTabs, openTab, resizeWindow, selectTab } from "./tab-handler.js"; const NAV_HISTORY = new Set(["back", "forward", "reload"]); +interface Gated { + params: Record; + meta: ResponseMeta; +} + /** * Policy gate. Resolves the target tab once (so gate and handler agree), * checks the host's tier against the method's required tier, and returns - * params with tabId pinned. list_tabs is gated per-tab (redaction) in the - * switch below; open_tab has no current tab and checks its destination. + * params with tabId pinned plus the resolved host/tier/tabId for the audit + * trail. list_tabs is gated per-tab (redaction) in runHandler; open_tab has + * no current tab and checks its destination. */ -async function gate(method: GatedMethod, params: unknown): Promise> { +async function gate(method: GatedMethod, params: unknown): Promise { const p = { ...((params ?? {}) as Record) }; - if (method === "list_tabs") return p; + if (method === "list_tabs") return { params: p, meta: {} }; if (method === "open_tab") { - await ensureAllowed("open_tab", hostOf(String(p.url ?? ""))); - return p; + const host = hostOf(String(p.url ?? "")); + const tier = await ensureAllowed("open_tab", host); + return { params: p, meta: { host, tier } }; } const tabId = await resolveTabId(typeof p.tabId === "number" ? p.tabId : undefined); - const tab = await chrome.tabs.get(tabId); - await ensureAllowed(method, hostOf(tab.url ?? "")); - if (method === "navigate") { - const to = String(p.to ?? ""); - if (!NAV_HISTORY.has(to)) { - let dest = hostOf(to); - if (dest === undefined) { - // Protocol-relative ("//bank.com/x") and path-relative targets - // resolve against the current page — check what they resolve to, - // or they would dodge the destination gate. - try { - dest = hostOf(new URL(to, tab.url).href); - } catch { - // unresolvable target — the handler will reject it + try { + const tab = await chrome.tabs.get(tabId); + const host = hostOf(tab.url ?? ""); + const tier = await ensureAllowed(method, host); + if (method === "navigate") { + const to = String(p.to ?? ""); + if (!NAV_HISTORY.has(to)) { + let dest = hostOf(to); + if (dest === undefined) { + // Protocol-relative ("//bank.com/x") and path-relative targets + // resolve against the current page — check what they resolve to, + // or they would dodge the destination gate. + try { + dest = hostOf(new URL(to, tab.url).href); + } catch { + // unresolvable target — the handler will reject it + } } + if (dest !== undefined) await ensureAllowed("navigate", dest); } - if (dest !== undefined) await ensureAllowed("navigate", dest); } + return { params: { ...p, tabId }, meta: { host, tier, tabId } }; + } catch (err) { + // A denial thrown in here knows host+tier but not the tab — add it. + if (err instanceof PolicyDenied && err.meta) err.meta = { ...err.meta, tabId }; + throw err; } - return { ...p, tabId }; } /** @@ -73,15 +88,7 @@ async function gate(method: GatedMethod, params: unknown): Promise { - if (method === "policy_get") return policy(); - if (method === "policy_tighten") { - const { pattern, tier } = PolicyTightenParams.parse(params ?? {}); - return tightenPolicy(pattern, tier); - } - if (!(method in METHOD_TIERS)) throw new Error(`unknown method: ${method}`); - const gated = await gate(method as GatedMethod, params); - +async function runHandler(method: GatedMethod, gated: Record): Promise { switch (method) { case "list_tabs": { const { tabs } = await listTabs(); @@ -142,3 +149,26 @@ export async function dispatchMethod(method: string, params: unknown): Promise { + if (method === "policy_get") return { result: await policy() }; + if (method === "policy_tighten") { + const { pattern, tier } = PolicyTightenParams.parse(params ?? {}); + return { result: await tightenPolicy(pattern, tier) }; + } + if (!(method in METHOD_TIERS)) throw new Error(`unknown method: ${method}`); + const gated = await gate(method as GatedMethod, params); + const result = await runHandler(method as GatedMethod, gated.params); + return { result, meta: gated.meta }; +} + +export async function dispatchMethod(method: string, params: unknown): Promise { + return (await dispatchWithMeta(method, params)).result; +} diff --git a/packages/extension/src/lib/policy.test.ts b/packages/extension/src/lib/policy.test.ts index c65279d..959ea4b 100644 --- a/packages/extension/src/lib/policy.test.ts +++ b/packages/extension/src/lib/policy.test.ts @@ -138,12 +138,12 @@ describe("applyPolicyChange", () => { describe("ensureAllowed", () => { it("full host allows read and full methods", async () => { stubStorage({ defaultTier: "full", rules: [] }); - await expect(ensureAllowed("click", "x.com")).resolves.toBeUndefined(); - await expect(ensureAllowed("read_text", "x.com")).resolves.toBeUndefined(); + await expect(ensureAllowed("click", "x.com")).resolves.toBe("full"); + await expect(ensureAllowed("read_text", "x.com")).resolves.toBe("full"); }); it("read host allows read, blocks full with popup hint", async () => { stubStorage({ defaultTier: "full", rules: [{ pattern: "x.com", tier: "read" }] }); - await expect(ensureAllowed("read_snapshot", "x.com")).resolves.toBeUndefined(); + await expect(ensureAllowed("read_snapshot", "x.com")).resolves.toBe("read"); await expect(ensureAllowed("click", "x.com")).rejects.toThrow(/x\.com is read-only.*popup/); }); it("deny host blocks everything", async () => { @@ -154,7 +154,7 @@ describe("ensureAllowed", () => { }); it("undefined host uses the default tier", async () => { stubStorage({ defaultTier: "read", rules: [] }); - await expect(ensureAllowed("screenshot", undefined)).resolves.toBeUndefined(); + await expect(ensureAllowed("screenshot", undefined)).resolves.toBe("read"); await expect(ensureAllowed("eval_js", undefined)).rejects.toThrow(PolicyDenied); }); it("carries code policy_denied", async () => { @@ -162,4 +162,16 @@ describe("ensureAllowed", () => { const err = await ensureAllowed("click", "x.com").catch((e: unknown) => e); expect((err as { code?: string }).code).toBe("policy_denied"); }); + it("returns the effective tier when allowed", async () => { + stubStorage(); // default policy is full everywhere + await expect(ensureAllowed("click", "app.example.com")).resolves.toBe("full"); + await expect(ensureAllowed("read_text", "app.example.com")).resolves.toBe("full"); + }); + it("stamps meta on PolicyDenied", async () => { + stubStorage({ defaultTier: "full", rules: [{ pattern: "bank.com", tier: "read" }] }); + const err = await ensureAllowed("click", "bank.com").catch((e: unknown) => e); + expect(err).toBeInstanceOf(PolicyDenied); + expect((err as PolicyDenied).code).toBe("policy_denied"); + expect((err as PolicyDenied).meta).toEqual({ host: "bank.com", tier: "read" }); + }); }); diff --git a/packages/extension/src/lib/policy.ts b/packages/extension/src/lib/policy.ts index ce29867..a5bf7e4 100644 --- a/packages/extension/src/lib/policy.ts +++ b/packages/extension/src/lib/policy.ts @@ -5,6 +5,7 @@ import { METHOD_TIERS, normalizePattern, Policy, + type ResponseMeta, Tier, tighterThan, } from "@reins/protocol"; @@ -16,6 +17,8 @@ export const POLICY_KEY = "reinsPolicy"; /** Refused by the policy gate. `code` survives to the ResponseFrame. */ export class PolicyDenied extends Error { readonly code = "policy_denied"; + /** Resolved target for the audit trail; the dispatch gate adds tabId. */ + meta?: ResponseMeta; } let cached: Policy | undefined; @@ -122,15 +125,18 @@ export function applyPolicyChange(change: PolicyChange): Promise { }); } -/** Throw PolicyDenied unless `host`'s tier covers `method`'s required tier. */ -export async function ensureAllowed(method: GatedMethod, host: string | undefined): Promise { +/** Throw PolicyDenied unless `host`'s tier covers `method`'s required tier; + * return the effective tier so dispatch can stamp it on the response. */ +export async function ensureAllowed(method: GatedMethod, host: string | undefined): Promise { const tier = effectiveTier(await policy(), host); const required = METHOD_TIERS[method]; - if (tier === "full" || (tier === "read" && required === "read")) return; + if (tier === "full" || (tier === "read" && required === "read")) return tier; const label = host ?? "this tab"; - throw new PolicyDenied( + const err = new PolicyDenied( tier === "deny" ? `blocked by policy: ${label} is denied — change its tier from the reins extension popup` : `blocked by policy: ${label} is read-only — grant full access from the reins extension popup`, ); + err.meta = { host, tier }; + throw err; } diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 9882775..bb25d8b 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -1,24 +1,25 @@ -import { BridgeClient, type SocketLike } from "./lib/bridge-client.js"; +import { BridgeClient, type DispatchOutcome, type SocketLike } from "./lib/bridge-client.js"; import { portFromUrl } from "./lib/settings.js"; let client: BridgeClient | undefined; /** * Relay dispatch requests to the background service worker, which owns the - * chrome.* APIs (e.g. chrome.tabs). The service worker returns { result } on - * success or { error } on failure. + * chrome.* APIs (e.g. chrome.tabs). The service worker returns { result, meta? } + * on success or { error, meta? } on failure. */ -async function offscreenDispatch(method: string, params: unknown): Promise { +async function offscreenDispatch(method: string, params: unknown): Promise { const res = (await chrome.runtime.sendMessage({ type: "reins:dispatch", method, params })) as - | { result: unknown; error?: undefined; code?: undefined } - | { error: string; code?: string; result?: undefined } + | { result: unknown; meta?: unknown; error?: undefined; code?: undefined } + | { error: string; code?: string; meta?: unknown; result?: undefined } | undefined; if (res?.error) { - const err = new Error(res.error) as Error & { code?: string }; + const err = new Error(res.error) as Error & { code?: string; meta?: unknown }; if (res.code) err.code = res.code; + if (res.meta !== undefined) err.meta = res.meta; throw err; } - return res?.result; + return { result: res?.result, meta: res?.meta }; } /** Best-effort human browser name (Chrome, Brave, Edge, …) for the daemon's roster. */ diff --git a/packages/protocol/src/bridge.test.ts b/packages/protocol/src/bridge.test.ts index b1cef76..997d18c 100644 --- a/packages/protocol/src/bridge.test.ts +++ b/packages/protocol/src/bridge.test.ts @@ -38,3 +38,33 @@ describe("bridge frames", () => { expect(WelcomeFrame.parse({ type: "welcome", server: "reins" }).server).toBe("reins"); }); }); + +describe("ResponseMeta", () => { + it("round-trips meta on a response frame", () => { + const frame = ResponseFrame.parse({ + type: "response", + id: "r1", + ok: true, + result: { done: true }, + meta: { host: "app.example.com", tier: "full", tabId: 412 }, + }); + expect(frame.meta).toEqual({ host: "app.example.com", tier: "full", tabId: 412 }); + }); + + it("parses frames without meta (older extensions)", () => { + const frame = ResponseFrame.parse({ type: "response", id: "r2", ok: true, result: 1 }); + expect(frame.meta).toBeUndefined(); + }); + + it("allows partial meta (denial without tabId)", () => { + const frame = ResponseFrame.parse({ + type: "response", + id: "r3", + ok: false, + error: { code: "policy_denied", message: "blocked" }, + meta: { host: "bank.com", tier: "read" }, + }); + expect(frame.meta?.host).toBe("bank.com"); + expect(frame.meta?.tabId).toBeUndefined(); + }); +}); diff --git a/packages/protocol/src/bridge.ts b/packages/protocol/src/bridge.ts index f157046..eae9d92 100644 --- a/packages/protocol/src/bridge.ts +++ b/packages/protocol/src/bridge.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { Tier } from "./policy.js"; /** A browser tab as seen by the agent. browserId/browser are tagged by the * daemon when aggregating tabs across several connected browsers. */ @@ -26,6 +27,17 @@ export type BrowserInfo = z.infer; export const FrameError = z.object({ code: z.string(), message: z.string() }); export type FrameError = z.infer; +/** Optional target metadata the extension stamps on a response: the + * resolved tab/host/tier the command actually hit. Consumed by the + * daemon's audit trail. Absent on daemon-side failures and on responses + * from extensions older than this field. */ +export const ResponseMeta = z.object({ + host: z.string().optional(), + tier: Tier.optional(), + tabId: z.number().optional(), +}); +export type ResponseMeta = z.infer; + /** Server → extension: invoke a method on the browser. */ export const RequestFrame = z.object({ type: z.literal("request"), @@ -42,6 +54,7 @@ export const ResponseFrame = z.object({ ok: z.boolean(), result: z.unknown().optional(), error: FrameError.optional(), + meta: ResponseMeta.optional(), }); export type ResponseFrame = z.infer; diff --git a/packages/web/src/routes/docs/security.tsx b/packages/web/src/routes/docs/security.tsx index 92814ca..5f78899 100644 --- a/packages/web/src/routes/docs/security.tsx +++ b/packages/web/src/routes/docs/security.tsx @@ -68,6 +68,25 @@ function SecurityPage() { Site permissions page.

+

Audit trail

+
    +
  • + Every command the daemon executes — and every one the policy blocks — appends one + structured line (timestamp, command, browser, tab, host, tier, outcome, duration) to{" "} + ~/.reins/logs/audit-YYYY-MM-DD.jsonl. reins audit renders the + trail; --denied shows only what policy blocked. +
  • +
  • + Value-bearing params — typed text, fill values, eval code, CDP payloads — are + redacted before the line is written, so the trail never stores what the agent typed, only + that it typed. +
  • +
  • + Audit files are pruned after 30 days. Writes are best-effort: a full disk never blocks a + command. +
  • +
+

Data handling