Files
6krrt/deploy/opencode-plugin/guardrails.js

1421 lines
46 KiB
JavaScript

/**
* 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": { "<rule-id>": "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 `<directory>/.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 `<base>/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 `<directory>/.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 <path>` in the command overrides everything.
* 2. Last `cd <path>` or `pushd <path>` 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 (`<<EOF`, `<<-EOF`); a quoted
* delimiter is parsed here even though _splitSegments only absorbs
* unquoted ones, because the segmenter newline-splits quoted-delimiter
* bodies and per-segment state would lose the heredoc context.
* - A redirect that follows a quoted string stays real
* (`python3 -c "print(1)" > out.txt` writes out.txt).
* - `tee <path>` 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: <<WORD ... WORD blocks are kept as one segment so that
* banned phrases in heredoc bodies are not split into standalone segments.
*/
function _splitSegments(command) {
const segments = [];
let seg = "";
let i = 0;
while (i < command.length) {
const ch = command[i];
if (ch === "'" || ch === '"' || ch === "`") {
const quote = ch;
seg += ch;
i++;
while (i < command.length) {
const c = command[i];
seg += c;
if (c === "\\") {
seg += command[i + 1] ?? "";
i += 2;
continue;
}
if (c === quote) { i++; break; }
i++;
}
continue;
}
// Heredoc: <<WORD consumes until WORD on its own line
if (i + 1 < command.length &&
command[i] === "<" && command[i + 1] === "<") {
seg += ch;
seg += command[i + 1];
i += 2;
// Allow <<- (skip dash)
if (i < command.length && command[i] === "-") {
seg += command[i];
i++;
}
// Skip leading whitespace
while (i < command.length && command[i] === " ") i++;
// Parse delimiter word
let word = "";
while (i < command.length && /[A-Za-z0-9_]/.test(command[i])) {
word += command[i];
seg += command[i];
i++;
}
// Consume until word appears alone on a line
if (word) {
while (i < command.length) {
// Check if we're at the start of a line that matches the delimiter
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 === word) {
// Add the closing delimiter line to the segment
seg += command.slice(i, end);
i = end;
break;
}
}
seg += command[i];
i++;
}
}
continue;
}
if (i + 1 < command.length) {
const two = command.slice(i, i + 2);
if (two === "&&" || two === "||") {
segments.push(seg);
seg = "";
i += 2;
continue;
}
}
if (ch === ";" || ch === "|" || ch === "\n") {
segments.push(seg);
seg = "";
i++;
continue;
}
seg += ch;
i++;
}
segments.push(seg);
return segments
.map((s) => 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 <name>=<value>, -C <path>)
}
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:<port> 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 `<directory>/.omo`.
* @returns {{ "tool.execute.before": (input: object, output: object) => Promise<void> }}
*/
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;