From 320cbd0c509186eb2313a9e67894bfbbd4c7a7ae Mon Sep 17 00:00:00 2001 From: trick77 Date: Fri, 28 Aug 2026 10:55:58 +0200 Subject: [PATCH] feat: drop the plugin-dcg preset dcg 0.13 ships its own OpenCode plugin: `dcg install --opencode` writes ~/.config/opencode/plugins/dcg-guard.js, and upstream's install.sh calls it automatically wherever opencode is detected. Installing opencode-plugin-dcg on top of that runs dcg twice per bash call, so the preset goes. Removes the conf, its PIN_SOURCES entry (test/pin-sources.test.ts fails on an orphan), the README row and section, and the two test tables that named it. Fixtures that only used dcg as a sample binary or plugin spec move to shellcheck / superpowers; the @requires-bin machinery stays, though no shipped preset declares one any more. --- README.md | 81 ++---------------------------------- presets/plugin-dcg.conf | 23 ---------- src/batch.ts | 2 +- src/parse-conf.ts | 2 +- src/pin-sources.ts | 1 - test/builtin-presets.test.ts | 43 ++----------------- test/merge.test.ts | 8 ++-- test/parse-conf.test.ts | 38 ++++++++--------- test/preconditions.test.ts | 18 ++++---- 9 files changed, 41 insertions(+), 175 deletions(-) delete mode 100644 presets/plugin-dcg.conf diff --git a/README.md b/README.md index 38e9425..53cf853 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 @@ -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. ``` diff --git a/presets/plugin-dcg.conf b/presets/plugin-dcg.conf deleted file mode 100644 index cd3cdc2..0000000 --- a/presets/plugin-dcg.conf +++ /dev/null @@ -1,23 +0,0 @@ -// @name: plugin-dcg -// @description: Needs the dcg (destructive command guard) binary on PATH — install it -// first, this preset does not. Experimental. Adds the opencode-plugin-dcg plugin: -// every bash command is checked by dcg before it runs, and the destructive ones are -// blocked. The plugin is at 0.2.x and its behaviour can still change. Without dcg on -// PATH the plugin warns once and every command runs unchecked, so this install -// refuses instead. -// @author: Jan -// @version: 0.6.0 -// @path: plugin -// @mode: append -// @requires-bin: dcg | # Homebrew, from the upstream tap (macOS and Linux): -// brew install dicklesworthstone/tap/dcg -// # or from crates.io, anywhere a Rust toolchain is available — get one with -// # `nix-env -iA nixpkgs.cargo`, `dnf install cargo`, `apt install cargo`: -// cargo install destructive_command_guard -// # this one lands in ~/.cargo/bin, which then has to be on PATH -// # or a signed release binary, checksums and attestations alongside each asset: -// # https://github.com/Dicklesworthstone/destructive_command_guard/releases -// # upstream also has an install.sh that curl-pipes to bash, if you like those -// @pins: opencode-plugin-dcg 0.2.0 - -["opencode-plugin-dcg@0.2.0"] diff --git a/src/batch.ts b/src/batch.ts index e7e99f9..21fcd67 100644 --- a/src/batch.ts +++ b/src/batch.ts @@ -95,7 +95,7 @@ export async function runBatch(opts: RunBatchOpts): Promise { // ── 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 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. diff --git a/src/parse-conf.ts b/src/parse-conf.ts index 0dd2468..45a30d2 100644 --- a/src/parse-conf.ts +++ b/src/parse-conf.ts @@ -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}"`); diff --git a/src/pin-sources.ts b/src/pin-sources.ts index 1047501..c625f27 100644 --- a/src/pin-sources.ts +++ b/src/pin-sources.ts @@ -13,7 +13,6 @@ export type PinSource = export const PIN_SOURCES: Record = { '@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' }, diff --git a/test/builtin-presets.test.ts b/test/builtin-presets.test.ts index 9e59631..c3228b6 100644 --- a/test/builtin-presets.test.ts +++ b/test/builtin-presets.test.ts @@ -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 = { - '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 = {}; for (const file of await shippedPresets()) { const { meta } = await parseConf(file); @@ -233,7 +199,6 @@ test('records the pinned third-party version of every preset that installs one', const expected: Record> = { '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' }], diff --git a/test/merge.test.ts b/test/merge.test.ts index 40d68c4..876c005 100644 --- a/test/merge.test.ts +++ b/test/merge.test.ts @@ -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); }); diff --git a/test/parse-conf.test.ts b/test/parse-conf.test.ts index 76a83df..d8737fb 100644 --- a/test/parse-conf.test.ts +++ b/test/parse-conf.test.ts @@ -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); @@ -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{}'; @@ -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', () => { @@ -349,15 +349,15 @@ 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'", }]); }); @@ -365,12 +365,12 @@ describe('parseConfString — @requires-bin', () => { // 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' }, ]); }); @@ -378,13 +378,13 @@ describe('parseConfString — @requires-bin', () => { // 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' }, ]); }); @@ -392,7 +392,7 @@ describe('parseConfString — @requires-bin', () => { // 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/, diff --git a/test/preconditions.test.ts b/test/preconditions.test.ts index 667f687..e9f907b 100644 --- a/test/preconditions.test.ts +++ b/test/preconditions.test.ts @@ -69,35 +69,35 @@ describe('checkDir', () => { describe('findOnPath', () => { test('finds an executable on the given PATH', async () => { const dir = await mkdtemp(join(tmpdir(), 'precond-bin-')); - const bin = join(dir, 'fakedcg'); + const bin = join(dir, 'fakebin'); await writeFile(bin, '#!/bin/sh\n'); await chmod(bin, 0o755); - assert.equal(await findOnPath('fakedcg', { PATH: dir }), bin); + assert.equal(await findOnPath('fakebin', { PATH: dir }), bin); }); test('does not find it when the dir is not on PATH', async () => { const dir = await mkdtemp(join(tmpdir(), 'precond-bin-')); - const bin = join(dir, 'fakedcg'); + const bin = join(dir, 'fakebin'); await writeFile(bin, '#!/bin/sh\n'); await chmod(bin, 0o755); - assert.equal(await findOnPath('fakedcg', { PATH: '/nonexistent-dir-9f3a2b' }), null); + assert.equal(await findOnPath('fakebin', { PATH: '/nonexistent-dir-9f3a2b' }), null); }); // Present but not runnable is the same as absent for our purposes: the // plugin would still fail to invoke it. test('does not accept a file that is not executable', async () => { const dir = await mkdtemp(join(tmpdir(), 'precond-bin-')); - await writeFile(join(dir, 'fakedcg'), '#!/bin/sh\n'); - await chmod(join(dir, 'fakedcg'), 0o644); + await writeFile(join(dir, 'fakebin'), '#!/bin/sh\n'); + await chmod(join(dir, 'fakebin'), 0o644); - assert.equal(await findOnPath('fakedcg', { PATH: dir }), null); + assert.equal(await findOnPath('fakebin', { PATH: dir }), null); }); test('survives an empty or unset PATH', async () => { - assert.equal(await findOnPath('fakedcg', { PATH: '' }), null); - assert.equal(await findOnPath('fakedcg', {}), null); + assert.equal(await findOnPath('fakebin', { PATH: '' }), null); + assert.equal(await findOnPath('fakebin', {}), null); }); });