950496afa2f17833a6713b271d099286a282c303

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Add haiku based permission checking

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+})