mirror of
https://github.com/Heretek-AI/IUMBTEMS.git
synced 2026-10-12 02:03:00 +00:00
Merge pull request #273 from Heretek-AI/audit/196-hooks
[Audit] Hook bridge: OpenCode v2 integration tests
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{"agent":"build","checks":{"contains":["HOOK:PreToolUse","HOOK:PostToolUse","HOOK:PostToolUseFailure","HOOK:ShellPreExec"],"toolsUsed":["read","bash"]},"description":"Hook bridge E2E: real opencode + .claude/settings.json from a real Claude Code project; each ENFORCED hook event fires a marker. Markers asserted via transcript; on-disk completeness needs kept-workspace review until a file-asserting fire kind exists.","files":{".claude/settings.json":"{\"hooks\":{\"PermissionRequest\":[{\"hooks\":[{\"command\":\"printf 'HOOK:PermissionRequest\\\\n' >> .factory/hooks.log\",\"type\":\"command\"}],\"matcher\":\"Bash\"}],\"PostToolUse\":[{\"hooks\":[{\"command\":\"printf 'HOOK:PostToolUse\\\\n' >> .factory/hooks.log\",\"type\":\"command\"}],\"matcher\":\"\"}],\"PostToolUseFailure\":[{\"hooks\":[{\"command\":\"printf 'HOOK:PostToolUseFailure\\\\n' >> .factory/hooks.log\",\"type\":\"command\"}],\"matcher\":\"\"}],\"PreToolUse\":[{\"hooks\":[{\"command\":\"exit 2\",\"type\":\"command\"}],\"matcher\":\"Bash(rm *)\"},{\"hooks\":[{\"command\":\"printf 'HOOK:PreToolUse\\\\n' >> .factory/hooks.log\",\"type\":\"command\"}],\"matcher\":\"\"}],\"SessionStart\":[{\"hooks\":[{\"command\":\"printf 'HOOK:SessionStart\\\\n' >> .factory/hooks.log\",\"type\":\"command\"}],\"matcher\":\"startup\"}],\"UserPromptSubmit\":[{\"hooks\":[{\"command\":\"printf 'HOOK:UserPromptSubmit\\\\n' >> .factory/hooks.log\",\"type\":\"command\"}],\"matcher\":\"\"}],\"x-es.ShellPreExec\":[{\"hooks\":[{\"command\":\"echo rewritten-by-hook\",\"type\":\"command\"}],\"matcher\":\"echo original\"}]}}","src/fail.sh":"exit 1\n","src/probe.txt":"hook bridge probe\n"},"id":"hooks-bridge","maxSteps":14,"prompt":"Follow these steps exactly, one tool call per step. (0) Run `mkdir -p .factory` with bash. (1) Read src/probe.txt with the read tool. (2) Run `echo hook-probe-ok` with bash. (3) Run `sh src/fail.sh` with bash (it exits 1; continue regardless). (4) Run `echo original` with bash (it will be rewritten; continue). (5) Run `cat .factory/hooks.log` with bash. (6) Narrate which HOOK: markers you observed. This run is headless: never call the question tool."}
|
||||
@@ -0,0 +1,262 @@
|
||||
// Issue #196 §2 — engine-level proof for every bridged hook event.
|
||||
//
|
||||
// Each event gets a hook that writes a marker file; the test drives the
|
||||
// matching engine method and asserts the marker landed. This complements
|
||||
// packages/opencode/test/hooks-events.test.ts, which drives the same events
|
||||
// through the real in-process OpenCode v2 host.
|
||||
//
|
||||
// NOTE (bug #265): hook commands run UNSANDBOXED with the full parent env
|
||||
// (run.ts uses inheritEnv:true and no sandbox knob). The "parent env is
|
||||
// visible" test below pins that observable behaviour without changing it —
|
||||
// Claude Code parity by design, not a sandbox.
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { type HumanSigner, sealHumanKey, unlockHumanKey } from "../src/approval/keystore.ts"
|
||||
import { fromOpenCode, HookEngine, type HookSource, trustProject } from "../src/index.ts"
|
||||
|
||||
let root: string
|
||||
let state: string
|
||||
const PASSPHRASE = "test-passphrase-196-events"
|
||||
let signer: HumanSigner
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), "es-hooks-events-"))
|
||||
state = await mkdtemp(path.join(tmpdir(), "es-hooks-events-state-"))
|
||||
await sealHumanKey(PASSPHRASE, state)
|
||||
signer = await unlockHumanKey(PASSPHRASE, state)
|
||||
await mkdir(path.join(root, ".opencode"), { recursive: true })
|
||||
})
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(state, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const sources = (): HookSource[] => [{ file: path.join(root, ".opencode/hooks.json"), scope: "project" }]
|
||||
async function engine(hooks: Record<string, unknown>) {
|
||||
await writeFile(path.join(root, ".opencode/hooks.json"), JSON.stringify({ hooks }))
|
||||
let created = await HookEngine.create({ root, stateDir: state, sources: sources(), audit: false })
|
||||
const status = created.status()
|
||||
await trustProject(root, status.projectHash, status.projectLines, { stateDir: state, kind: "hooks", signer })
|
||||
created = await HookEngine.create({ root, stateDir: state, sources: sources(), audit: false })
|
||||
return created
|
||||
}
|
||||
const base = { sessionId: "ses_196", agent: "build" }
|
||||
const marker = (name: string) => path.join(root, name)
|
||||
const touch = (name: string, extra = "") => `touch ${marker(name)}${extra ? `; ${extra}` : ""}`
|
||||
|
||||
describe("hook events write marker files (issue #196 §2)", () => {
|
||||
test("PreToolUse marker lands; exit 2 deny blocks the tool", async () => {
|
||||
const hooks = await engine({
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: "Write",
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: `${touch("PRE_196")}; input=$(cat); echo "$input" | grep -q "deny-pre-196" && { echo "pre-deny-196 blocked" >&2; exit 2; }; exit 0`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
const ok = await hooks.preToolUse(base, fromOpenCode("write", { path: "a.txt", content: "fine" }, root), "t1")
|
||||
expect(ok.decision).toBeUndefined()
|
||||
expect(await Bun.file(marker("PRE_196")).exists()).toBe(true)
|
||||
const denied = await hooks.preToolUse(
|
||||
base,
|
||||
fromOpenCode("write", { path: "b.txt", content: "deny-pre-196" }, root),
|
||||
"t2",
|
||||
)
|
||||
expect(denied).toMatchObject({ decision: "deny", reason: "pre-deny-196 blocked" })
|
||||
})
|
||||
|
||||
test("PostToolUse marker lands with additionalContext", async () => {
|
||||
const hooks = await engine({
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: "Read",
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: `${touch("POST_196")}; echo '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"POST-MARKER-196"}}'`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
const out = await hooks.postToolUse(base, fromOpenCode("read", { path: "a.txt" }, root), "t1", { ok: true }, false)
|
||||
expect(await Bun.file(marker("POST_196")).exists()).toBe(true)
|
||||
expect(out.additionalContext).toContain("POST-MARKER-196")
|
||||
})
|
||||
|
||||
test("PostToolUseFailure marker lands when driven with failed=true (bug #268)", async () => {
|
||||
const hooks = await engine({
|
||||
PostToolUseFailure: [
|
||||
{
|
||||
matcher: "Read",
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: `${touch("FAIL_196")}; echo '{"hookSpecificOutput":{"hookEventName":"PostToolUseFailure","additionalContext":"FAILURE-MARKER-196"}}'`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
// failed=false must NOT run the PostToolUseFailure handler.
|
||||
const ok = await hooks.postToolUse(base, fromOpenCode("read", { path: "a.txt" }, root), "t1", "content", false)
|
||||
expect(ok.additionalContext).toEqual([])
|
||||
expect(await Bun.file(marker("FAIL_196")).exists()).toBe(false)
|
||||
// failed=true routes to the PostToolUseFailure event (engine.ts).
|
||||
const failed = await hooks.postToolUse(
|
||||
base,
|
||||
fromOpenCode("read", { path: "missing.txt" }, root),
|
||||
"t2",
|
||||
"Error: file not found",
|
||||
true,
|
||||
)
|
||||
expect(await Bun.file(marker("FAIL_196")).exists()).toBe(true)
|
||||
expect(failed.additionalContext).toContain("FAILURE-MARKER-196")
|
||||
})
|
||||
|
||||
test("UserPromptSubmit marker lands; block blocks", async () => {
|
||||
const hooks = await engine({
|
||||
UserPromptSubmit: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: `${touch("PROMPT_196")}; grep -q "block-prompt-196" && echo '{"decision":"block","reason":"prompt blocked 196"}' || echo "PROMPT-MARKER-196"`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
const out = await hooks.userPromptSubmit(base, "hello")
|
||||
expect(await Bun.file(marker("PROMPT_196")).exists()).toBe(true)
|
||||
expect(out.additionalContext).toContain("PROMPT-MARKER-196")
|
||||
expect((await hooks.userPromptSubmit(base, "please block-prompt-196")).block).toContain("prompt blocked 196")
|
||||
})
|
||||
|
||||
test("SessionStart marker lands for startup only", async () => {
|
||||
const hooks = await engine({
|
||||
SessionStart: [
|
||||
{ matcher: "startup", hooks: [{ type: "command", command: touch("START_196") + "; echo START-MARKER-196" }] },
|
||||
],
|
||||
})
|
||||
const out = await hooks.sessionStart(base, "startup")
|
||||
expect(await Bun.file(marker("START_196")).exists()).toBe(true)
|
||||
expect(out.additionalContext).toContain("START-MARKER-196")
|
||||
await rm(marker("START_196"), { force: true })
|
||||
expect((await hooks.sessionStart(base, "compact")).additionalContext).toEqual([])
|
||||
expect(await Bun.file(marker("START_196")).exists()).toBe(false)
|
||||
})
|
||||
|
||||
test("PermissionRequest marker lands; deny JSON denies", async () => {
|
||||
const hooks = await engine({
|
||||
PermissionRequest: [
|
||||
{
|
||||
matcher: "Read",
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: `${touch("PERM_196")}; echo '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"perm-deny-196"}}}'`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
const out = await hooks.permissionRequest(base, fromOpenCode("read", { path: ".env" }, root))
|
||||
expect(await Bun.file(marker("PERM_196")).exists()).toBe(true)
|
||||
expect(out).toMatchObject({ behavior: "deny", message: "perm-deny-196" })
|
||||
})
|
||||
|
||||
test("Stop marker lands; exit 2 blocks (emulated after the turn, ADVISORY for plain chat)", async () => {
|
||||
const hooks = await engine({
|
||||
Stop: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
// The engine passes stop_hook_active in the input; the hook itself
|
||||
// suppresses the block on a continuation, as Claude Code does.
|
||||
command: `${touch("STOP_196")}; input=$(cat); echo "$input" | grep -q '"stop_hook_active":true' && exit 0; echo "stop-continue-196" >&2; exit 2`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
const out = await hooks.stop(base, { stopHookActive: false, lastAssistantMessage: "done" })
|
||||
expect(await Bun.file(marker("STOP_196")).exists()).toBe(true)
|
||||
expect(out.block).toContain("stop-continue-196")
|
||||
// stop_hook_active suppresses the block, as Claude Code does.
|
||||
expect((await hooks.stop(base, { stopHookActive: true, lastAssistantMessage: "done" })).block).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("x-es.ShellPreExec rewrite AND deny (bug #268)", () => {
|
||||
test("rewrite branch returns the replacement command", async () => {
|
||||
const hooks = await engine({
|
||||
"x-es.ShellPreExec": [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: `input=$(cat); echo "$input" | grep -q "rewrite-shell-196" && echo '{"hookSpecificOutput":{"updatedInput":{"command":"echo shell-rewritten-196"}}}' || true`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(await hooks.shellPreExec(base, { command: "echo rewrite-shell-196", cwd: root })).toMatchObject({
|
||||
command: "echo shell-rewritten-196",
|
||||
})
|
||||
expect((await hooks.shellPreExec(base, { command: "echo plain", cwd: root })).command).toBeUndefined()
|
||||
})
|
||||
|
||||
test("deny branch: exit 2 denies; deny wins over a simultaneous rewrite (command && !deny precedence)", async () => {
|
||||
const hooks = await engine({
|
||||
"x-es.ShellPreExec": [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: `input=$(cat); echo "$input" | grep -q "deny-shell-196" && { echo "shell-deny-196 blocked" >&2; exit 2; }; echo "$input" | grep -q "both-shell-196" && echo '{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"both denied 196","updatedInput":{"command":"echo should-not-run-196"}}}' || true`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
// Exit-2 deny carries the stderr reason.
|
||||
const denied = await hooks.shellPreExec(base, { command: "echo deny-shell-196", cwd: root })
|
||||
expect(denied.deny).toContain("shell-deny-196 blocked")
|
||||
expect(denied.command).toBeUndefined()
|
||||
// JSON deny with a simultaneous updatedInput.command: deny wins, command is suppressed.
|
||||
const both = await hooks.shellPreExec(base, { command: "echo both-shell-196", cwd: root })
|
||||
expect(both.deny).toContain("both denied 196")
|
||||
expect(both.command).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("hooks run unsandboxed with parent env (bug #265, observable behaviour)", () => {
|
||||
test("parent process env is visible inside a hook command", async () => {
|
||||
const key = "ES_HOOK_PARENT_PROBE_196"
|
||||
const value = `probe-196-${Date.now()}`
|
||||
process.env[key] = value
|
||||
try {
|
||||
const hooks = await engine({
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: "Bash",
|
||||
hooks: [{ type: "command", command: `echo "$${key}" > ${marker("ENV_196")}` }],
|
||||
},
|
||||
],
|
||||
})
|
||||
await hooks.preToolUse(base, fromOpenCode("shell", { command: "echo hi" }, root), "t1")
|
||||
expect((await Bun.file(marker("ENV_196")).text()).trim()).toBe(value)
|
||||
} finally {
|
||||
delete process.env[key]
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,247 @@
|
||||
// Issue #196 §2 — hook bridge on the real in-process OpenCode v2 host.
|
||||
//
|
||||
// One hook per event writes a marker file; the tests drive each event through
|
||||
// the host (scripted fake LLM) and assert the marker landed. Complements
|
||||
// packages/core/test/hooks-events.test.ts (engine-direct proof).
|
||||
//
|
||||
// NOTE (bug #265): hook commands run UNSANDBOXED with the full parent env
|
||||
// (core run.ts: inheritEnv:true, no sandbox knob). The PRE_ENV_196 assertion
|
||||
// pins that observable behaviour without changing it — Claude Code parity by
|
||||
// design, not a sandbox.
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import {
|
||||
commandSetHash,
|
||||
HookEngine,
|
||||
type HumanSigner,
|
||||
loadGatesConfig,
|
||||
sealHumanKey,
|
||||
trustProject,
|
||||
unlockHumanKey,
|
||||
} from "@heretek-ai/es-core"
|
||||
import { allText, boot, directiveScript, type Harness } from "@heretek-ai/es-testkit"
|
||||
|
||||
const pluginDir = path.resolve(import.meta.dir, "..")
|
||||
const call = (name: string, args: Record<string, unknown> = {}) => `@@CALL ${name} ${JSON.stringify(args)}@@`
|
||||
const json = (value: unknown) => `echo '${JSON.stringify(value)}'`
|
||||
const at = (name: string) => path.join(h.directory, name)
|
||||
const exists = (name: string) => Bun.file(at(name)).exists()
|
||||
|
||||
// Parent-env probe (bug #265): visible inside every hook command.
|
||||
const ENV_KEY = "ES_HOOK_PARENT_PROBE_196"
|
||||
const ENV_VALUE = `probe-196-${Date.now()}`
|
||||
|
||||
const hooks = {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: "Write",
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
// Always marks + records the parent env; denies only payloads carrying the canary.
|
||||
command: `touch "$CLAUDE_PROJECT_DIR/PRE_196"; echo "$${ENV_KEY}" > "$CLAUDE_PROJECT_DIR/PRE_ENV_196"; input=$(cat); echo "$input" | grep -q "deny-pre-196" && { echo "pre-deny-196 blocked" >&2; exit 2; }; exit 0`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: "Read",
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: `touch "$CLAUDE_PROJECT_DIR/POST_196"; ${json({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: "POST-MARKER-196" } })}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
PostToolUseFailure: [
|
||||
{
|
||||
matcher: "Read",
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
// Bug #268: this handler was routed but never proven — the failing-read
|
||||
// test below drives it through a real tool error.
|
||||
command: `touch "$CLAUDE_PROJECT_DIR/FAIL_196"; ${json({ hookSpecificOutput: { hookEventName: "PostToolUseFailure", additionalContext: "FAILURE-MARKER-196" } })}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
UserPromptSubmit: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: `touch "$CLAUDE_PROJECT_DIR/PROMPT_196"; echo PROMPT-MARKER-196`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
SessionStart: [
|
||||
{
|
||||
matcher: "startup",
|
||||
hooks: [{ type: "command", command: `touch "$CLAUDE_PROJECT_DIR/START_196"; echo START-MARKER-196` }],
|
||||
},
|
||||
],
|
||||
Stop: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
// Marker only, no block: proves the emulated Stop hook ran after the
|
||||
// turn without re-prompting (ADVISORY for plain chat).
|
||||
command: `touch "$CLAUDE_PROJECT_DIR/STOP_196"; exit 0`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
PermissionRequest: [
|
||||
{
|
||||
matcher: "Read",
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
// Runs only when the host would ask (effect ask — e.g. .env reads):
|
||||
// always marks, denies only .env payloads.
|
||||
command: `touch "$CLAUDE_PROJECT_DIR/PERM_196"; input=$(cat); echo "$input" | grep -q "\\.env" && ${json({ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "deny", message: "perm-deny-196 blocked" } } })} || ${json({ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "allow" } } })}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const extraHooks = {
|
||||
hooks: {
|
||||
"x-es.ShellPreExec": [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
// Bug #268: rewrite was proven, deny was not. Always marks; denies
|
||||
// *deny-shell-196*, rewrites *rewrite-shell-196*.
|
||||
command: `touch "$CLAUDE_PROJECT_DIR/SHELL_196"; input=$(cat); if echo "$input" | grep -q "deny-shell-196"; then ${json({ hookSpecificOutput: { permissionDecision: "deny", permissionDecisionReason: "shell-deny-196 blocked" } })}; elif echo "$input" | grep -q "rewrite-shell-196"; then ${json({ hookSpecificOutput: { updatedInput: { command: "echo shell-rewritten-196" } } })}; fi; true`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
let state: string
|
||||
let h: Harness
|
||||
|
||||
async function waitFor(name: string, timeoutMs = 15_000): Promise<boolean> {
|
||||
const start = Date.now()
|
||||
for (;;) {
|
||||
if (await exists(name)) return true
|
||||
if (Date.now() - start > timeoutMs) return false
|
||||
await Bun.sleep(200)
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env[ENV_KEY] = ENV_VALUE
|
||||
state = await mkdtemp(path.join(tmpdir(), "es-hookbridge-ev-state-"))
|
||||
await sealHumanKey("test-passphrase-1234", state)
|
||||
const signer: HumanSigner = await unlockHumanKey("test-passphrase-1234", state)
|
||||
h = await boot({
|
||||
git: true,
|
||||
script: directiveScript,
|
||||
plugins: [{ path: pluginDir, options: { stateDir: state, pr: "off" } }],
|
||||
files: {
|
||||
"README.md": "# hooks-events\n",
|
||||
".env": "SECRET=1\n",
|
||||
".claude/settings.json": JSON.stringify({ hooks }),
|
||||
".opencode/hooks.json": JSON.stringify(extraHooks),
|
||||
},
|
||||
})
|
||||
// Trust the project hook set through the human key, as hooks.test.ts does.
|
||||
const { config } = await loadGatesConfig(h.directory)
|
||||
const gates = await commandSetHash(h.directory, config.commands)
|
||||
const status = (await HookEngine.create({ root: h.directory, stateDir: state, audit: false })).status()
|
||||
await trustProject(h.directory, gates.hash, gates.lines, { stateDir: state, signer })
|
||||
if (status.projectHash)
|
||||
await trustProject(h.directory, status.projectHash, status.projectLines, {
|
||||
stateDir: state,
|
||||
kind: "hooks",
|
||||
signer,
|
||||
})
|
||||
}, 60_000)
|
||||
afterAll(async () => {
|
||||
delete process.env[ENV_KEY]
|
||||
await h?.close()
|
||||
await rm(state, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("hook bridge markers on the real host (issue #196 §2)", () => {
|
||||
test("SessionStart + UserPromptSubmit hooks mark every session", async () => {
|
||||
await h.run("hello marker run")
|
||||
expect(await exists("START_196")).toBe(true)
|
||||
expect(await exists("PROMPT_196")).toBe(true)
|
||||
})
|
||||
|
||||
test("PreToolUse hook marks the write; parent env is visible unsandboxed (bug #265)", async () => {
|
||||
const { tools } = await h.run(call("write", { path: "ok-196.txt", content: "fine" }))
|
||||
expect(tools[0]?.status).toBe("completed")
|
||||
expect(await exists("PRE_196")).toBe(true)
|
||||
expect((await Bun.file(at("PRE_ENV_196")).text()).trim()).toBe(ENV_VALUE)
|
||||
})
|
||||
|
||||
test("PreToolUse deny (exit 2) blocks the tool", async () => {
|
||||
const { tools } = await h.run(call("write", { path: "blocked-196.txt", content: "deny-pre-196 please" }))
|
||||
expect(tools[0]?.status).toBe("error")
|
||||
expect(tools[0]?.text).toContain("pre-deny-196 blocked")
|
||||
expect(await Bun.file(at("blocked-196.txt")).exists()).toBe(false)
|
||||
})
|
||||
|
||||
test("PostToolUse hook marks a successful read", async () => {
|
||||
const { tools } = await h.run(call("read", { path: "README.md" }))
|
||||
expect(tools[0]?.status).toBe("completed")
|
||||
expect(tools[0]?.text).toContain("POST-MARKER-196")
|
||||
expect(await exists("POST_196")).toBe(true)
|
||||
})
|
||||
|
||||
test("PostToolUseFailure hook marks a failing read (bug #268)", async () => {
|
||||
const { tools } = await h.run(call("read", { path: "does-not-exist-196.txt" }))
|
||||
expect(tools[0]?.status).toBe("error")
|
||||
// The bridge routes failures to PostToolUseFailure (hooks.ts); the marker
|
||||
// proves the handler ran for a real tool error. Observed: the handler's
|
||||
// additionalContext is NOT appended to the tool error text (bridge returns
|
||||
// early for failed tools) — routing proven, feedback path not.
|
||||
expect(await waitFor("FAIL_196")).toBe(true)
|
||||
})
|
||||
|
||||
test("PermissionRequest hook marks and denies an .env read", async () => {
|
||||
const { tools } = await h.run(call("read", { path: ".env" }))
|
||||
expect(tools[0]?.status).toBe("error")
|
||||
expect(tools[0]?.text).toContain("perm-deny-196 blocked")
|
||||
expect(await exists("PERM_196")).toBe(true)
|
||||
})
|
||||
|
||||
test("x-es.ShellPreExec rewrites a matching command", async () => {
|
||||
const { tools } = await h.run(call("shell", { command: "echo rewrite-shell-196" }))
|
||||
expect(tools[0]?.text).toContain("shell-rewritten-196")
|
||||
expect(await exists("SHELL_196")).toBe(true)
|
||||
})
|
||||
|
||||
test("x-es.ShellPreExec deny substitutes exit 126 (bug #268)", async () => {
|
||||
const { tools } = await h.run(call("shell", { command: "echo deny-shell-196" }))
|
||||
// The bridge replaces the denied command with `printf … >&2; exit 126`
|
||||
// (hooks.ts): the block reason surfaces as the tool output.
|
||||
expect(tools[0]?.text).toContain("shell-deny-196 blocked")
|
||||
expect(tools[0]?.text).toContain("Exited with code 126")
|
||||
})
|
||||
|
||||
test("Stop hook marker lands after the turn (emulated, ADVISORY for plain chat)", async () => {
|
||||
await h.run(call("read", { path: "README.md" }))
|
||||
expect(await waitFor("STOP_196")).toBe(true)
|
||||
})
|
||||
|
||||
test("UserPromptSubmit context reaches the model", async () => {
|
||||
await h.run("an ordinary prompt 196")
|
||||
expect(allText(h.llm.requests.at(-1))).toContain("PROMPT-MARKER-196")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user