diff --git a/2026-06-20-issue-audit.md b/2026-06-20-issue-audit.md deleted file mode 100644 index 4cd8992..0000000 --- a/2026-06-20-issue-audit.md +++ /dev/null @@ -1,69 +0,0 @@ -# Automated Issue Audit — 2026-06-20 - -This document summarizes the comprehensive automated analysis of the `opencode-goal-mode` repository performed on 2026-06-20. The analysis launched multiple parallel agents to review all source code, tests, CI/CD, packaging, agent definitions, and documentation. - -## Analysis Scope - -- **17 source files** in `plugins/goal-guard/` — guard core, shell analyzer, state machine, review runner, auto-continue, persistence, completion enforcement, sidebar data -- **21 test files** in `tests/` — all 359 tests passing -- **27 agent definition files** in `agents/` -- **7 command files** in `commands/` -- **4 CI/CD workflows** in `.github/workflows/` -- **6 scripts** in `scripts/` -- **Lab tool** — server, web frontend, orchestrator -- **All existing issues** (#2–#197) and their comments/conversations - -## New Issues Opened - -24 genuinely new, non-duplicate issues were identified and opened: - -### Security (2 issues) -- [#232](https://github.com/devinoldenburg/opencode-goal-mode/issues/232) — publish.yml has no branch guard on tag-triggered publish -- [#370](https://github.com/devinoldenburg/opencode-goal-mode/issues/370) — Review agents have unnecessary webfetch/websearch:allow - -### CI/CD (7 issues) -- [#248](https://github.com/devinoldenburg/opencode-goal-mode/issues/248) — CI jobs missing timeout-minutes -- [#255](https://github.com/devinoldenburg/opencode-goal-mode/issues/255) — postinstall.mjs spawnSync has no timeout -- [#263](https://github.com/devinoldenburg/opencode-goal-mode/issues/263) — check-npm-publish-ready.mjs fetch has no timeout -- [#319](https://github.com/devinoldenburg/opencode-goal-mode/issues/319) — No lint or format script in package.json -- [#322](https://github.com/devinoldenburg/opencode-goal-mode/issues/322) — No post-publish smoke test -- [#325](https://github.com/devinoldenburg/opencode-goal-mode/issues/325) — CI workflow missing schedule trigger -- [#328](https://github.com/devinoldenburg/opencode-goal-mode/issues/328) — CI visual job missing Bun cache - -### Code Bugs (3 issues) -- [#296](https://github.com/devinoldenburg/opencode-goal-mode/issues/296) — ensureReviewClient unconditionally overwrites working SDK methods -- [#300](https://github.com/devinoldenburg/opencode-goal-mode/issues/300) — guard.js decidingIdle check-then-act race condition -- [#303](https://github.com/devinoldenburg/opencode-goal-mode/issues/303) — goal_status tool doesn't validate sessionID - -### Test Coverage (1 issue) -- [#291](https://github.com/devinoldenburg/opencode-goal-mode/issues/291) — 5 critical modules have zero direct unit tests (completion.js, events.js, system.js, summary.js, agents.js) - -### Documentation (4 issues) -- [#272](https://github.com/devinoldenburg/opencode-goal-mode/issues/272) — goal.md missing references to goal_reviewer_memory and goal_reset tools -- [#281](https://github.com/devinoldenburg/opencode-goal-mode/issues/281) — goal.md review gate bullet list incomplete (missing 5 of 15 reviewers) -- [#305](https://github.com/devinoldenburg/opencode-goal-mode/issues/305) — CONTRIBUTING.md missing test suite script documentation -- [#309](https://github.com/devinoldenburg/opencode-goal-mode/issues/309) — CHANGELOG.md missing Unreleased section - -### Configuration (4 issues) -- [#311](https://github.com/devinoldenburg/opencode-goal-mode/issues/311) — .gitignore missing *.tgz and .npmrc entries -- [#315](https://github.com/devinoldenburg/opencode-goal-mode/issues/315) — Install script has no file locking on tui.json -- [#330](https://github.com/devinoldenburg/opencode-goal-mode/issues/330) — validate-opencode-config.mjs fails on CRLF line endings - -### Lab Tool (4 issues) -- [#334](https://github.com/devinoldenburg/opencode-goal-mode/issues/334) — Lab orchestrator silently swallows git failures -- [#344](https://github.com/devinoldenburg/opencode-goal-mode/issues/344) — Lab store appendEvent synchronous emit can recurse -- [#350](https://github.com/devinoldenburg/opencode-goal-mode/issues/350) — Lab metrics computeMetrics is O(runs x events) -- [#354](https://github.com/devinoldenburg/opencode-goal-mode/issues/354) — Lab web ticker uses O(n) splice instead of circular buffer - -## Verification - -- All 359 tests pass (`npm test`) -- All E2E tests pass (`tools/e2e/goal-e2e.mjs` — 9 passed) -- All fresh-install tests pass (`tools/e2e/fresh-install.mjs` — 15 passed) -- Validation passes (`scripts/validate-opencode-config.mjs`) -- Publish check passes (`npm run publish:check`) -- npm audit: 0 vulnerabilities - -## Duplicate Prevention - -Each issue was verified against all 197+ existing issues (titles AND body content) before opening. No duplicates were created. diff --git a/README.md b/README.md index bd1c75d..53dadc5 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,30 @@ Goal Mode works great with zero configuration. When you want to tune it, set opt | `sidebarMutedColor` / `GOAL_GUARD_SIDEBAR_MUTED_COLOR` | `#808080` | Foreground colour for **pending** Goal todo rows (□ items) while a goal is running. | | `completionMarker` / `GOAL_GUARD_COMPLETION_MARKER` | `Goal Completed` | Phrase that, at the start of a message, claims completion. | | `blockedMarker` / `GOAL_GUARD_BLOCKED_MARKER` | `Goal Not Completed` | Replacement written when a completion claim is blocked. | +| `yolo` / `GOAL_GUARD_YOLO` | `false` | **YOLO mode.** Relax the guard so it never blocks/nags for ordinary work — turns off network-exec blocking, completion enforcement, the Goal-only subagent lock, and block toasts. Destructive guarding stays on unless `allowDestructive` is also set. Any key you set explicitly still wins. | +| `allowDestructive` / `GOAL_GUARD_ALLOW_DESTRUCTIVE` | `false` | Turn **off** destructive-command guarding. With `yolo: true` this is "full YOLO" — nothing is blocked and the agent has ALL rights. Works standalone too. Dangerous. | +| `allowCommands` / `GOAL_GUARD_ALLOW_COMMANDS` | `[]` | Custom **allow-list**: a bash command matching ANY of these JS regex patterns is never blocked, whatever the analyzer thinks. Array, or a comma/newline-separated string (env). | +| `extraDestructive` / `GOAL_GUARD_EXTRA_DESTRUCTIVE` | `[]` | Custom **deny-list**: a bash command matching ANY of these JS regex patterns is treated as destructive, extending the built-in analyzer with your own rules. | + +### YOLO mode + +Every gate is individually tunable, but YOLO is the one-switch escape hatch: + +```jsonc +// opencode.json — never blocks or asks for anything (ALL rights): +["./plugins/goal-guard.js", { "yolo": true, "allowDestructive": true }] +``` + +```bash +# Or via env (e.g. for a throwaway sandbox): +GOAL_GUARD_YOLO=1 GOAL_GUARD_ALLOW_DESTRUCTIVE=1 opencode +``` + +- `yolo: true` alone → no completion gating, no subagent lock, no network-exec block, no toasts — but a destructive `rm -rf /` is **still** stopped. +- add `allowDestructive: true` → that last guard drops too: full YOLO. +- Prefer surgical control? Leave YOLO off and use `allowCommands` (whitelist exactly the commands you want to wave through) and/or `extraDestructive` (block extra ones), e.g. `{ "allowCommands": ["^docker compose ", "^rm -rf \\./tmp/"] }`. + +The customization skill (`/goal-mode-customize`, installed alongside the plugin) walks the agent through tuning every one of these. **Slash commands:** `/goal`, `/goal-contract`, `/goal-review`, `/goal-evidence-map`, `/goal-status`, `/goal-repair`, `/goal-final`. diff --git a/commands/goal-mode-customize.md b/commands/goal-mode-customize.md new file mode 100644 index 0000000..4648b3f --- /dev/null +++ b/commands/goal-mode-customize.md @@ -0,0 +1,50 @@ +--- +description: Customize the OpenCode Goal Mode plugin — guard config, YOLO mode, allow/deny rules, sidebar, then reinstall. +agent: goal +--- + +## What this command does + +`/goal-mode-customize` teaches you to reconfigure the Goal Mode guard for this user/project and apply the change correctly. The whole guard is data-driven: every behavior is a key in `DEFAULT_CONFIG` (`plugins/goal-guard/config.js`), settable three ways (later wins): built-in default → `GOAL_GUARD_*` env var → the plugin `options` object in `opencode.json`. + +### 1. Find the knob + +Read `plugins/goal-guard/config.js` (`DEFAULT_CONFIG`) — it is the single source of truth and each key is documented inline. Common asks: + +- **"Stop nagging / just let it run" →** `yolo: true`. Relaxes the soft gates (network-exec block, completion enforcement, the Goal-only subagent lock, block toasts) but KEEPS destructive guarding. +- **"Let it do literally anything (incl. `rm -rf`)" →** `yolo: true, allowDestructive: true` (full YOLO). `allowDestructive` also works on its own. +- **"Allow these specific commands only" →** `allowCommands: ["", …]` — a matching bash command is never blocked, whatever the analyzer thinks. +- **"Also treat these as destructive" →** `extraDestructive: ["", …]`. +- **Sidebar colours / markers / review timing / session caps →** the corresponding `sidebar*`, `completionMarker`/`blockedMarker`, `review*`, `maxSessions`/`sessionTtlMs` keys. + +YOLO only relaxes keys the user did NOT set explicitly, so a per-key option always wins over YOLO. + +### 2. Apply it + +Edit `opencode.json` so the plugin entry carries the options, e.g.: + +```jsonc +["opencode-goal-mode", { "yolo": true, "allowCommands": ["^docker compose ", "^rm -rf \\./tmp/"] }] +``` + +…or export the env equivalent (`GOAL_GUARD_YOLO=1`, `GOAL_GUARD_ALLOW_COMMANDS="^docker compose ,^rm -rf \./tmp/"`). Lists accept an array or a comma/newline-separated string. An invalid regex is ignored, never fatal. + +### 3. Reinstall + restart (so the change actually loads) + +After editing files in the installed copy or upgrading the package, re-run the installer and clear the TUI cache so the new version loads: + +```bash +opencode-goal-mode --global # idempotent reinstall/update; --force to replace files you edited +``` + +Then fully restart OpenCode. (Global `npm install -g opencode-goal-mode@latest` re-runs this installer automatically via `postinstall`.) + +### 4. Verify + +Confirm the effective config behaves as intended — e.g. a destructive command is allowed under full YOLO, or your `allowCommands` entry passes while others are still blocked. The guard's block error names the offending command and reason. + +Additional context / the specific customization the user wants: + +```text +$ARGUMENTS +``` diff --git a/package.json b/package.json index 5db6ee3..7971025 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "docs/", "plugins/", "research/", + "skills/", "scripts/install.mjs", "scripts/postinstall.mjs", "ARCHITECTURE.md", @@ -55,6 +56,8 @@ "prepublishOnly": "npm run ci && npm run publish:check", "install:local": "node scripts/install.mjs", "install:global": "node scripts/install.mjs --global", + "update": "npm install -g opencode-goal-mode@latest", + "reinstall": "node scripts/install.mjs --global --force", "postinstall": "node scripts/postinstall.mjs" }, "keywords": [ diff --git a/plugins/goal-guard/config.js b/plugins/goal-guard/config.js index c49f120..9fb8ae3 100644 --- a/plugins/goal-guard/config.js +++ b/plugins/goal-guard/config.js @@ -73,6 +73,26 @@ export const DEFAULT_CONFIG = Object.freeze({ completionMarker: "Goal Completed", /** Replacement marker when completion is blocked. */ blockedMarker: "Goal Not Completed", + + // ── YOLO / customization ────────────────────────────────────────────────── + /** YOLO mode: relax the guard so it never blocks or nags for ordinary work — + * turns OFF network-exec blocking, completion enforcement, the Goal-only + * subagent restriction, and block toasts. Destructive-command guarding STAYS + * ON unless `allowDestructive` is also set. Any key you set explicitly (via + * options or env) still wins over YOLO's relaxations, so YOLO is a baseline you + * can override per-key. */ + yolo: false, + /** Turn OFF destructive-command guarding. Combined with `yolo: true` this is + * "full YOLO" — the guard blocks nothing and the agent has ALL rights. It also + * works on its own as a direct override. Dangerous; off by default. */ + allowDestructive: false, + /** Custom allow-list. A bash command matching ANY of these JS regex patterns is + * never blocked by the guard, regardless of classification — fine-grained YOLO + * without disabling the guard globally. Strings (regex source) or an array. */ + allowCommands: [], + /** Custom deny-list. A bash command matching ANY of these JS regex patterns is + * treated as destructive, extending the built-in analyzer with project rules. */ + extraDestructive: [], }); function coerceBool(value, fallback) { @@ -110,6 +130,15 @@ function coerceStr(value, fallback) { return s.trim() === "" ? fallback : s; } +/** Coerce a list config value: accept an array, or a comma/newline-separated + * string. Entries are trimmed and blanks dropped. Used for the customizable + * allow/deny pattern lists. Always returns a (possibly empty) array. */ +function coerceList(value, fallback) { + if (value === undefined || value === null) return fallback; + const raw = Array.isArray(value) ? value : String(value).split(/[\n,]/); + return raw.map((s) => String(s).trim()).filter((s) => s.length > 0); +} + function fromEnv(env) { const out = {}; const map = { @@ -140,6 +169,10 @@ function fromEnv(env) { GOAL_GUARD_SIDEBAR_MUTED_COLOR: ["sidebarMutedColor", coerceStr], GOAL_GUARD_COMPLETION_MARKER: ["completionMarker", coerceStr], GOAL_GUARD_BLOCKED_MARKER: ["blockedMarker", coerceStr], + GOAL_GUARD_YOLO: ["yolo", coerceBool], + GOAL_GUARD_ALLOW_DESTRUCTIVE: ["allowDestructive", coerceBool], + GOAL_GUARD_ALLOW_COMMANDS: ["allowCommands", coerceList], + GOAL_GUARD_EXTRA_DESTRUCTIVE: ["extraDestructive", coerceList], }; for (const [key, [field, coerce]] of Object.entries(map)) { if (env[key] !== undefined) out[field] = coerce(env[key], DEFAULT_CONFIG[field]); @@ -162,13 +195,38 @@ export function resolveConfig(options, env) { const opts = options && typeof options === "object" ? options : {}; const merged = { ...DEFAULT_CONFIG, ...envConfig }; + // Track which keys the user set EXPLICITLY (env or options). YOLO only relaxes + // keys the user left at their default — an explicit choice always wins. + const explicit = new Set(Object.keys(envConfig)); + for (const key of Object.keys(DEFAULT_CONFIG)) { if (opts[key] === undefined) continue; + explicit.add(key); const def = DEFAULT_CONFIG[key]; - if (typeof def === "boolean") merged[key] = coerceBool(opts[key], merged[key]); + if (Array.isArray(def)) merged[key] = coerceList(opts[key], merged[key]); + else if (typeof def === "boolean") merged[key] = coerceBool(opts[key], merged[key]); else if (typeof def === "number") merged[key] = coerceInt(opts[key], merged[key]); else merged[key] = coerceStr(opts[key], merged[key]); // string keys: ignore empty/blank, never inject "" } + + // YOLO mode derivation. `relax` only applies when the user did NOT set the key + // explicitly, so YOLO is an overridable baseline. + const relax = (key, value) => { + if (!explicit.has(key)) merged[key] = value; + }; + if (merged.yolo) { + relax("blockNetworkExec", false); + relax("enforceCompletion", false); + relax("restrictSubagents", false); + relax("toastOnBlock", false); + } + // Destructive guarding is the one safety net YOLO keeps by default; turning it + // off is its own opt-in (works with or without yolo) → "full YOLO" = both. + if (merged.allowDestructive) relax("blockDestructive", false); + + // Freeze the customizable lists so a frozen config stays effectively immutable. + merged.allowCommands = Object.freeze([...merged.allowCommands]); + merged.extraDestructive = Object.freeze([...merged.extraDestructive]); // maxSessions must hold at least the goal session AND its reviewer child sessions. // A value of 0 (or negative) made the store's eviction loop (while (size >= // maxSessions)) always true, so every stateFor for a different sessionID evicted diff --git a/plugins/goal-guard/guard.js b/plugins/goal-guard/guard.js index 2fc3a0b..e9d08fa 100644 --- a/plugins/goal-guard/guard.js +++ b/plugins/goal-guard/guard.js @@ -85,6 +85,32 @@ function partsText(parts) { .trim(); } +/** Compile a list of regex-source strings to RegExp, skipping any that don't + * compile so one bad user pattern can never break the guard. */ +function compileGuardPatterns(list) { + const out = []; + for (const pattern of list || []) { + try { + out.push(new RegExp(pattern)); + } catch { + /* ignore an invalid custom pattern */ + } + } + return out; +} + +/** True if `command` matches any of the compiled patterns. */ +function matchesAnyPattern(patterns, command) { + return patterns.some((re) => { + try { + re.lastIndex = 0; + return re.test(command); + } catch { + return false; + } + }); +} + /** * Build a guard instance. Exposed for tests; the default export wraps it. * @@ -94,6 +120,9 @@ function partsText(parts) { */ export function createGuard(input = {}, options = {}, overrides = {}) { const config = overrides.config || resolveConfig(options, overrides.env); + // Pre-compile the customizable allow/deny pattern lists once per guard. + const allowCommandPatterns = compileGuardPatterns(config.allowCommands); + const extraDestructivePatterns = compileGuardPatterns(config.extraDestructive); const reviewClient = overrides.reviewClient || input.client; // In-memory only (never persisted): coalesce overlapping idle decisions, detect a @@ -548,9 +577,14 @@ export function createGuard(input = {}, options = {}, overrides = {}) { if (inp?.tool === "bash") { const command = commandOf(inp, out); + // Custom allow-list: a matching command bypasses the guard entirely — no + // block, no dirty mark — regardless of how the analyzer classifies it. + const allowlisted = allowCommandPatterns.length > 0 && matchesAnyPattern(allowCommandPatterns, command); const analysis = analyzeCommand(command); - const blockDestructive = config.blockDestructive && analysis.destructive; - const blockNetwork = config.blockNetworkExec && analysis.networkExec; + // Custom deny-list extends the analyzer with project-specific destructive rules. + const destructive = analysis.destructive || (extraDestructivePatterns.length > 0 && matchesAnyPattern(extraDestructivePatterns, command)); + const blockDestructive = !allowlisted && config.blockDestructive && destructive; + const blockNetwork = !allowlisted && config.blockNetworkExec && analysis.networkExec; if (blockDestructive || blockNetwork) { // Only record the block reason / persist for an ACTIVE goal session. // Destructive blocking itself applies in EVERY mode (the throw below), diff --git a/scripts/install.mjs b/scripts/install.mjs index 0d04bf1..0584bd7 100755 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -59,8 +59,10 @@ try { const root = resolve(fileURLToPath(new URL("..", import.meta.url))); const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); -/** Component directories installed into an OpenCode config dir. */ -const COMPONENT_DIRS = ["agents", "commands", "plugins"]; +/** Component directories installed into an OpenCode config dir. `skills/` carries + * the goal-mode-customization skill so it ships with the package and is refreshed + * on every (re)install/update, just like agents/commands/plugins. */ +const COMPONENT_DIRS = ["agents", "commands", "plugins", "skills"]; const MANIFEST_NAME = ".goal-mode-manifest.json"; if (values.help) { diff --git a/skills/goal-mode-customization/SKILL.md b/skills/goal-mode-customization/SKILL.md new file mode 100644 index 0000000..0955c87 --- /dev/null +++ b/skills/goal-mode-customization/SKILL.md @@ -0,0 +1,89 @@ +--- +name: goal-mode-customization +description: Customize the OpenCode Goal Mode guard plugin — its config keys, YOLO mode, allow/deny command rules, sidebar, and the reinstall/update flow. Use when asked to change, relax, tighten, or tune Goal Mode / the goal guard, enable YOLO, allow or block specific commands, or make the plugin do (or stop doing) something. +--- + +# Customizing OpenCode Goal Mode + +Goal Mode is fully data-driven: every behavior is a key in `DEFAULT_CONFIG` +(`plugins/goal-guard/config.js`). Change behavior by changing config — almost +never by editing guard logic. + +## Configuration model + +Precedence (lowest → highest): + +1. **Built-in defaults** — `DEFAULT_CONFIG` in `plugins/goal-guard/config.js`. +2. **Environment** — `GOAL_GUARD_*` variables (e.g. `GOAL_GUARD_YOLO=1`). +3. **Plugin options** — the object in the `opencode.json` plugin entry: + `["opencode-goal-mode", { "yolo": true }]`. + +`config.js` is the single source of truth — read it first; every key is +documented inline. Booleans accept `1/0/true/false/yes/no/on/off`; integer keys +accept plain/decimal/scientific spellings; list keys accept an array or a +comma/newline-separated string. + +## YOLO mode (the escape hatch) + +- `yolo: true` — relax the *soft* gates: turns off network-exec blocking, + completion enforcement, the Goal-only subagent lock, and block toasts. + Destructive-command guarding (e.g. `rm -rf /`) **stays on**. +- `allowDestructive: true` — turn off destructive guarding too. With `yolo` this + is **full YOLO**: the guard blocks nothing, the agent has all rights. It also + works standalone as a direct override of `blockDestructive`. +- **Important:** YOLO only relaxes keys the user did NOT set explicitly, so any + per-key option still wins (e.g. `{ "yolo": true, "blockNetworkExec": true }` + keeps network-exec blocking on). + +```jsonc +// opencode.json — never blocks or asks for anything: +["opencode-goal-mode", { "yolo": true, "allowDestructive": true }] +``` + +## Surgical control (without going full YOLO) + +- `allowCommands: ["", …]` — a bash command matching ANY pattern is + never blocked, regardless of classification. Example: + `{ "allowCommands": ["^docker compose ", "^rm -rf \\./tmp/"] }`. +- `extraDestructive: ["", …]` — a command matching ANY pattern is + treated as destructive, extending the built-in analyzer. Example: + `{ "extraDestructive": ["^kubectl delete ", "^terraform destroy"] }`. + +An invalid regex in either list is skipped, never fatal. + +## Other commonly-tuned keys + +| Want to… | Key(s) | +| --- | --- | +| Stop auto-continuing idle goals | `autoContinue: false` (or cap with `maxAutoContinue`) | +| Skip programmatic reviews | `programmaticReview: false` | +| Loosen review timing | `reviewTimeoutMs`, `reviewPollMs`, `reviewIdleDeferMs`, `maxReviewCycles` | +| Change completion wording | `completionMarker`, `blockedMarker` | +| Recolour / hide the sidebar | `sidebarColor`, `sidebarDoneColor`, `sidebarMutedColor`, `sidebarBanner` | +| Disable state persistence | `persist: false` | + +The full, authoritative list is `DEFAULT_CONFIG` — prefer it over this table. + +## Applying a change (make it actually load) + +1. Edit `opencode.json` (or set the `GOAL_GUARD_*` env var). +2. If you edited the **installed** copy or upgraded the package, re-run the + installer so the new version is copied in and OpenCode's plugin cache is + cleared: + ```bash + opencode-goal-mode --global # idempotent; --force replaces files you edited + ``` + A global `npm install -g opencode-goal-mode@latest` runs this installer + automatically (via `postinstall`), so an upgrade is always a full reinstall. +3. **Fully restart OpenCode** — TUI plugins are cached. +4. Verify the new behavior (e.g. a destructive command passes under full YOLO, + or your `allowCommands` entry passes while others are still blocked). The + guard's block error names the offending command and the reason. + +## When you DO need code, not config + +Only edit guard code for genuinely new behavior. The relevant modules: +`config.js` (keys), `guard.js` (hook wiring + the block decision), `shell.js` +(the command analyzer), `completion.js` (the "Goal Completed" gate), +`gates.js` (which specialist reviewers are required). Add a test in `tests/` +for any behavioral change and keep `npm test` + `npm run lint` green. diff --git a/tests/install.test.mjs b/tests/install.test.mjs index 799abe5..166b9fa 100644 --- a/tests/install.test.mjs +++ b/tests/install.test.mjs @@ -43,7 +43,7 @@ function runFail(args, opts = {}) { test("installer source only references safe component directories", () => { const text = readRepo("scripts/install.mjs"); - assert.match(text, /COMPONENT_DIRS = \["agents", "commands", "plugins"\]/); + assert.match(text, /COMPONENT_DIRS = \["agents", "commands", "plugins", "skills"\]/); assert.doesNotMatch(text, /auth|sessions|preauth|hosts\.yml/); }); @@ -71,6 +71,9 @@ test("installer copies components, including the nested plugin module directory" // The multi-file plugin's modules must be copied too, or imports break. assert.equal(existsSync(join(cfg, "plugins", "goal-guard", "shell.js")), true); assert.equal(existsSync(join(cfg, "plugins", "goal-guard", "state.js")), true); + // The customization skill + command ship and install too. + assert.equal(existsSync(join(cfg, "skills", "goal-mode-customization", "SKILL.md")), true); + assert.equal(existsSync(join(cfg, "commands", "goal-mode-customize.md")), true); assert.equal(existsSync(join(cfg, ".goal-mode-manifest.json")), true); }); diff --git a/tests/yolo.test.mjs b/tests/yolo.test.mjs new file mode 100644 index 0000000..455748f --- /dev/null +++ b/tests/yolo.test.mjs @@ -0,0 +1,81 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { resolveConfig } from "../plugins/goal-guard/config.js"; +import { createGuard } from "../plugins/goal-guard/guard.js"; + +const noopPersistence = { load: () => null, save: () => {}, flush: () => false, file: "", isDegraded: () => false }; +function mkGuard(options = {}) { + let t = 0; + return createGuard( + { client: { app: { log: async () => undefined }, tui: { showToast: async () => undefined } } }, + options, + { persistence: noopPersistence, clock: () => (t += 1), syncIdle: true }, + ); +} +const runBash = (hooks, sid, command) => + hooks["tool.execute.before"]({ tool: "bash", sessionID: sid, callID: "c" }, { args: { command } }); + +// ── config resolution ──────────────────────────────────────────────────────── + +test("yolo relaxes the soft gates but keeps destructive guarding by default", () => { + const c = resolveConfig({ yolo: true }, {}); + assert.equal(c.blockNetworkExec, false); + assert.equal(c.enforceCompletion, false); + assert.equal(c.restrictSubagents, false); + assert.equal(c.toastOnBlock, false); + assert.equal(c.blockDestructive, true, "base YOLO still guards destructive commands"); +}); + +test("full YOLO (yolo + allowDestructive) turns off destructive guarding", () => { + assert.equal(resolveConfig({ yolo: true, allowDestructive: true }, {}).blockDestructive, false); + assert.equal(resolveConfig({ allowDestructive: true }, {}).blockDestructive, false, "allowDestructive works on its own too"); +}); + +test("an explicit per-key option overrides YOLO's relaxation", () => { + const c = resolveConfig({ yolo: true, blockNetworkExec: true }, {}); + assert.equal(c.blockNetworkExec, true); +}); + +test("YOLO + customization is configurable via environment variables", () => { + const c = resolveConfig({}, { + GOAL_GUARD_YOLO: "1", + GOAL_GUARD_ALLOW_COMMANDS: "^rm -rf build$, ^docker .*", + GOAL_GUARD_EXTRA_DESTRUCTIVE: "kubectl delete", + }); + assert.equal(c.yolo, true); + assert.deepEqual([...c.allowCommands], ["^rm -rf build$", "^docker .*"]); + assert.deepEqual([...c.extraDestructive], ["kubectl delete"]); +}); + +test("pattern lists accept a comma/newline string or an array", () => { + assert.deepEqual([...resolveConfig({ allowCommands: "a, b" }, {}).allowCommands], ["a", "b"]); + assert.deepEqual([...resolveConfig({ allowCommands: ["a", " b "] }, {}).allowCommands], ["a", "b"]); +}); + +// ── guard behaviour ────────────────────────────────────────────────────────── + +test("default guard blocks a destructive command", async () => { + const { hooks } = mkGuard({}); + await assert.rejects(() => runBash(hooks, "s", "rm -rf /tmp/x/$(whoami)"), /blocked a destructive/i); +}); + +test("full YOLO lets a destructive command through", async () => { + const { hooks } = mkGuard({ yolo: true, allowDestructive: true }); + await runBash(hooks, "s", "rm -rf /tmp/x/$(whoami)"); // must not throw +}); + +test("allowCommands bypasses the guard for a matching command only", async () => { + const { hooks } = mkGuard({ allowCommands: ["^rm -rf /tmp/build$"] }); + await runBash(hooks, "s", "rm -rf /tmp/build"); // allow-listed → no throw + await assert.rejects(() => runBash(hooks, "s", "rm -rf /etc"), /blocked a destructive/i); // still guarded +}); + +test("extraDestructive blocks a command the built-in analyzer would allow", async () => { + const { hooks } = mkGuard({ extraDestructive: ["^kubectl delete "] }); + await assert.rejects(() => runBash(hooks, "s", "kubectl delete namespace prod"), /blocked a destructive/i); +}); + +test("an invalid custom pattern is ignored, not fatal", async () => { + const { hooks } = mkGuard({ allowCommands: ["("], extraDestructive: ["["] }); // both invalid regex + await assert.rejects(() => runBash(hooks, "s", "rm -rf /etc"), /blocked a destructive/i); // guard still works +});