/** * Opencode plugin -- guardrails skeleton. * * A lightweight pre-flight guard for tool.execute hooks, gated behind a * per-project config file and a "boulder" that represents an active opencode * work session. Rules are loaded from a JSON config and may be in * block/warn/off modes; scope constraints enforce that a tool is only allowed * when the calling session falls within the boulder's work scope. * * Failure policy (Design C): any internal exception -- config load, log write, * boulder read, session walk -- allows the call through and logs an * `internal_error` line. Only a deliberate rule violation throws (blocking). * * Install: * command cp -f deploy/opencode-plugin/guardrails.js ~/.config/opencode/plugins/ * * Config shape (`.omc/guardrails.json`): * { * "rules": { "": "block" | "warn" | "off", ... }, * "log": ".omc/guardrails.log", // path relative to * "boulder": { ... }, // (optional) embedded * "protected_ports": [8080], // (optional) port list * "tick_gate": { // (optional) gate cfg * "cmd": ["python3", "{directory}/scripts/verify_commit.py", * "--repo", "{worktree}", "--require-clean", "HEAD"], * "timeout_s": 900 * } * } * * Config defaults: rules{} => all block, log => `.omc/guardrails.log`, * protected_ports => [8080]. * * Rule IDs implemented (Design C table): * - task_needs_agent -- block task/call_omo_agent missing category, * subagent_type, AND task_id. * - task_banned_agent -- block task/call_omo_agent whose subagent_type * starts with "oh-my-claudecode:". * - task_worktree_line -- prepend WORKTREE line when boulder is active * with worktree_path, or block when existing * WORKTREE path differs. * - write_outside_worktree -- block writes to directory outside directory/.omo/ * when the boulder has a worktree_path. * - bash_main_checkout -- block git operations and redirections into the * main checkout when boulder is active with * worktree_path. * - bash_banned -- block banned shell commands regardless of boulder * (always-scope, no boulder gate). * - bash_protected_port -- block curl/wget targeting protected ports * regardless of boulder (always-scope, no boulder gate). * - plan_tick_gate -- refuse to tick a todo while verify_commit fails. * * Boulder v2 fields used: boulder.status, boulder.session_ids, * boulder.worktree_path. */ import { readFileSync, existsSync, statSync, mkdirSync, openSync, closeSync, writeSync } from "node:fs"; import { join } from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { setTimeout } from "node:timers/promises"; const execFileAsync = promisify(execFile); // --------------------------------------------------------------------------- // Config load with mtime cache // --------------------------------------------------------------------------- const CONFIG_FILE = ".omo/guardrails.json"; /** Load and parse guardrails config from directory, with mtime cache. * Returns { config, parseOk, error } or null if file absent. * * The cache is keyed by (directory, mtime) so that different directories * with the same mtime don't accidentally share stale state. * * @param {string} directory -- project directory * @param {string} [base] -- base dir for artifacts; defaults to `/.omo` */ let _config = null; let _configMtime = 0; let _configDir = null; let _configBase = null; function loadConfig(directory, base) { const cfgBase = base ?? join(directory, ".omo"); const configPath = join(cfgBase, "guardrails.json"); if (!existsSync(configPath)) return null; const st = statSync(configPath); const mtime = Number(st.mtimeMs); // Return cached only if both directory, base, and mtime match if (_config && _configMtime === mtime && _configDir === directory && _configBase === cfgBase) return _config; try { const raw = readFileSync(configPath, "utf-8"); const parsed = JSON.parse(raw); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new SyntaxError("invalid JSON"); } const rules = parsed.rules || {}; const protectedPorts = Array.isArray(parsed.protected_ports) ? parsed.protected_ports : [8080]; _config = { config: { rules, logPath: parsed.log, protected_ports: protectedPorts, tick_gate: parsed.tick_gate, }, parseOk: true, }; _configMtime = mtime; _configDir = directory; _configBase = cfgBase; return _config; } catch (err) { _config = { config: null, parseOk: false, error: err }; _configMtime = mtime; _configBase = cfgBase; return _config; } } // --------------------------------------------------------------------------- // JSON-line log // --------------------------------------------------------------------------- let _logFd = null; let _logPath = null; function _openLog(logPath, directory, base) { const cfgBase = base ?? join(directory, ".omo"); if (!logPath || logPath.startsWith("/")) { _logPath = logPath || join(cfgBase, "guardrails.log"); } else { _logPath = join(directory, logPath); } const parentDir = join(_logPath, ".."); try { mkdirSync(parentDir, { recursive: true }); } catch { /* non-fatal */ } try { _logFd = openSync(_logPath, "a"); } catch { _logFd = null; } } function _writeLog(entry) { if (!_logFd) return; const line = JSON.stringify(entry) + "\n"; try { writeSync(_logFd, line); } catch { /* non-fatal */ } } function _closeLog() { if (_logFd !== null) { try { closeSync(_logFd); } catch { /* ignore */ } _logFd = null; } } /** Append a log line for a guardrails event. */ function logEvent(_config, sessionID, ruleID, toolName, detail) { const entry = { ts: new Date().toISOString(), session: sessionID, rule: ruleID, tool: toolName, detail, }; _writeLog(entry); } // --------------------------------------------------------------------------- // Boulder read (v2 schema with v1 fallback) // --------------------------------------------------------------------------- const BOULDER_FILE = ".omo/boulder.json"; /** Read boulder state from `/boulder.json`, cached by mtime. * * Schema v2: looks at `works[active_work_id]` first. * Fallback: top-level keys when `active_work_id` is absent. * * Returns the resolved boulder work object, or null if absent / error. * * @param {string} directory -- project directory * @param {string} [base] -- base dir for artifacts; defaults to `/.omo` */ let _boulder = null; let _boulderMtime = 0; let _boulderDir = null; let _boulderBase = null; async function readBoulder(directory, base) { const cfgBase = base ?? join(directory, ".omo"); const boulderPath = join(cfgBase, "boulder.json"); if (!existsSync(boulderPath)) return null; const st = statSync(boulderPath); const mtime = Number(st.mtimeMs); if (_boulder && _boulderMtime === mtime && _boulderDir === directory && _boulderBase === cfgBase) return _boulder; try { const raw = readFileSync(boulderPath, "utf-8"); const parsed = JSON.parse(raw); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new SyntaxError("invalid JSON"); } let result = null; // v2 schema: works[active_work_id] if (parsed.schema_version === 2) { const activeWorkId = parsed.active_work_id; if (activeWorkId && parsed.works && typeof parsed.works === "object") { result = parsed.works[activeWorkId]; } } // v1 fallback: top-level keys if (!result) { result = parsed; } if (result && typeof result === "object") { _boulder = result; _boulderMtime = mtime; _boulderDir = directory; _boulderBase = cfgBase; return _boulder; } return null; } catch { _boulder = null; _boulderMtime = mtime; _boulderDir = directory; _boulderBase = cfgBase; return null; } } // --------------------------------------------------------------------------- // Session scope walk // --------------------------------------------------------------------------- /** Check whether sessionID falls within the active work's scope. * * (B) rules apply ONLY when: * - boulder file exists and parses * - boulder.status === "active" * - active work has worktree_path * - sessionID (with "opencode:" prefix stripped) is in session_ids * or is a descendant (parentID walk, max 5 levels, 250 ms/lookup). * * Missing/unparsable/inactive boulder => returns false (no fallback to * "applies everywhere"). Always-scope rules ignore this check. * * When parent lookup fails, the session counts as in scope. */ async function checkScope(boulder, sessionID, client) { // No boulder, inactive, or missing worktree_path => (B) rules skip if (!boulder) return false; if (boulder.status !== "active") return false; if (!boulder.worktree_path) return false; const sessionIds = boulder.session_ids; if (!Array.isArray(sessionIds) || sessionIds.length === 0) return false; // Strip "opencode:" prefix for comparison (boulder stores prefixed IDs) const sessionPrefix = "opencode:"; const sessionKey = sessionID.startsWith(sessionPrefix) ? sessionID.slice(sessionPrefix.length) : sessionID; // Direct match for (const sid of sessionIds) { const sidKey = sid.startsWith(sessionPrefix) ? sid.slice(sessionPrefix.length) : sid; if (sidKey === sessionKey) return true; } // Walk parent chain (max 5 levels, 250 ms per lookup) let current = sessionID; const seen = new Set(); let depth = 0; while (current && !seen.has(current) && depth < 5) { seen.add(current); depth++; try { const result = await client.session.get({ path: { id: current } }); const parentID = result?.data?.parentID; if (!parentID) return false; for (const sid of sessionIds) { const sidKey = sid.startsWith(sessionPrefix) ? sid.slice(sessionPrefix.length) : sid; if (sidKey === parentID || parentID.startsWith(sessionPrefix + sidKey)) { return true; } } // 250 ms budget per lookup await setTimeout(250); current = parentID; } catch { // Failed lookup => counts as in scope return true; } } return false; } // --------------------------------------------------------------------------- // Path and CWD helpers // --------------------------------------------------------------------------- /** Resolve a filePath against directory. Absolute paths pass through. */ function _resolvePath(filePath, directory) { if (filePath.startsWith("/")) return filePath; return join(directory, filePath); } /** Check whether path is inside parent (same or starts with parent + /). */ function _isInside(path, parent) { if (!path || !parent) return false; const p = path.replace(/\/+$/, ""); const par = parent.replace(/\/+$/, ""); return p === par || p.startsWith(par + "/"); } /** * Resolve effective working directory for a bash command. * * Priority: * 1. `git -C ` in the command overrides everything. * 2. Last `cd ` or `pushd ` segment (quote-aware split). * 3. `workdir` arg from the tool input. * 4. `directory` (project root). */ function _resolveEffectiveCwd(command, workdir, directory) { if (!command) return workdir || directory; const segments = _splitSegments(command); let cwd = null; for (const seg of segments) { const stripped = _stripPrefixes(seg); if (/^\s*git\b/i.test(stripped)) { const tokens = stripped.split(/\s+/); for (let i = 1; i < tokens.length; i++) { if (tokens[i] === "-C" && i + 1 < tokens.length) { let p = tokens[i + 1]; if (p.startsWith("/")) { cwd = p; } else { cwd = join(directory, p); } break; } } } const stripped2 = _stripPrefixes(seg); const cdMatch = stripped2.match(/^\s*(?:cd|pushd)\s+(\S+)/); if (cdMatch) { const p = cdMatch[1]; if (p.startsWith("/")) { cwd = p; } else if (cwd) { cwd = join(cwd, p); } else { cwd = join(directory, p); } } } if (cwd) return cwd; if (workdir) return workdir; return directory; } /** * Extract file targets from shell redirects (>, >>, N>, &>, tee) in a command. * Returns absolute paths resolved against the given CWD. * * Single raw pass over the command, tracking quote state and heredoc bodies: * - Text inside single quotes, double quotes, and backticks never counts * (a `>` inside python -c "...", node -e "...", awk '...' is data). * - Heredoc bodies never count. The delimiter may be quoted * (`<< 'EOF'`, `<< "EOF"`) or bare (`< out.txt` writes out.txt). * - `tee ` counts only as the first word of a segment (after a * newline, `;`, `|`, `&`, or start), so a `tee` that is an argument * (`grep tee file`) never counts. * - `<<<` here-strings are skipped (redirects after them stay real). */ function _findRedirectTargets(command, cwd) { const targets = []; let i = 0; let inQuote = null; let inHeredoc = false; let heredocWord = ""; let atSegmentStart = true; while (i < command.length) { const ch = command[i]; if (inHeredoc) { if (i === 0 || command[i - 1] === "\n") { let end = i; while (end < command.length && command[end] !== "\n") end++; const line = command.slice(i, end).trim(); if (line === heredocWord) { inHeredoc = false; i = end; } } i++; continue; } if (inQuote) { if (ch === inQuote) inQuote = null; i++; continue; } if (ch === "'" || ch === '"' || ch === "`") { inQuote = ch; i++; continue; } // Heredoc start: <<, <<-, with bare or quoted delimiter word. if (i + 1 < command.length && command[i] === "<" && command[i + 1] === "<") { // Here-string (<<<): not a heredoc; redirects after it stay real. if (i + 2 < command.length && command[i + 2] === "<") { i += 3; continue; } inHeredoc = true; i += 2; if (i < command.length && command[i] === "-") i++; while (i < command.length && command[i] === " ") i++; let word = ""; const delimQuote = command[i] === "'" || command[i] === '"' ? command[i] : null; if (delimQuote) i++; while (i < command.length && /[A-Za-z0-9_]/.test(command[i])) { word += command[i]; i++; } if (delimQuote && i < command.length && command[i] === delimQuote) i++; if (word) { heredocWord = word; } else { // Unparseable delimiter: do not swallow the rest of the command. inHeredoc = false; } continue; } // Segment boundaries reset "first word of segment" tracking for tee. if (ch === ";" || ch === "|" || ch === "\n" || ch === "&") { atSegmentStart = true; i++; continue; } if (ch === ">") { const redirMatch = command.slice(i).match(/^(?:\d*>{1,2}|&>)\s*(\S+)/); if (redirMatch) { const target = redirMatch[1]; if (!target.startsWith("&")) { if (target.startsWith("/")) { targets.push(target); } else if (!target.startsWith("-")) { targets.push(join(cwd, target)); } } i += redirMatch[0].length; continue; } } if (ch === "t" && command.slice(i, i + 3) === "tee" && atSegmentStart && (i === 0 || /[\s|]/.test(command[i - 1]))) { const teeMatch = command.slice(i).match(/^tee\s+(\S+)/); if (teeMatch) { const target = teeMatch[1]; if (target.startsWith("/")) { targets.push(target); } else if (!target.startsWith("-")) { targets.push(join(cwd, target)); } i += teeMatch[0].length; continue; } } if (!/\s/.test(ch)) { atSegmentStart = false; } i++; } return targets; } // --------------------------------------------------------------------------- // Rule helpers // --------------------------------------------------------------------------- const MODE_WARN = "warn"; const MODE_OFF = "off"; /** Look up the mode for a rule; returns the configured mode or "block" if absent. */ function getRuleMode(config, ruleID) { return config?.rules?.[ruleID] ?? "block"; } /** * Strip single-quoted, double-quoted, and backtick-quoted strings * from a command segment by replacing their content with a space. * * Preserves unquoted characters so that patterns like `git add .` still * match after quotes are stripped from surrounding context. * * Uses `[\s\S]*?` (non-greedy) instead of `[^']*` etc. so that quoted * strings spanning multiple lines are correctly handled. * * Also strips `env` wrapper with its VAR=val assignments and options * (e.g. `env -i`, `env -u VAR`, `env GIT_X=1`) so that the underlying * command becomes visible to downstream patterns. */ function _stripQuotedStrings(s) { return s .replace(/'[\s\S]*?'/g, " ") .replace(/"[\s\S]*?"/g, " ") .replace(/`[\s\S]*?`/g, " "); } /** * Split a command on shell delimiters &&, ;, ||, |, and newlines. * * Quote-aware: delimiters inside single-quoted ('), double-quoted (""), or * backtick-quoted (`) strings do NOT cause a split. * * Heredoc-aware: < s.trim()) .filter((s) => s.length > 0); } /** * Strip leading VAR=value assignments, "sudo ", "command ", and "env" * wrappers (with optional -i / -u VAR options) from a segment, then * trim whitespace. * * The env handler skips env's arguments until finding the actual command, * so that `env GIT_X=1 git add .` => `git add .` and `env -u VAR -i git stash` * => `git stash`. This makes the underlying command visible to downstream * pattern matching. */ function _stripPrefixes(segment) { const tokens = segment.trimStart().split(/\s+/); let i = 0; // Skip leading VAR=value assignments. while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) { i++; } if (i < tokens.length && tokens[i].toLowerCase() === "env") { i++; // Skip env flag arguments until the actual command. while (i < tokens.length) { const tok = tokens[i]; if (/^-i$/.test(tok)) { i++; } else if (/^-u$/.test(tok)) { i++; // skip -u if (i < tokens.length) i++; // skip the variable name } else if (/^-[A-Za-z]$/.test(tok)) { i++; // skip flag if (i < tokens.length && !/^-/.test(tokens[i])) i++; // skip arg if not a flag } else if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tok)) { i++; // skip VAR=val } else { break; // reached the command } } } const result = tokens.slice(i); // Strip leading "sudo " or "command " while (result.length > 0 && /^(sudo|command)$/i.test(result[0])) { result.shift(); } return result.join(" ").trimStart(); } /** * Token-based detector for git commit with -a/--all/-am flags. * Mirrors checkBashMainCheckout's option-skipping loop (lines ~1188-1198) * to skip git global options (-c, -C) before the subcommand, then scans * for flag tokens after "commit" that carry the "all" intent. This * replaces the old /\bam\b/i word-match and regex patterns that fired on * "am" inside flag values or unrelated tokens like --diff-filter=AM. * * Returns {matched, desc} or null. */ function _matchGitCommitAll(bareSegment) { if (!/^git\b/i.test(bareSegment.trim())) return null; const tokens = bareSegment.trim().split(/\s+/); let subcmd = null; let subcmdIdx = -1; // Skip git global options before the subcommand for (let i = 1; i < tokens.length; i++) { const t = tokens[i]; if (/^-/.test(t)) { if (t.toLowerCase() === "-c" && i + 1 < tokens.length) { i++; // skip the option value (-c =, -C ) } continue; } subcmd = t; subcmdIdx = i; break; } if (subcmd !== "commit") return null; // Scan tokens after "commit" for -a/--all (NOT --amend) let found = null; // "git commit -am" | "git commit --all" | "git commit -a" for (let i = subcmdIdx + 1; i < tokens.length; i++) { const t = tokens[i]; if (t === "--amend") continue; if (t === "--all") { found = "git commit --all"; break; } if (t === "-a") { found = "git commit -a"; break; } if (/^-[A-Za-z]+$/.test(t) && t.includes("a")) { found = "git commit -am"; break; } } if (found) return { matched: found, desc: found }; return null; } /** * Check whether a bare segment (after prefix stripping) matches any banned * pattern. Returns {matched, desc} on match, or null. */ function _matchesBanned(bareSegment) { // --- git add (pathspec required) --- // git add -A / git add --all / git add . if (/^git\s+add\s+(-a|--all\b|\.(?:\s|$))/i.test(bareSegment)) return { matched: "git add -A/--all/. (full add)", desc: "git add -A/--all/." }; if (/^git\s+add\s+--\s/i.test(bareSegment)) return { matched: "git add -- (explicit pathspec)", desc: "git add --" }; // --- git commit with -a/--all/-am flags (NOT --amend) --- const commitAll = _matchGitCommitAll(bareSegment); if (commitAll) return commitAll; // --- git push --- if (/^git\s+push\b/i.test(bareSegment)) return { matched: "git push", desc: "git push" }; // --- git rebase --- if (/^git\s+rebase\b/i.test(bareSegment)) return { matched: "git rebase", desc: "git rebase" }; // --- git reset --soft --- if (/^git\s+reset\s+--soft\b/i.test(bareSegment)) return { matched: "git reset --soft", desc: "git reset --soft" }; // --- git merge --squash --- if (/^git\s+merge\s+--squash\b/i.test(bareSegment)) return { matched: "git merge --squash", desc: "git merge --squash" }; // --- gh pr create --- if (/^gh\s+pr\s+create\b/i.test(bareSegment)) return { matched: "gh pr create", desc: "gh pr create" }; // --- tea pr create --- if (/^tea\s+pr\s+create\b/i.test(bareSegment)) return { matched: "tea pr create", desc: "tea pr create" }; // --- tea pulls create --- if (/^tea\s+pulls\s+create\b/i.test(bareSegment)) return { matched: "tea pulls create", desc: "tea pulls create" }; // --- pkill --- if (/^pkill\b/i.test(bareSegment)) return { matched: "pkill/killall", desc: "pkill/killall" }; // --- killall --- if (/^killall\b/i.test(bareSegment)) return { matched: "killall", desc: "killall" }; // --- systemctl --user stop|restart|kill llm-router --- if (/^systemctl\s+--?\s*user\s+(stop|restart|kill)\s+llm-router\b/i.test(bareSegment)) return { matched: "systemctl --user stop|restart|kill llm-router", desc: "systemctl llm-router" }; // --- git worktree remove --- if (/^git\s+worktree\s+remove\b/i.test(bareSegment)) return { matched: "git worktree remove", desc: "git worktree remove" }; // --- git stash (bare command only, not subcommands like stash list) --- if (/^git\s+stash\b(?!\s+list\b)/i.test(bareSegment)) return { matched: "git stash", desc: "git stash" }; return null; } /** * Check a command against the bash_banned rule. * * Returns {matched, desc} on violation, null if clean. * * Pipeline: split on delimiters => strip quoted strings in each segment => * strip prefixes => match banned patterns. */ function _checkBashBanned(command) { const segments = _splitSegments(command); for (const seg of segments) { const withoutQuotes = _stripQuotedStrings(seg); const bare = _stripPrefixes(withoutQuotes); if (bare) { const result = _matchesBanned(bare); if (result) return result; } } return null; } /** * Check a command for protected-port access. * * Returns {matched, port} on violation, null if clean. */ function _checkBashProtectedPort(command, protectedPorts) { const segments = _splitSegments(command); const textOnlyCommands = new Set([ "echo", "printf", "grep", "egrep", "fgrep", "rg", "ag", "cat", "head", "tail", "sed", "awk", "ls", "wc", "diff", "tee" ]); const interpreters = new Set([ "bash", "sh", "zsh", "python", "node", "ruby", "perl" ]); let skipHeredocBody = null; for (const seg of segments) { if (skipHeredocBody !== null) { if (/^[A-Za-z0-9_]+\s*$/.test(seg) && seg.trim() === skipHeredocBody) { skipHeredocBody = null; } continue; } const stripped = _stripPrefixes(seg); const tokens = stripped.split(/\s+/); const cmd = tokens[0]?.toLowerCase(); if (cmd && textOnlyCommands.has(cmd)) { const heredocStartMatch = seg.match(/<<\s*-?\s*['"]?([A-Za-z0-9_]+)['"]?/); if (heredocStartMatch) { skipHeredocBody = heredocStartMatch[1]; } continue; } let segToCheck = seg; if (seg.includes("<<")) { const isFedToInterpreter = interpreters.has(cmd); if (!isFedToInterpreter) { const heredocMatch = seg.match(/<<\s*[A-Za-z0-9_]+[\s\S]*?^\s*[A-Za-z0-9_]+\s*$/m); if (heredocMatch) { segToCheck = seg.replace(heredocMatch[0], " "); } } } for (const port of protectedPorts) { const portRegex = new RegExp( "\\b(?:localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0):" + port + "\\b", ); if (portRegex.test(segToCheck)) { return { matched: true, port: port }; } } } return null; } /** * Check bash_banned rule. * * Splits command on &&/;/||/|/newlines, strips quoted strings, strips * prefixes (VAR=val, sudo, command), and checks against banned patterns. * * Returns {matched, desc} on violation, null if clean. */ function checkBashBanned(mode, command) { if (mode === MODE_OFF) return null; const result = _checkBashBanned(command); if (!result) return null; if (mode === MODE_WARN) { logEvent(null, "_guardrails", "bash_banned", "bash", result.desc); return null; } // block mode const err = new Error(`bash/banned: blocked -- ${result.desc}`); err.ruleId = "bash_banned"; throw err; } /** * Count ticked checkboxes (done todos) in plan content. * * Matches lines like `- [x] 1. ` or `- [X] 1. ` under ## TODOs. * Returns the count of matched lines. */ function countTicked(content) { if (typeof content !== "string") return 0; const matches = content.match(/^- \[[xX]\] [1-9]\d*\. /gm); return matches ? matches.length : 0; } /** * Check bash_protected_port rule. * * Matches localhost/127.0.0.1/0.0.0.0: for every port in the protected * list. Default protected ports: [8080]. * * Returns {matched, port} on violation, null if clean. */ function checkBashProtectedPort(mode, command, config) { if (mode === MODE_OFF) return null; const protectedPorts = config?.protected_ports ?? [8080]; const result = _checkBashProtectedPort(command, protectedPorts); if (!result) return null; if (mode === MODE_WARN) { logEvent(null, "_guardrails", "bash_protected_port", "bash", `port ${result.port} accessed`); return null; } // block mode const err = new Error(`bash/protected_port: blocked -- access to port ${result.port}`); err.ruleId = "bash_protected_port"; throw err; } /** * Check task_needs_agent rule. * * Block (or warn) when task/call_omo_agent has no category, no subagent_type, * and no task_id. When task_id is present (resume from a plan), allow freely. * * Throws Error on block; logs and returns on warn; no-op on off/absent. */ function checkTaskNeedsAgent(mode, args) { if (mode === MODE_OFF) return; // task_id present => resuming existing task; exempt. if (args?.task_id || args?.category || args?.subagent_type) return; const msg = "task/call_omo_agent needs category or subagent_type (or task_id for resume)"; if (mode === MODE_WARN) { logEvent(null, "_guardrails", "task_needs_agent", "task", msg); return; } const err = new Error(msg); err.ruleId = "task_needs_agent"; throw err; } /** * Check task_banned_agent rule. * * Block (or warn) when subagent_type starts with "oh-my-claudecode:". * * Throws Error on block; logs and returns on warn; no-op on off/absent. */ function checkTaskBannedAgent(mode, args) { if (mode === MODE_OFF) return; const subagentType = args?.subagent_type ?? ""; if (!subagentType.startsWith("oh-my-claudecode:")) return; const msg = "subagent_type must not start with oh-my-claudecode: (banned)"; if (mode === MODE_WARN) { logEvent(null, "_guardrails", "task_banned_agent", "task", msg); return; } const err = new Error(msg); err.ruleId = "task_banned_agent"; throw err; } /** * Check task_worktree_line rule. * * Only active when boulder.status === "active" and boulder.worktree_path * is set. Two cases: * Case 1 -- prompt lacks a WORKTREE: line => rewrite by prepending the * standard line plus a newline then the original prompt. * Case 2 -- prompt has a WORKTREE: line but the path differs => block/warn. * * If boulder is absent or inactive, or worktree_path is unset, this is a no-op. */ function checkWorktreeLine(config, boulder, mode, prompt, output, directory, base) { // Rule only active with an active boulder that has a worktree_path. if (mode === MODE_OFF) return; if (!boulder || boulder.status !== "active") return; const worktreePath = boulder.worktree_path; if (!worktreePath) return; const expectedLine = `WORKTREE: ${worktreePath}. cd there first; never edit under ${directory}/.`; if (typeof prompt !== "string") return; // Case 2: existing WORKTREE line. // Parse path: first whitespace-delimited token after 'WORKTREE: ', strip one trailing '.'. const existingLineMatch = prompt.match(/^(WORKTREE:\s*(\S+))/m); if (existingLineMatch) { let existingPath = existingLineMatch[2]; if (existingPath.endsWith(".")) { existingPath = existingPath.slice(0, -1); } if (existingPath !== worktreePath) { const msg = `WORKTREE line path ${existingPath} does not match expected ${worktreePath}`; if (mode === MODE_WARN) { logEvent(config, "_guardrails", "task_worktree_line", "task", msg); return; } const err = new Error(msg); err.ruleId = "task_worktree_line"; throw err; } // Paths match -- no action needed. return; } // Case 1: no WORKTREE line => rewrite prompt. const rewrittenPrompt = expectedLine + "\n" + prompt; if (output && typeof output.args === "object" && output.args !== null) { output.args.prompt = rewrittenPrompt; } else { // Fallback if output.args is missing (though the hook wrapper should ensure it) if (!output) return; output.args = { prompt: rewrittenPrompt }; } } /** * Check write_outside_worktree rule. * * Block (or warn) when an edit/write targets a path inside directory but * NOT under directory/.omo/. This keeps agent writes out of the main * checkout when the boulder has a separate worktree_path. * * Throws Error on block; logs and returns on warn; no-op on off/absent. */ function checkWriteOutsideWorktree(mode, boulder, args, directory, base) { if (mode === MODE_OFF) return; if (!boulder || boulder.status !== "active") return; const worktreePath = boulder.worktree_path; if (!worktreePath) return; const filePath = args?.filePath; if (!filePath) return; const resolved = _resolvePath(filePath, directory); // Block only paths inside the main directory if (!_isInside(resolved, directory)) return; // Allow .omo/ files (guardrails config lives there). // Use join(directory, ".omo") rather than `base` because `base` may be // overridden (e.g. replay tests write to a temp omoDir); the exemption // must always reference the project's canonical .omo directory. if (_isInside(resolved, join(directory, ".omo"))) return; // Allow paths that resolve inside the worktree if (_isInside(resolved, worktreePath)) return; const msg = `write/outside_worktree: write to ${resolved} blocked. Work is in worktree ${worktreePath}; use that instead.`; if (mode === MODE_WARN) { logEvent(null, "_guardrails", "write_outside_worktree", "edit/write", msg); return; } const err = new Error(msg); err.ruleId = "write_outside_worktree"; throw err; } /** * Execute the tick gate command and return the exit code. * * Substitutes {directory} and {worktree} tokens in command args. * Throws on non-zero exit, timeout, or missing script. * * Returns { allowed: true } on success, or throws with the reason on failure. */ async function _runTickGate(cmdArgs, worktree, directory, timeoutMs) { const resolvedArgs = cmdArgs.map((arg) => arg.replace(/\{directory\}/g, directory).replace(/\{worktree\}/g, worktree), ); const scriptPath = resolvedArgs[0]; if (!existsSync(scriptPath)) { const err = new Error( `plan_tick_gate: script not found at ${scriptPath}. ` + `Set rule to "warn" in .omo/guardrails.json if this is intentional.`, ); err.ruleId = "plan_tick_gate"; throw err; } try { await execFileAsync(scriptPath, resolvedArgs.slice(1), { timeout: timeoutMs }); return { allowed: true }; } catch (err) { if (err.code === "ETIMEDOUT" || err.signal === "SIGTERM") { const timeoutErr = new Error( `plan_tick_gate: verification timed out after ${timeoutMs / 1000}s -- blocked.`, ); timeoutErr.ruleId = "plan_tick_gate"; throw timeoutErr; } const output = ((err.stdout || "") + (err.stderr || "")).trim(); const lines = output.split("\n").slice(0, 25).join("\n"); const gateErr = new Error( `plan_tick_gate: verify_commit failed -- ${lines.replace(/\n/g, ' ')} -- Fix, commit, then tick again.`, ); gateErr.ruleId = "plan_tick_gate"; throw gateErr; } } /** * Check plan_tick_gate rule. * * Only active when boulder.status === "active" and boulder.worktree_path is set. * * Trigger: edit or write to the file matching boulder.active_plan. * Counts tick marks (`- [x] N. `) before and after the operation. * On tick increase, executes the configured verify_commit script. * * - Exit 0 => allow * - Non-zero => block with first 25 lines of output + "Fix, commit, then tick again." * - Timeout => block * - Missing script path => block with path name + suggestion to set to warn * - Edits to other plan files are ignored (not boulder.active_plan) * * Throws Error on block; logs and returns on warn; no-op on off/absent. */ async function checkPlanTickGate(config, mode, boulder, args, directory, base) { if (mode === MODE_OFF) return; if (!boulder || boulder.status !== "active") return; const worktreePath = boulder.worktree_path; if (!worktreePath) return; const filePath = args?.filePath; if (!filePath) return; const activePlan = boulder.active_plan; const resolved = _resolvePath(filePath, directory); if (resolved !== activePlan) return; const worktree = worktreePath; let tickDelta = 0; if (args?.content !== undefined) { tickDelta = countTicked(args.content) - countTicked(""); } else if (args?.oldString && args?.newString) { tickDelta = countTicked(args.newString) - countTicked(args.oldString); } if (tickDelta <= 0) return; const gate = config?.tick_gate; if (!gate || !Array.isArray(gate.cmd)) return; const cmd = gate.cmd; const timeoutMs = (gate.timeout_s ?? 900) * 1000; if (mode === MODE_WARN) { logEvent(config, "_guardrails", "plan_tick_gate", "edit/write", `tick gate warning -- ${tickDelta} new tick(s), script: ${cmd[0]}`); return; } try { await _runTickGate(cmd, worktree, directory, timeoutMs); } catch (err) { throw new Error(err.message); } } /** * Check bash_main_checkout rule. * * Trigger 1 -- git operations: block when the command contains a git keyword * (add, commit, checkout, switch, stash, reset, restore, apply, am, merge, * rebase, cherry-pick, rm, mv) AND effective CWD is directory (main checkout). * * Trigger 2 -- redirections: block when > or >> or tee targets a path inside * directory but outside directory/.omo/. * * Effective CWD is resolved from git -C > last cd/pushd > workdir arg > directory. * * Throws Error on block; logs and returns on warn; no-op on off/absent. */ function checkBashMainCheckout(mode, boulder, command, workdir, directory, base) { if (mode === MODE_OFF) return; if (!boulder || boulder.status !== "active") return; const worktreePath = boulder.worktree_path; if (!worktreePath) return; const effectiveCwd = _resolveEffectiveCwd(command, workdir, directory); const gitKeywords = new Set([ "add", "commit", "checkout", "switch", "stash", "reset", "restore", "apply", "am", "merge", "rebase", "cherry-pick", "rm", "mv", ]); let hasGitOp = false; const segments = _splitSegments(command); for (const seg of segments) { const stripped = _stripPrefixes(seg); const bare = stripped.replace(/"[^"]*"/g, " ").replace(/'[^']*'/g, " ").replace(/`[^`]*`/g, " ").trimStart(); if (!/^\s*git\b/i.test(bare)) continue; const tokens = bare.split(/\s+/); // tokens[0] = "git", skip it; tokens[1..] may be options or subcommand let subcmd = null; for (let i = 1; i < tokens.length; i++) { if (/^-/.test(tokens[i])) { if (tokens[i].toLowerCase() === "-c" && i + 1 < tokens.length) { i++; } continue; } subcmd = tokens[i]; break; } if (subcmd && gitKeywords.has(subcmd)) { hasGitOp = true; break; } } if (hasGitOp && effectiveCwd === directory) { const msg = `bash/main_checkout: git operation blocked in ${effectiveCwd}. Work is in worktree ${worktreePath}; use git -C ${worktreePath} instead.`; if (mode === MODE_WARN) { logEvent(null, "_guardrails", "bash_main_checkout", "bash", msg); } else { const err = new Error(msg); err.ruleId = "bash_main_checkout"; throw err; } return; } const targets = _findRedirectTargets(command, effectiveCwd); for (const target of targets) { if ( _isInside(target, directory) && !_isInside(target, join(directory, ".omo")) ) { const msg = `bash/main_checkout: redirect to ${target} blocked. Work is in worktree ${worktreePath}; use that instead.`; if (mode === MODE_WARN) { logEvent(null, "_guardrails", "bash_main_checkout", "bash", msg); } else { const err = new Error(msg); err.ruleId = "bash_main_checkout"; throw err; } return; } } } // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- /** * @param {object} params * @param {object} params.client -- opencode SDK client (has session.get) * @param {string} params.directory -- project directory * @param {string} [params.omoDir] -- directory for .omo artifacts (config, boulder, log). * Defaults to `/.omo`. * @returns {{ "tool.execute.before": (input: object, output: object) => Promise }} */ export const Guardrails = async ({ client, directory, omoDir }) => { const base = omoDir ?? join(directory, ".omo"); const raw = loadConfig(directory, base); const config = raw?.config ?? null; _openLog(config?.logPath, directory, base); // Log unparsable config (file exists but isn't valid JSON) if (raw && !raw.parseOk) { logEvent(null, "_config", "N/A", "unparsable config: " + raw.error.message); } let boulder = null; try { boulder = await readBoulder(directory, base); } catch { // boulder unreadable => scope checks default to blocked } return { /** * tool.execute.before hook. * * Rules are empty in this skeleton; scope and boulder checks still run * but have no blocking effect until rules are implemented. * * @param {object} input -- tool execution input (has sessionID, tool, args) * @param {object} output -- mutable output object (unused in before) */ "tool.execute.before": async (input, output) => { const sessionID = input?.sessionID ?? "unknown"; const toolName = input?.tool ?? "unknown"; // No config => no-op if (!config) return; let inScope = false; try { inScope = await checkScope(boulder, sessionID, client); } catch (err) { // Internal exception during boulder/scope read: allow the call. logEvent(config, sessionID, "__internal_error__", toolName, err.message); return; } if (inScope) { // Ensure output.args exists and is an object. // If missing or invalid, log internal_error and allow the call. if (!output || typeof output.args !== "object" || output.args === null) { logEvent(config, sessionID, "__internal_error__", toolName, "missing or invalid output.args"); return; } const args = output.args; // Rules check -- deliberate violations throw (blocking); not caught here. const taskTools = ["task", "call_omo_agent"]; if (taskTools.includes(toolName)) { const prompt = args?.prompt ?? ""; const out = output; // task_worktree_line -- scope-gated; rewrites prompt in-place const worktreeMode = getRuleMode(config, "task_worktree_line"); checkWorktreeLine(config, boulder, worktreeMode, prompt, out, directory, base); // task_needs_agent -- exempt when task_id is present (resume) const needsAgentMode = getRuleMode(config, "task_needs_agent"); checkTaskNeedsAgent(needsAgentMode, args); // task_banned_agent const bannedMode = getRuleMode(config, "task_banned_agent"); checkTaskBannedAgent(bannedMode, args); } // write_outside_worktree -- edit/write tools if (toolName === "edit" || toolName === "write") { const wwMode = getRuleMode(config, "write_outside_worktree"); checkWriteOutsideWorktree(wwMode, boulder, args, directory, base); // plan_tick_gate -- scope-gated; blocks tick on verify_commit failure const tickGateMode = getRuleMode(config, "plan_tick_gate"); await checkPlanTickGate(config, tickGateMode, boulder, args, directory, base); } // bash_main_checkout -- bash tool if (toolName === "bash") { const command = args?.command ?? ""; const workdir = args?.workdir ?? ""; const bmMode = getRuleMode(config, "bash_main_checkout"); checkBashMainCheckout(bmMode, boulder, command, workdir, directory, base); } } // Always-scope rules -- fire regardless of boulder/scope state. // These must sit OUTSIDE the try/catch above so deliberate // violations throw out of the hook naturally (not swallowed). if (toolName === "bash") { const command = output?.args?.command ?? ""; const bbMode = getRuleMode(config, "bash_banned"); checkBashBanned(bbMode, command); const bpMode = getRuleMode(config, "bash_protected_port"); checkBashProtectedPort(bpMode, command, config); } }, }; }; // Expose internals for test introspection Guardrails.loadConfig = loadConfig; Guardrails.readBoulder = readBoulder; Guardrails.checkScope = checkScope; Guardrails.resolvePath = _resolvePath; Guardrails.isInside = _isInside; Guardrails.resolveEffectiveCwd = _resolveEffectiveCwd; Guardrails.findRedirectTargets = _findRedirectTargets; Guardrails.stripQuotedStrings = _stripQuotedStrings; Guardrails.splitSegments = _splitSegments; Guardrails.stripPrefixes = _stripPrefixes; Guardrails.checkBashBanned = checkBashBanned; Guardrails.checkBashProtectedPort = checkBashProtectedPort; Guardrails._checkBashBanned = _checkBashBanned; Guardrails._checkBashProtectedPort = _checkBashProtectedPort; Guardrails.countTicked = countTicked;