diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15d34b1..8d84323 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,3 +43,27 @@ jobs: - name: pytest working-directory: sdk/python run: python -m pytest -v + + typescript: + name: TypeScript tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: sdk/typescript/package-lock.json + + - name: npm ci + working-directory: sdk/typescript + run: npm ci + + - name: build + working-directory: sdk/typescript + run: npm run build + + - name: unit tests + working-directory: sdk/typescript + run: npm test --silent diff --git a/README.md b/README.md index 9224af1..f0aa985 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,14 @@ make build && ./bin/acf-sidecar pip install -e sdk/python ``` +### 3b. Build the TypeScript SDK + +```bash +cd sdk/typescript +npm ci +npm run build +``` + ### 4. Send your first request ```python @@ -200,6 +208,16 @@ cd sidecar && go test ./... -v # Python unit tests cd sdk/python && python -m pytest -v +# TypeScript unit tests +cd sdk/typescript && npm test + +# TypeScript sidecar integration tests +cd sdk/typescript && npm run test:e2e +# Prerequisites: export ACF_HMAC_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))") and start the sidecar in a separate shell (see step 2 above) + +# TypeScript/Python parity tests (against live sidecar) +cd sdk/typescript && npm run test:parity + # Or both via make (from repo root) make test # Go tests make sdk-test-python # Python tests @@ -224,7 +242,7 @@ acf-sdk/ ├── sidecar/ Go enforcement kernel (PDP) ├── sdk/ │ ├── python/ Python SDK v1 — zero external dependencies -│ └── typescript/ TypeScript SDK v2 — deferred until v1 is stable +│ └── typescript/ TypeScript SDK v1 — frame/transport/firewall complete ├── policies/v1/ Rego policies + data ├── tests/integration/ 33-payload adversarial test suite ├── config/ Sidecar configuration @@ -263,6 +281,7 @@ See [PHILOSOPHY.md](PHILOSOPHY.md) for the full design rationale. The short vers - [Phase 1](docs/phase1.md) — wire protocol and crypto implementation - [Phase 2](docs/phase2.md) — pipeline stages, strict_mode switch, scoring - [Crypto](docs/crypto.md) — HMAC signing and nonce replay protection +- [TypeScript SDK](sdk/typescript/README.md) — usage, test commands, troubleshooting - [Policy authoring](docs/policy-authoring.md) — how to write and test Rego policies - [Philosophy](PHILOSOPHY.md) — design principles and threat model rationale diff --git a/sdk/python/acf/firewall.py b/sdk/python/acf/firewall.py index 672739a..5f43108 100644 --- a/sdk/python/acf/firewall.py +++ b/sdk/python/acf/firewall.py @@ -143,7 +143,7 @@ def _build_payload( "payload": content, "state": None, } - return json.dumps(ctx, separators=(",", ":")).encode("utf-8") + return json.dumps(ctx, separators=(",", ":"), sort_keys=True).encode("utf-8") def _send(self, payload: bytes) -> Decision | SanitiseResult: resp = self._transport.send(payload) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md new file mode 100644 index 0000000..adafcff --- /dev/null +++ b/sdk/typescript/README.md @@ -0,0 +1,95 @@ +# ACF TypeScript SDK + +TypeScript client for ACF-SDK (Agentic Cognitive Firewall). + +This SDK sends signed risk-context payloads over IPC to the Go sidecar and +returns enforcement decisions: + +- `ALLOW` +- `SANITISE` +- `BLOCK` + +## Prerequisites + +- Node.js 18+ +- Go sidecar built and running + +## Install + +From `sdk/typescript`: + +```bash +npm ci +npm run build +``` + +## Configuration + +The firewall requires a shared HMAC key: + +```bash +export ACF_HMAC_KEY=<64-hex-char-key> +``` + +Optional custom socket path: + +```bash +export ACF_SOCKET_PATH=/tmp/acf.sock +``` + +## Quick Start + +```ts +import { Firewall, Decision } from "@acf-sdk/acf" + +const fw = new Firewall() +const result = await fw.onPrompt("hello world") + +if (result === Decision.BLOCK) { + throw new Error("Blocked by firewall") +} +``` + +## Test Commands + +From `sdk/typescript`: + +```bash +# Unit tests (fast, default) +npm test + +# Live sidecar E2E tests +npm run test:e2e + +# TypeScript/Python parity tests against live sidecar +npm run test:parity +``` + +## Troubleshooting + +### `No HMAC key provided` + +Set `ACF_HMAC_KEY` or pass key bytes directly: + +```ts +const fw = new Firewall(undefined, Buffer.from("", "hex")) +``` + +### Sidecar not reachable + +- Ensure sidecar is running. +- Ensure `ACF_SOCKET_PATH` matches both sidecar and SDK. +- Ensure SDK and sidecar use the same `ACF_HMAC_KEY`. + +### E2E tests skipped in sandboxed environments + +Some environments disallow Unix socket bind/listen. E2E/parity tests will skip +with a clear reason when this restriction is detected. + +## Compatibility Contract + +TypeScript and Python SDKs must remain wire-compatible: + +- canonical JSON signing +- identical frame structure +- decision-level parity for shared scenarios diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json new file mode 100644 index 0000000..0f01e4e --- /dev/null +++ b/sdk/typescript/package-lock.json @@ -0,0 +1,50 @@ +{ + "name": "@acf-sdk/acf", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@acf-sdk/acf", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^25.5.2", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 062e76e..4258a28 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -6,13 +6,15 @@ "types": "dist/index.d.ts", "scripts": { "build": "tsc", - "test": "node --test tests/" + "test": "npm run build && node --test dist/tests/*.test.js", + "test:e2e": "npm run build && node --test dist/tests/integration.e2e.js", + "test:parity": "npm run build && node --test dist/tests/parity.e2e.js" }, "engines": { "node": ">=18" }, - "dependencies": {}, "devDependencies": { - "typescript": "^5.4.0" + "@types/node": "^25.5.2", + "typescript": "^5.9.3" } } diff --git a/sdk/typescript/src/firewall.ts b/sdk/typescript/src/firewall.ts index df0bc99..11b77e7 100644 --- a/sdk/typescript/src/firewall.ts +++ b/sdk/typescript/src/firewall.ts @@ -5,3 +5,177 @@ * onToolCall(name: string, params: Record): Promise * onMemory(key: string, value: string, op: string): Promise */ + +import { + ChunkResult, + Decision, + FirewallError, + SanitiseResult, + decisionFromByte, +} from "./models.js" +import { type ResponseFrame } from "./frame.js" +import { Transport } from "./transport.js" + +type HookType = "on_prompt" | "on_context" | "on_tool_call" | "on_memory" +type Provenance = "user" | "rag" | "agent" + +interface RiskContext { + score: number + signals: string[] + provenance: Provenance + session_id: string + hook_type: HookType + payload: unknown + state: null +} + +type TransportLike = { + send(payload: Buffer): Promise +} + +type TransportFactory = (socketPath?: string, key?: Buffer) => TransportLike + +function resolveKey(inputKey?: Buffer): Buffer { + if (inputKey) { + return Buffer.from(inputKey) + } + + const raw = process.env.ACF_HMAC_KEY || "" + if (raw.length === 0) { + throw new FirewallError( + "No HMAC key provided. Pass hmacKey or set ACF_HMAC_KEY (hex-encoded).", + ) + } + + if (raw.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(raw)) { + throw new FirewallError("ACF_HMAC_KEY is not valid hex") + } + + return Buffer.from(raw, "hex") +} + +function isSanitiseResult( + value: Decision | SanitiseResult, +): value is SanitiseResult { + return typeof value === "object" && value !== null +} + +export class Firewall { + private readonly transport: TransportLike + + constructor( + socketPath?: string, + hmacKey?: Buffer, + transportFactory: TransportFactory = (path, key) => new Transport(path, key), + ) { + const key = resolveKey(hmacKey) + this.transport = transportFactory(socketPath, key) + } + + async onPrompt(text: string): Promise { + if (typeof text !== "string") { + throw new FirewallError("onPrompt expects a string payload") + } + const payload = this.buildPayload("on_prompt", text, "user") + return this.send(payload) + } + + async onContext(chunks: string[]): Promise { + if (!Array.isArray(chunks)) { + throw new FirewallError("onContext expects an array of strings") + } + + const out: ChunkResult[] = [] + for (const chunk of chunks) { + if (typeof chunk !== "string") { + throw new FirewallError("onContext expects an array of strings") + } + const payload = this.buildPayload("on_context", chunk, "rag") + const decision = await this.send(payload) + if (isSanitiseResult(decision)) { + out.push({ + original: chunk, + decision: Decision.SANITISE, + sanitisedText: decision.sanitisedText, + }) + } else { + out.push({ + original: chunk, + decision, + sanitisedText: null, + }) + } + } + return out + } + + async onToolCall( + name: string, + params: Record, + ): Promise { + if (typeof name !== "string") { + throw new FirewallError("onToolCall expects name to be a string") + } + if (!params || typeof params !== "object" || Array.isArray(params)) { + throw new FirewallError("onToolCall expects params to be an object") + } + const payload = this.buildPayload( + "on_tool_call", + { name, params }, + "agent", + ) + return this.send(payload) + } + + async onMemory( + key: string, + value: string, + op: string = "write", + ): Promise { + if (typeof key !== "string" || typeof value !== "string") { + throw new FirewallError("onMemory expects key and value to be strings") + } + if (typeof op !== "string") { + throw new FirewallError("onMemory expects op to be a string") + } + const payload = this.buildPayload( + "on_memory", + { key, value, op }, + "agent", + ) + return this.send(payload) + } + + private buildPayload( + hookType: HookType, + content: unknown, + provenance: Provenance, + sessionId: string = "", + ): Buffer { + const ctx: RiskContext = { + score: 0.0, + signals: [], + provenance, + session_id: sessionId, + hook_type: hookType, + payload: content, + state: null, + } + return Buffer.from(JSON.stringify(ctx), "utf-8") + } + + private async send(payload: Buffer): Promise { + const resp = await this.transport.send(payload) + const decision = decisionFromByte(resp.decision) + + if (decision === Decision.SANITISE) { + const raw = Buffer.from(resp.sanitisedPayload) + return { + decision: Decision.SANITISE, + sanitisedPayload: raw, + sanitisedText: raw.length > 0 ? raw.toString("utf-8") : null, + } + } + return decision + } +} diff --git a/sdk/typescript/src/frame.ts b/sdk/typescript/src/frame.ts index 293164e..e242543 100644 --- a/sdk/typescript/src/frame.ts +++ b/sdk/typescript/src/frame.ts @@ -1,5 +1,201 @@ + /** * Binary frame encoder/decoder using Node.js Buffer. - * Mirrors sidecar/internal/transport/frame.go and sdk/python/acf/frame.py exactly. - * Same 54-byte header + payload request format, same response format. + * Mirrors sidecar/internal/transport/frame.go and sdk/python/acf/frame.py. + * + * Request frame layout (54-byte header + payload): + * [0] magic — 0xAC + * [1] version — 1 + * [2:6] length — uint32 big-endian + * [6:22] nonce — 16 random bytes + * [22:54] hmac — 32 bytes HMAC-SHA256 over signedMessage(...) + * [54:] payload — JSON bytes + * + * Response frame layout: + * [0] decision — 0x00 ALLOW · 0x01 SANITISE · 0x02 BLOCK + * [1:5] san_len — uint32 big-endian (0 if not SANITISE) + * [5:] sanitised — JSON bytes (SANITISE only) */ + +import { createHmac, randomBytes } from "node:crypto" + +export const MAGIC = 0xAC +export const VERSION = 0x01 +export const HEADER_SIZE = 54 // 1 + 1 + 4 + 16 + 32 + +export interface RequestFrame { + readonly version: number + readonly nonce: Buffer + readonly hmac: Buffer + readonly payload: Buffer +} + +export interface ResponseFrame { + readonly decision: number + readonly sanitisedPayload: Buffer +} + +export class FrameError extends Error { + constructor(message: string) { + super(message) + this.name = "FrameError" + if ((Error as any).captureStackTrace) { + (Error as any).captureStackTrace(this, FrameError) + } + } +} + +export class CanonicaliseError extends FrameError { + constructor(message: string) { + super(message) + this.name = "CanonicaliseError" + if ((Error as any).captureStackTrace) { + (Error as any).captureStackTrace(this, CanonicaliseError) + } + } +} + +/** + * Recursively sort object keys at every nesting level. + * Arrays preserve order. Primitives pass through. + */ +export function sortKeysRecursive(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => sortKeysRecursive(item)) + } + + if (value !== null && typeof value === "object") { + const out: Record = {} + const entries = Object.entries(value as Record) + entries + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .forEach(([k, v]) => { + out[k] = sortKeysRecursive(v) + }) + return out + } + + // JSON.parse already resolves \uXXXX escapes to the literal code point. + // Keep primitive values unchanged for parity with Python/Go JSON handling. + return value +} + +/** + * Parse, recursively canonicalise (sorted keys), and compact-encode JSON. + */ +export function canonicalPayload(payload: Buffer): Buffer { + let parsed: unknown + try { + parsed = JSON.parse(payload.toString("utf-8")) + } catch (err) { + const reason = err instanceof Error ? err.message : String(err) + throw new CanonicaliseError(`invalid JSON payload: ${reason}`) + } + + const canonical = sortKeysRecursive(parsed) + return Buffer.from(JSON.stringify(canonical), "utf-8") +} + +/** + * version(1B) || length(4B BE) || nonce(16B) || payload + */ +export function signedMessage( + version: number, + length: number, + nonce: Buffer, + payload: Buffer, +): Buffer { + if (nonce.length !== 16) { + throw new FrameError(`nonce must be 16 bytes, got ${nonce.length}`) + } + + const header = Buffer.allocUnsafe(21) + header.writeUInt8(version, 0) + header.writeUInt32BE(length, 1) + nonce.copy(header, 5) + return Buffer.concat([header, payload], 21 + payload.length) +} + +export function encodeRequest(payload: Buffer, key: Buffer): Buffer { + const canonical = canonicalPayload(payload) + const nonce = randomBytes(16) + const length = canonical.length + const msg = signedMessage(VERSION, length, nonce, canonical) + const mac = createHmac("sha256", key).update(msg).digest() + + const header = Buffer.allocUnsafe(HEADER_SIZE) + header.writeUInt8(MAGIC, 0) + header.writeUInt8(VERSION, 1) + header.writeUInt32BE(length, 2) + nonce.copy(header, 6) + mac.copy(header, 22) + return Buffer.concat([header, canonical], HEADER_SIZE + canonical.length) +} + +export function decodeRequest(data: Buffer): RequestFrame { + if (data.length < HEADER_SIZE) { + throw new FrameError( + `truncated frame: got ${data.length} bytes, need at least ${HEADER_SIZE}` + ) + } + + const magic = data.readUInt8(0) + if (magic !== MAGIC) { + throw new FrameError( + `bad magic byte: got 0x${magic.toString(16).padStart(2, "0")}, want 0x${MAGIC.toString(16)}` + ) + } + + const version = data.readUInt8(1) + if (version !== VERSION) { + throw new FrameError(`unsupported version: ${version}`) + } + + const length = data.readUInt32BE(2) + const end = HEADER_SIZE + length + if (data.length < end) { + throw new FrameError( + `truncated payload: got ${data.length - HEADER_SIZE} bytes, want ${length}` + ) + } + + return { + version, + nonce: Buffer.from(data.subarray(6, 22)), + hmac: Buffer.from(data.subarray(22, 54)), + payload: Buffer.from(data.subarray(54, end)), + } +} + +export function encodeResponse(decision: number, sanitised: Buffer = Buffer.alloc(0)): Buffer { + const sanLen = decision === 0x01 ? sanitised.length : 0 + const header = Buffer.allocUnsafe(5) + header.writeUInt8(decision, 0) + header.writeUInt32BE(sanLen, 1) + if (sanLen === 0) { + return header + } + return Buffer.concat([header, sanitised.subarray(0, sanLen)], 5 + sanLen) +} + +export function decodeResponse(data: Buffer): ResponseFrame { + if (data.length < 5) { + throw new FrameError( + `truncated response: got ${data.length} bytes, need at least 5` + ) + } + + const decision = data.readUInt8(0) + const sanLen = data.readUInt32BE(1) + const end = 5 + sanLen + if (data.length < end) { + throw new FrameError( + `truncated sanitised payload: got ${data.length - 5} bytes, want ${sanLen}` + ) + } + + return { + decision, + sanitisedPayload: sanLen > 0 ? Buffer.from(data.subarray(5, end)) : Buffer.alloc(0), + } +} diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 11e404b..82c5c63 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -1,3 +1,17 @@ /** * Barrel exports for the ACF TypeScript SDK (v2 — deferred). */ + +export { Firewall } from "./firewall.js" + +export { + Decision, + FirewallConnectionError, + FirewallError, + decisionFromByte, +} from "./models.js" + +export type { + ChunkResult, + SanitiseResult, +} from "./models.js" diff --git a/sdk/typescript/src/models.ts b/sdk/typescript/src/models.ts index 56c1f38..803396c 100644 --- a/sdk/typescript/src/models.ts +++ b/sdk/typescript/src/models.ts @@ -3,3 +3,93 @@ * Decision, SanitiseResult, ChunkResult types mirror the Python SDK models. * Same wire protocol as Python v1. */ +/** + * models.ts + * ========= + * Data models for the ACF TypeScript SDK. + * Mirrors sdk/python/acf/models.py exactly. + * + * Decision byte values match the wire protocol defined in + * sidecar/internal/transport/frame.go — never change these values. + */ + +// ── Decision ────────────────────────────────────────────────────────────────── + +/** + * The three possible enforcement outcomes returned by all hook calls. + * Byte values match the wire protocol exactly. + */ +export enum Decision { + ALLOW = 0x00, + SANITISE = 0x01, + BLOCK = 0x02, +} + +/** + * Parse a raw response byte into a Decision. + * Throws RangeError for any unrecognised byte. + */ +export function decisionFromByte(b: number): Decision { + switch (b) { + case 0x00: return Decision.ALLOW + case 0x01: return Decision.SANITISE + case 0x02: return Decision.BLOCK + default: + throw new RangeError( + `Unknown decision byte: 0x${b.toString(16).padStart(2, "0")}` + ) + } +} + +// ── Result types ────────────────────────────────────────────────────────────── + +/** + * Returned when the sidecar decides SANITISE. + * Use sanitisedText in place of the original input. + */ +export interface SanitiseResult { + readonly decision: Decision.SANITISE + readonly sanitisedPayload: Uint8Array + readonly sanitisedText: string | null +} + +/** + * Per-chunk result returned by Firewall.onContext(). + * One ChunkResult is returned per input chunk. + */ +export interface ChunkResult { + readonly original: string + readonly decision: Decision + readonly sanitisedText: string | null +} + +// ── Errors ──────────────────────────────────────────────────────────────────── + +/** + * Base error class for all ACF SDK errors. + * Catch this to handle any SDK failure in one place. + */ +export class FirewallError extends Error { + constructor(message: string) { + super(message) + this.name = "FirewallError" + // Maintains proper stack trace in V8 + if ((Error as any).captureStackTrace) { + (Error as any).captureStackTrace(this, FirewallError) + } + } +} + +/** + * Raised when the transport cannot connect to the sidecar after all retries. + * Subclasses FirewallError — callers catching FirewallError see this too. + */ +export class FirewallConnectionError extends FirewallError { + constructor(message: string) { + super(message) + this.name = "FirewallConnectionError" + if ((Error as any).captureStackTrace) { + (Error as any).captureStackTrace(this, FirewallConnectionError) + } + } +} \ No newline at end of file diff --git a/sdk/typescript/src/transport.ts b/sdk/typescript/src/transport.ts index c78aa4f..f6fba03 100644 --- a/sdk/typescript/src/transport.ts +++ b/sdk/typescript/src/transport.ts @@ -1,5 +1,160 @@ /** - * UDS client transport using net.createConnection. - * Async/await interface. Signs payloads with HMAC-SHA256 (Node crypto module). - * Zero external dependencies — Node stdlib only. + * IPC client transport for the ACF TypeScript SDK. + * + * Responsibilities: + * - Sign each payload with HMAC-SHA256 + per-request nonce via frame.encodeRequest + * - Send one request over one IPC connection + * - Read exactly one response frame and decode it + * - Retry on transient sidecar availability errors (ECONNREFUSED, ENOENT) */ + +import net from "node:net" + +import { decodeResponse, encodeRequest, type ResponseFrame } from "./frame.js" +import { FirewallConnectionError, FirewallError } from "./models.js" + +const IS_WINDOWS = process.platform === "win32" + +export const DEFAULT_SOCKET_PATH = IS_WINDOWS ? "\\\\.\\pipe\\acf" : "/tmp/acf.sock" +export const MAX_ATTEMPTS = 3 +export const BACKOFF_BASE_MS = 100 + +type NodeErrno = Error & { code?: string } +type Dialer = (path: string) => net.Socket + +function isTransientConnectError(err: unknown): err is NodeErrno { + if (!err || typeof err !== "object") { + return false + } + const code = (err as NodeErrno).code + return code === "ECONNREFUSED" || code === "ENOENT" +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export class Transport { + readonly socketPath: string + readonly key: Buffer + private readonly dialer: Dialer + + constructor( + socketPath?: string, + key: Buffer = Buffer.alloc(0), + dialer: Dialer = (path) => net.createConnection(path), + ) { + this.socketPath = socketPath || process.env.ACF_SOCKET_PATH || DEFAULT_SOCKET_PATH + this.key = key + this.dialer = dialer + } + + async send(payload: Buffer): Promise { + const frame = encodeRequest(payload, this.key) + let delay = BACKOFF_BASE_MS + let lastErr: NodeErrno | null = null + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + try { + const raw = await this.connectAndSend(frame) + return decodeResponse(raw) + } catch (err) { + if (isTransientConnectError(err)) { + lastErr = err + if (attempt < MAX_ATTEMPTS) { + await sleep(delay) + delay *= 2 + continue + } + break + } + throw this.asFirewallError(err, "transport request failed") + } + } + + throw new FirewallConnectionError( + `Could not connect to sidecar at ${this.socketPath} after ${MAX_ATTEMPTS} attempts: ${lastErr?.message ?? "unknown error"}` + ) + } + + protected async connectAndSend(frame: Buffer): Promise { + return new Promise((resolve, reject) => { + const socket = this.dialer(this.socketPath) + const chunks: Buffer[] = [] + let total = 0 + let expectedLength: number | null = null + let settled = false + + const fail = (err: unknown): void => { + if (settled) { + return + } + settled = true + socket.destroy() + reject(err) + } + + const succeed = (buf: Buffer): void => { + if (settled) { + return + } + settled = true + socket.destroy() + resolve(buf) + } + + socket.once("error", (err) => fail(err)) + + socket.once("connect", () => { + socket.write(frame, (err?: Error | null) => { + if (err) { + fail(err) + } + }) + }) + + socket.on("data", (chunk: Buffer) => { + if (settled) { + return + } + + chunks.push(chunk) + total += chunk.length + + if (expectedLength === null && total >= 5) { + const merged = Buffer.concat(chunks, total) + expectedLength = 5 + merged.readUInt32BE(1) + chunks.length = 0 + chunks.push(merged) + total = merged.length + } + + if (expectedLength !== null && total >= expectedLength) { + const merged = Buffer.concat(chunks, total) + succeed(Buffer.from(merged.subarray(0, expectedLength))) + } + }) + + socket.once("close", () => { + if (settled) { + return + } + fail( + new FirewallError( + `connection closed before full response was received (got ${total} bytes)` + ) + ) + }) + }) + } + + private asFirewallError(err: unknown, context: string): FirewallError { + if (err instanceof FirewallError) { + return err + } + if (err instanceof Error) { + return new FirewallError(`${context}: ${err.message}`) + } + return new FirewallError(`${context}: ${String(err)}`) + } +} diff --git a/sdk/typescript/tests/e2e_helpers.ts b/sdk/typescript/tests/e2e_helpers.ts new file mode 100644 index 0000000..a627b7d --- /dev/null +++ b/sdk/typescript/tests/e2e_helpers.ts @@ -0,0 +1,180 @@ +import { spawn, spawnSync, type ChildProcess } from "node:child_process" +import { existsSync } from "node:fs" +import path from "node:path" +import os from "node:os" + +import { Decision, type SanitiseResult } from "../src/models.js" +import { Firewall } from "../src/firewall.js" + +type SidecarHandle = { + proc: ChildProcess + logs: string[] +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export function findRepoRoot(): string { + let cursor = process.cwd() + for (let i = 0; i < 10; i += 1) { + if ( + existsSync(path.join(cursor, "sidecar")) && + existsSync(path.join(cursor, "sdk")) + ) { + return cursor + } + const parent = path.dirname(cursor) + if (parent === cursor) { + break + } + cursor = parent + } + throw new Error(`Could not locate repo root from ${cursor}`) +} + +export function sidecarBinaryPath(repoRoot: string): string { + const exe = process.platform === "win32" ? "acf-sidecar.exe" : "acf-sidecar" + return process.env.ACF_SIDECAR_BIN || path.join(repoRoot, "bin", exe) +} + +export function makeSocketPath(label: string): string { + if (process.platform === "win32") { + return `\\\\.\\pipe\\acf_ts_${label}_${process.pid}_${Date.now()}` + } + return path.join( + os.tmpdir(), + `acf_ts_${label}_${process.pid}_${Date.now()}.sock`, + ) +} + +export function ensureSidecarBinary(repoRoot: string): { ok: true } | { ok: false; reason: string } { + const binPath = sidecarBinaryPath(repoRoot) + if (existsSync(binPath)) { + return { ok: true } + } + + const build = spawnSync( + "go", + ["build", "-o", binPath, "./cmd/sidecar"], + { + cwd: path.join(repoRoot, "sidecar"), + env: { + ...process.env, + GOCACHE: process.env.GOCACHE || path.join(os.tmpdir(), "acf-go-build-cache"), + }, + encoding: "utf-8", + }, + ) + + if (build.status !== 0 || !existsSync(binPath)) { + return { + ok: false, + reason: `unable to build sidecar binary at ${binPath}: ${build.stderr || build.stdout || "unknown build error"}`, + } + } + return { ok: true } +} + +export function hasPython3(): boolean { + const out = spawnSync("python3", ["--version"], { encoding: "utf-8" }) + return out.status === 0 +} + +export function startSidecar(params: { + repoRoot: string + keyHex: string + socketPath: string +}): SidecarHandle { + const binPath = sidecarBinaryPath(params.repoRoot) + const logs: string[] = [] + const proc = spawn( + binPath, + [], + { + env: { + ...process.env, + ACF_HMAC_KEY: params.keyHex, + ACF_SOCKET_PATH: params.socketPath, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ) + + proc.stdout.on("data", (d) => logs.push(String(d))) + proc.stderr.on("data", (d) => logs.push(String(d))) + return { proc, logs } +} + +export async function stopSidecar(handle: SidecarHandle): Promise { + const { proc } = handle + if (proc.exitCode !== null) { + return + } + + const closed = new Promise((resolve) => { + proc.once("close", () => resolve()) + }) + proc.kill("SIGTERM") + + const timeout = sleep(1500).then(() => { + if (proc.exitCode === null) { + proc.kill("SIGKILL") + } + }) + await Promise.race([closed, timeout]) + await closed +} + +export async function waitForSidecarReady(params: { + socketPath: string + keyHex: string + sidecar: SidecarHandle + timeoutMs?: number +}): Promise { + const timeoutMs = params.timeoutMs ?? 5000 + const fw = new Firewall(params.socketPath, Buffer.from(params.keyHex, "hex")) + const deadline = Date.now() + timeoutMs + + while (Date.now() < deadline) { + if (params.sidecar.proc.exitCode !== null) { + throw new Error( + `sidecar exited early with code ${params.sidecar.proc.exitCode}: ${params.sidecar.logs.join("")}`, + ) + } + try { + await fw.onPrompt("acf-ready-check") + return + } catch { + await sleep(50) + } + } + throw new Error(`sidecar readiness timeout after ${timeoutMs}ms: ${params.sidecar.logs.join("")}`) +} + +export function isSocketBindPermissionError(err: unknown): boolean { + if (!(err instanceof Error)) { + return false + } + const msg = err.message.toLowerCase() + return ( + msg.includes("bind: operation not permitted") || + msg.includes("failed to create listener") || + msg.includes("listen unix") + ) +} + +export function normalizeDecision( + result: Decision | SanitiseResult, +): "ALLOW" | "SANITISE" | "BLOCK" { + if (typeof result === "object") { + return "SANITISE" + } + if (result === Decision.ALLOW) { + return "ALLOW" + } + if (result === Decision.BLOCK) { + return "BLOCK" + } + return "SANITISE" +} diff --git a/sdk/typescript/tests/firewall.test.ts b/sdk/typescript/tests/firewall.test.ts new file mode 100644 index 0000000..8956d07 --- /dev/null +++ b/sdk/typescript/tests/firewall.test.ts @@ -0,0 +1,416 @@ +/// + +import { afterEach, describe, it } from "node:test" +import assert from "node:assert/strict" + +import { Firewall } from "../src/firewall.js" +import { Decision, FirewallConnectionError, FirewallError } from "../src/models.js" +import type { ResponseFrame } from "../src/frame.js" + +class FakeTransport { + readonly seenPayloads: Buffer[] = [] + private readonly queue: Array<() => Promise> + + constructor(queue: Array<() => Promise>) { + this.queue = queue + } + + async send(payload: Buffer): Promise { + this.seenPayloads.push(Buffer.from(payload)) + const next = this.queue.shift() + if (!next) { + throw new Error("no scripted response") + } + return next() + } +} + +type FactoryCapture = { + socketPath?: string + key?: Buffer + transport: FakeTransport +} + +function makeFactory( + queue: Array<() => Promise>, +): { capture: FactoryCapture; factory: (socketPath?: string, key?: Buffer) => FakeTransport } { + const capture: FactoryCapture = { + socketPath: undefined, + key: undefined, + transport: new FakeTransport(queue), + } + + return { + capture, + factory: (socketPath?: string, key?: Buffer) => { + capture.socketPath = socketPath + capture.key = key ? Buffer.from(key) : undefined + return capture.transport + }, + } +} + +const ORIGINAL_KEY = process.env.ACF_HMAC_KEY +afterEach(() => { + if (ORIGINAL_KEY === undefined) { + delete process.env.ACF_HMAC_KEY + } else { + process.env.ACF_HMAC_KEY = ORIGINAL_KEY + } +}) + +describe("Firewall constructor", () => { + it("uses provided hmacKey over env value", () => { + process.env.ACF_HMAC_KEY = "aaaaaaaa" + const { capture, factory } = makeFactory([ + async () => ({ decision: 0x00, sanitisedPayload: Buffer.alloc(0) }), + ]) + + const provided = Buffer.from("00112233", "hex") + new Firewall("/tmp/acf.sock", provided, factory) + + assert.strictEqual(capture.socketPath, "/tmp/acf.sock") + assert.deepStrictEqual(capture.key, provided) + }) + + it("reads ACF_HMAC_KEY when key not provided", () => { + process.env.ACF_HMAC_KEY = "0011223344556677" + const { capture, factory } = makeFactory([ + async () => ({ decision: 0x00, sanitisedPayload: Buffer.alloc(0) }), + ]) + + new Firewall(undefined, undefined, factory) + + assert.deepStrictEqual(capture.key, Buffer.from("0011223344556677", "hex")) + }) + + it("throws FirewallError when no key is available", () => { + delete process.env.ACF_HMAC_KEY + const { factory } = makeFactory([]) + assert.throws( + () => new Firewall(undefined, undefined, factory), + FirewallError, + ) + }) + + it("throws FirewallError when env key is invalid hex", () => { + process.env.ACF_HMAC_KEY = "zzzz" + const { factory } = makeFactory([]) + assert.throws( + () => new Firewall(undefined, undefined, factory), + FirewallError, + ) + }) +}) + +describe("Firewall hook payloads and decisions", () => { + it("onPrompt sends on_prompt payload and returns ALLOW", async () => { + const { capture, factory } = makeFactory([ + async () => ({ decision: 0x00, sanitisedPayload: Buffer.alloc(0) }), + ]) + const fw = new Firewall( + undefined, + Buffer.from("0011223344556677", "hex"), + factory, + ) + + const result = await fw.onPrompt("hello") + assert.strictEqual(result, Decision.ALLOW) + assert.strictEqual(capture.transport.seenPayloads.length, 1) + + const payload = JSON.parse(capture.transport.seenPayloads[0].toString("utf-8")) + assert.strictEqual(payload.hook_type, "on_prompt") + assert.strictEqual(payload.provenance, "user") + assert.strictEqual(payload.payload, "hello") + assert.strictEqual(payload.score, 0) + assert.deepStrictEqual(payload.signals, []) + assert.strictEqual(payload.state, null) + }) + + it("onToolCall sends structured payload and returns BLOCK", async () => { + const { capture, factory } = makeFactory([ + async () => ({ decision: 0x02, sanitisedPayload: Buffer.alloc(0) }), + ]) + const fw = new Firewall( + undefined, + Buffer.from("0011223344556677", "hex"), + factory, + ) + + const result = await fw.onToolCall("search", { q: "karachi weather" }) + assert.strictEqual(result, Decision.BLOCK) + + const payload = JSON.parse(capture.transport.seenPayloads[0].toString("utf-8")) + assert.strictEqual(payload.hook_type, "on_tool_call") + assert.strictEqual(payload.provenance, "agent") + assert.deepStrictEqual(payload.payload, { + name: "search", + params: { q: "karachi weather" }, + }) + }) + + it("onMemory defaults op to write", async () => { + const { capture, factory } = makeFactory([ + async () => ({ decision: 0x00, sanitisedPayload: Buffer.alloc(0) }), + ]) + const fw = new Firewall( + undefined, + Buffer.from("0011223344556677", "hex"), + factory, + ) + + await fw.onMemory("pref", "dark") + + const payload = JSON.parse(capture.transport.seenPayloads[0].toString("utf-8")) + assert.strictEqual(payload.hook_type, "on_memory") + assert.deepStrictEqual(payload.payload, { + key: "pref", + value: "dark", + op: "write", + }) + }) + + it("onMemory includes explicit op when provided", async () => { + const { capture, factory } = makeFactory([ + async () => ({ decision: 0x00, sanitisedPayload: Buffer.alloc(0) }), + ]) + const fw = new Firewall( + undefined, + Buffer.from("0011223344556677", "hex"), + factory, + ) + + await fw.onMemory("pref", "dark", "read") + + const payload = JSON.parse(capture.transport.seenPayloads[0].toString("utf-8")) + assert.deepStrictEqual(payload.payload, { + key: "pref", + value: "dark", + op: "read", + }) + }) + + it("maps SANITISE response to SanitiseResult", async () => { + const body = Buffer.from("safe content") + const { factory } = makeFactory([ + async () => ({ decision: 0x01, sanitisedPayload: body }), + ]) + const fw = new Firewall( + undefined, + Buffer.from("0011223344556677", "hex"), + factory, + ) + + const result = await fw.onPrompt("test") + assert.strictEqual(typeof result, "object") + assert(result && typeof result === "object") + if (typeof result === "object") { + assert.strictEqual(result.decision, Decision.SANITISE) + assert.strictEqual(result.sanitisedText, "safe content") + assert.deepStrictEqual(Buffer.from(result.sanitisedPayload), body) + } + }) + + it("maps empty SANITISE payload to sanitisedText null", async () => { + const { factory } = makeFactory([ + async () => ({ decision: 0x01, sanitisedPayload: Buffer.alloc(0) }), + ]) + const fw = new Firewall( + undefined, + Buffer.from("0011223344556677", "hex"), + factory, + ) + + const result = await fw.onPrompt("test") + assert(result && typeof result === "object") + if (typeof result === "object") { + assert.strictEqual(result.decision, Decision.SANITISE) + assert.strictEqual(result.sanitisedText, null) + } + }) +}) + +describe("Firewall onContext behavior", () => { + it("returns empty array for empty chunks", async () => { + const { factory } = makeFactory([]) + const fw = new Firewall( + undefined, + Buffer.from("0011223344556677", "hex"), + factory, + ) + const result = await fw.onContext([]) + assert.deepStrictEqual(result, []) + }) + + it("returns per-chunk decisions with sanitised text on SANITISE", async () => { + const { factory } = makeFactory([ + async () => ({ decision: 0x00, sanitisedPayload: Buffer.alloc(0) }), + async () => ({ decision: 0x01, sanitisedPayload: Buffer.from("cleaned") }), + async () => ({ decision: 0x02, sanitisedPayload: Buffer.alloc(0) }), + ]) + const fw = new Firewall( + undefined, + Buffer.from("0011223344556677", "hex"), + factory, + ) + const result = await fw.onContext(["a", "b", "c"]) + + assert.strictEqual(result.length, 3) + assert.strictEqual(result[0].decision, Decision.ALLOW) + assert.strictEqual(result[0].sanitisedText, null) + assert.strictEqual(result[1].decision, Decision.SANITISE) + assert.strictEqual(result[1].sanitisedText, "cleaned") + assert.strictEqual(result[2].decision, Decision.BLOCK) + assert.strictEqual(result[2].sanitisedText, null) + }) + + it("sends on_context payload with rag provenance per chunk", async () => { + const { capture, factory } = makeFactory([ + async () => ({ decision: 0x00, sanitisedPayload: Buffer.alloc(0) }), + async () => ({ decision: 0x00, sanitisedPayload: Buffer.alloc(0) }), + ]) + const fw = new Firewall( + undefined, + Buffer.from("0011223344556677", "hex"), + factory, + ) + + await fw.onContext(["chunk one", "chunk two"]) + + assert.strictEqual(capture.transport.seenPayloads.length, 2) + const first = JSON.parse(capture.transport.seenPayloads[0].toString("utf-8")) + const second = JSON.parse(capture.transport.seenPayloads[1].toString("utf-8")) + assert.strictEqual(first.hook_type, "on_context") + assert.strictEqual(first.provenance, "rag") + assert.strictEqual(first.payload, "chunk one") + assert.strictEqual(second.payload, "chunk two") + }) +}) + +describe("Firewall error propagation and validation", () => { + it("propagates FirewallConnectionError from transport", async () => { + const { factory } = makeFactory([ + async () => { throw new FirewallConnectionError("down") }, + ]) + const fw = new Firewall( + undefined, + Buffer.from("0011223344556677", "hex"), + factory, + ) + await assert.rejects(() => fw.onPrompt("x"), FirewallConnectionError) + }) + + it("propagates FirewallError from transport", async () => { + const { factory } = makeFactory([ + async () => { throw new FirewallError("bad") }, + ]) + const fw = new Firewall( + undefined, + Buffer.from("0011223344556677", "hex"), + factory, + ) + await assert.rejects(() => fw.onPrompt("x"), FirewallError) + }) + + it("throws on unknown decision byte", async () => { + const { factory } = makeFactory([ + async () => ({ decision: 0xFF, sanitisedPayload: Buffer.alloc(0) }), + ]) + const fw = new Firewall( + undefined, + Buffer.from("0011223344556677", "hex"), + factory, + ) + await assert.rejects(() => fw.onPrompt("x"), RangeError) + }) + + it("validates onContext argument type", async () => { + const { factory } = makeFactory([]) + const fw = new Firewall( + undefined, + Buffer.from("0011223344556677", "hex"), + factory, + ) + await assert.rejects( + () => fw.onContext("bad" as unknown as string[]), + FirewallError, + ) + }) + + it("validates onToolCall params type", async () => { + const { factory } = makeFactory([]) + const fw = new Firewall( + undefined, + Buffer.from("0011223344556677", "hex"), + factory, + ) + await assert.rejects( + () => fw.onToolCall("search", null as unknown as Record), + FirewallError, + ) + }) + + it("validates onMemory key/value types", async () => { + const { factory } = makeFactory([]) + const fw = new Firewall( + undefined, + Buffer.from("0011223344556677", "hex"), + factory, + ) + await assert.rejects( + () => fw.onMemory(123 as unknown as string, "x"), + FirewallError, + ) + }) +}) + +describe("Firewall edge cases", () => { + it("supports unicode text payload", async () => { + const { capture, factory } = makeFactory([ + async () => ({ decision: 0x00, sanitisedPayload: Buffer.alloc(0) }), + ]) + const fw = new Firewall( + undefined, + Buffer.from("0011223344556677", "hex"), + factory, + ) + + await fw.onPrompt("こんにちは世界") + const payload = JSON.parse(capture.transport.seenPayloads[0].toString("utf-8")) + assert.strictEqual(payload.payload, "こんにちは世界") + }) + + it("multiple sequential calls are independent", async () => { + const { capture, factory } = makeFactory([ + async () => ({ decision: 0x00, sanitisedPayload: Buffer.alloc(0) }), + async () => ({ decision: 0x02, sanitisedPayload: Buffer.alloc(0) }), + ]) + const fw = new Firewall( + undefined, + Buffer.from("0011223344556677", "hex"), + factory, + ) + + const r1 = await fw.onPrompt("first") + const r2 = await fw.onPrompt("second") + assert.strictEqual(r1, Decision.ALLOW) + assert.strictEqual(r2, Decision.BLOCK) + assert.strictEqual(capture.transport.seenPayloads.length, 2) + }) + + it("does not mutate input params object", async () => { + const { factory } = makeFactory([ + async () => ({ decision: 0x00, sanitisedPayload: Buffer.alloc(0) }), + ]) + const fw = new Firewall( + undefined, + Buffer.from("0011223344556677", "hex"), + factory, + ) + + const params = { q: "test" } + const before = JSON.stringify(params) + await fw.onToolCall("search", params) + assert.strictEqual(JSON.stringify(params), before) + }) +}) diff --git a/sdk/typescript/tests/frame.test.ts b/sdk/typescript/tests/frame.test.ts new file mode 100644 index 0000000..164edc6 --- /dev/null +++ b/sdk/typescript/tests/frame.test.ts @@ -0,0 +1,183 @@ +/// + +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import { createHmac } from "node:crypto" + +import { FIXED_NONCE, TEST_KEY } from "./helpers.js" +import { + CanonicaliseError, + FrameError, + HEADER_SIZE, + MAGIC, + VERSION, + canonicalPayload, + decodeRequest, + decodeResponse, + encodeRequest, + encodeResponse, + signedMessage, +} from "../src/frame.js" + +describe("canonicalPayload", () => { + it("sorts keys recursively for nested objects", () => { + const input = Buffer.from('{"z":{"b":2,"a":1},"a":0}') + const out = canonicalPayload(input) + assert.strictEqual(out.toString("utf-8"), '{"a":0,"z":{"a":1,"b":2}}') + }) + + it("is idempotent", () => { + const input = Buffer.from('{"b":2,"a":1}') + const once = canonicalPayload(input) + const twice = canonicalPayload(once) + assert.deepStrictEqual(twice, once) + }) + + it("normalizes unicode escapes via JSON parsing", () => { + const input = Buffer.from('{"key":"\\u0041"}') + const out = canonicalPayload(input) + assert.strictEqual(out.toString("utf-8"), '{"key":"A"}') + }) + + it("throws CanonicaliseError on invalid JSON", () => { + assert.throws( + () => canonicalPayload(Buffer.from("{not-json")), + CanonicaliseError, + ) + }) + + it("CanonicaliseError is caught as FrameError", () => { + assert.throws( + () => canonicalPayload(Buffer.from("{not-json")), + FrameError, + ) + }) +}) + +describe("signedMessage", () => { + it("produces identical bytes for equivalent payload key orders", () => { + const p1 = canonicalPayload(Buffer.from('{"b":2,"a":1}')) + const p2 = canonicalPayload(Buffer.from('{"a":1,"b":2}')) + const m1 = signedMessage(VERSION, p1.length, FIXED_NONCE, p1) + const m2 = signedMessage(VERSION, p2.length, FIXED_NONCE, p2) + assert.deepStrictEqual(m1, m2) + }) + + it("throws FrameError when nonce is wrong length", () => { + assert.throws( + () => signedMessage(VERSION, 0, Buffer.alloc(8), Buffer.alloc(0)), + FrameError, + ) + }) + + it("produces identical HMAC for equivalent payload key orders — core cross-SDK guarantee", () => { + const pythonStyle = Buffer.from('{"hook_type":"on_prompt","score":0.0,"payload":"hello"}') + const typescriptStyle = Buffer.from('{"payload":"hello","score":0.0,"hook_type":"on_prompt"}') + + const canon1 = canonicalPayload(pythonStyle) + const canon2 = canonicalPayload(typescriptStyle) + + const msg1 = signedMessage(VERSION, canon1.length, FIXED_NONCE, canon1) + const msg2 = signedMessage(VERSION, canon2.length, FIXED_NONCE, canon2) + + const hmac1 = createHmac("sha256", TEST_KEY).update(msg1).digest() + const hmac2 = createHmac("sha256", TEST_KEY).update(msg2).digest() + + assert.deepStrictEqual( + hmac1, + hmac2, + "HMAC mismatch for semantically identical JSON with different key order", + ) + }) +}) + +describe("encodeRequest/decodeRequest", () => { + it("encodes valid request header fields", () => { + const frame = encodeRequest(Buffer.from('{"b":2,"a":1}'), TEST_KEY) + assert.strictEqual(frame[0], MAGIC) + assert.strictEqual(frame[1], VERSION) + }) + + it("uses canonical payload length in length field", () => { + const raw = Buffer.from('{"b":2,"a":1}') + const canonical = canonicalPayload(raw) + const frame = encodeRequest(raw, TEST_KEY) + const length = frame.readUInt32BE(2) + assert.strictEqual(length, canonical.length) + }) + + it("encodes a verifiable HMAC over canonical payload", () => { + const frame = encodeRequest(Buffer.from('{"b":2,"a":1}'), TEST_KEY) + const req = decodeRequest(frame) + const msg = signedMessage(req.version, req.payload.length, req.nonce, req.payload) + const expected = createHmac("sha256", TEST_KEY).update(msg).digest() + assert.deepStrictEqual(req.hmac, expected) + }) + + it("decodeRequest returns canonical payload bytes", () => { + const frame = encodeRequest(Buffer.from('{"b":2,"a":1}'), TEST_KEY) + const req = decodeRequest(frame) + assert.strictEqual(req.payload.toString("utf-8"), '{"a":1,"b":2}') + }) + + it("rejects bad magic", () => { + const frame = Buffer.from(encodeRequest(Buffer.from("{}"), TEST_KEY)) + frame[0] = 0xFF + assert.throws(() => decodeRequest(frame), FrameError) + }) + + it("rejects bad version", () => { + const frame = Buffer.from(encodeRequest(Buffer.from("{}"), TEST_KEY)) + frame[1] = 0x02 + assert.throws(() => decodeRequest(frame), FrameError) + }) + + it("rejects truncated frame header", () => { + assert.throws( + () => decodeRequest(Buffer.from([MAGIC, VERSION, 0x00])), + FrameError, + ) + }) + + it("rejects truncated payload", () => { + const frame = Buffer.from(encodeRequest(Buffer.from('{"a":1}'), TEST_KEY)) + const length = frame.readUInt32BE(2) + assert(length > 0) + const short = frame.subarray(0, HEADER_SIZE + length - 1) + assert.throws(() => decodeRequest(short), FrameError) + }) +}) + +describe("encodeResponse/decodeResponse", () => { + it("round-trips ALLOW", () => { + const encoded = encodeResponse(0x00) + const decoded = decodeResponse(encoded) + assert.strictEqual(decoded.decision, 0x00) + assert.deepStrictEqual(decoded.sanitisedPayload, Buffer.alloc(0)) + }) + + it("round-trips BLOCK", () => { + const encoded = encodeResponse(0x02) + const decoded = decodeResponse(encoded) + assert.strictEqual(decoded.decision, 0x02) + assert.deepStrictEqual(decoded.sanitisedPayload, Buffer.alloc(0)) + }) + + it("round-trips SANITISE with payload", () => { + const body = Buffer.from("safe content") + const encoded = encodeResponse(0x01, body) + const decoded = decodeResponse(encoded) + assert.strictEqual(decoded.decision, 0x01) + assert.deepStrictEqual(decoded.sanitisedPayload, body) + }) + + it("rejects truncated response header", () => { + assert.throws(() => decodeResponse(Buffer.from([0x00, 0x00])), FrameError) + }) + + it("rejects truncated sanitised payload body", () => { + const encoded = encodeResponse(0x01, Buffer.from("hello")) + const short = encoded.subarray(0, encoded.length - 1) + assert.throws(() => decodeResponse(short), FrameError) + }) +}) diff --git a/sdk/typescript/tests/helpers.ts b/sdk/typescript/tests/helpers.ts new file mode 100644 index 0000000..9217f0b --- /dev/null +++ b/sdk/typescript/tests/helpers.ts @@ -0,0 +1,29 @@ +/** + * tests/helpers.ts + * ================ + * Shared test utilities. + * Imported by every test file — no external test framework needed. + */ + + +// @ts-ignore - Buffer is a global in Node.js +import { Buffer } from "buffer" + +/** + * Fixed 32-byte test key — matches the Python and Go test suites exactly. + * Using the same key across all three SDKs means cross-SDK HMAC tests + * can assert identical outputs without coordination. + */ +export const TEST_KEY = Buffer.from( + "test-key-32-bytes-long-padded!!!", + "utf-8" +) + +/** + * Fixed 16-byte nonce for deterministic cross-SDK interop tests. + * Production nonces are random — this is only for testing. + */ +export const FIXED_NONCE = Buffer.from([ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, +]) \ No newline at end of file diff --git a/sdk/typescript/tests/integration.e2e.ts b/sdk/typescript/tests/integration.e2e.ts new file mode 100644 index 0000000..84308ef --- /dev/null +++ b/sdk/typescript/tests/integration.e2e.ts @@ -0,0 +1,100 @@ +/// + +import { describe, it } from "node:test" +import assert from "node:assert/strict" + +import { Firewall } from "../src/firewall.js" +import { Decision, FirewallConnectionError, FirewallError } from "../src/models.js" +import { + ensureSidecarBinary, + findRepoRoot, + isSocketBindPermissionError, + makeSocketPath, + normalizeDecision, + startSidecar, + stopSidecar, + waitForSidecarReady, +} from "./e2e_helpers.js" + +describe("TypeScript SDK integration with live sidecar", () => { + it("round-trips all v1 hooks against a real sidecar process", async (t) => { + const repoRoot = findRepoRoot() + const ready = ensureSidecarBinary(repoRoot) + if (!ready.ok) { + t.skip(ready.reason) + return + } + + const keyHex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" + const socketPath = makeSocketPath("hooks") + const sidecar = startSidecar({ repoRoot, keyHex, socketPath }) + + try { + try { + await waitForSidecarReady({ socketPath, keyHex, sidecar }) + } catch (err) { + if (isSocketBindPermissionError(err)) { + t.skip(`sidecar socket bind is not permitted in this environment: ${String((err as Error).message)}`) + return + } + throw err + } + const fw = new Firewall(socketPath, Buffer.from(keyHex, "hex")) + + const prompt = await fw.onPrompt("hello world") + assert.strictEqual(normalizeDecision(prompt), "ALLOW") + + const context = await fw.onContext(["chunk one", "chunk two"]) + assert.strictEqual(context.length, 2) + assert.strictEqual(context[0]?.decision, Decision.ALLOW) + assert.strictEqual(context[1]?.decision, Decision.ALLOW) + + const tool = await fw.onToolCall("search", { q: "weather" }) + assert.strictEqual(normalizeDecision(tool), "ALLOW") + + const memory = await fw.onMemory("pref", "dark", "write") + assert.strictEqual(normalizeDecision(memory), "ALLOW") + } finally { + await stopSidecar(sidecar) + } + }) + + it("fails closed when sidecar is running with a different key", async (t) => { + const repoRoot = findRepoRoot() + const ready = ensureSidecarBinary(repoRoot) + if (!ready.ok) { + t.skip(ready.reason) + return + } + + const keyHex = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + const wrongKeyHex = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + const socketPath = makeSocketPath("wrong-key") + const sidecar = startSidecar({ repoRoot, keyHex, socketPath }) + + try { + try { + await waitForSidecarReady({ socketPath, keyHex, sidecar }) + } catch (err) { + if (isSocketBindPermissionError(err)) { + t.skip(`sidecar socket bind is not permitted in this environment: ${String((err as Error).message)}`) + return + } + throw err + } + const fwWrong = new Firewall(socketPath, Buffer.from(wrongKeyHex, "hex")) + await assert.rejects( + () => fwWrong.onPrompt("hello"), + FirewallError, + ) + } finally { + await stopSidecar(sidecar) + } + }) + + it("returns FirewallConnectionError when sidecar is unavailable", async () => { + const keyHex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" + const fw = new Firewall(makeSocketPath("missing"), Buffer.from(keyHex, "hex")) + await assert.rejects(() => fw.onPrompt("hello"), FirewallConnectionError) + }) +}) diff --git a/sdk/typescript/tests/models.test.ts b/sdk/typescript/tests/models.test.ts new file mode 100644 index 0000000..ee16308 --- /dev/null +++ b/sdk/typescript/tests/models.test.ts @@ -0,0 +1,124 @@ +/// +/** + * tests/models.test.ts + * ==================== + * Tests for Decision enum, decisionFromByte, and error classes. + * Zero dependencies — Node built-in test runner only. + * + * Run with: node --test tests/models.test.ts + */ +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import { + Decision, + decisionFromByte, + FirewallError, + FirewallConnectionError, +} from "../src/models.js" + +// ── Decision enum values ────────────────────────────────────────────────────── + +describe("Decision", () => { + + it("ALLOW has correct wire byte 0x00", () => { + assert.strictEqual(Decision.ALLOW, 0x00) + }) + + it("SANITISE has correct wire byte 0x01", () => { + assert.strictEqual(Decision.SANITISE, 0x01) + }) + + it("BLOCK has correct wire byte 0x02", () => { + assert.strictEqual(Decision.BLOCK, 0x02) + }) + +}) + +// ── decisionFromByte ────────────────────────────────────────────────────────── + +describe("decisionFromByte", () => { + + it("0x00 returns Decision.ALLOW", () => { + assert.strictEqual(decisionFromByte(0x00), Decision.ALLOW) + }) + + it("0x01 returns Decision.SANITISE", () => { + assert.strictEqual(decisionFromByte(0x01), Decision.SANITISE) + }) + + it("0x02 returns Decision.BLOCK", () => { + assert.strictEqual(decisionFromByte(0x02), Decision.BLOCK) + }) + + it("unknown byte throws RangeError", () => { + assert.throws( + () => decisionFromByte(0xFF), + RangeError + ) + }) + + it("error message contains the bad byte value", () => { + assert.throws( + () => decisionFromByte(0xAB), + (err: unknown) => { + assert(err instanceof RangeError) + assert((err as RangeError).message.includes("ab")) + return true + } + ) +}) + + +}) + +// ── Error hierarchy ─────────────────────────────────────────────────────────── + +describe("FirewallError", () => { + + it("is an instance of Error", () => { + const err = new FirewallError("test") + assert(err instanceof Error) + }) + + it("has correct name property", () => { + const err = new FirewallError("test") + assert.strictEqual(err.name, "FirewallError") + }) + + it("preserves message", () => { + const err = new FirewallError("something went wrong") + assert.strictEqual(err.message, "something went wrong") + }) + +}) + +describe("FirewallConnectionError", () => { + + it("is an instance of FirewallError", () => { + const err = new FirewallConnectionError("no socket") + assert(err instanceof FirewallError) + }) + + it("is an instance of Error", () => { + const err = new FirewallConnectionError("no socket") + assert(err instanceof Error) + }) + + it("has correct name property", () => { + const err = new FirewallConnectionError("no socket") + assert.strictEqual(err.name, "FirewallConnectionError") + }) + + it("preserves message", () => { + const err = new FirewallConnectionError("could not connect") + assert.strictEqual(err.message, "could not connect") + }) + + it("can be caught as FirewallError", () => { + assert.throws( + () => { throw new FirewallConnectionError("down") }, + FirewallError + ) + }) + +}) \ No newline at end of file diff --git a/sdk/typescript/tests/parity.e2e.ts b/sdk/typescript/tests/parity.e2e.ts new file mode 100644 index 0000000..2310556 --- /dev/null +++ b/sdk/typescript/tests/parity.e2e.ts @@ -0,0 +1,185 @@ +/// + +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import { spawnSync } from "node:child_process" +import path from "node:path" + +import { Firewall } from "../src/firewall.js" +import { normalizeDecision } from "./e2e_helpers.js" +import { + ensureSidecarBinary, + findRepoRoot, + hasPython3, + isSocketBindPermissionError, + makeSocketPath, + startSidecar, + stopSidecar, + waitForSidecarReady, +} from "./e2e_helpers.js" + +type HookScenario = + | { hook: "on_prompt"; payload: string } + | { hook: "on_context"; payload: string[] } + | { hook: "on_tool_call"; payload: { name: string; params: Record } } + | { hook: "on_memory"; payload: { key: string; value: string; op: string } } + +function runPythonDecision(params: { + repoRoot: string + socketPath: string + keyHex: string + scenario: HookScenario +}): { ok: true; decision: "ALLOW" | "SANITISE" | "BLOCK" } | { ok: false; reason: string } { + const script = ` +import json,sys +from acf import Firewall, Decision +from acf.models import SanitiseResult + +socket_path = sys.argv[1] +key_hex = sys.argv[2] +scenario = json.loads(sys.argv[3]) + +fw = Firewall(socket_path=socket_path, hmac_key=bytes.fromhex(key_hex)) +hook = scenario["hook"] +payload = scenario["payload"] + +if hook == "on_prompt": + result = fw.on_prompt(payload) +elif hook == "on_context": + rows = fw.on_context(payload) + rank = {"ALLOW": 0, "SANITISE": 1, "BLOCK": 2} + worst = "ALLOW" + for r in rows: + name = r.decision.name + if rank[name] > rank[worst]: + worst = name + result = worst +elif hook == "on_tool_call": + result = fw.on_tool_call(payload["name"], payload["params"]) +elif hook == "on_memory": + result = fw.on_memory(payload["key"], payload["value"], payload["op"]) +else: + raise ValueError("unknown hook") + +if isinstance(result, str): + print(json.dumps({"decision": result})) +elif isinstance(result, SanitiseResult): + print(json.dumps({"decision": "SANITISE"})) +elif isinstance(result, Decision): + print(json.dumps({"decision": result.name})) +else: + raise ValueError("unknown response type") +` + + const out = spawnSync( + "python3", + ["-c", script, params.socketPath, params.keyHex, JSON.stringify(params.scenario)], + { + cwd: params.repoRoot, + env: { + ...process.env, + PYTHONPATH: path.join(params.repoRoot, "sdk", "python"), + ACF_HMAC_KEY: params.keyHex, + ACF_SOCKET_PATH: params.socketPath, + }, + encoding: "utf-8", + }, + ) + + if (out.status !== 0) { + return { ok: false, reason: out.stderr || out.stdout || "python invocation failed" } + } + + try { + const parsed = JSON.parse(out.stdout) + const decision = parsed.decision as "ALLOW" | "SANITISE" | "BLOCK" + return { ok: true, decision } + } catch { + return { ok: false, reason: `invalid python output: ${out.stdout}` } + } +} + +describe("TypeScript/Python parity against live sidecar", () => { + it("matches decision category across SDKs for shared scenarios", async (t) => { + if (!hasPython3()) { + t.skip("python3 is not available in PATH") + return + } + + const repoRoot = findRepoRoot() + const ready = ensureSidecarBinary(repoRoot) + if (!ready.ok) { + t.skip(ready.reason) + return + } + + const keyHex = "111122223333444455556666777788889999aaaabbbbccccddddeeeeffff0000" + const socketPath = makeSocketPath("parity") + const sidecar = startSidecar({ repoRoot, keyHex, socketPath }) + + try { + try { + await waitForSidecarReady({ socketPath, keyHex, sidecar }) + } catch (err) { + if (isSocketBindPermissionError(err)) { + t.skip(`sidecar socket bind is not permitted in this environment: ${String((err as Error).message)}`) + return + } + throw err + } + const fw = new Firewall(socketPath, Buffer.from(keyHex, "hex")) + + const scenarios: HookScenario[] = [ + { hook: "on_prompt", payload: "hello from parity" }, + { hook: "on_context", payload: ["doc one", "doc two"] }, + { hook: "on_tool_call", payload: { name: "search", params: { q: "news" } } }, + { hook: "on_memory", payload: { key: "pref", value: "dark", op: "write" } }, + ] + + for (const scenario of scenarios) { + let tsDecision: "ALLOW" | "SANITISE" | "BLOCK" + if (scenario.hook === "on_prompt") { + tsDecision = normalizeDecision(await fw.onPrompt(scenario.payload)) + } else if (scenario.hook === "on_context") { + const rows = await fw.onContext(scenario.payload) + if (rows.some((r) => r.decision === 0x02)) { + tsDecision = "BLOCK" + } else if (rows.some((r) => r.decision === 0x01)) { + tsDecision = "SANITISE" + } else { + tsDecision = "ALLOW" + } + } else if (scenario.hook === "on_tool_call") { + tsDecision = normalizeDecision( + await fw.onToolCall(scenario.payload.name, scenario.payload.params), + ) + } else { + tsDecision = normalizeDecision( + await fw.onMemory( + scenario.payload.key, + scenario.payload.value, + scenario.payload.op, + ), + ) + } + + const py = runPythonDecision({ + repoRoot, + socketPath, + keyHex, + scenario, + }) + if (!py.ok) { + assert.fail(py.reason) + } + assert.strictEqual( + tsDecision, + py.decision, + `parity mismatch for ${scenario.hook}`, + ) + } + } finally { + await stopSidecar(sidecar) + } + }) +}) diff --git a/sdk/typescript/tests/transport.test.ts b/sdk/typescript/tests/transport.test.ts new file mode 100644 index 0000000..2f23a63 --- /dev/null +++ b/sdk/typescript/tests/transport.test.ts @@ -0,0 +1,183 @@ +/// + +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import { EventEmitter } from "node:events" + +import { encodeResponse } from "../src/frame.js" +import { + BACKOFF_BASE_MS, + MAX_ATTEMPTS, + Transport, +} from "../src/transport.js" +import { FirewallConnectionError, FirewallError } from "../src/models.js" +import { TEST_KEY } from "./helpers.js" + +class ScriptedTransport extends Transport { + attempts = 0 + + constructor( + socketPath: string, + key: Buffer, + private readonly script: Array<() => Promise>, + ) { + super(socketPath, key) + } + + protected override async connectAndSend(_frame: Buffer): Promise { + this.attempts += 1 + const step = this.script[this.attempts - 1] + if (!step) { + throw new Error("script exhausted unexpectedly") + } + return step() + } +} + +function errnoError(code: string, message: string): Error & { code: string } { + const err = new Error(message) as Error & { code: string } + err.code = code + return err +} + +class FakeSocket extends EventEmitter { + readonly writes: Buffer[] = [] + destroyed = false + + write( + data: Buffer | Uint8Array, + cb?: ((err?: Error | null) => void) | undefined, + ): boolean { + this.writes.push(Buffer.from(data)) + if (cb) { + cb(undefined) + } + return true + } + + destroy(): this { + this.destroyed = true + return this + } +} + +describe("Transport retry behavior", () => { + it("retries on ECONNREFUSED then succeeds", async () => { + const t = new ScriptedTransport("/tmp/acf.sock", TEST_KEY, [ + async () => { throw errnoError("ECONNREFUSED", "refused") }, + async () => encodeResponse(0x00), + ]) + + const started = Date.now() + const result = await t.send(Buffer.from("{}")) + const elapsed = Date.now() - started + + assert.strictEqual(result.decision, 0x00) + assert.strictEqual(t.attempts, 2) + assert(elapsed >= BACKOFF_BASE_MS) + }) + + it("retries on ENOENT then succeeds", async () => { + const t = new ScriptedTransport("/tmp/acf.sock", TEST_KEY, [ + async () => { throw errnoError("ENOENT", "missing socket") }, + async () => encodeResponse(0x02), + ]) + + const result = await t.send(Buffer.from("{}")) + + assert.strictEqual(result.decision, 0x02) + assert.strictEqual(t.attempts, 2) + }) + + it("throws FirewallConnectionError after retry exhaustion", async () => { + const t = new ScriptedTransport("/tmp/acf.sock", TEST_KEY, [ + async () => { throw errnoError("ENOENT", "missing") }, + async () => { throw errnoError("ECONNREFUSED", "refused") }, + async () => { throw errnoError("ENOENT", "still missing") }, + ]) + + await assert.rejects( + () => t.send(Buffer.from("{}")), + FirewallConnectionError, + ) + assert.strictEqual(t.attempts, MAX_ATTEMPTS) + }) + + it("does not retry non-transient errors", async () => { + const t = new ScriptedTransport("/tmp/acf.sock", TEST_KEY, [ + async () => { throw errnoError("EACCES", "permission denied") }, + ]) + + await assert.rejects( + () => t.send(Buffer.from("{}")), + FirewallError, + ) + assert.strictEqual(t.attempts, 1) + }) +}) + +describe("Transport socket read path", () => { + it("reads ALLOW response from a socket stream", async () => { + const fake = new FakeSocket() + const t = new Transport( + "/tmp/acf.sock", + TEST_KEY, + () => fake as unknown as import("node:net").Socket, + ) + + queueMicrotask(() => { + fake.emit("connect") + fake.emit("data", encodeResponse(0x00)) + }) + + const result = await t.send(Buffer.from('{"b":2,"a":1}')) + + assert.strictEqual(result.decision, 0x00) + assert.deepStrictEqual(result.sanitisedPayload, Buffer.alloc(0)) + assert.strictEqual(fake.destroyed, true) + }) + + it("reassembles fragmented SANITISE responses from chunked data events", async () => { + const body = Buffer.from("safe content") + const encoded = encodeResponse(0x01, body) + const fake = new FakeSocket() + const t = new Transport( + "/tmp/acf.sock", + TEST_KEY, + () => fake as unknown as import("node:net").Socket, + ) + + queueMicrotask(() => { + fake.emit("connect") + fake.emit("data", encoded.subarray(0, 2)) + fake.emit("data", encoded.subarray(2, 5)) + fake.emit("data", encoded.subarray(5)) + }) + + const result = await t.send(Buffer.from("{}")) + + assert.strictEqual(result.decision, 0x01) + assert.deepStrictEqual(result.sanitisedPayload, body) + assert.strictEqual(fake.destroyed, true) + }) + + it("fails when socket closes before full response is read", async () => { + const fake = new FakeSocket() + const t = new Transport( + "/tmp/acf.sock", + TEST_KEY, + () => fake as unknown as import("node:net").Socket, + ) + + queueMicrotask(() => { + fake.emit("connect") + fake.emit("data", Buffer.from([0x01, 0x00])) // partial header + fake.emit("close") + }) + + await assert.rejects( + () => t.send(Buffer.from("{}")), + FirewallError, + ) + }) +}) diff --git a/sdk/typescript/tsconfig.json b/sdk/typescript/tsconfig.json index bb77560..2a341c6 100644 --- a/sdk/typescript/tsconfig.json +++ b/sdk/typescript/tsconfig.json @@ -8,6 +8,6 @@ "strict": true, "esModuleInterop": true }, - "include": ["src/**/*"], - "exclude": ["dist", "tests"] -} + "include": ["src/**/*", "tests/**/*"], + "exclude": ["dist"] +} \ No newline at end of file