Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 3 additions & 78 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ something outside your opencode config say so in their description.
| `plugin-litellm-pricing` | Plugin | append | Install `provider-litellm` too — without a `litellm` provider pointing at your proxy the plugin does nothing. Adds `opencode-plugin-litellm-pricing`: discovers a LiteLLM proxy's models at runtime and adds them to the picker with the proxy's own per-model pricing instead of `$0` (pins `opencode-plugin-litellm-pricing` 0.9.0) |
| `provider-litellm` | Provider | replace | Point the `litellm` provider at your proxy URL and key for `plugin-litellm-pricing`, which prices the models against that same proxy (prompts for base URL and API key; no models list) |
| `plugin-superpowers` | Plugin | append | Add the Superpowers OpenCode plugin from `obra/superpowers` (brainstorming, plans, TDD, review workflows; pins tag `v6.3.0`) |
| `plugin-dcg` | Plugin | append | Install the external `dcg` binary yourself first — `brew install dicklesworthstone/tap/dcg`; or `cargo install destructive_command_guard` wherever a Rust toolchain is available (dcg is not in nixpkgs, so on nix that means `nix-env -iA nixpkgs.cargo` then the cargo line, and `~/.cargo/bin` on `PATH`); or a signed release binary from [the releases page](https://github.com/Dicklesworthstone/destructive_command_guard/releases), which ships a `.sha256` and sigstore attestations per asset — the x86_64 Linux build is static musl, so it needs nothing from the distro. Upstream also has a curl-pipe `install.sh` if you like those. The install refuses while `dcg` is not on `PATH`, because without it the plugin warns once and every command runs unchecked. **Experimental** — the plugin is at 0.2.x and its behaviour can still change. Adds `opencode-plugin-dcg`: runs every bash command past `dcg` and blocks the destructive ones (pins `opencode-plugin-dcg` 0.2.0) |
| `privacy-share-disabled` | Privacy | replace | In the bundle. Sets `share` to "disabled" so opencode never publishes a session, automatically or on command |
| `agent-runaway-guard` | Agent | merge | Adds step limits to built-in agents to prevent runaway tool loops |
| `default-agent-plan` | Agent | replace | Sets the default agent to "plan" so opencode always starts in plan mode instead of build mode |
Expand Down Expand Up @@ -199,80 +198,6 @@ grep litellm-pricing ~/.local/share/opencode/log/opencode.log
the key to hand. Coming from an earlier install, re-run it once: that is what
clears the now-unused `options.catalogURL` out of your config.

### Checking commands with `dcg`

**Experimental**, and the label belongs to `opencode-plugin-dcg`, not to the
preset: the plugin is at 0.2.x, so its defaults and environment variables can
still change between releases and the preset follows them. What the preset does
is the same one-line plugin entry as every other `Plugin` row here.

`plugin-dcg` is a second tier, not an alternative one: the deny rules
glob-match the command line,
[dcg](https://github.com/Dicklesworthstone/destructive_command_guard) parses it.
That catches shapes a whole-line pattern cannot — it splits compound commands,
and it extracts and re-checks inline scripts and heredoc bodies, so a
`bash -c "git reset --hard"` or a `python -c "shutil.rmtree(…)"` is judged on
what it would run. It is still a guardrail, not a boundary: env-var prefixes,
shell aliases and anything dcg cannot statically reconstruct can slip past it
too.

**Run it alongside `permissions-recommended`, not instead of it.** opencode
fires plugin hooks before the tool executes and asks for permission inside it,
so the order is: dcg decides first, then your permission rules apply to whatever
it let through. Two consequences worth knowing before you install both:

- An `allow` rule does **not** buy a command past dcg. The allowlists still do
their job — no prompts for `ls`, `git status` and friends — but dcg has
already seen every one of them.
- The overlap is only with the deny half, on the classic footguns (`rm -rf /`,
`dd`, `mkfs`). Redundant, and deliberately so: dcg fails **open** when its
binary is missing or times out, and only inspects the tools in
`DCG_PLUGIN_TOOLS` — the deny presets are what remains when it does. Keep
them.

The two announce themselves differently, which is the point: a dcg block quotes
its rule id and a suggestion, an opencode deny names the pattern it matched.

The binary is a separate project and the preset does not install it. Homebrew
covers macOS and Linux; the install script is upstream's own recommendation, and
[dcg's docs](https://github.com/Dicklesworthstone/destructive_command_guard#installation)
list the rest (cargo, prebuilt release binaries, manual build):

```sh
brew install dicklesworthstone/tap/dcg
# or — binary only, no agent hooks wired up
curl -fsSL "https://raw.githubusercontent.com/Dicklesworthstone/destructive_command_guard/main/install.sh?$(date +%s)" | bash -s -- --no-configure

dcg --version
dcg --robot test "rm -rf /" # prints JSON with a deny decision
```

`--no-configure` is the flag that matters here. Left off, the installer wires
dcg into the hooks of every coding agent it detects — Claude Code, Codex CLI,
Cursor and friends — which `plugin-dcg` neither needs nor uses: it calls the
binary itself, and all it needs is `dcg` on `PATH`. Drop the flag (or run dcg's
own `dcg install` later) if you do want dcg guarding those other agents too.

The binary lands in `~/.local/bin`. If that is not already on your `PATH`, add
`--easy-mode`, which appends it to your shell rc files. And keep the URL quoted:
the `?` cache-buster is a glob in zsh.

Without the binary the plugin warns once per session and lets commands through
unchecked. Everything else is tuned by environment variable: `DCG_PLUGIN_FAIL_MODE=closed`
blocks instead when dcg is unavailable, `DCG_PLUGIN_ENABLED=false` turns it off,
and `DCG_PLUGIN_TOOLS`, `DCG_PLUGIN_TIMEOUT_MS` and `DCG_PLUGIN_BINARY` cover the
rest. Since plugin 0.2.0 the same five settings can also live in the config file
the preset writes, which is the durable place for them — replace the plugin
string with a `[spec, options]` pair and the environment still overrides it:

```json
"plugin": [["opencode-plugin-dcg@0.2.0", { "failMode": "closed" }]]
```

The preset writes the plain string; edit it by hand if you want the options
form. Which commands count as destructive is dcg's own policy, in
`~/.config/dcg/config.toml` or a project `.dcg.toml`.

## Use

```sh
Expand Down Expand Up @@ -463,12 +388,12 @@ rejects `/usr/local/bin/foo`. The setup half is optional but omitting it
means the refusal cannot tell you what to run:

```jsonc
// @requires-bin: dcg | brew install dicklesworthstone/tap/dcg
// @requires-bin: jq | brew install jq
```

```
error: plugin-dcg requires "dcg" on PATH.
brew install dicklesworthstone/tap/dcg
error: mcp-example requires "jq" on PATH.
brew install jq
then run this again — nothing was written.
```

Expand Down
23 changes: 0 additions & 23 deletions presets/plugin-dcg.conf

This file was deleted.

2 changes: 1 addition & 1 deletion src/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export async function runBatch(opts: RunBatchOpts): Promise<void> {
// ── Check required binaries ──
//
// Before the summary, so nobody confirms an install that cannot survive its
// own preconditions: the summary prints "needs dcg on PATH ✓" and getting
// own preconditions: the summary prints "needs <bin> on PATH ✓" and getting
// that far now means it was verified, not merely declared. Install only —
// `remove` must keep working after the binary is gone, which is precisely
// when you want the entry out of your config.
Expand Down
2 changes: 1 addition & 1 deletion src/parse-conf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ function parseRequiresBin(value: string, filePath: string, line: number): Requir
const bin = (sep === -1 ? value : value.slice(0, sep)).trim();
const setup = sep === -1 ? '' : value.slice(sep + 1).trim();
if (!bin) throw parseError(filePath, line, '@requires-bin needs an executable name');
// A name, resolved against PATH — not a path. Accepting "/opt/x/bin/dcg"
// A name, resolved against PATH — not a path. Accepting "/opt/x/bin/jq"
// here would make the check pass on one machine and fail on the next.
if (bin.includes('/') || bin.includes('\\') || /\s/.test(bin)) {
throw parseError(filePath, line, `@requires-bin must be an executable name on PATH, not a path, got "${bin}"`);
Expand Down
1 change: 0 additions & 1 deletion src/pin-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ export type PinSource =

export const PIN_SOURCES: Record<string, PinSource> = {
'@playwright/mcp': { kind: 'npm', pkg: '@playwright/mcp' },
'opencode-plugin-dcg': { kind: 'npm', pkg: 'opencode-plugin-dcg' },
'opencode-plugin-litellm-pricing': { kind: 'npm', pkg: 'opencode-plugin-litellm-pricing' },
'opencode-planify-german': { kind: 'npm', pkg: 'opencode-planify-german' },
'superpowers': { kind: 'github-release', repo: 'obra/superpowers' },
Expand Down
43 changes: 4 additions & 39 deletions test/builtin-presets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,46 +53,12 @@ test('ships a litellm plugin preset that appends the runtime-discovery plugin',
assert.deepEqual(body, ['opencode-plugin-litellm-pricing@0.9.0']);
});

test('ships a dcg plugin preset that appends the destructive-command guard plugin', async () => {
const preset = resolve(process.cwd(), 'presets/plugin-dcg.conf');

const { meta, body } = await parseConf(preset);

assert.equal(meta.name, 'plugin-dcg');
assert.equal(meta.path, 'plugin');
assert.equal(meta.mode, 'append');
assert.deepEqual(body, ['opencode-plugin-dcg@0.2.0']);

// The plugin is a shim over an external binary. Without dcg on PATH it warns
// once and every command runs unchecked — a guard sitting in the config that
// is not guarding, which is worse than no plugin at all. The preset declares
// the dependency so install refuses instead of writing that state.
// Several ways in, because there is no single one and no one route suits every
// shop: Homebrew has the upstream tap, crates.io covers anywhere with a Rust
// toolchain (nix included — dcg itself is not in nixpkgs), and the signed
// release assets are there for anyone who will not curl-pipe an installer.
assert.deepEqual(meta.requiresBin, [
{
bin: 'dcg',
setup: '# Homebrew, from the upstream tap (macOS and Linux):\n' +
'brew install dicklesworthstone/tap/dcg\n' +
'# or from crates.io, anywhere a Rust toolchain is available \u2014 get one with\n' +
'# `nix-env -iA nixpkgs.cargo`, `dnf install cargo`, `apt install cargo`:\n' +
'cargo install destructive_command_guard\n' +
'# this one lands in ~/.cargo/bin, which then has to be on PATH\n' +
'# or a signed release binary, checksums and attestations alongside each asset:\n' +
'# https://github.com/Dicklesworthstone/destructive_command_guard/releases\n' +
'# upstream also has an install.sh that curl-pipes to bash, if you like those',
},
]);
});

test('only the presets that drive an external binary declare @requires-bin', async () => {
// A precondition that refuses an install is not something to acquire by
// accident: every preset carrying one is listed here on purpose.
const expected: Record<string, string[]> = {
'plugin-dcg': ['dcg'],
};
// accident: every preset carrying one is listed here on purpose. No shipped
// preset needs an external binary right now, so the map is empty and the
// loop below is what catches one arriving unannounced.
const expected: Record<string, string[]> = {};

for (const file of await shippedPresets()) {
const { meta } = await parseConf(file);
Expand Down Expand Up @@ -233,7 +199,6 @@ test('records the pinned third-party version of every preset that installs one',
const expected: Record<string, Array<{ name: string; version: string }>> = {
'jdtls-lombok': [{ name: 'lombok', version: '1.18.46' }],
'mcp-playwright': [{ name: '@playwright/mcp', version: '0.0.79' }],
'plugin-dcg': [{ name: 'opencode-plugin-dcg', version: '0.2.0' }],
'plugin-litellm-pricing': [{ name: 'opencode-plugin-litellm-pricing', version: '0.9.0' }],
'plugin-opencode-planify-german': [{ name: 'opencode-planify-german', version: '0.3.2' }],
'plugin-superpowers': [{ name: 'superpowers', version: '6.3.0' }],
Expand Down
8 changes: 4 additions & 4 deletions test/merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,17 +103,17 @@ describe('applyAtPath — append mode', () => {
// opencode loads every entry in `plugin`, so leaving an old `pkg@0.8.1`
// behind next to a new `pkg@0.9.0` loads the plugin twice at two versions.
test('supersedes an older version of the same package instead of stacking it', () => {
const root = { plugin: ['opencode-plugin-dcg@0.2.0', 'pricing@0.8.1'] };
const root = { plugin: ['superpowers@6.3.0', 'pricing@0.8.1'] };
const { next, stats } = applyAtPath(root, 'plugin', ['pricing@0.9.0'], 'append');
assert.deepEqual((next as any).plugin, ['opencode-plugin-dcg@0.2.0', 'pricing@0.9.0']);
assert.deepEqual((next as any).plugin, ['superpowers@6.3.0', 'pricing@0.9.0']);
assert.equal(stats.added, 0);
assert.equal(stats.superseded, 1);
});

test('collapses a config that already stacked several versions, keeping position', () => {
const root = { plugin: ['pricing@0.7.0', 'dcg@0.2.0', 'pricing@0.8.0', 'pricing@0.8.1'] };
const root = { plugin: ['pricing@0.7.0', 'superpowers@6.3.0', 'pricing@0.8.0', 'pricing@0.8.1'] };
const { next, stats } = applyAtPath(root, 'plugin', ['pricing@0.9.0'], 'append');
assert.deepEqual((next as any).plugin, ['pricing@0.9.0', 'dcg@0.2.0']);
assert.deepEqual((next as any).plugin, ['pricing@0.9.0', 'superpowers@6.3.0']);
assert.equal(stats.superseded, 3);
});

Expand Down
38 changes: 19 additions & 19 deletions test/parse-conf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ describe('parseConfString — @include', () => {
['// @fetch: https://x/y.jar -> {{cache}}/y.jar\n', /must not set @fetch/],
['// @prompt: url | text | where\n', /must not set @prompt/],
['// @pins: lombok 1.18.46\n', /must not set @pins/],
['// @requires-bin: dcg\n', /must not set @requires-bin/],
['// @requires-bin: shellcheck\n', /must not set @requires-bin/],
];
for (const [directive, re] of cases) {
assert.throws(() => parseConfString(bundleHeader + directive + '// @include: a\n'), re);
Expand Down Expand Up @@ -256,9 +256,9 @@ describe('parseConfString — @prompt', () => {
assert.throws(() => parseConfString(src), /@prompt must be/);
});

// The hint is a shell command; README's own dcg entry documents a
// curl-pipe installer. Splitting it into fields would reject it with a
// field-count error that never names the real cause.
// The hint is a shell command, and a curl-pipe installer is a normal shape
// for one. Splitting it into fields would reject it with a field-count
// error that never names the real cause.
test('a setup hint keeps its own pipes', () => {
const src = minimalHeader +
'// @prompt: clone | dir | where | | curl -fsSL https://x/i.sh | bash -s -- --no-configure\n\n{}';
Expand Down Expand Up @@ -328,15 +328,15 @@ describe('parseConfString — @prompt', () => {

describe('parseConfString — @requires-bin', () => {
test('parses a binary name', () => {
const src = minimalHeader + '// @requires-bin: dcg\n\n{}';
const src = minimalHeader + '// @requires-bin: shellcheck\n\n{}';
const { meta } = parseConfString(src);
assert.deepEqual(meta.requiresBin, [{ bin: 'dcg' }]);
assert.deepEqual(meta.requiresBin, [{ bin: 'shellcheck' }]);
});

test('is repeatable', () => {
const src = minimalHeader + '// @requires-bin: dcg\n// @requires-bin: jq\n\n{}';
const src = minimalHeader + '// @requires-bin: shellcheck\n// @requires-bin: jq\n\n{}';
const { meta } = parseConfString(src);
assert.deepEqual(meta.requiresBin, [{ bin: 'dcg' }, { bin: 'jq' }]);
assert.deepEqual(meta.requiresBin, [{ bin: 'shellcheck' }, { bin: 'jq' }]);
});

test('defaults to empty', () => {
Expand All @@ -349,50 +349,50 @@ describe('parseConfString — @requires-bin', () => {
// has to survive as something the user can paste.
test('continues a setup hint across following comment lines', () => {
const src = minimalHeader +
'// @requires-bin: dcg | brew install dicklesworthstone/tap/dcg\n' +
'// @requires-bin: shellcheck | brew install shellcheck\n' +
'// # or:\n' +
"// nix-shell -p cargo rustc --run 'cargo install destructive_command_guard'\n" +
"// nix-shell -p cabal-install --run 'cabal install ShellCheck'\n" +
'\n{}';
const { meta } = parseConfString(src);
assert.deepEqual(meta.requiresBin, [{
bin: 'dcg',
setup: 'brew install dicklesworthstone/tap/dcg\n# or:\n' +
"nix-shell -p cargo rustc --run 'cargo install destructive_command_guard'",
bin: 'shellcheck',
setup: 'brew install shellcheck\n# or:\n' +
"nix-shell -p cabal-install --run 'cabal install ShellCheck'",
}]);
});

// The hint may start on the continuation lines: the @ line then carries the
// binary name alone, with no trailing pipe to leave dangling.
test('starts a setup hint on a continuation line', () => {
const src = minimalHeader +
'// @requires-bin: dcg\n' +
'// brew install dicklesworthstone/tap/dcg\n' +
'// @requires-bin: shellcheck\n' +
'// brew install shellcheck\n' +
'\n{}';
const { meta } = parseConfString(src);
assert.deepEqual(meta.requiresBin, [
{ bin: 'dcg', setup: 'brew install dicklesworthstone/tap/dcg' },
{ bin: 'shellcheck', setup: 'brew install shellcheck' },
]);
});

// Two @requires-bin lines in a row: a continuation belongs to the one right
// above it, not to the first or to all of them.
test('attaches a continuation to the most recent @requires-bin', () => {
const src = minimalHeader +
'// @requires-bin: dcg\n' +
'// @requires-bin: shellcheck\n' +
'// @requires-bin: jq\n' +
'// brew install jq\n' +
'\n{}';
const { meta } = parseConfString(src);
assert.deepEqual(meta.requiresBin, [
{ bin: 'dcg' },
{ bin: 'shellcheck' },
{ bin: 'jq', setup: 'brew install jq' },
]);
});

// A path would make the check pass on the author's machine and fail on
// everyone else's; the whole point is a name resolved against PATH.
test('rejects a path rather than a name', () => {
for (const bad of ['/usr/local/bin/dcg', './dcg', 'bin/dcg']) {
for (const bad of ['/usr/local/bin/shellcheck', './shellcheck', 'bin/shellcheck']) {
assert.throws(
() => parseConfString(minimalHeader + `// @requires-bin: ${bad}\n\n{}`),
/must be an executable name on PATH/,
Expand Down
Loading