Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 142 additions & 29 deletions mcp/hooks/openvisio-gate.mjs
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
#!/usr/bin/env node
// openvisio-gate — PreToolUse hook that ENFORCES "openvisio first".
// openvisio-gate — PreToolUse hook that ENFORCES "openvisio first" PER TASK.
//
// An MCP server's `instructions` field is only advisory; the model can ignore
// it. This hook is the deterministic layer: it hard-blocks code-discovery tools
// (Read / Grep / Glob, and grep/find/rg/ag/cat/sed/awk via Bash) until an
// openvisio MCP tool has been called at least once in the session. The first
// openvisio call "primes" the session; after that every tool passes (so the
// agent can read the anchored slices the graph pointed it at).
// it. This hook is the deterministic layer:
//
// Why prime-once-per-session and not per-read: a hook cannot see task
// boundaries, and gating every read forever would make anchored-slice reads
// impossible. Priming forces openvisio-FIRST deterministically; the strengthened
// server `instructions` carry the "every task" expectation from there.
// • Write Bash commands (edits, builds, commits) signal a task boundary and
// clear the "primed" marker, forcing the agent to call resolve_context
// again on the next task.
// • Write commands also delete .openvisio/graph.json so the viewer
// re-indexes on the next request.
// • Read/Grep/Glob for non-code files (configs, docs, lockfiles) are always
// allowed without priming.
// • All other code-discovery tools (Read/Grep/Glob on code, grep/find/etc.
// in Bash) are DENIED until an openvisio tool primes the session.
//
// The first openvisio tool call in each task "primes" the session; after that
// all tools pass (anchored reads, git status, etc. all flow freely).
//
// Install in the repo you point the agent at — .claude/settings.json:
// {
Expand All @@ -30,10 +34,14 @@
// The matcher MUST include `mcp__openvisio__.*` so the hook also sees openvisio
// calls and can prime the session.

import { readFileSync, existsSync, writeFileSync } from 'node:fs'
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function readStdin() {
try {
return readFileSync(0, 'utf8')
Expand All @@ -42,54 +50,159 @@ function readStdin() {
}
}

/** Known READ-ONLY Bash commands — everything else is treated as a write
* (task-boundary signal). */
const READONLY_BASH = /\b(grep|rg|ag|ack|find|cat|head|tail|wc|diff|echo|pwd|which|type|file|du|df|ls)\b/

/** Bash commands that do code search — these are GATED (denied before prime)
* just like Read/Grep/Glob. A subset of non-readonly Bash. */
const SEARCH_BASH = /\b(grep|rg|ag|ack|find|cat|sed|awk|head|tail|ls)\b/

/** File extensions that are never code — reading these is always allowed. */
const NON_CODE_EXT = /\.(md|json|yaml|yml|toml|lock|txt|cfg|ini|env|gitignore|dockerignore|svg|png|jpg|jpeg|gif|ico|woff2?|ttf|eot|csv|sql|log)$/i

function isNonCodeRead(toolName, toolInput) {
if (toolName === 'Read') {
const path = String(toolInput?.file_path || toolInput?.path || '')
return NON_CODE_EXT.test(path)
}
if (toolName === 'Grep') {
// Grep config/docs files — typically --include or a path ending in a
// non-code extension in the second arg. Heuristic: if the pattern itself
// is short and not a code construct, allow. Simpler: always allow Grep
// on paths that look like non-code.
const include = String(toolInput?.include || '')
const path = String(toolInput?.path || '')
return NON_CODE_EXT.test(include || path)
}
if (toolName === 'Glob') {
const pattern = String(toolInput?.pattern || '')
return NON_CODE_EXT.test(pattern)
}
return false
}

function deleteIfExists(file) {
try {
if (existsSync(file)) unlinkSync(file)
} catch {
/* best-effort */
}
}

function repoRoot() {
// Walk up from cwd looking for .git or .openvisio.
let dir = process.cwd()
if (!dir.endsWith('/')) dir += '/'
for (let i = 0; i < 20; i++) {
if (existsSync(join(dir, '.git')) || existsSync(join(dir, '.openvisio'))) {
return dir.replace(/\/$/, '')
}
const parent = join(dir, '..')
if (parent === dir || parent.length >= dir.length) break
dir = parent
}
return process.cwd()
}

// ---------------------------------------------------------------------------
// Parse hook input
// ---------------------------------------------------------------------------

let input = {}
try {
input = JSON.parse(readStdin())
} catch {
// Malformed hook payload → fail open (never wedge the agent).
process.exit(0)
}

const sessionId = String(input.session_id || 'nosession').replace(/[^a-zA-Z0-9_-]/g, '')
const marker = join(tmpdir(), `openvisio-primed-${sessionId}`)
const toolName = String(input.tool_name || '')
const toolInput = input.tool_input || {}

// ---------------------------------------------------------------------------
// Rule 1: openvisio MCP tools always pass AND prime the session.
// ---------------------------------------------------------------------------

// Any openvisio tool primes the session and always passes.
if (toolName.startsWith('mcp__openvisio__')) {
try {
writeFileSync(marker, '1')
} catch {
/* best-effort; failing to write the marker only costs one extra nudge */
/* best-effort */
}
process.exit(0)
}

// Already primed → trust the agent (it may read anchored slices, run git, etc.).
if (existsSync(marker)) process.exit(0)
// ---------------------------------------------------------------------------
// Rule 2: Bash write commands signal a task boundary.
// Clear the prime + delete stale graph.json.
// ---------------------------------------------------------------------------

// Pre-prime: is this a code-discovery action we gate?
const GATED_TOOLS = new Set(['Read', 'Grep', 'Glob'])
let gated = GATED_TOOLS.has(toolName)
if (toolName === 'Bash') {
const cmd = String(input.tool_input?.command || '')
// Bash loopholes for code search: grep/rg/ag/ack/find/cat/sed/awk/head/tail/ls.
gated = /\b(grep|rg|ag|ack|find|cat|sed|awk|head|tail|ls)\b/.test(cmd)
const cmd = String(toolInput.command || '')

// Not a write command → pass through (may still be gated as search below).
if (!READONLY_BASH.test(cmd)) {
// Write command detected — clear the prime marker for per-task gating.
deleteIfExists(marker)
// Also blow away the viewer's stale cached graph.
deleteIfExists(join(repoRoot(), '.openvisio', 'graph.json'))
}

// Gate search commands before priming (same as pre-prime Read/Grep/Glob).
if (existsSync(marker)) process.exit(0)
if (SEARCH_BASH.test(cmd)) {
process.stdout.write(
JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason:
'openvisio-gate: call the openvisio MCP first. Before searching code, ' +
'call `resolve_context` with your task (then find_symbol / get_neighborhood ' +
'/ get_dependents) to get path:line anchors. Then retry this action.',
},
}),
)
process.exit(0)
}
process.exit(0)
}

if (!gated) process.exit(0)
// ---------------------------------------------------------------------------
// Rule 3: Already primed for this task → everything passes.
// ---------------------------------------------------------------------------

if (existsSync(marker)) process.exit(0)

// ---------------------------------------------------------------------------
// Rule 4: Ungated tools (non-Bash, non-Read/Grep/Glob) pass through.
// ---------------------------------------------------------------------------

const GATED_TOOLS = new Set(['Read', 'Grep', 'Glob'])
if (!GATED_TOOLS.has(toolName)) process.exit(0)

// ---------------------------------------------------------------------------
// Rule 5: Non-code file reads are always allowed.
// ---------------------------------------------------------------------------

if (isNonCodeRead(toolName, toolInput)) process.exit(0)

const reason =
'openvisio-gate: call the openvisio MCP first. Before reading, grepping, or ' +
'globbing any file in this repo, call `resolve_context` with your task (then ' +
'find_symbol / get_neighborhood / get_dependents) to get path:line anchors. ' +
'Then retry this action.'
// ---------------------------------------------------------------------------
// Rule 6: Deny — call openvisio first.
// ---------------------------------------------------------------------------

process.stdout.write(
JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: reason,
permissionDecisionReason:
'openvisio-gate: call the openvisio MCP first. Before reading, grepping, or ' +
'globbing any file in this repo, call `resolve_context` with your task (then ' +
'find_symbol / get_neighborhood / get_dependents) to get path:line anchors. ' +
'Then retry this action.',
},
}),
)
Expand Down
4 changes: 4 additions & 0 deletions ui/app/api/local-graph/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@ function cliPath(): string {
return process.env.OPENVISIO_CLI || path.resolve(process.cwd(), '..', 'mcp', 'dist', 'cli.js')
}

const GRAPH_TTL_MS = Number(process.env.OPENVISIO_GRAPH_TTL_MIN || '60') * 60 * 1000

function isGraphFresh(outFile: string, repoPath: string): boolean {
try {
const graphStat = fs.statSync(outFile)
// TTL: if the cached graph is older than the max age, treat as stale.
if (Date.now() - graphStat.mtimeMs > GRAPH_TTL_MS) return false
// Compare against .git/index which git touches on any staged/committed change.
// If .git/index doesn't exist (bare checkout, tarball), treat as stale.
const ref = path.join(repoPath, '.git', 'index')
Expand Down
Loading