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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 # 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
Expand Down
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 114 specs across 13 files green. Live anchor verified on Galileo testnet.

## Stack (do not assume Rust/Solidity)

Expand All @@ -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 # 114 specs
bun run demo # CLI four-scene runner against the live API

bun run build # bundle server + Astro static output
Expand All @@ -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/` — 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)
Expand Down Expand Up @@ -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` — 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

Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** | 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/` |
Expand Down Expand Up @@ -223,15 +223,15 @@ The Astro UI lands at <http://localhost:4321>; the API health check at <http://l
| `bun run dev:web` | Just Astro |
| `bun run demo` | CLI runs four canonical scenes against the live API |
| `bun run typecheck` | `tsc --noEmit` (server) + `astro check` (web) |
| `bun test` | All 109 specs |
| `bun test` | All 114 specs |
| `bun run build` | Server bundle + Astro static output |
| `bun run clean` | Remove `dist`, `coverage`, `.tsbuildinfo`, `web/dist`, `web/.astro` |

---

## Test coverage

`109 specs / 13 files / 317 assertions / ~340 ms cold`
`114 specs / 13 files`

| File | What it covers |
|---|---|
Expand Down Expand Up @@ -264,11 +264,11 @@ Live anchor proofs are pinned as test constants in `tests/webFormat.test.ts` so
| `src/transport/` | `GossipTransport` interface, `AxlGossipTransport`, `NoopGossip` |
| `src/risk-gate/` | Fastify `app.ts` + `server.ts` composition root |
| `src/cli/` | `demo.ts` - four canonical scene runner |
| `tests/` | 109 specs across 13 files |
| `tests/` | 114 specs across 13 files |
| `web/` | Astro 6 frontend (separate Bun workspace) |
| `docs/` | `submission.md`, `demo-script.md`, `architecture.md`, `deploy.md`, `sponsors/` |
| `scripts/` | `kh.sh` (KeeperHub helper), `dev.sh` (parallel dev) |
| `.github/workflows/` | CI: install + dual typecheck + 109 specs + Astro build + emoji scan |
| `.github/workflows/` | CI: install + dual typecheck + 114 specs + Astro build + emoji scan |

---

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Design for the ChainShield product, mapped onto the sponsor APIs that ship in th
| Persistence — read path | In-memory cache (anchored writes are best-effort durability, not the read source) |
| Remediation execution | KeeperHub REST (`https://app.keeperhub.com/api/workflow/{id}/execute`) via `fetch` |
| Browser UI | Astro 6 at `web/` (vanilla TS, no React/Vue) |
| Tests | `bun:test` (90 specs across 10 files at the time of writing) |
| Tests | `bun:test` (114 specs across 13 files at the time of writing) |
| Containerization | Docker (`oven/bun:1`) |

## Current Module Map (`src/`)
Expand Down
16 changes: 12 additions & 4 deletions docs/submission.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@

## Progress

**Done** — Phases 1-6 shipped and merged to `main`. 90 specs across 10 files green, server `tsc --noEmit` and Astro `astro check` both clean, Astro production build succeeds. 0G anchor verified live on Galileo (rootHash + storage tx + block + gas all recorded below).
**Done** — Phases 1-6 shipped and merged to `main`. 114 specs across 13 files green, server `tsc --noEmit` and Astro `astro check` both clean, Astro production build succeeds. 0G anchor verified live on Galileo (rootHash + storage tx + block + gas all recorded below).

**Left** — record demo per [`./demo-script.md`](./demo-script.md), submit at the ETHGlobal portal.

**De-scoped** — 0G Compute (stretch), Gensyn AXL, Rust + Solidity port (post-hackathon).
**De-scoped** — 0G Compute (stretch), Rust + Solidity port (post-hackathon).

## What it does

Expand Down Expand Up @@ -71,14 +71,19 @@ Verify independently:
- HTML 404 pages and other non-JSON error bodies are scrubbed out of error messages so they never leak into the UI
- Helper script `scripts/kh.sh` provides `list/get/run/status/ping` subcommands for testing

### Gensyn AXL (wired)

- `src/transport/axlGossip.ts` publishes every `BLOCK` decision to the configured AXL bridge
- `src/risk-gate/server.ts` uses `AxlGossipTransport` when `AXL_BASE_URL` is set and `NoopGossip` otherwise
- Soft failures are logged and never block the verdict response

### Optional integrations

- **Discord** notifications: `WebhookChannel` posts an embed to a Discord webhook on every `BLOCK`. Set `NOTIFY_DISCORD_WEBHOOK` to enable.

### Stretch / not shipped

- **0G Compute (Inference)** — env stub present; `ZERO_G_INFERENCE_PROVIDER` is discovered at runtime, not yet wired
- **Gensyn AXL** — env stub orphaned, never integrated. De-scoped after timeline pivot to TypeScript.

## How the decision engine works

Expand Down Expand Up @@ -111,11 +116,14 @@ For the Astro frontend specifically:
## Test coverage

```
90 specs across 10 files · all green · ~280ms
114 specs across 13 files

tests/api.test.ts Risk-Gate API end-to-end
tests/apiAnchor.test.ts Anchor surfacing on policy + decision responses
(incl. real ZeroGStore + buildApp e2e)
tests/axlGossip.test.ts AXL gossip transport + no-op fallback
tests/clientIsolation.test.ts Per-browser isolation + invalid client-id rejection
tests/cors.test.ts WEB_ORIGIN allowlist + preview regex
tests/engine.test.ts 5-rule decision ladder + invalidIntentValue / invalidApprovalCap guards
tests/engineRemediation.test.ts Playbook trigger + notification fan-out
tests/engineSimulation.test.ts Simulator integration + revert escalation
Expand Down
2 changes: 1 addition & 1 deletion src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ flowchart LR

| | |
|---|---|
| Tests | [`../tests/`](../tests) — 109 specs across 13 files |
| Tests | [`../tests/`](../tests) — 114 specs across 13 files |
| Composition root | [`risk-gate/server.ts`](./risk-gate/server.ts) |
| Frontend | [`../web/`](../web) — Astro 6, separate Bun workspace |
| Conventions | [`../AGENTS.md`](../AGENTS.md) |
9 changes: 8 additions & 1 deletion src/core/policyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import type { Policy } from "./types.js";
import type { Store } from "../memory/store.js";
import { policyInputSchema, type PolicyInput } from "./schemas.js";

export class PolicyNotFoundError extends Error {
constructor(id: string) {
super(`Policy ${id} not found`);
this.name = "PolicyNotFoundError";
}
}

export class PolicyService {
constructor(
private readonly store: Store,
Expand All @@ -26,7 +33,7 @@ export class PolicyService {

async update(id: string, input: PolicyInput, clientId?: string): Promise<Policy> {
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,
Expand Down
45 changes: 39 additions & 6 deletions src/risk-gate/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -16,6 +16,15 @@ export interface AppDeps {
}

const DEFAULT_WEB_ORIGINS = ["http://127.0.0.1:4321", "http://localhost:4321"];
const CLIENT_ID_MAX_LENGTH = 128;
const CLIENT_ID_PATTERN = /^[A-Za-z0-9._:-]+$/;

class InvalidClientIdError extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidClientIdError";
}
}

/**
* Parse a single comma-separated `WEB_ORIGIN` entry. Entries surrounded by
Expand Down Expand Up @@ -82,6 +91,10 @@ export function buildApp(deps: AppDeps = {}): FastifyInstance {
reply.status(400).send({ error: "ValidationError", issues: err.issues });
return;
}
if (err instanceof InvalidClientIdError) {
reply.status(400).send({ error: "InvalidClientId", message: err.message });
return;
}
const message = err instanceof Error ? err.message : String(err);
reply.status(500).send({ error: "InternalError", message });
});
Expand All @@ -98,13 +111,29 @@ export function buildApp(deps: AppDeps = {}): FastifyInstance {
* Requests without the header (curl, the demo CLI, integration tests) get
* `undefined` and the Store reverts to "no filter" — i.e. global view —
* which is the legacy behaviour and is still useful for admin / debug.
* Requests with a present-but-invalid header are rejected instead of being
* treated as admin, so malformed browser traffic cannot bypass isolation.
*/
function clientIdOf(req: { headers: Record<string, unknown> }): 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;
}

Expand All @@ -117,11 +146,15 @@ 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) {
if (!(err instanceof PolicyNotFoundError)) {
throw err;
}
reply.status(404);
return { error: "NotFound", message: (err as Error).message };
return { error: "NotFound", message: err.message };
}
});

Expand Down
4 changes: 2 additions & 2 deletions tests/README.md
Original file line number Diff line number Diff line change
@@ -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.
> 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 109 specs
bun test # all 114 specs
bun test tests/engine.test.ts # one file
bun test --watch # watch mode
bun test --coverage # coverage report
Expand Down
47 changes: 47 additions & 0 deletions tests/api.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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();
});
});
Loading