1421 lines
46 KiB
JavaScript
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;
|