created test file for the hook and check if the hooks (pre and post) hooks are working

This commit is contained in:
Bethel Yohannes 2026-02-21 11:40:17 +03:00
parent 56653f5dfd
commit 085f30872a
6 changed files with 288 additions and 16 deletions

View file

@ -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"

View file

@ -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"}]}]}]}

255
scripts/test-hooks.ts Normal file
View file

@ -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> | void): Promise<void> {
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("<intent_context>"), "root tag")
assert(xml.includes("<id>INT-001</id>"), "id")
assert(xml.includes("<constraint>"), "constraints")
assert(xml.includes("<scope>"), "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("<intent_context>"), "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)
})

View file

@ -45,7 +45,7 @@ export function buildIntentContextXml(context: IntentContext): string {
? context.owned_scope.map((s) => ` <scope>${escapeXml(s)}</scope>`).join("\n")
: " <scope>No scope restriction</scope>"
const criteriaXml =
(context.acceptance_criteria?.length ?? 0 > 0)
(context.acceptance_criteria?.length ?? 0) > 0
? context.acceptance_criteria!.map((a) => ` <criterion>${escapeXml(a)}</criterion>`).join("\n")
: " <criterion>None specified</criterion>"

View file

@ -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<boolean> {
return new Promise((resolve) => {
vscode.window
.showWarningMessage(`Approve destructive action: ${toolName}?`, { modal: true }, "Approve", "Reject")
.then((choice) => resolve(choice === "Approve"))
})
}
}