11#!/usr/bin/env node
2- // openvisio-gate — PreToolUse hook that ENFORCES "openvisio first".
2+ // openvisio-gate — PreToolUse hook that ENFORCES "openvisio first" PER TASK .
33//
44// An MCP server's `instructions` field is only advisory; the model can ignore
5- // it. This hook is the deterministic layer: it hard-blocks code-discovery tools
6- // (Read / Grep / Glob, and grep/find/rg/ag/cat/sed/awk via Bash) until an
7- // openvisio MCP tool has been called at least once in the session. The first
8- // openvisio call "primes" the session; after that every tool passes (so the
9- // agent can read the anchored slices the graph pointed it at).
5+ // it. This hook is the deterministic layer:
106//
11- // Why prime-once-per-session and not per-read: a hook cannot see task
12- // boundaries, and gating every read forever would make anchored-slice reads
13- // impossible. Priming forces openvisio-FIRST deterministically; the strengthened
14- // server `instructions` carry the "every task" expectation from there.
7+ // • Write Bash commands (edits, builds, commits) signal a task boundary and
8+ // clear the "primed" marker, forcing the agent to call resolve_context
9+ // again on the next task.
10+ // • Write commands also delete .openvisio/graph.json so the viewer
11+ // re-indexes on the next request.
12+ // • Read/Grep/Glob for non-code files (configs, docs, lockfiles) are always
13+ // allowed without priming.
14+ // • All other code-discovery tools (Read/Grep/Glob on code, grep/find/etc.
15+ // in Bash) are DENIED until an openvisio tool primes the session.
16+ //
17+ // The first openvisio tool call in each task "primes" the session; after that
18+ // all tools pass (anchored reads, git status, etc. all flow freely).
1519//
1620// Install in the repo you point the agent at — .claude/settings.json:
1721// {
3034// The matcher MUST include `mcp__openvisio__.*` so the hook also sees openvisio
3135// calls and can prime the session.
3236
33- import { readFileSync , existsSync , writeFileSync } from 'node:fs'
37+ import { existsSync , readFileSync , unlinkSync , writeFileSync } from 'node:fs'
3438import { tmpdir } from 'node:os'
3539import { join } from 'node:path'
3640
41+ // ---------------------------------------------------------------------------
42+ // Helpers
43+ // ---------------------------------------------------------------------------
44+
3745function readStdin ( ) {
3846 try {
3947 return readFileSync ( 0 , 'utf8' )
@@ -42,54 +50,159 @@ function readStdin() {
4250 }
4351}
4452
53+ /** Known READ-ONLY Bash commands — everything else is treated as a write
54+ * (task-boundary signal). */
55+ const READONLY_BASH = / \b ( g r e p | r g | a g | a c k | f i n d | c a t | h e a d | t a i l | w c | d i f f | e c h o | p w d | w h i c h | t y p e | f i l e | d u | d f | l s ) \b /
56+
57+ /** Bash commands that do code search — these are GATED (denied before prime)
58+ * just like Read/Grep/Glob. A subset of non-readonly Bash. */
59+ const SEARCH_BASH = / \b ( g r e p | r g | a g | a c k | f i n d | c a t | s e d | a w k | h e a d | t a i l | l s ) \b /
60+
61+ /** File extensions that are never code — reading these is always allowed. */
62+ const NON_CODE_EXT = / \. ( m d | j s o n | y a m l | y m l | t o m l | l o c k | t x t | c f g | i n i | e n v | g i t i g n o r e | d o c k e r i g n o r e | s v g | p n g | j p g | j p e g | g i f | i c o | w o f f 2 ? | t t f | e o t | c s v | s q l | l o g ) $ / i
63+
64+ function isNonCodeRead ( toolName , toolInput ) {
65+ if ( toolName === 'Read' ) {
66+ const path = String ( toolInput ?. file_path || toolInput ?. path || '' )
67+ return NON_CODE_EXT . test ( path )
68+ }
69+ if ( toolName === 'Grep' ) {
70+ // Grep config/docs files — typically --include or a path ending in a
71+ // non-code extension in the second arg. Heuristic: if the pattern itself
72+ // is short and not a code construct, allow. Simpler: always allow Grep
73+ // on paths that look like non-code.
74+ const include = String ( toolInput ?. include || '' )
75+ const path = String ( toolInput ?. path || '' )
76+ return NON_CODE_EXT . test ( include || path )
77+ }
78+ if ( toolName === 'Glob' ) {
79+ const pattern = String ( toolInput ?. pattern || '' )
80+ return NON_CODE_EXT . test ( pattern )
81+ }
82+ return false
83+ }
84+
85+ function deleteIfExists ( file ) {
86+ try {
87+ if ( existsSync ( file ) ) unlinkSync ( file )
88+ } catch {
89+ /* best-effort */
90+ }
91+ }
92+
93+ function repoRoot ( ) {
94+ // Walk up from cwd looking for .git or .openvisio.
95+ let dir = process . cwd ( )
96+ if ( ! dir . endsWith ( '/' ) ) dir += '/'
97+ for ( let i = 0 ; i < 20 ; i ++ ) {
98+ if ( existsSync ( join ( dir , '.git' ) ) || existsSync ( join ( dir , '.openvisio' ) ) ) {
99+ return dir . replace ( / \/ $ / , '' )
100+ }
101+ const parent = join ( dir , '..' )
102+ if ( parent === dir || parent . length >= dir . length ) break
103+ dir = parent
104+ }
105+ return process . cwd ( )
106+ }
107+
108+ // ---------------------------------------------------------------------------
109+ // Parse hook input
110+ // ---------------------------------------------------------------------------
111+
45112let input = { }
46113try {
47114 input = JSON . parse ( readStdin ( ) )
48115} catch {
49- // Malformed hook payload → fail open (never wedge the agent).
50116 process . exit ( 0 )
51117}
52118
53119const sessionId = String ( input . session_id || 'nosession' ) . replace ( / [ ^ a - z A - Z 0 - 9 _ - ] / g, '' )
54120const marker = join ( tmpdir ( ) , `openvisio-primed-${ sessionId } ` )
55121const toolName = String ( input . tool_name || '' )
122+ const toolInput = input . tool_input || { }
123+
124+ // ---------------------------------------------------------------------------
125+ // Rule 1: openvisio MCP tools always pass AND prime the session.
126+ // ---------------------------------------------------------------------------
56127
57- // Any openvisio tool primes the session and always passes.
58128if ( toolName . startsWith ( 'mcp__openvisio__' ) ) {
59129 try {
60130 writeFileSync ( marker , '1' )
61131 } catch {
62- /* best-effort; failing to write the marker only costs one extra nudge */
132+ /* best-effort */
63133 }
64134 process . exit ( 0 )
65135}
66136
67- // Already primed → trust the agent (it may read anchored slices, run git, etc.).
68- if ( existsSync ( marker ) ) process . exit ( 0 )
137+ // ---------------------------------------------------------------------------
138+ // Rule 2: Bash write commands signal a task boundary.
139+ // Clear the prime + delete stale graph.json.
140+ // ---------------------------------------------------------------------------
69141
70- // Pre-prime: is this a code-discovery action we gate?
71- const GATED_TOOLS = new Set ( [ 'Read' , 'Grep' , 'Glob' ] )
72- let gated = GATED_TOOLS . has ( toolName )
73142if ( toolName === 'Bash' ) {
74- const cmd = String ( input . tool_input ?. command || '' )
75- // Bash loopholes for code search: grep/rg/ag/ack/find/cat/sed/awk/head/tail/ls.
76- gated = / \b ( g r e p | r g | a g | a c k | f i n d | c a t | s e d | a w k | h e a d | t a i l | l s ) \b / . test ( cmd )
143+ const cmd = String ( toolInput . command || '' )
144+
145+ // Not a write command → pass through (may still be gated as search below).
146+ if ( ! READONLY_BASH . test ( cmd ) ) {
147+ // Write command detected — clear the prime marker for per-task gating.
148+ deleteIfExists ( marker )
149+ // Also blow away the viewer's stale cached graph.
150+ deleteIfExists ( join ( repoRoot ( ) , '.openvisio' , 'graph.json' ) )
151+ }
152+
153+ // Gate search commands before priming (same as pre-prime Read/Grep/Glob).
154+ if ( existsSync ( marker ) ) process . exit ( 0 )
155+ if ( SEARCH_BASH . test ( cmd ) ) {
156+ process . stdout . write (
157+ JSON . stringify ( {
158+ hookSpecificOutput : {
159+ hookEventName : 'PreToolUse' ,
160+ permissionDecision : 'deny' ,
161+ permissionDecisionReason :
162+ 'openvisio-gate: call the openvisio MCP first. Before searching code, ' +
163+ 'call `resolve_context` with your task (then find_symbol / get_neighborhood ' +
164+ '/ get_dependents) to get path:line anchors. Then retry this action.' ,
165+ } ,
166+ } ) ,
167+ )
168+ process . exit ( 0 )
169+ }
170+ process . exit ( 0 )
77171}
78172
79- if ( ! gated ) process . exit ( 0 )
173+ // ---------------------------------------------------------------------------
174+ // Rule 3: Already primed for this task → everything passes.
175+ // ---------------------------------------------------------------------------
176+
177+ if ( existsSync ( marker ) ) process . exit ( 0 )
178+
179+ // ---------------------------------------------------------------------------
180+ // Rule 4: Ungated tools (non-Bash, non-Read/Grep/Glob) pass through.
181+ // ---------------------------------------------------------------------------
182+
183+ const GATED_TOOLS = new Set ( [ 'Read' , 'Grep' , 'Glob' ] )
184+ if ( ! GATED_TOOLS . has ( toolName ) ) process . exit ( 0 )
185+
186+ // ---------------------------------------------------------------------------
187+ // Rule 5: Non-code file reads are always allowed.
188+ // ---------------------------------------------------------------------------
189+
190+ if ( isNonCodeRead ( toolName , toolInput ) ) process . exit ( 0 )
80191
81- const reason =
82- 'openvisio-gate: call the openvisio MCP first. Before reading, grepping, or ' +
83- 'globbing any file in this repo, call `resolve_context` with your task (then ' +
84- 'find_symbol / get_neighborhood / get_dependents) to get path:line anchors. ' +
85- 'Then retry this action.'
192+ // ---------------------------------------------------------------------------
193+ // Rule 6: Deny — call openvisio first.
194+ // ---------------------------------------------------------------------------
86195
87196process . stdout . write (
88197 JSON . stringify ( {
89198 hookSpecificOutput : {
90199 hookEventName : 'PreToolUse' ,
91200 permissionDecision : 'deny' ,
92- permissionDecisionReason : reason ,
201+ permissionDecisionReason :
202+ 'openvisio-gate: call the openvisio MCP first. Before reading, grepping, or ' +
203+ 'globbing any file in this repo, call `resolve_context` with your task (then ' +
204+ 'find_symbol / get_neighborhood / get_dependents) to get path:line anchors. ' +
205+ 'Then retry this action.' ,
93206 } ,
94207 } ) ,
95208)
0 commit comments