From 085f30872af9ebab7ecdf6daf2ce95110cb912cb Mon Sep 17 00:00:00 2001 From: Bethel Yohannes Date: Sat, 21 Feb 2026 11:40:17 +0300 Subject: [PATCH] created test file for the hook and check if the hooks (pre and post) hooks are working --- .orchestration/active_intents.yaml | 12 ++ .orchestration/agent_trace.jsonl | 3 + scripts/test-hooks.ts | 255 +++++++++++++++++++++++++++++ src/hooks/ post-hook.ts | 0 src/hooks/context-loader.ts | 2 +- src/hooks/pre-hook.ts | 32 ++-- 6 files changed, 288 insertions(+), 16 deletions(-) create mode 100644 .orchestration/active_intents.yaml create mode 100644 .orchestration/agent_trace.jsonl create mode 100644 scripts/test-hooks.ts delete mode 100644 src/hooks/ post-hook.ts diff --git a/.orchestration/active_intents.yaml b/.orchestration/active_intents.yaml new file mode 100644 index 0000000000..87492967f5 --- /dev/null +++ b/.orchestration/active_intents.yaml @@ -0,0 +1,12 @@ +active_intents: + - id: "INT-001" + name: "Build Weather API" + status: "IN_PROGRESS" + owned_scope: + - "src/api/**" + constraints: + - "Use REST conventions" + - "Return JSON" + acceptance_criteria: + - "GET /weather returns 200 with location and temperature" + diff --git a/.orchestration/agent_trace.jsonl b/.orchestration/agent_trace.jsonl new file mode 100644 index 0000000000..48c0a71402 --- /dev/null +++ b/.orchestration/agent_trace.jsonl @@ -0,0 +1,3 @@ +{"id":"df1a0b04-1993-45f4-b3cb-c0b4fcb2dba2","timestamp":"2026-02-21T08:37:15.794Z","vcs":{"revision_id":"abc123"},"files":[{"relative_path":"src/api/weather.ts","conversations":[{"url":"session-1","contributor":{"entity_type":"AI","model_identifier":"test-model"},"ranges":[{"start_line":1,"end_line":2,"content_hash":"sha256:2e4ec5cf14cfeb55ef32cb663ffd2c4fe6ab22bb2d2fe77ee8e284fe62866cac"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} +{"id":"7f9541c8-7fd4-4cef-ae44-c51f191b942a","timestamp":"2026-02-21T08:37:15.797Z","files":[{"relative_path":"src/api/forecast.ts","conversations":[{"contributor":{"entity_type":"AI","model_identifier":"unknown"},"ranges":[{"start_line":1,"end_line":1,"content_hash":"sha256:c47272be41ffa9d8a897b81b3f9f6d5e13fbb5d6dd4609a7b20c282950f11842"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} +{"id":"da60914f-8e16-4c59-8c5d-2c673793287d","timestamp":"2026-02-21T08:37:15.803Z","vcs":{"revision_id":"rev-1"},"files":[{"relative_path":"src/api/demo.ts","conversations":[{"url":"log-1","contributor":{"entity_type":"AI","model_identifier":"model-1"},"ranges":[{"start_line":1,"end_line":1,"content_hash":"sha256:bd994c26b795571c7c1e0357cba348f1d0238b648b748b9ec7a4d0de264d212f"}],"related":[{"type":"specification","value":"INT-001"}]}]}]} diff --git a/scripts/test-hooks.ts b/scripts/test-hooks.ts new file mode 100644 index 0000000000..f7176b69a5 --- /dev/null +++ b/scripts/test-hooks.ts @@ -0,0 +1,255 @@ +/** + * Run each hook component and assert expected behavior. + * Run from repo root: pnpm tsx scripts/test-hooks.ts + */ +import fs from "fs/promises" +import path from "path" + +import { contentHash, contentHashForRange } from "../src/hooks/content-hash" +import { loadIntentContext, buildIntentContextXml } from "../src/hooks/context-loader" +import { pathInScope } from "../src/hooks/scope" +import { PreHook } from "../src/hooks/pre-hook" +import { HookMiddleware } from "../src/hooks/middleware" +import { appendAgentTrace } from "../src/hooks/post-hook" + +const cwd = process.cwd() + +function assert(condition: boolean, message: string): void { + if (!condition) throw new Error(`FAIL: ${message}`) +} + +async function run(name: string, fn: () => Promise | void): Promise { + try { + await fn() + console.log(` OK ${name}`) + } catch (e) { + console.error(` FAIL ${name}`) + throw e + } +} + +async function main() { + console.log("=== 1. content-hash ===\n") + + await run("contentHash returns sha256: prefix", async () => { + const h = contentHash("hello") + assert(h.startsWith("sha256:"), "prefix") + assert(h.length === 71, "length 7 prefix + 64 hex") + }) + + await run("contentHash is deterministic", async () => { + const a = contentHash("same") + const b = contentHash("same") + assert(a === b, "same input => same hash") + }) + + await run("contentHashForRange hashes line range", async () => { + const text = "line1\nline2\nline3\nline4" + const h = contentHashForRange(text, 2, 3) + assert(h.startsWith("sha256:"), "prefix") + // line2\nline3 + const expected = contentHash("line2\nline3") + assert(h === expected, "range hash matches manual slice") + }) + + console.log("\n=== 2. context-loader ===\n") + + await run("loadIntentContext returns null when file missing", async () => { + const ctx = await loadIntentContext("/nonexistent", "INT-001") + assert(ctx === null, "missing dir => null") + }) + + await run("loadIntentContext loads INT-001 from .orchestration/active_intents.yaml", async () => { + const ctx = await loadIntentContext(cwd, "INT-001") + assert(ctx !== null, "context exists") + assert(ctx!.id === "INT-001", "id") + assert(ctx!.name === "Build Weather API", "name") + assert(Array.isArray(ctx!.owned_scope) && ctx!.owned_scope!.includes("src/api/**"), "owned_scope") + assert(Array.isArray(ctx!.constraints) && ctx!.constraints!.length > 0, "constraints") + }) + + await run("loadIntentContext returns null for unknown intent", async () => { + const ctx = await loadIntentContext(cwd, "INT-999") + assert(ctx === null, "unknown id => null") + }) + + await run("buildIntentContextXml produces valid XML block", async () => { + const ctx = await loadIntentContext(cwd, "INT-001") + assert(ctx !== null, "context exists") + const xml = buildIntentContextXml(ctx!) + assert(xml.includes(""), "root tag") + assert(xml.includes("INT-001"), "id") + assert(xml.includes(""), "constraints") + assert(xml.includes(""), "scope") + }) + + console.log("\n=== 3. scope ===\n") + + await run("pathInScope: empty scope => allowed", async () => { + assert(pathInScope("any/file.ts", [], cwd) === true, "empty scope allows all") + }) + + await run("pathInScope: src/api/** matches src/api/weather.ts", async () => { + assert(pathInScope("src/api/weather.ts", ["src/api/**"], cwd) === true, "in scope") + }) + + await run("pathInScope: src/api/** does not match src/other/file.ts", async () => { + assert(pathInScope("src/other/unauthorized.ts", ["src/api/**"], cwd) === false, "out of scope") + }) + + await run("pathInScope: exact file matches", async () => { + assert(pathInScope("src/middleware/jwt.ts", ["src/middleware/jwt.ts"], cwd) === true, "exact match") + }) + + console.log("\n=== 4. pre-hook (PreHook) ===\n") + + let activeIntentId: string | null = null + const preHook = new PreHook({ + cwd, + getActiveIntentId: () => activeIntentId, + setActiveIntentId: (id) => { + activeIntentId = id + }, + requireIntentForDestructiveOnly: true, + }) + + await run("PreHook: write_to_file with no intent => blocked", async () => { + activeIntentId = null + const r = await preHook.intercept("write_to_file", { path: "src/api/x.ts", content: "x" }) + assert(r.blocked === true, "blocked") + assert(r.error != null && r.error.includes("select an active intent"), "error message") + }) + + await run("PreHook: select_active_intent with valid ID => injectResult XML", async () => { + const r = await preHook.intercept("select_active_intent", { intent_id: "INT-001" }) + assert(r.blocked === false, "not blocked") + assert(r.injectResult != null && r.injectResult.includes(""), "XML injected") + assert(activeIntentId === "INT-001", "active intent set") + }) + + await run("PreHook: select_active_intent with invalid ID => blocked", async () => { + const r = await preHook.intercept("select_active_intent", { intent_id: "INT-999" }) + assert(r.blocked === true, "blocked") + assert(r.error != null && r.error.includes("INT-999"), "error mentions id") + }) + + await run("PreHook: write_to_file in owned_scope => allowed", async () => { + activeIntentId = "INT-001" + const r = await preHook.intercept("write_to_file", { + path: "src/api/weather.ts", + content: "// code", + }) + assert(r.blocked === false, "not blocked") + }) + + await run("PreHook: write_to_file outside owned_scope => blocked", async () => { + activeIntentId = "INT-001" + const r = await preHook.intercept("write_to_file", { + path: "src/other/unauthorized.ts", + content: "// bad", + }) + assert(r.blocked === true, "blocked") + assert(r.error != null && r.error.includes("Scope Violation"), "scope violation message") + }) + + await run("PreHook: read_file (safe) without intent => allowed", async () => { + activeIntentId = null + const r = await preHook.intercept("read_file", { path: "src/api/x.ts" }) + assert(r.blocked === false, "safe tool allowed without intent") + }) + + // TDD: path traversal must be blocked (test first, then implement) + await run("PreHook: write_to_file with path traversal (..) => blocked", async () => { + activeIntentId = "INT-001" + const r = await preHook.intercept("write_to_file", { + path: "src/api/../../../etc/escape.ts", + content: "// path traversal", + }) + assert(r.blocked === true, "blocked") + assert(r.error != null && r.error.toLowerCase().includes("path"), "error mentions path/traversal") + }) + + console.log("\n=== 5. post-hook (appendAgentTrace) ===\n") + + const tracePath = path.join(cwd, ".orchestration", "agent_trace.jsonl") + // Start fresh for this test + try { + await fs.unlink(tracePath) + } catch { + // ignore if missing + } + + await run("appendAgentTrace creates .orchestration/agent_trace.jsonl", async () => { + await appendAgentTrace(cwd, { + relativePath: "src/api/weather.ts", + content: "// weather API\nconst x = 1;", + intentId: "INT-001", + mutationClass: "INTENT_EVOLUTION", + sessionLogId: "session-1", + modelIdentifier: "test-model", + vcsRevisionId: "abc123", + }) + const raw = await fs.readFile(tracePath, "utf-8") + const line = raw.trim().split("\n")[0] + assert(line != null, "at least one line") + const entry = JSON.parse(line!) + assert(entry.id != null, "id") + assert(entry.timestamp != null, "timestamp") + assert(entry.vcs?.revision_id === "abc123", "vcs") + assert(entry.files?.length === 1, "one file") + assert(entry.files[0].relative_path === "src/api/weather.ts", "path") + const conv = entry.files[0].conversations[0] + assert(conv.contributor?.entity_type === "AI", "contributor") + assert(conv.ranges?.[0]?.content_hash?.startsWith("sha256:"), "content_hash") + assert(conv.related?.[0]?.value === "INT-001", "related intent") + }) + + await run("appendAgentTrace appends second entry", async () => { + await appendAgentTrace(cwd, { + relativePath: "src/api/forecast.ts", + content: "// forecast", + intentId: "INT-001", + }) + const raw = await fs.readFile(tracePath, "utf-8") + const lines = raw.trim().split("\n").filter(Boolean) + assert(lines.length >= 2, "two or more lines") + }) + + console.log("\n=== 6. middleware (full flow) ===\n") + + activeIntentId = null // reset so we simulate: select_intent then write + const middleware = new HookMiddleware({ + preHook, + getActiveIntentId: () => activeIntentId, + getCwd: () => cwd, + getSessionLogId: () => "log-1", + getModelId: () => "model-1", + getVcsRevisionId: () => "rev-1", + }) + + await run( + "Middleware: preToolUse(select_active_intent) then preToolUse(write_to_file) then postToolUse", + async () => { + const r1 = await middleware.preToolUse("select_active_intent", { intent_id: "INT-001" }) + assert(!r1.blocked && r1.injectResult != null, "select ok") + const r2 = await middleware.preToolUse("write_to_file", { + path: "src/api/demo.ts", + content: "// demo", + }) + assert(!r2.blocked, "write allowed") + await middleware.postToolUse("write_to_file", { path: "src/api/demo.ts", content: "// demo" }, {}) + const raw = await fs.readFile(tracePath, "utf-8") + const lastLine = raw.trim().split("\n").filter(Boolean).pop() + assert(lastLine != null, "new line") + const entry = JSON.parse(lastLine!) + assert(entry.files[0].relative_path === "src/api/demo.ts", "demo.ts traced") + }, + ) + + console.log("\n=== All hook checks passed. ===\n") +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/src/hooks/ post-hook.ts b/src/hooks/ post-hook.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/hooks/context-loader.ts b/src/hooks/context-loader.ts index 9d8a2f15d3..62063c4fc8 100644 --- a/src/hooks/context-loader.ts +++ b/src/hooks/context-loader.ts @@ -45,7 +45,7 @@ export function buildIntentContextXml(context: IntentContext): string { ? context.owned_scope.map((s) => ` ${escapeXml(s)}`).join("\n") : " No scope restriction" const criteriaXml = - (context.acceptance_criteria?.length ?? 0 > 0) + (context.acceptance_criteria?.length ?? 0) > 0 ? context.acceptance_criteria!.map((a) => ` ${escapeXml(a)}`).join("\n") : " None specified" diff --git a/src/hooks/pre-hook.ts b/src/hooks/pre-hook.ts index 6a47559e1a..be650829cf 100644 --- a/src/hooks/pre-hook.ts +++ b/src/hooks/pre-hook.ts @@ -1,11 +1,18 @@ -import * as vscode from "vscode" import path from "path" -import type { HookResult, IntentContext, MutationClass } from "./types" +import type { HookResult } from "./types" import { DESTRUCTIVE_TOOLS } from "./types" import { loadIntentContext, buildIntentContextXml } from "./context-loader" import { pathInScope } from "./scope" +/** Block paths that escape workspace (.. or absolute outside cwd). */ +function isPathTraversal(relPath: string, cwd: string): boolean { + const normalized = path.normalize(relPath) + if (normalized.includes("..")) return true + const resolved = path.resolve(cwd, relPath) + return !resolved.startsWith(cwd) +} + export interface PreHookOptions { cwd: string /** Current intent ID set by select_active_intent (per task/session) */ @@ -61,6 +68,13 @@ export class PreHook { // Scope enforcement for write_to_file if (toolName === "write_to_file" && params.path) { const relPath = String(params.path) + // Path traversal: block paths that escape workspace (e.g. .. or absolute) + if (isPathTraversal(relPath, cwd)) { + return { + blocked: true, + error: `Path traversal not allowed: "${relPath}" would escape the workspace. Use a path relative to the workspace only.`, + } + } const context = await loadIntentContext(cwd, activeId) if (context && context.owned_scope.length > 0 && !pathInScope(relPath, context.owned_scope, cwd)) { return { @@ -71,19 +85,7 @@ export class PreHook { } } - // Optional: HITL for destructive tools (can be wired via askApproval in host) + // Optional: HITL for destructive tools (wire via askApproval in host / extension) return { blocked: false } } - - /** - * Optional: prompt for Human-in-the-Loop approval on destructive actions. - * Call this from the host when askApproval is invoked for destructive tools. - */ - static async askApprovalDestructive(toolName: string, message: string): Promise { - return new Promise((resolve) => { - vscode.window - .showWarningMessage(`Approve destructive action: ${toolName}?`, { modal: true }, "Approve", "Reject") - .then((choice) => resolve(choice === "Approve")) - }) - } }