From c5b2febd285fe562ab97093dfda50fbae8eaa864 Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Sun, 3 May 2026 20:33:30 +0530 Subject: [PATCH 1/2] harden client id validation --- AGENTS.md | 2 +- CLAUDE.md | 8 ++-- README.md | 10 ++--- docs/architecture.md | 2 +- docs/submission.md | 16 ++++++-- src/README.md | 2 +- src/risk-gate/app.ts | 38 +++++++++++++++++-- tests/README.md | 4 +- tests/clientIsolation.test.ts | 71 ++++++++++++++++++++++++++++++++--- 9 files changed, 126 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b8796e1..81f56cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ This hackathon submission is **TypeScript on Bun, end to end**. There is no Rust bun install # install deps from bun.lock bun run dev # start risk-gate server with watch on 127.0.0.1:8787 bun run start # production-style boot (no watch) -bun test # 90 specs across 10 files +bun test # 112 specs across 13 files bun run test:coverage # v8 coverage report bun run typecheck # tsc --noEmit, must exit 0 bun run build # bundle to ./dist/server.js diff --git a/CLAUDE.md b/CLAUDE.md index ad013a2..82e6215 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Project context for Claude Code. Read this first. **ChainShield Agent** — a policy-bound risk gate for treasury wallets. Built for ETHGlobal OpenAgents 2026. The server takes a transaction intent, evaluates it against deterministic rules + a heuristic ERC-20 simulator, anchors the resulting decision JSON on 0G Storage, and fires KeeperHub remediation playbooks on `BLOCK`. Three verdicts: `ALLOW`, `REQUIRE_HUMAN_CONFIRMATION`, `BLOCK`. -The submission is shipped: PR #6 merged into `main` on 2026-05-03. 90 specs across 10 files green. Live anchor verified on Galileo testnet. +The submission is shipped: PR #6 merged into `main` on 2026-05-03. 112 specs across 13 files green. Live anchor verified on Galileo testnet. ## Stack (do not assume Rust/Solidity) @@ -31,7 +31,7 @@ bun run dev:server # just the Fastify server bun run dev:web # just the Astro frontend bun run typecheck # both: server + web (must be 0 errors) -bun test # 90 specs, ~280ms +bun test # 112 specs bun run demo # CLI four-scene runner against the live API bun run build # bundle server + Astro static output @@ -46,7 +46,7 @@ docker compose up --build # containerised path - `src/playbooks/` — `PlaybookRunner` interface, `KeeperHubRunner`, notification channels - `src/risk-gate/` — Fastify `app.ts` and `server.ts` composition root - `src/cli/demo.ts` — four-canonical-scene CLI -- `tests/` — 90 specs across 10 files +- `tests/` — 112 specs across 13 files - `web/` — Astro 6 frontend (components, lib, pages, styles) - `docs/` — `submission.md` (judge one-pager), `demo-script.md`, `architecture.md`, `sponsors/` - `scripts/` — `kh.sh` (KeeperHub helper), `dev.sh` (parallel dev) @@ -97,7 +97,7 @@ Every PR to `main` and every push to `main` runs [`.github/workflows/ci.yml`](./ 2. Installs web deps with `bun install --frozen-lockfile` 3. `bun run typecheck:server` — `tsc --noEmit` 4. `bun run typecheck:web` — `astro check` -5. `bun test` — 90 specs +5. `bun test` — 112 specs 6. `bun run build:web` — Astro production build 7. Emoji scan — fails the build if any banned emoji byte sequence appears in tracked files diff --git a/README.md b/README.md index f828cd7..9e8d9ef 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ | **API hot path** | `< 50 ms` (anchor uploads stream in the background) | | **Verdicts** | `ALLOW` / `REQUIRE_HUMAN_CONFIRMATION` / `BLOCK` | | **Decision ladder** | 5 deterministic rules + 1 heuristic ERC-20 simulator | -| **Test suite** | 109 specs / 13 files / 317 assertions / `~340 ms` cold | +| **Test suite** | 112 specs / 13 files | | **Type safety** | `tsc --noEmit` + `astro check`, both zero-error, strict + `noUncheckedIndexedAccess` | | **Sponsors integrated** | 0G Storage, KeeperHub, Gensyn AXL, Discord webhooks | | **Lines of TypeScript (server)** | `~1,700` across `src/` | @@ -223,7 +223,7 @@ The Astro UI lands at ; the API health check at ; the API health check at }): string | undefined { const raw = req.headers["x-client-id"]; - if (typeof raw !== "string") return undefined; + if (raw === undefined) return undefined; + if (typeof raw !== "string") { + throw new InvalidClientIdError("X-Client-Id must be a string."); + } const trimmed = raw.trim(); - // Cap at 128 chars to bound the dimension of the in-memory store. - if (trimmed.length === 0 || trimmed.length > 128) return undefined; + if (trimmed.length === 0) { + throw new InvalidClientIdError("X-Client-Id must not be blank."); + } + if (trimmed.length > CLIENT_ID_MAX_LENGTH) { + throw new InvalidClientIdError( + `X-Client-Id must be ${CLIENT_ID_MAX_LENGTH} characters or fewer.`, + ); + } + if (!CLIENT_ID_PATTERN.test(trimmed)) { + throw new InvalidClientIdError( + "X-Client-Id may only contain letters, numbers, dot, underscore, colon, or hyphen.", + ); + } return trimmed; } @@ -117,8 +146,9 @@ export function buildApp(deps: AppDeps = {}): FastifyInstance { app.put("/policies/:id", async (req, reply) => { const { id } = req.params as { id: string }; const parsed = policyInputSchema.parse(req.body); + const cid = clientIdOf(req); try { - return withAnchorPolicy(await policyService.update(id, parsed, clientIdOf(req))); + return withAnchorPolicy(await policyService.update(id, parsed, cid)); } catch (err) { reply.status(404); return { error: "NotFound", message: (err as Error).message }; diff --git a/tests/README.md b/tests/README.md index c761011..1b29038 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,9 +1,9 @@ # `tests/` — unit + integration coverage -> 109 specs across 13 files, all green in `~340 ms`. Every external dependency is faked at the trait boundary; live anchor hashes are pinned as test constants so the renderer is exercised against real chain data. +> 112 specs across 13 files. Every external dependency is faked at the trait boundary; live anchor hashes are pinned as test constants so the renderer is exercised against real chain data. ```sh -bun test # all 109 specs +bun test # all 112 specs bun test tests/engine.test.ts # one file bun test --watch # watch mode bun test --coverage # coverage report diff --git a/tests/clientIsolation.test.ts b/tests/clientIsolation.test.ts index 244ea3a..8eae610 100644 --- a/tests/clientIsolation.test.ts +++ b/tests/clientIsolation.test.ts @@ -31,6 +31,15 @@ function getPolicy(app: ReturnType, id: string, clientId?: stri }); } +function putPolicy(app: ReturnType, id: string, clientId?: string) { + return app.inject({ + method: "PUT", + url: `/policies/${encodeURIComponent(id)}`, + headers: clientId ? { "x-client-id": clientId } : {}, + payload: { owner: TREASURY, rules: { allowedDestinations: [COLD_VAULT] } }, + }); +} + function postEvaluate( app: ReturnType, policyId: string, @@ -130,15 +139,67 @@ describe("Per-browser session isolation via X-Client-Id", () => { await app.close(); }); - it("rejects oversized X-Client-Id headers and falls back to admin view", async () => { + it("rejects oversized X-Client-Id headers instead of falling back to admin view", async () => { const app = buildApp(); const oversized = "x".repeat(200); + const created = await postPolicy(app, oversized); - expect(created.statusCode).toBe(201); - // Server treated the oversized id as "no clientId", so the policy was - // tagged null and the admin view (no header) sees it. + expect(created.statusCode).toBe(400); + expect(created.json().error).toBe("InvalidClientId"); + const adminSeesIt = await listPolicies(app); - expect(adminSeesIt.json().length).toBeGreaterThanOrEqual(1); + expect(adminSeesIt.json()).toHaveLength(0); + await app.close(); + }); + + it("rejects blank X-Client-Id headers instead of writing unscoped rows", async () => { + const app = buildApp(); + + const created = await postPolicy(app, " "); + expect(created.statusCode).toBe(400); + expect(created.json().error).toBe("InvalidClientId"); + + const adminSeesIt = await listPolicies(app); + expect(adminSeesIt.json()).toHaveLength(0); + await app.close(); + }); + + it("rejects invalid X-Client-Id headers on read paths instead of exposing admin data", async () => { + const app = buildApp(); + const created = await postPolicy(app, A); + const policyId = created.json().id; + + const oversized = "x".repeat(200); + const policyRead = await getPolicy(app, policyId, oversized); + expect(policyRead.statusCode).toBe(400); + expect(policyRead.json().error).toBe("InvalidClientId"); + + const timelineRead = await getTimeline(app, oversized); + expect(timelineRead.statusCode).toBe(400); + expect(timelineRead.json().error).toBe("InvalidClientId"); + await app.close(); + }); + + it("rejects malformed X-Client-Id headers consistently across scoped API routes", async () => { + const app = buildApp(); + const created = await postPolicy(app, A); + const policyId = created.json().id; + const malformed = "browser id with spaces"; + + const calls = [ + postPolicy(app, malformed), + listPolicies(app, malformed), + getPolicy(app, policyId, malformed), + putPolicy(app, policyId, malformed), + postEvaluate(app, policyId, malformed), + getTimeline(app, malformed), + ]; + + for (const res of await Promise.all(calls)) { + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe("InvalidClientId"); + } + await app.close(); }); }); From b995dd1d39fb7fc1facc6627aaf8c3c8cf29a802 Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Sun, 3 May 2026 20:48:54 +0530 Subject: [PATCH 2/2] narrow policy update errors --- AGENTS.md | 2 +- CLAUDE.md | 8 +++---- README.md | 10 ++++----- docs/architecture.md | 2 +- docs/submission.md | 4 ++-- src/README.md | 2 +- src/core/policyService.ts | 9 +++++++- src/risk-gate/app.ts | 7 ++++-- tests/README.md | 4 ++-- tests/api.test.ts | 47 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 76 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 81f56cf..5e6bbe8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ This hackathon submission is **TypeScript on Bun, end to end**. There is no Rust bun install # install deps from bun.lock bun run dev # start risk-gate server with watch on 127.0.0.1:8787 bun run start # production-style boot (no watch) -bun test # 112 specs across 13 files +bun test # 114 specs across 13 files bun run test:coverage # v8 coverage report bun run typecheck # tsc --noEmit, must exit 0 bun run build # bundle to ./dist/server.js diff --git a/CLAUDE.md b/CLAUDE.md index 82e6215..ef49560 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Project context for Claude Code. Read this first. **ChainShield Agent** — a policy-bound risk gate for treasury wallets. Built for ETHGlobal OpenAgents 2026. The server takes a transaction intent, evaluates it against deterministic rules + a heuristic ERC-20 simulator, anchors the resulting decision JSON on 0G Storage, and fires KeeperHub remediation playbooks on `BLOCK`. Three verdicts: `ALLOW`, `REQUIRE_HUMAN_CONFIRMATION`, `BLOCK`. -The submission is shipped: PR #6 merged into `main` on 2026-05-03. 112 specs across 13 files green. Live anchor verified on Galileo testnet. +The submission is shipped: PR #6 merged into `main` on 2026-05-03. 114 specs across 13 files green. Live anchor verified on Galileo testnet. ## Stack (do not assume Rust/Solidity) @@ -31,7 +31,7 @@ bun run dev:server # just the Fastify server bun run dev:web # just the Astro frontend bun run typecheck # both: server + web (must be 0 errors) -bun test # 112 specs +bun test # 114 specs bun run demo # CLI four-scene runner against the live API bun run build # bundle server + Astro static output @@ -46,7 +46,7 @@ docker compose up --build # containerised path - `src/playbooks/` — `PlaybookRunner` interface, `KeeperHubRunner`, notification channels - `src/risk-gate/` — Fastify `app.ts` and `server.ts` composition root - `src/cli/demo.ts` — four-canonical-scene CLI -- `tests/` — 112 specs across 13 files +- `tests/` — 114 specs across 13 files - `web/` — Astro 6 frontend (components, lib, pages, styles) - `docs/` — `submission.md` (judge one-pager), `demo-script.md`, `architecture.md`, `sponsors/` - `scripts/` — `kh.sh` (KeeperHub helper), `dev.sh` (parallel dev) @@ -97,7 +97,7 @@ Every PR to `main` and every push to `main` runs [`.github/workflows/ci.yml`](./ 2. Installs web deps with `bun install --frozen-lockfile` 3. `bun run typecheck:server` — `tsc --noEmit` 4. `bun run typecheck:web` — `astro check` -5. `bun test` — 112 specs +5. `bun test` — 114 specs 6. `bun run build:web` — Astro production build 7. Emoji scan — fails the build if any banned emoji byte sequence appears in tracked files diff --git a/README.md b/README.md index 9e8d9ef..7cd4313 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ | **API hot path** | `< 50 ms` (anchor uploads stream in the background) | | **Verdicts** | `ALLOW` / `REQUIRE_HUMAN_CONFIRMATION` / `BLOCK` | | **Decision ladder** | 5 deterministic rules + 1 heuristic ERC-20 simulator | -| **Test suite** | 112 specs / 13 files | +| **Test suite** | 114 specs / 13 files | | **Type safety** | `tsc --noEmit` + `astro check`, both zero-error, strict + `noUncheckedIndexedAccess` | | **Sponsors integrated** | 0G Storage, KeeperHub, Gensyn AXL, Discord webhooks | | **Lines of TypeScript (server)** | `~1,700` across `src/` | @@ -223,7 +223,7 @@ The Astro UI lands at ; the API health check at ; the API health check at { const existing = await this.store.getPolicy(id, clientId); - if (!existing) throw new Error(`Policy ${id} not found`); + if (!existing) throw new PolicyNotFoundError(id); const policy: Policy = { ...existing, owner: input.owner, diff --git a/src/risk-gate/app.ts b/src/risk-gate/app.ts index 4823b6d..b67ebc0 100644 --- a/src/risk-gate/app.ts +++ b/src/risk-gate/app.ts @@ -4,7 +4,7 @@ import { ZodError } from "zod"; import type { AnchorRecord, Store } from "../memory/store.js"; import { InMemoryStore } from "../memory/memoryStore.js"; import { DecisionEngine, type DecisionEngineOptions } from "../core/engine.js"; -import { PolicyService } from "../core/policyService.js"; +import { PolicyNotFoundError, PolicyService } from "../core/policyService.js"; import { evaluateRequestSchema, policyInputSchema } from "../core/schemas.js"; import type { Decision, Policy } from "../core/types.js"; import { HeuristicSimulator } from "../simulator/heuristic.js"; @@ -150,8 +150,11 @@ export function buildApp(deps: AppDeps = {}): FastifyInstance { try { return withAnchorPolicy(await policyService.update(id, parsed, cid)); } catch (err) { + if (!(err instanceof PolicyNotFoundError)) { + throw err; + } reply.status(404); - return { error: "NotFound", message: (err as Error).message }; + return { error: "NotFound", message: err.message }; } }); diff --git a/tests/README.md b/tests/README.md index 1b29038..3c26187 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,9 +1,9 @@ # `tests/` — unit + integration coverage -> 112 specs across 13 files. Every external dependency is faked at the trait boundary; live anchor hashes are pinned as test constants so the renderer is exercised against real chain data. +> 114 specs across 13 files. Every external dependency is faked at the trait boundary; live anchor hashes are pinned as test constants so the renderer is exercised against real chain data. ```sh -bun test # all 112 specs +bun test # all 114 specs bun test tests/engine.test.ts # one file bun test --watch # watch mode bun test --coverage # coverage report diff --git a/tests/api.test.ts b/tests/api.test.ts index 40b9d52..1bae924 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import { buildApp } from "../src/risk-gate/app.js"; +import type { PolicyService } from "../src/core/policyService.js"; import { TREASURY, COLD_VAULT, ATTACKER } from "./helpers.js"; describe("Risk-Gate API", () => { @@ -149,4 +150,50 @@ describe("Risk-Gate API", () => { expect(res.json().error).toBe("ValidationError"); await app.close(); }); + + it("returns 404 when updating an unknown policy", async () => { + const app = buildApp(); + const res = await app.inject({ + method: "PUT", + url: "/policies/missing", + payload: { + owner: TREASURY, + rules: { allowedDestinations: [COLD_VAULT] }, + }, + }); + + const body = res.json() as { error: string; message: string }; + expect(res.statusCode).toBe(404); + expect(body).toEqual({ + error: "NotFound", + message: "Policy missing not found", + }); + await app.close(); + }); + + it("does not report unexpected policy update failures as NotFound", async () => { + const policyService = { + update: async () => { + throw new Error("storage unavailable"); + }, + } as unknown as PolicyService; + const app = buildApp({ policyService }); + + const res = await app.inject({ + method: "PUT", + url: "/policies/policy-1", + payload: { + owner: TREASURY, + rules: { allowedDestinations: [COLD_VAULT] }, + }, + }); + + const body = res.json() as { error: string; message: string }; + expect(res.statusCode).toBe(500); + expect(body).toEqual({ + error: "InternalError", + message: "storage unavailable", + }); + await app.close(); + }); });