From 2b83905511c4b7c5e0ad95011b203dda499024f9 Mon Sep 17 00:00:00 2001
From: Karn
Date: Sat, 11 Jul 2026 20:43:06 +0530
Subject: [PATCH 01/15] docs: roadmap status pass + audit log design spec
Mark Phase 1 site-policy/read-tier items shipped (v0.3.0, #15),
leaving audit log, SECURITY.md, and skill hardening. Add the approved
audit-log design: extension stamps host/tier/tabId on response meta,
daemon writes redacted JSONL, reins audit views it, 30-day retention.
Co-Authored-By: Claude Fable 5
---
docs/ROADMAP.md | 57 +++----
.../specs/2026-07-11-audit-log-design.md | 146 ++++++++++++++++++
2 files changed, 176 insertions(+), 27 deletions(-)
create mode 100644 docs/superpowers/specs/2026-07-11-audit-log-design.md
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 7d6951f..0f55671 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,38 @@ 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.** Structured per-action log line (timestamp, command,
+ browser, tab, origin) — `~/.reins/logs` has daemon lifecycle logs today, but
+ no per-action trail. Make it first-class and document it. `reins audit` to
+ view. Include policy denials — the trail should show what was blocked, not
+ just what ran.
+- ⬜ **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 +118,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/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..2681220
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-11-audit-log-design.md
@@ -0,0 +1,146 @@
+# 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":{"fields":"[redacted 2 fields]"},"ok":false,"denied":true,"error":"POLICY_BLOCKED: blocked by policy: bank.com is read-only","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 (`POLICY_BLOCKED`).
+- `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`, `code`, `expression` → `"[redacted chars]"`.
+- `fill` field maps → `"[redacted fields]"`.
+- `upload` file paths → basename only.
+- Everything else — selectors, URLs, tabIds, key names (`Enter`), scroll
+ deltas, CDP method names — 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. **Structured denial code.** Policy denials get error code
+ `POLICY_BLOCKED` (today the code is generic and the CLI would need
+ string-matching). The auditor classifies `denied: true` off the code.
+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`.
+- `-n ` — last N records, newest last, crossing day-file boundaries.
+- `--denied` — denials only.
+- `--json` — raw JSONL lines (composable with `-n`/`--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_BLOCKED`.
+- **cli:** table-driven redaction tests; auditor unit tests (denial
+ classification, duration, daemon-side failure records, browser-name
+ resolution); viewer tests (parse, `-n` 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.
From 172670ddfade06ee7ae06637dbd3c32ba74a387e Mon Sep 17 00:00:00 2001
From: Karn
Date: Sat, 11 Jul 2026 20:56:53 +0530
Subject: [PATCH 02/15] docs: audit log implementation plan + spec corrections
Spec fixes found while planning: policy denials already carry the
policy_denied error code (no new code needed), and the CLI flag parser
only supports --flag forms, so the viewer count flag is --last .
Co-Authored-By: Claude Fable 5
---
.../superpowers/plans/2026-07-11-audit-log.md | 1521 +++++++++++++++++
.../specs/2026-07-11-audit-log-design.md | 31 +-
2 files changed, 1538 insertions(+), 14 deletions(-)
create mode 100644 docs/superpowers/plans/2026-07-11-audit-log.md
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
index 2681220..6cb65f0 100644
--- a/docs/superpowers/specs/2026-07-11-audit-log-design.md
+++ b/docs/superpowers/specs/2026-07-11-audit-log-design.md
@@ -31,7 +31,7 @@ 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":{"fields":"[redacted 2 fields]"},"ok":false,"denied":true,"error":"POLICY_BLOCKED: blocked by policy: bank.com is read-only","ms":12}
+{"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:
@@ -47,7 +47,7 @@ Fields:
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 (`POLICY_BLOCKED`).
+- `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.
@@ -61,11 +61,12 @@ show attempts, not just completions.
Redaction happens in the daemon **before** the write; plaintext never
reaches disk. A fixed field-name list, not heuristics:
-- `text`, `value`, `code`, `expression` → `"[redacted chars]"`.
-- `fill` field maps → `"[redacted fields]"`.
-- `upload` file paths → basename only.
+- `text`, `value`, `expression` → `"[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, CDP method names — passes through verbatim.
+ 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.
@@ -81,9 +82,10 @@ test, so adding a future value-bearing param means one row + one test case.
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. **Structured denial code.** Policy denials get error code
- `POLICY_BLOCKED` (today the code is generic and the CLI would need
- string-matching). The auditor classifies `denied: true` off the code.
+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
@@ -97,9 +99,10 @@ 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`.
-- `-n ` — last N records, newest last, crossing day-file boundaries.
+- `--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 `-n`/`--denied`).
+- `--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.
@@ -128,11 +131,11 @@ separate change).
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_BLOCKED`.
+ 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, `-n` across files, `--denied`, corrupt
- lines); prune-on-startup test; end-to-end record via the stand-in WS
+ 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/`).
From 2cf3eb981140e93fcf99bfb80c3f012262cbfb43 Mon Sep 17 00:00:00 2001
From: Karn
Date: Sat, 11 Jul 2026 21:36:52 +0530
Subject: [PATCH 03/15] feat(protocol): optional ResponseMeta (host/tier/tabId)
on ResponseFrame
Co-Authored-By: Claude Fable 5
---
packages/protocol/src/bridge.test.ts | 30 ++++++++++++++++++++++++++++
packages/protocol/src/bridge.ts | 13 ++++++++++++
2 files changed, 43 insertions(+)
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;
From f0cee80c862b54dee0f23125f097461203394135 Mon Sep 17 00:00:00 2001
From: Karn
Date: Sat, 11 Jul 2026 21:43:16 +0530
Subject: [PATCH 04/15] feat(extension): ensureAllowed returns tier,
PolicyDenied carries meta
Co-Authored-By: Claude Fable 5
---
packages/extension/src/lib/policy.test.ts | 20 ++++++++++++++++----
packages/extension/src/lib/policy.ts | 14 ++++++++++----
2 files changed, 26 insertions(+), 8 deletions(-)
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;
}
From 3f8680a610c7aa0298bd1f82c0ecb1a23582ee99 Mon Sep 17 00:00:00 2001
From: Karn
Date: Sat, 11 Jul 2026 21:49:13 +0530
Subject: [PATCH 05/15] feat(extension): dispatchWithMeta stamps resolved
host/tier/tabId
Co-Authored-By: Claude Fable 5
---
packages/extension/src/lib/dispatch.test.ts | 34 +++++++-
packages/extension/src/lib/dispatch.ts | 94 ++++++++++++++-------
2 files changed, 95 insertions(+), 33 deletions(-)
diff --git a/packages/extension/src/lib/dispatch.test.ts b/packages/extension/src/lib/dispatch.test.ts
index 097a69e..8d86a24 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,35 @@ 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("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;
+}
From 24894f9f5c6ec2ab125177114604f362220b4db1 Mon Sep 17 00:00:00 2001
From: Karn
Date: Sat, 11 Jul 2026 21:56:07 +0530
Subject: [PATCH 06/15] test(extension): cover open_tab/list_tabs meta shapes
and metaless denial
Co-Authored-By: Claude Fable 5
---
packages/extension/src/lib/dispatch.test.ts | 28 +++++++++++++++++++++
1 file changed, 28 insertions(+)
diff --git a/packages/extension/src/lib/dispatch.test.ts b/packages/extension/src/lib/dispatch.test.ts
index 8d86a24..0e0a498 100644
--- a/packages/extension/src/lib/dispatch.test.ts
+++ b/packages/extension/src/lib/dispatch.test.ts
@@ -306,6 +306,34 @@ describe("dispatchWithMeta", () => {
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();
From 4c8969bfdd5e7679537d8b5026a1d74757abb215 Mon Sep 17 00:00:00 2001
From: Karn
Date: Sat, 11 Jul 2026 22:02:41 +0530
Subject: [PATCH 07/15] feat(extension): carry dispatch meta on response frames
Co-Authored-By: Claude Fable 5
---
packages/extension/src/background.ts | 9 +-
.../extension/src/lib/bridge-client.test.ts | 110 ++++++++++++++++--
packages/extension/src/lib/bridge-client.ts | 34 +++++-
packages/extension/src/offscreen.ts | 17 +--
4 files changed, 146 insertions(+), 24 deletions(-)
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/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. */
From 8b73adc186181fdf6905f23349bee9ea6c1ecd5d Mon Sep 17 00:00:00 2001
From: Karn
Date: Sat, 11 Jul 2026 22:08:58 +0530
Subject: [PATCH 08/15] feat(cli): audit record redaction, JSONL writer, 30-day
prune
Co-Authored-By: Claude Fable 5
---
packages/cli/src/audit.test.ts | 115 +++++++++++++++++++++++++++++++++
packages/cli/src/audit.ts | 111 +++++++++++++++++++++++++++++++
2 files changed, 226 insertions(+)
create mode 100644 packages/cli/src/audit.test.ts
create mode 100644 packages/cli/src/audit.ts
diff --git a/packages/cli/src/audit.test.ts b/packages/cli/src/audit.test.ts
new file mode 100644
index 0000000..cbeb9f7
--- /dev/null
+++ b/packages/cli/src/audit.test.ts
@@ -0,0 +1,115 @@
+import { chmodSync, 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" }],
+ ])("%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([]);
+ });
+});
diff --git a/packages/cli/src/audit.ts b/packages/cli/src/audit.ts
new file mode 100644
index 0000000..5868a77
--- /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"]);
+
+/**
+ * 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;
+}
From fb3119a6e74e1c2968ec949c6c2913703dc36bcf Mon Sep 17 00:00:00 2001
From: Karn
Date: Sat, 11 Jul 2026 22:20:37 +0530
Subject: [PATCH 09/15] feat(cli): BridgeHost.requestFull exposes meta,
browserId, error code
Co-Authored-By: Claude Fable 5
---
packages/cli/src/bridge.test.ts | 66 +++++++++++++++++++++++++++++++++
packages/cli/src/bridge.ts | 30 ++++++++++++---
2 files changed, 91 insertions(+), 5 deletions(-)
diff --git a/packages/cli/src/bridge.test.ts b/packages/cli/src/bridge.test.ts
index 10fa583..40ae7be 100644
--- a/packages/cli/src/bridge.test.ts
+++ b/packages/cli/src/bridge.test.ts
@@ -248,6 +248,72 @@ 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 });
+ 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..b3d2b33 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,16 @@ 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;
+ };
+ e.code = err.code;
+ e.meta = frame.meta;
+ pending.reject(e);
}
}
@@ -228,7 +244,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,7 +253,7 @@ 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`));
@@ -255,6 +271,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();
From 91fbf28d6b435417376431f3536624e8547e86e1 Mon Sep 17 00:00:00 2001
From: Karn
Date: Sat, 11 Jul 2026 22:27:01 +0530
Subject: [PATCH 10/15] test(cli): fakeBridge satisfies BridgePort.requestFull
Co-Authored-By: Claude Fable 5
---
packages/cli/src/rpc.test.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/cli/src/rpc.test.ts b/packages/cli/src/rpc.test.ts
index fddbacf..17cdb3f 100644
--- a/packages/cli/src/rpc.test.ts
+++ b/packages/cli/src/rpc.test.ts
@@ -12,6 +12,7 @@ function fakeBridge(overrides: Partial = {}): BridgePort {
}
return { ok: true };
}),
+ requestFull: vi.fn(async () => ({ result: undefined, browserId: "b1" })),
...overrides,
} as BridgePort;
}
From 67077b03fdf8a2886bddc371f8ded66a2732b5be Mon Sep 17 00:00:00 2001
From: Karn
Date: Sat, 11 Jul 2026 22:34:55 +0530
Subject: [PATCH 11/15] feat(cli): daemon writes an audit record per /rpc
action
Co-Authored-By: Claude Fable 5
---
packages/cli/src/daemon.ts | 4 +-
packages/cli/src/integration.test.ts | 43 ++++++++++-
packages/cli/src/rpc.test.ts | 108 ++++++++++++++++++++++++++-
packages/cli/src/rpc.ts | 61 +++++++++++++--
packages/cli/src/serve.ts | 7 +-
5 files changed, 208 insertions(+), 15 deletions(-)
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 17cdb3f..295e72a 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,7 +13,13 @@ function fakeBridge(overrides: Partial = {}): BridgePort {
}
return { ok: true };
}),
- requestFull: vi.fn(async () => ({ result: undefined, browserId: "b1" })),
+ 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;
}
@@ -25,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 },
@@ -41,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 () => {
@@ -87,3 +94,96 @@ 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;
+ };
+ 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();
+ 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);
+ });
+});
diff --git a/packages/cli/src/rpc.ts b/packages/cli/src/rpc.ts
index e495132..7f7d6d4 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,63 @@ 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;
+ 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;
+ }
}
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()),
}),
);
From 7de27a9a827aa36635649de27a094c17ee26a9e4 Mon Sep 17 00:00:00 2001
From: Karn
Date: Sat, 11 Jul 2026 22:43:25 +0530
Subject: [PATCH 12/15] fix(cli): audit hook exceptions never affect the RPC
result
Co-Authored-By: Claude Fable 5
---
packages/cli/src/rpc.test.ts | 11 +++++++++++
packages/cli/src/rpc.ts | 34 ++++++++++++++++++++--------------
2 files changed, 31 insertions(+), 14 deletions(-)
diff --git a/packages/cli/src/rpc.test.ts b/packages/cli/src/rpc.test.ts
index 295e72a..0949e1b 100644
--- a/packages/cli/src/rpc.test.ts
+++ b/packages/cli/src/rpc.test.ts
@@ -181,6 +181,17 @@ describe("audit hook", () => {
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();
diff --git a/packages/cli/src/rpc.ts b/packages/cli/src/rpc.ts
index 7f7d6d4..85ed3ee 100644
--- a/packages/cli/src/rpc.ts
+++ b/packages/cli/src/rpc.ts
@@ -67,20 +67,26 @@ export async function handleRpc(
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 {
+ 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,
+ });
+ } 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 {
From 65309da0903ebfed87f6ece4b3d679bc17684d47 Mon Sep 17 00:00:00 2001
From: Karn
Date: Sat, 11 Jul 2026 22:50:21 +0530
Subject: [PATCH 13/15] =?UTF-8?q?feat(cli):=20reins=20audit=20=E2=80=94=20?=
=?UTF-8?q?view=20the=20per-action=20trail?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Fable 5
---
packages/cli/src/audit-cli.test.ts | 110 ++++++++++++++++++++++++++
packages/cli/src/audit-cli.ts | 110 ++++++++++++++++++++++++++
packages/cli/src/cli-commands.test.ts | 4 +
packages/cli/src/cli-commands.ts | 1 +
packages/cli/src/cli.ts | 8 ++
5 files changed, 233 insertions(+)
create mode 100644 packages/cli/src/audit-cli.test.ts
create mode 100644 packages/cli/src/audit-cli.ts
diff --git a/packages/cli/src/audit-cli.test.ts b/packages/cli/src/audit-cli.test.ts
new file mode 100644
index 0000000..2522083
--- /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\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");
+ });
+});
diff --git a/packages/cli/src/audit-cli.ts b/packages/cli/src/audit-cli.ts
new file mode 100644
index 0000000..d416b5d
--- /dev/null
+++ b/packages/cli/src/audit-cli.ts
@@ -0,0 +1,110 @@
+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 };
+}
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":
From f3c005ca7915ba47be2140679668959a85da80b6 Mon Sep 17 00:00:00 2001
From: Karn
Date: Sat, 11 Jul 2026 22:55:50 +0530
Subject: [PATCH 14/15] docs: audit trail on security page, changeset, roadmap
tick
Co-Authored-By: Claude Fable 5
---
.changeset/audit-log.md | 6 ++++++
docs/ROADMAP.md | 9 ++++-----
packages/web/src/routes/docs/security.tsx | 19 +++++++++++++++++++
3 files changed, 29 insertions(+), 5 deletions(-)
create mode 100644 .changeset/audit-log.md
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 0f55671..033e0c7 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -43,11 +43,10 @@ the scariest sentence in the README. Ship containment before growth.
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.** Structured per-action log line (timestamp, command,
- browser, tab, origin) — `~/.reins/logs` has daemon lifecycle logs today, but
- no per-action trail. Make it first-class and document it. `reins audit` to
- view. Include policy denials — the trail should show what was blocked, not
- just what ran.
+- ✅ **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
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
From d923d28d94cb2def9b45910d919394e629e6c433 Mon Sep 17 00:00:00 2001
From: Karn
Date: Sat, 11 Jul 2026 23:10:11 +0530
Subject: [PATCH 15/15] =?UTF-8?q?fix(cli):=20final-review=20fixes=20?=
=?UTF-8?q?=E2=80=94=20redact=20promptText,=20denial=20browserId,=20tabId?=
=?UTF-8?q?=20fallback?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Redact promptText (handle_dialog) — agent-typed dialog text no longer hits disk in plaintext.
- Denial/error audit records now carry the browser id that was actually reached, sourced from
the rejection error (bridge #settle and requestFull timeout stamp .browserId).
- tabId falls back to params when response meta lacks it, per spec.
- finish()'s roster lookup moved inside its try/catch so no part of the audit path can throw
into the RPC.
- audit-cli viewer now guards against JSON-parseable but shape-invalid lines (e.g. `5`, `{}`)
instead of crashing on r.ts.slice.
- createAuditor test added for a not-yet-existing nested log directory.
Co-Authored-By: Claude Fable 5
---
.../specs/2026-07-11-audit-log-design.md | 2 +-
packages/cli/src/audit-cli.test.ts | 4 +--
packages/cli/src/audit-cli.ts | 15 ++++++++++-
packages/cli/src/audit.test.ts | 25 ++++++++++++++++++-
packages/cli/src/audit.ts | 2 +-
packages/cli/src/bridge.test.ts | 1 +
packages/cli/src/bridge.ts | 8 +++++-
packages/cli/src/rpc.test.ts | 10 +++++++-
packages/cli/src/rpc.ts | 13 ++++++----
9 files changed, 67 insertions(+), 13 deletions(-)
diff --git a/docs/superpowers/specs/2026-07-11-audit-log-design.md b/docs/superpowers/specs/2026-07-11-audit-log-design.md
index 6cb65f0..bb57e90 100644
--- a/docs/superpowers/specs/2026-07-11-audit-log-design.md
+++ b/docs/superpowers/specs/2026-07-11-audit-log-design.md
@@ -61,7 +61,7 @@ show attempts, not just completions.
Redaction happens in the daemon **before** the write; plaintext never
reaches disk. A fixed field-name list, not heuristics:
-- `text`, `value`, `expression` → `"[redacted chars]"`.
+- `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.
diff --git a/packages/cli/src/audit-cli.test.ts b/packages/cli/src/audit-cli.test.ts
index 2522083..ffc87c4 100644
--- a/packages/cli/src/audit-cli.test.ts
+++ b/packages/cli/src/audit-cli.test.ts
@@ -96,11 +96,11 @@ describe("runAudit", () => {
const dir = tempDir();
writeFileSync(
join(dir, "audit-2026-07-11.jsonl"),
- `not json\n${line({ host: undefined, tabId: undefined, browser: undefined })}`,
+ `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 1 corrupt audit line"]);
+ expect(view.warnings).toEqual(["skipped 3 corrupt audit lines"]);
});
it("says so when there is nothing to show", () => {
diff --git a/packages/cli/src/audit-cli.ts b/packages/cli/src/audit-cli.ts
index d416b5d..fe65bb8 100644
--- a/packages/cli/src/audit-cli.ts
+++ b/packages/cli/src/audit-cli.ts
@@ -15,6 +15,14 @@ interface Loaded {
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;
@@ -28,7 +36,12 @@ function loadFiles(dir: string, files: string[]): Loaded {
for (const line of text.split("\n")) {
if (line.trim() === "") continue;
try {
- records.push(JSON.parse(line) as AuditRecord);
+ const parsed: unknown = JSON.parse(line);
+ if (!isAuditRecord(parsed)) {
+ skipped += 1;
+ continue;
+ }
+ records.push(parsed);
} catch {
skipped += 1;
}
diff --git a/packages/cli/src/audit.test.ts b/packages/cli/src/audit.test.ts
index cbeb9f7..4d8722d 100644
--- a/packages/cli/src/audit.test.ts
+++ b/packages/cli/src/audit.test.ts
@@ -1,4 +1,12 @@
-import { chmodSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+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";
@@ -42,6 +50,11 @@ describe("redactParams", () => {
],
["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);
});
@@ -95,6 +108,16 @@ describe("createAuditor", () => {
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", () => {
diff --git a/packages/cli/src/audit.ts b/packages/cli/src/audit.ts
index 5868a77..a4be9c6 100644
--- a/packages/cli/src/audit.ts
+++ b/packages/cli/src/audit.ts
@@ -21,7 +21,7 @@ export interface AuditRecord {
export type AuditHook = (record: AuditRecord) => void;
/** Param keys whose string values are typed/filled/evaluated content. */
-const VALUE_KEYS = new Set(["text", "value", "expression"]);
+const VALUE_KEYS = new Set(["text", "value", "expression", "promptText"]);
/**
* Strip secrets from params before they reach disk. Fixed field-name table,
diff --git a/packages/cli/src/bridge.test.ts b/packages/cli/src/bridge.test.ts
index 40ae7be..79b6c55 100644
--- a/packages/cli/src/bridge.test.ts
+++ b/packages/cli/src/bridge.test.ts
@@ -296,6 +296,7 @@ describe("BridgeHost (requestFull)", () => {
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();
});
diff --git a/packages/cli/src/bridge.ts b/packages/cli/src/bridge.ts
index b3d2b33..000f6d4 100644
--- a/packages/cli/src/bridge.ts
+++ b/packages/cli/src/bridge.ts
@@ -215,9 +215,11 @@ export class BridgeHost implements BridgePort {
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);
}
}
@@ -256,7 +258,11 @@ export class BridgeHost implements BridgePort {
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 {
diff --git a/packages/cli/src/rpc.test.ts b/packages/cli/src/rpc.test.ts
index 0949e1b..73a211e 100644
--- a/packages/cli/src/rpc.test.ts
+++ b/packages/cli/src/rpc.test.ts
@@ -131,10 +131,13 @@ describe("audit hook", () => {
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;
},
@@ -146,6 +149,8 @@ describe("audit hook", () => {
method: "click",
ok: false,
denied: true,
+ browserId: "b1",
+ browser: "Chromium",
host: "bank.com",
tier: "read",
tabId: 7,
@@ -161,14 +166,17 @@ describe("audit hook", () => {
},
});
await expect(
- handleRpc(bridge, { method: "click", params: {} }, (r) => records.push(r)),
+ 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();
});
diff --git a/packages/cli/src/rpc.ts b/packages/cli/src/rpc.ts
index 85ed3ee..29c1fb4 100644
--- a/packages/cli/src/rpc.ts
+++ b/packages/cli/src/rpc.ts
@@ -64,16 +64,18 @@ export async function handleRpc(
error?: Error & { code?: string };
}): void => {
if (!audit) return;
- const browser = outcome.browserId
- ? bridge.browsers.find((b) => b.id === outcome.browserId)?.browser
- : undefined;
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 } : {}),
- ...(outcome.meta?.tabId !== undefined ? { tabId: outcome.meta.tabId } : {}),
+ ...(tabId !== undefined ? { tabId } : {}),
...(outcome.meta?.host !== undefined ? { host: outcome.meta.host } : {}),
...(outcome.meta?.tier !== undefined ? { tier: outcome.meta.tier } : {}),
params: redactParams(method, params),
@@ -102,8 +104,9 @@ export async function handleRpc(
const e = (err instanceof Error ? err : new Error(String(err))) as Error & {
code?: string;
meta?: ResponseMeta;
+ browserId?: string;
};
- finish({ ok: false, browserId, meta: e.meta, error: e });
+ finish({ ok: false, browserId: e.browserId ?? browserId, meta: e.meta, error: e });
throw err;
}
}