Diff
1diff --git a/src/api.ts b/src/api.ts
2new file mode 100644
3index 0000000000000000000000000000000000000000..6d3c28b2ad7dda5e2bd24b612018ceb47d5685b6
4--- /dev/null
5+++ b/src/api.ts
6@@ -0,0 +1,48 @@
7+const DEFAULT_MODEL = "claude-haiku-4-5-20251001"
8+
9+let capturedApiKey: string | undefined
10+
11+export function setCapturedApiKey(key: string) {
12+ capturedApiKey = key
13+}
14+
15+function resolveApiKey(): string {
16+ const key =
17+ process.env.ANTHROPIC_API_KEY ??
18+ capturedApiKey
19+ if (!key) throw new Error("No Anthropic API key available")
20+ return key
21+}
22+
23+function resolveModel(): string {
24+ return process.env.ANTHROPIC_SMALL_FAST_MODEL ?? DEFAULT_MODEL
25+}
26+
27+export async function callClaude(
28+ messages: { role: string; content: string }[],
29+ maxTokens = 512,
30+): Promise<string> {
31+ const resp = await fetch("https://api.anthropic.com/v1/messages", {
32+ method: "POST",
33+ headers: {
34+ "content-type": "application/json",
35+ "x-api-key": resolveApiKey(),
36+ "anthropic-version": "2023-06-01",
37+ },
38+ body: JSON.stringify({
39+ model: resolveModel(),
40+ max_tokens: maxTokens,
41+ messages,
42+ }),
43+ })
44+
45+ if (!resp.ok) {
46+ const body = await resp.text()
47+ throw new Error(`Anthropic API ${resp.status}: ${body.slice(0, 200)}`)
48+ }
49+
50+ const data = (await resp.json()) as {
51+ content?: { type: string; text: string }[]
52+ }
53+ return data.content?.[0]?.text ?? ""
54+}
55diff --git a/src/cache.ts b/src/cache.ts
56new file mode 100644
57index 0000000000000000000000000000000000000000..975a5f938b139f61b6723a4da360973a57460d73
58--- /dev/null
59+++ b/src/cache.ts
60@@ -0,0 +1,67 @@
61+import { mkdirSync, readFileSync, appendFileSync } from "fs"
62+import { existsSync } from "fs"
63+import { homedir } from "os"
64+import { join } from "path"
65+import type { Decision } from "./types"
66+import { POLICY_VERSION } from "./rules"
67+
68+const CACHE_DIR = join(homedir(), ".config", "opencode", "hooks", "cache")
69+const CACHE_FILE = join(CACHE_DIR, "decisions.jsonl")
70+
71+type CacheEntry = {
72+ key: string
73+ ts: string
74+ policy_version: string
75+ tool_type: string
76+ normalized: string
77+ decision: Decision
78+ source: string
79+}
80+
81+function ensureDir() {
82+ if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true })
83+}
84+
85+export function lookupCache(key: string): Decision | undefined {
86+ if (!existsSync(CACHE_FILE)) return undefined
87+ try {
88+ const data = readFileSync(CACHE_FILE, "utf-8")
89+ for (const line of data.split("\n")) {
90+ if (!line) continue
91+ try {
92+ const e: CacheEntry = JSON.parse(line)
93+ if (e.key === key && e.policy_version === POLICY_VERSION)
94+ return e.decision
95+ } catch {
96+ continue
97+ }
98+ }
99+ } catch {
100+ return undefined
101+ }
102+ return undefined
103+}
104+
105+export function writeCache(
106+ key: string,
107+ toolType: string,
108+ normalized: string,
109+ decision: Decision,
110+ source: string,
111+): void {
112+ ensureDir()
113+ const entry: CacheEntry = {
114+ key,
115+ ts: new Date().toISOString(),
116+ policy_version: POLICY_VERSION,
117+ tool_type: toolType,
118+ normalized,
119+ decision,
120+ source,
121+ }
122+ try {
123+ appendFileSync(CACHE_FILE, JSON.stringify(entry) + "\n")
124+ } catch {
125+ // non-fatal
126+ }
127+}
128diff --git a/src/haiku.ts b/src/haiku.ts
129new file mode 100644
130index 0000000000000000000000000000000000000000..7c77a5568d745761e15fafa50168a706508c67b8
131--- /dev/null
132+++ b/src/haiku.ts
133@@ -0,0 +1,86 @@
134+import type { Decision } from "./types"
135+import { HAIKU_POLICY_PROMPT } from "./rules"
136+import { callClaude } from "./api"
137+import { formatPermissionsForPrompt } from "./permissions"
138+
139+const ASK_FALLBACK: Decision = {
140+ decision: "ask",
141+ reason: "Policy engine error",
142+ category: "uncertain",
143+}
144+
145+export function parseHaikuResponse(content: string): Decision | undefined {
146+ // Strategy 1: markdown code block
147+ const codeBlock = content.match(/```(?:json)?\s*(\{.*?\})\s*```/s)
148+ let jsonStr = codeBlock?.[1]
149+
150+ // Strategy 2: balanced braces
151+ if (!jsonStr) {
152+ const start = content.indexOf("{")
153+ if (start === -1) return undefined
154+ let depth = 0
155+ let end = start
156+ for (let i = start; i < content.length; i++) {
157+ if (content[i] === "{") depth++
158+ else if (content[i] === "}") {
159+ depth--
160+ if (depth === 0) {
161+ end = i + 1
162+ break
163+ }
164+ }
165+ }
166+ if (depth !== 0) return undefined
167+ jsonStr = content.slice(start, end)
168+ }
169+
170+ try {
171+ const data = JSON.parse(jsonStr)
172+ const d = data.decision
173+ if (d !== "allow" && d !== "deny" && d !== "ask") return undefined
174+ return {
175+ decision: d,
176+ reason: ((data.reason as string) ?? "No reason provided").slice(0, 200),
177+ category: (data.category as string) ?? "uncertain",
178+ }
179+ } catch {
180+ return undefined
181+ }
182+}
183+
184+export type HaikuResult = {
185+ decision: Decision
186+ rawResponse?: string
187+ error?: string
188+}
189+
190+export async function callHaiku(
191+ toolType: string,
192+ toolInput: Record<string, unknown>,
193+ sessionId?: string,
194+ sessionPermissions?: string[],
195+): Promise<HaikuResult> {
196+ const perms = sessionPermissions ?? []
197+ const permBlock = perms.length > 0 ? formatPermissionsForPrompt(perms) : ""
198+
199+ const prompt = HAIKU_POLICY_PROMPT.replace("{tool_name}", toolType)
200+ .replace("{tool_input_json}", JSON.stringify(toolInput, null, 2))
201+ .replace("{permissions_block}", permBlock)
202+
203+ try {
204+ const raw = await callClaude([{ role: "user", content: prompt }])
205+ const decision = parseHaikuResponse(raw)
206+ if (decision) return { decision, rawResponse: raw }
207+ return {
208+ decision: { ...ASK_FALLBACK, reason: "Unparseable LLM response" },
209+ rawResponse: raw,
210+ error: "Failed to parse response JSON",
211+ }
212+ } catch (e) {
213+ const msg = e instanceof Error ? e.message : String(e)
214+ return {
215+ decision: { ...ASK_FALLBACK, reason: `Policy engine error: ${msg.slice(0, 100)}` },
216+ error: msg,
217+ }
218+ }
219+}
220diff --git a/src/logger.ts b/src/logger.ts
221new file mode 100644
222index 0000000000000000000000000000000000000000..6d6bcb5f49ab85fefffdbc61ef42236e56d7a047
223--- /dev/null
224+++ b/src/logger.ts
225@@ -0,0 +1,34 @@
226+import { mkdirSync, appendFileSync, existsSync } from "fs"
227+import { homedir } from "os"
228+import { join } from "path"
229+import type { Decision } from "./types"
230+
231+const LOG_DIR = join(homedir(), ".config", "opencode", "hooks", "logs")
232+const LOG_FILE = join(LOG_DIR, "policy.jsonl")
233+
234+let enabled = true
235+
236+export function setLoggingEnabled(v: boolean) {
237+ enabled = v
238+}
239+
240+export function logDecision(entry: {
241+ toolType: string
242+ normalized: string
243+ decision: Decision
244+ source: string
245+ timingMs: number
246+ sessionId?: string
247+ rawResponse?: string
248+ error?: string
249+ permissions?: string[]
250+}): void {
251+ if (!enabled) return
252+ try {
253+ if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true })
254+ const record = { ts: new Date().toISOString(), ...entry }
255+ appendFileSync(LOG_FILE, JSON.stringify(record) + "\n")
256+ } catch {
257+ // non-fatal
258+ }
259+}
260diff --git a/src/permissions.ts b/src/permissions.ts
261new file mode 100644
262index 0000000000000000000000000000000000000000..b3cfa550e51ca69c58d5d06f9de3c7de87aface3
263--- /dev/null
264+++ b/src/permissions.ts
265@@ -0,0 +1,23 @@
266+// In-memory per-session permission storage.
267+
268+const store = new Map<string, string[]>()
269+
270+export function addPermissions(sessionId: string, perms: string[]): void {
271+ const existing = store.get(sessionId) ?? []
272+ store.set(sessionId, [...existing, ...perms])
273+}
274+
275+export function getPermissions(sessionId: string): string[] {
276+ return store.get(sessionId) ?? []
277+}
278+
279+export function clearPermissions(sessionId: string): void {
280+ store.delete(sessionId)
281+}
282+
283+export function formatPermissionsForPrompt(perms: string[]): string {
284+ const unique = [...new Set(perms)]
285+ if (unique.length === 0) return ""
286+ const bullets = unique.map((p) => `- ${p}`).join("\n")
287+ return `\n## User-Granted Session Permissions\nThe user has explicitly granted these permissions for this session:\n${bullets}\n\nIf the current operation matches a granted permission, lean toward allowing it.\n`
288+}
289diff --git a/test/cache.test.ts b/test/cache.test.ts
290new file mode 100644
291index 0000000000000000000000000000000000000000..fa6b8df59652faffd496d0b9c51f47900f3f1fea
292--- /dev/null
293+++ b/test/cache.test.ts
294@@ -0,0 +1,37 @@
295+import { describe, expect, test, beforeEach } from "bun:test"
296+import { lookupCache, writeCache } from "../src/cache"
297+import { unlinkSync, existsSync } from "fs"
298+import { homedir } from "os"
299+import { join } from "path"
300+
301+const CACHE_FILE = join(
302+ homedir(),
303+ ".config",
304+ "opencode",
305+ "hooks",
306+ "cache",
307+ "decisions.jsonl",
308+)
309+
310+beforeEach(() => {
311+ if (existsSync(CACHE_FILE)) unlinkSync(CACHE_FILE)
312+})
313+
314+describe("cache", () => {
315+ test("miss on empty cache", () => {
316+ expect(lookupCache("nonexistent")).toBeUndefined()
317+ })
318+
319+ test("write then lookup", () => {
320+ const decision = { decision: "allow" as const, reason: "safe", category: "read_only" }
321+ writeCache("testkey", "bash", "Bash:git status", decision, "deterministic")
322+ const result = lookupCache("testkey")
323+ expect(result).toEqual(decision)
324+ })
325+
326+ test("different key returns miss", () => {
327+ const decision = { decision: "allow" as const, reason: "safe", category: "read_only" }
328+ writeCache("key1", "bash", "Bash:git status", decision, "deterministic")
329+ expect(lookupCache("key2")).toBeUndefined()
330+ })
331+})
332diff --git a/test/haiku.test.ts b/test/haiku.test.ts
333new file mode 100644
334index 0000000000000000000000000000000000000000..b500c55e3aadca0efd9f1d7b30b5d13928b93b14
335--- /dev/null
336+++ b/test/haiku.test.ts
337@@ -0,0 +1,52 @@
338+import { describe, expect, test } from "bun:test"
339+import { parseHaikuResponse } from "../src/haiku"
340+
341+describe("parseHaikuResponse", () => {
342+ test("plain JSON", () => {
343+ const d = parseHaikuResponse(
344+ '{"decision":"allow","reason":"safe","category":"read_only"}',
345+ )
346+ expect(d).toEqual({ decision: "allow", reason: "safe", category: "read_only" })
347+ })
348+
349+ test("markdown code block", () => {
350+ const d = parseHaikuResponse(
351+ 'Here is my analysis:\n```json\n{"decision":"deny","reason":"dangerous","category":"system"}\n```',
352+ )
353+ expect(d).toEqual({ decision: "deny", reason: "dangerous", category: "system" })
354+ })
355+
356+ test("JSON embedded in text", () => {
357+ const d = parseHaikuResponse(
358+ 'I think this is safe. {"decision":"ask","reason":"ambiguous","category":"uncertain"} That is my answer.',
359+ )
360+ expect(d).toEqual({ decision: "ask", reason: "ambiguous", category: "uncertain" })
361+ })
362+
363+ test("invalid decision value returns undefined", () => {
364+ expect(
365+ parseHaikuResponse('{"decision":"block","reason":"x","category":"y"}'),
366+ ).toBeUndefined()
367+ })
368+
369+ test("no JSON returns undefined", () => {
370+ expect(parseHaikuResponse("I have no JSON for you")).toBeUndefined()
371+ })
372+
373+ test("unbalanced braces returns undefined", () => {
374+ expect(parseHaikuResponse('{"decision":"allow"')).toBeUndefined()
375+ })
376+
377+ test("truncates long reason to 200 chars", () => {
378+ const long = "x".repeat(300)
379+ const d = parseHaikuResponse(
380+ `{"decision":"allow","reason":"${long}","category":"read_only"}`,
381+ )
382+ expect(d!.reason).toHaveLength(200)
383+ })
384+
385+ test("defaults missing category to uncertain", () => {
386+ const d = parseHaikuResponse('{"decision":"allow","reason":"ok"}')
387+ expect(d!.category).toBe("uncertain")
388+ })
389+})
390diff --git a/test/permissions.test.ts b/test/permissions.test.ts
391new file mode 100644
392index 0000000000000000000000000000000000000000..cb82d22a1b429e7728ff0db84b0712ff5d47792f
393--- /dev/null
394+++ b/test/permissions.test.ts
395@@ -0,0 +1,38 @@
396+import { describe, expect, test } from "bun:test"
397+import {
398+ addPermissions,
399+ getPermissions,
400+ clearPermissions,
401+ formatPermissionsForPrompt,
402+} from "../src/permissions"
403+
404+describe("session permissions", () => {
405+ test("add and get", () => {
406+ addPermissions("s1", ["push to feature branches"])
407+ expect(getPermissions("s1")).toEqual(["push to feature branches"])
408+ clearPermissions("s1")
409+ })
410+
411+ test("accumulates", () => {
412+ addPermissions("s2", ["a"])
413+ addPermissions("s2", ["b"])
414+ expect(getPermissions("s2")).toEqual(["a", "b"])
415+ clearPermissions("s2")
416+ })
417+
418+ test("empty session", () => {
419+ expect(getPermissions("nonexistent")).toEqual([])
420+ })
421+
422+ test("formatPermissionsForPrompt deduplicates", () => {
423+ const out = formatPermissionsForPrompt(["a", "b", "a"])
424+ expect(out).toContain("- a")
425+ expect(out).toContain("- b")
426+ // only one "- a"
427+ expect(out.match(/- a/g)!.length).toBe(1)
428+ })
429+
430+ test("formatPermissionsForPrompt empty", () => {
431+ expect(formatPermissionsForPrompt([])).toBe("")
432+ })
433+})