-
Notifications
You must be signed in to change notification settings - Fork 0
feat(hook): add fact-checking PreToolUse hook for Write/Edit tools #83
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
| @@ -0,0 +1,22 @@ | ||||
| #!/bin/sh | ||||
| # enforce-factcheck-before-edit.sh | ||||
| # PreToolUse hook: warns when Write/Edit tools are invoked without evidence of prior research | ||||
| # Exit 0 = pass (advisory only), stderr message becomes hook context | ||||
|
|
||||
| TOOL="$OPENCODE_TOOL_NAME" | ||||
| INPUT="$OPENCODE_TOOL_INPUT" | ||||
|
|
||||
| # Only apply to write/edit tools | ||||
| case "$TOOL" in | ||||
| write|edit) ;; | ||||
| *) exit 0 ;; | ||||
| esac | ||||
|
|
||||
| # Check if tool input contains evidence of prior file reading | ||||
| if printf '%s\n' "$INPUT" | grep -qiE '(based on reading|as shown in|from the file|grep result|read tool output|line [0-9]+)'; then | ||||
| echo "FACT-CHECK: Evidence of prior research detected" >&2 | ||||
|
||||
| echo "FACT-CHECK: Evidence of prior research detected" >&2 |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -122,4 +122,16 @@ | |||||||||||||||||||||
| "github-triage": false, | ||||||||||||||||||||||
| "github-pr-search": false, | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| "hooks": { | ||||||||||||||||||||||
| "PreToolUse": [ | ||||||||||||||||||||||
| { | ||||||||||||||||||||||
| "command": ".opencode/hooks/enforce-factcheck-before-edit.sh", | ||||||||||||||||||||||
| "matcher": "write", | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| { | ||||||||||||||||||||||
| "command": ".opencode/hooks/enforce-factcheck-before-edit.sh", | ||||||||||||||||||||||
|
Comment on lines
+128
to
+132
|
||||||||||||||||||||||
| "command": ".opencode/hooks/enforce-factcheck-before-edit.sh", | |
| "matcher": "write", | |
| }, | |
| { | |
| "command": ".opencode/hooks/enforce-factcheck-before-edit.sh", | |
| "command": "sh \"$OPENCODE_PROJECT_DIR/.opencode/hooks/enforce-factcheck-before-edit.sh\"", | |
| "matcher": "write", | |
| }, | |
| { | |
| "command": "sh \"$OPENCODE_PROJECT_DIR/.opencode/hooks/enforce-factcheck-before-edit.sh\"", |
Copilot
AI
Apr 5, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This PreToolUse hook only matches write/edit, but OpenCode can disable those tools in favor of apply_patch (and edits can also happen via multiedit). In those cases the fact-check hook won’t run at all, which undermines the intended coverage; consider adding matchers (and corresponding script support) for the other edit-capable tools.
| }, | |
| }, | |
| { | |
| "command": ".opencode/hooks/enforce-factcheck-before-edit.sh", | |
| "matcher": "apply_patch", | |
| }, | |
| { | |
| "command": ".opencode/hooks/enforce-factcheck-before-edit.sh", | |
| "matcher": "multiedit", | |
| }, |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import path from "node:path" | ||
| import { describe, expect, test } from "bun:test" | ||
| import { runHook, type HookEnv } from "../../src/hook" | ||
|
|
||
| const SCRIPT_PATH = path.resolve( | ||
| import.meta.dir, | ||
| "../../../../.opencode/hooks/enforce-factcheck-before-edit.sh", | ||
| ) | ||
|
|
||
| function makeEnv(toolName: string, toolInput: string): HookEnv { | ||
| return { | ||
| OPENCODE_HOOK_EVENT: "PreToolUse", | ||
| OPENCODE_TOOL_NAME: toolName, | ||
| OPENCODE_TOOL_INPUT: toolInput, | ||
| OPENCODE_PROJECT_DIR: "/tmp/test", | ||
| OPENCODE_SESSION_ID: "test-session", | ||
| } | ||
| } | ||
|
|
||
| describe("enforce-factcheck-before-edit", () => { | ||
| test("passes non-edit tools without message", async () => { | ||
| const result = await runHook({ command: SCRIPT_PATH }, makeEnv("bash", "{}")) | ||
| expect(result.action).toBe("pass") | ||
| expect(result.message).toBeUndefined() | ||
| }) | ||
|
|
||
| test("passes non-edit tool 'read'", async () => { | ||
| const result = await runHook({ command: SCRIPT_PATH }, makeEnv("read", '{"file":"/tmp/x"}')) | ||
| expect(result.action).toBe("pass") | ||
| expect(result.message).toBeUndefined() | ||
| }) | ||
|
|
||
| test("warns on Write without evidence", async () => { | ||
| const result = await runHook( | ||
| { command: SCRIPT_PATH }, | ||
| makeEnv("Write", '{"file_path":"/tmp/test.ts","content":"hello"}'), | ||
| ) | ||
| expect(result.action).toBe("pass") | ||
| expect(result.message).toContain("FACT-CHECK WARNING") | ||
| }) | ||
|
|
||
| test("warns on edit without evidence", async () => { | ||
| const result = await runHook( | ||
| { command: SCRIPT_PATH }, | ||
| makeEnv("edit", '{"old_string":"foo","new_string":"bar"}'), | ||
| ) | ||
|
Comment on lines
+33
to
+46
|
||
| expect(result.action).toBe("pass") | ||
| expect(result.message).toContain("FACT-CHECK WARNING") | ||
| }) | ||
|
|
||
| test("passes Edit with evidence phrase 'based on reading'", async () => { | ||
| const result = await runHook( | ||
| { command: SCRIPT_PATH }, | ||
| makeEnv("Edit", '{"old_string":"based on reading the config"}'), | ||
| ) | ||
| expect(result.action).toBe("pass") | ||
| expect(result.message).toContain("Evidence of prior research detected") | ||
| }) | ||
|
|
||
| test("passes Write with evidence phrase 'line 42'", async () => { | ||
| const result = await runHook( | ||
| { command: SCRIPT_PATH }, | ||
| makeEnv("Write", '{"content":"fix at line 42"}'), | ||
| ) | ||
| expect(result.action).toBe("pass") | ||
| expect(result.message).toContain("Evidence of prior research detected") | ||
| }) | ||
|
|
||
| test("passes write with evidence phrase 'grep result'", async () => { | ||
| const result = await runHook( | ||
| { command: SCRIPT_PATH }, | ||
| makeEnv("write", '{"content":"grep result shows usage"}'), | ||
| ) | ||
| expect(result.action).toBe("pass") | ||
| expect(result.message).toContain("Evidence of prior research detected") | ||
| }) | ||
|
|
||
| test("passes Edit with evidence phrase 'read tool output'", async () => { | ||
| const result = await runHook( | ||
| { command: SCRIPT_PATH }, | ||
| makeEnv("Edit", '{"old_string":"per read tool output"}'), | ||
| ) | ||
| expect(result.action).toBe("pass") | ||
| expect(result.message).toContain("Evidence of prior research detected") | ||
| }) | ||
|
|
||
| test("never blocks (advisory only)", async () => { | ||
| const result = await runHook( | ||
| { command: SCRIPT_PATH }, | ||
| makeEnv("Write", '{"file_path":"/tmp/x","content":"no evidence here"}'), | ||
| ) | ||
| expect(result.action).toBe("pass") | ||
| }) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider using
printf '%s' "$INPUT"instead ofecho "$INPUT"for POSIX portability and to avoid any shell-specificechoedge cases (even though JSON usually starts with{).