Official

opencode

OpenCode agent provider: runs opencode serve and talks to it over HTTP and server-sent events.

The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/opencode@0.2.0

Permissions in 0.2.0

  • Read files fs.readMediumReads files in the listed places.Read, once, the servers the previous OpenCode provider keptPlaces: its own data folder
  • Provide agents agents.provideMediumAdds agents to the app.Provide the OpenCode agent, and serve plugin tools to it through the host's loopback MCP server
  • Run named programs processMediumStarts the listed programs.Run the OpenCode server (`opencode serve`, or the binary you choose in Settings), read its catalog from the command line when the server cannot answer, upgrade it (`opencode upgrade`), and ask or tell the npm installation that owns it about a newer versionPrograms: opencodenpm${settings.binaryPath}
  • Network access netMediumConnects to the listed hosts.Talk to the OpenCode server it starts on this computer, at the port it picks for each launch, and to the external server you choose in SettingsHosts: localhost:*${settings.serverUrl}
  • Environment variables envMediumReads the listed environment variables.Expand ~ in a configured binary, and read the OpenCode settings you set in the environment: the binary to run, an external server's address, and the server's user name and passwordVariables: HOMEOPENCODE_PATHOPENCODE_SERVER_URLOPENCODE_SERVER_USERNAMEOPENCODE_SERVER_PASSWORDCONVERGENCE_OPENCODE_SERVER_PASSWORD

Files

permission.test.ts3.8 KB
import { test } from "node:test";
import assert from "node:assert/strict";
import { alreadyApplies, repliesAutomatically, ruleset } from "./permission.ts";

/// The server applies the last matching rule, and its `*` also matches a
/// path separator: that is what makes the order of the `.env` rules the
/// difference between asking and not.
function globMatches(pattern: string, target: string): boolean {
  const parts = pattern.split("*");
  const first = parts.shift() ?? "";
  if (!target.startsWith(first)) return false;
  let rest = target.slice(first.length);
  if (!parts.length) return rest === "";
  const last = parts.pop() ?? "";
  for (const part of parts) {
    const at = rest.indexOf(part);
    if (at < 0) return false;
    rest = rest.slice(at + part.length);
  }
  return rest.length >= last.length && rest.endsWith(last);
}

function actionFor(mode: string, permission: string, target: string): string | null {
  const matching = ruleset(mode).filter(
    (rule) => (rule.permission === "*" || rule.permission === permission) && globMatches(rule.pattern, target),
  );
  return matching.at(-1)?.action ?? null;
}

test("environment files keep asking even though reads are allowed", () => {
  for (const mode of ["supervised", "auto_edits"]) {
    assert.equal(actionFor(mode, "read", "src/main.rs"), "allow");
    assert.equal(actionFor(mode, "read", ".env"), "ask");
    assert.equal(actionFor(mode, "read", ".env.local"), "ask");
    assert.equal(actionFor(mode, "read", "config/service.env"), "ask");
    // The example file is committed on purpose, and its rule comes after
    // the two that would otherwise catch it.
    assert.equal(actionFor(mode, "read", ".env.example"), "allow");
    assert.equal(actionFor(mode, "read", "config/service.env.example"), "allow");
  }
});

test("only edits change between supervised and auto-accept edits", () => {
  assert.equal(actionFor("supervised", "edit", "src/main.rs"), "ask");
  assert.equal(actionFor("auto_edits", "edit", "src/main.rs"), "allow");
  for (const mode of ["supervised", "auto_edits"]) {
    assert.equal(actionFor(mode, "bash", "rm -rf /"), "ask");
    assert.equal(actionFor(mode, "external_directory", "/etc"), "ask");
  }
  const supervised = ruleset("supervised");
  const autoEdits = ruleset("auto_edits");
  const differing = supervised
    .filter((rule, index) => JSON.stringify(rule) !== JSON.stringify(autoEdits[index]))
    .map((rule) => rule.permission);
  assert.deepEqual(differing, ["edit"]);
});

test("without an approval reviewer Auto is the same ruleset as Supervised", () => {
  assert.deepEqual(ruleset("auto"), ruleset("supervised"));
  assert.equal(repliesAutomatically("auto"), false);
});

test("full access allows everything, other folders included", () => {
  assert.deepEqual(ruleset("full"), [
    { permission: "*", pattern: "*", action: "allow" },
    { permission: "external_directory", pattern: "*", action: "allow" },
  ]);
  assert.equal(repliesAutomatically("full"), true);
});

test("a tool nobody listed falls through to the leading wildcard, the plugin tools too", () => {
  assert.equal(actionFor("supervised", "convergence_lucky_number", "*"), "ask");
  assert.equal(actionFor("full", "convergence_lucky_number", "*"), "allow");
});

test("rules already in force are not pushed again, because the server appends", () => {
  const wanted = ruleset("supervised");
  const stored = [{ permission: "bash", pattern: "*", action: "ask" }, ...wanted];
  assert.equal(alreadyApplies(stored, wanted), true, "the ruleset is already the tail");
  // A mode change puts other rules last, so the push has to happen.
  assert.equal(alreadyApplies([...wanted, { permission: "edit", pattern: "*", action: "allow" }], wanted), false);
  assert.equal(alreadyApplies(null, wanted), false, "a session with no rules needs them");
  assert.equal(alreadyApplies([], wanted), false);
});

Versions

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <394.8 KB5 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.