mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-13 23:11:10 +00:00
created test file for the hook and check if the hooks (pre and post) hooks are working
This commit is contained in:
parent
56653f5dfd
commit
085f30872a
6 changed files with 288 additions and 16 deletions
12
.orchestration/active_intents.yaml
Normal file
12
.orchestration/active_intents.yaml
Normal 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"
|
||||
|
||||
3
.orchestration/agent_trace.jsonl
Normal file
3
.orchestration/agent_trace.jsonl
Normal 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
255
scripts/test-hooks.ts
Normal 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)
|
||||
})
|
||||
|
|
@ -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>"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue