opencode-workflow-guard 1.15.5 → 1.17.0

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.
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Process-local plugin instance registry.
3
+ *
4
+ * OpenCode 2 merges plugin arrays instead of replacing them, so naming the same
5
+ * package in both the global and the project config loads the same entrypoint
6
+ * twice under two ids. Two instances register duplicate hooks and duplicate
7
+ * tool definitions: every tool call is evaluated and audited twice, and the two
8
+ * instances can disagree about `effectiveRoot` in the same tick, which primes
9
+ * the process-global workspace latch from two roots.
10
+ *
11
+ * The first instance for a (module path, workspace root) key owns enforcement.
12
+ * A later instance with the same key stays inert and records a warn-level
13
+ * duplicate so the misconfiguration is visible in the audit trail. Different
14
+ * workspace roots are legitimate (concurrent loops share one process) and get
15
+ * independent keys.
16
+ */
17
+
18
+ import { realpathSync } from "node:fs";
19
+ import { fileURLToPath } from "node:url";
20
+ import { resolve } from "node:path";
21
+ import { audit } from "./audit.ts";
22
+
23
+ export interface PluginInstanceInfo {
24
+ /** Stable key: resolved module path plus resolved workspace root. */
25
+ key: string;
26
+ modulePath: string;
27
+ workspaceRoot: string;
28
+ /** True when this key already had an active instance in this process. */
29
+ duplicate: boolean;
30
+ }
31
+
32
+ interface ActiveInstance {
33
+ modulePath: string;
34
+ workspaceRoot: string;
35
+ registrations: number;
36
+ }
37
+
38
+ // Deliberately process-global, not module-global: OpenCode can load the same
39
+ // package twice from two distinct paths (a cache copy plus a source checkout),
40
+ // and each copy evaluates this module separately. A `Symbol.for` slot on
41
+ // `globalThis` gives both copies one registry so the duplicate stays visible
42
+ // even when the module instances differ.
43
+ const INSTANCE_REGISTRY = Symbol.for("opencode-workflow-guard.plugin-instances.v1");
44
+ const globalRegistry = globalThis as unknown as Record<symbol, Map<string, ActiveInstance> | undefined>;
45
+ const activeInstances: Map<string, ActiveInstance> =
46
+ globalRegistry[INSTANCE_REGISTRY] ?? (globalRegistry[INSTANCE_REGISTRY] = new Map());
47
+
48
+ /** Resolved path of the loaded plugin module; the stable half of the key. */
49
+ export function loadedPluginModulePath(moduleUrl: string = import.meta.url): string {
50
+ const lexical = (() => {
51
+ try {
52
+ return fileURLToPath(moduleUrl);
53
+ } catch {
54
+ return moduleUrl;
55
+ }
56
+ })();
57
+ try {
58
+ return realpathSync(lexical);
59
+ } catch {
60
+ return lexical;
61
+ }
62
+ }
63
+
64
+ function canonicalRoot(root: string): string {
65
+ try {
66
+ return realpathSync(resolve(root));
67
+ } catch {
68
+ return resolve(root);
69
+ }
70
+ }
71
+
72
+ export function pluginInstanceKey(modulePath: string, workspaceRoot: string): string {
73
+ return `${canonicalRoot(workspaceRoot)}::${modulePath}`;
74
+ }
75
+
76
+ /**
77
+ * Register one setup call. A duplicate key is journaled at warn level and the
78
+ * caller must stay inert: returning early is what makes the load count one
79
+ * policy decision per tool call instead of two.
80
+ */
81
+ export function registerPluginInstance(options: {
82
+ modulePath?: string;
83
+ workspaceRoot: string;
84
+ }): PluginInstanceInfo {
85
+ const modulePath = options.modulePath ?? loadedPluginModulePath();
86
+ const workspaceRoot = canonicalRoot(options.workspaceRoot);
87
+ const key = pluginInstanceKey(modulePath, workspaceRoot);
88
+ const existing = activeInstances.get(key);
89
+ if (existing) {
90
+ existing.registrations++;
91
+ audit({
92
+ ts: new Date().toISOString(),
93
+ tool: "plugin.instance",
94
+ decision: "allow",
95
+ phase: "event",
96
+ reason: "duplicate_plugin_load",
97
+ input: { instanceKey: key, modulePath, workspaceRoot, registrations: existing.registrations },
98
+ });
99
+ return { key, modulePath, workspaceRoot, duplicate: true };
100
+ }
101
+ activeInstances.set(key, { modulePath, workspaceRoot, registrations: 1 });
102
+ audit({
103
+ ts: new Date().toISOString(),
104
+ tool: "plugin.instance",
105
+ decision: "allow",
106
+ phase: "event",
107
+ reason: "plugin_loaded",
108
+ input: { instanceKey: key, modulePath, workspaceRoot },
109
+ });
110
+ return { key, modulePath, workspaceRoot, duplicate: false };
111
+ }
112
+
113
+ /** Drop one registration. The key is reusable once every registration is gone. */
114
+ export function releasePluginInstance(key: string): void {
115
+ const existing = activeInstances.get(key);
116
+ if (!existing) return;
117
+ existing.registrations--;
118
+ if (existing.registrations <= 0) activeInstances.delete(key);
119
+ }
120
+
121
+ /** Test seam: forget every live instance so fixtures can re-register. */
122
+ export function resetPluginInstancesForTesting(): void {
123
+ activeInstances.clear();
124
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Static policy posture for OpenCode 2.
3
+ *
4
+ * Three enforcement seats exist, and this module feeds the only one a
5
+ * repository cannot lift. `ctx.permission.hook("evaluate")` and
6
+ * `execute.before` run inside the plugin, so removing the plugin removes them.
7
+ * Static policies are configuration the host evaluates itself.
8
+ *
9
+ * Two host shapes exist and they are not interchangeable:
10
+ *
11
+ * - **OpenCode 2.0.x** reads `experimental.policies` and requires
12
+ * `action: "permission"`. Its evaluator matches the composite string
13
+ * `<permission action>:<resource>` as a whole-string glob
14
+ * (`opencode.config.policy` in the 2.0.15 binary). A deny surfaces as
15
+ * "Blocked by configuration policy".
16
+ * - **OpenCode 2.1.x** reads a top-level `policies` array and requires
17
+ * `action: "tool.use"` instead. It ignores `experimental.policies`.
18
+ *
19
+ * Each host silently ignores the key it does not read, which makes a
20
+ * single-key posture look applied while enforcing nothing. The default
21
+ * therefore emits both keys. `--format 2.0` and `--format next` remain for
22
+ * pinning one host, and the reason to avoid them is the failure above.
23
+ *
24
+ * Schema acceptance is not runtime acceptance: `@opencode/schema` 2.0.10
25
+ * admits a top-level `policies` array that the 2.0.15 runtime never reads.
26
+ * The envelopes below were verified by live denial canaries on both hosts,
27
+ * not by decoding config.
28
+ *
29
+ * The plugin never writes another tool's configuration. The caller prints the
30
+ * block and the operator decides where it lands, because a plugin that can
31
+ * install its own static rails has just moved them back inside the plugin.
32
+ */
33
+
34
+ /** Static invariants: each one holds for every session, with no runtime input. */
35
+ export interface StaticInvariant {
36
+ /** Stable id, used by tests and documentation. */
37
+ id: string;
38
+ /** Host permission action the rule applies to. */
39
+ action: string;
40
+ /** Resources denied for that action, before the `<action>:` prefix. */
41
+ resources: readonly string[];
42
+ /** Why this cannot be left to a runtime hook. */
43
+ reason: string;
44
+ }
45
+
46
+ /**
47
+ * The invariants, in the order they are emitted.
48
+ *
49
+ * Every entry is a denial. A posture that grants access would be a second,
50
+ * weaker policy engine living in a config file, and the plugin already owns
51
+ * that job.
52
+ */
53
+ export const STATIC_INVARIANTS: readonly StaticInvariant[] = [
54
+ {
55
+ id: "protected-trunk-push",
56
+ action: "shell",
57
+ resources: [
58
+ // The host matches the whole command string, so `git push * main`
59
+ // covers `git push origin main` and `git push -f origin main`, while
60
+ // the refspec forms need their own entries.
61
+ "git push * main",
62
+ "git push * master",
63
+ "git push * HEAD:main",
64
+ "git push * HEAD:master",
65
+ ],
66
+ reason:
67
+ "Pushing to protected trunk must stay blocked even when the plugin is absent or disabled. The runtime git policy already refuses this; the static rule removes the dependency on it. A whole-string glob cannot see a compound command line or an exotic refspec, so this is a floor, not a sandbox: the plugin seat covers the rest.",
68
+ },
69
+ {
70
+ id: "live-control-plane-write",
71
+ action: "edit",
72
+ resources: [
73
+ // No `~/` here. On 2.0.x, `~` and `$HOME` are expanded only for the
74
+ // `permissions` key, not for `experimental.policies`, so a tilde
75
+ // resource would never match an absolute path. A leading `*/` is
76
+ // version-independent and covers every home directory.
77
+ "*/.config/opencode/*",
78
+ "/etc/opencode/*",
79
+ "*/Library/Application Support/opencode/*",
80
+ ],
81
+ reason:
82
+ "An agent that can edit the live control plane can rewrite the rules that constrain it, including the plugin list. The tamper policy is a runtime check; this rule is a configuration-level one.",
83
+ },
84
+ {
85
+ id: "secret-material-read",
86
+ action: "read",
87
+ resources: ["*/.ssh/*", "*id_rsa*"],
88
+ reason:
89
+ "Private keys should never enter a model context, regardless of which session or role asks. The secret policy depends on the plugin being loaded.",
90
+ },
91
+ {
92
+ id: "external-directory-widening",
93
+ action: "external_directory",
94
+ resources: ["*"],
95
+ reason:
96
+ "`external_directory` defaults to allow, so a foreign read needs no approval. Denying it statically is what keeps the write boundary a boundary rather than an advisory. This is the strict posture: drop this rule if cross-repository reads are part of normal work.",
97
+ },
98
+ {
99
+ id: "code-mode-surface",
100
+ action: "execute",
101
+ resources: ["*"],
102
+ reason:
103
+ "Code Mode wraps tools behind a single `execute` surface, and its nested calls reach the tool layer on a path this guard does not evaluate. Denying `execute` removes that surface where the host asserts the action. The qualified hosts (2.0.15 and 2.1.3) register no `execute` tool and never assert an `execute` permission action, so this rule is inert on both; the plugin's read-only pruning plus `execute.before` remain the coverage.",
104
+ },
105
+ ];
106
+
107
+ export type PolicyPostureFormat = "2.0" | "next" | "both";
108
+
109
+ export interface StaticPolicyRule {
110
+ action: string;
111
+ resource: string;
112
+ effect: "deny";
113
+ }
114
+
115
+ export interface PolicyPosture {
116
+ format: PolicyPostureFormat;
117
+ /**
118
+ * Rules as `{ action, resource }` pairs with the resource unprefixed. The
119
+ * host builds the `<action>:<resource>` string when it matches; `config`
120
+ * holds the enveloped form that is actually pasted into a config file.
121
+ */
122
+ rules: StaticPolicyRule[];
123
+ /** Config fragment, ready to print. Never written by the plugin. */
124
+ config: Record<string, unknown>;
125
+ /** Human-readable rationale per invariant, for the printed header. */
126
+ rationale: Array<{ id: string; reason: string }>;
127
+ }
128
+
129
+ /**
130
+ * Compose one rule. The host matches `resource` against `<action>:<resource>`,
131
+ * so the action name is part of the string, not a separate field.
132
+ */
133
+ function ruleFor(invariant: StaticInvariant, resource: string): StaticPolicyRule {
134
+ return { action: invariant.action, resource, effect: "deny" };
135
+ }
136
+
137
+ function rulesFor(invariants: readonly StaticInvariant[]): StaticPolicyRule[] {
138
+ return invariants.flatMap((invariant) => invariant.resources.map((resource) => ruleFor(invariant, resource)));
139
+ }
140
+
141
+ /**
142
+ * Envelope a rule list for the target host.
143
+ *
144
+ * `2.0` uses `experimental.policies` with the `permission` action, which is
145
+ * what the 2.0.x evaluator compares. `next` uses the top-level `policies`
146
+ * array with `tool.use`, which is what 2.1.x reads. `both` emits each rule
147
+ * under its own host's key: neither host reads the other's key, so the
148
+ * duplicate cannot double-apply or conflict.
149
+ */
150
+ function envelope(
151
+ format: PolicyPostureFormat,
152
+ rules: readonly StaticPolicyRule[],
153
+ ): Record<string, unknown> {
154
+ const legacy = {
155
+ experimental: {
156
+ policies: rules.map((rule) => ({
157
+ action: "permission",
158
+ resource: `${rule.action}:${rule.resource}`,
159
+ effect: rule.effect,
160
+ })),
161
+ },
162
+ };
163
+ const current = {
164
+ policies: rules.map((rule) => ({
165
+ action: "tool.use",
166
+ resource: `${rule.action}:${rule.resource}`,
167
+ effect: rule.effect,
168
+ })),
169
+ };
170
+ if (format === "2.0") return legacy;
171
+ if (format === "next") return current;
172
+ return { ...legacy, ...current };
173
+ }
174
+
175
+ export function buildPolicyPosture(options?: { format?: PolicyPostureFormat }): PolicyPosture {
176
+ const format = options?.format ?? "both";
177
+ const rules = rulesFor(STATIC_INVARIANTS);
178
+ return {
179
+ format,
180
+ rules,
181
+ config: envelope(format, rules),
182
+ rationale: STATIC_INVARIANTS.map((invariant) => ({ id: invariant.id, reason: invariant.reason })),
183
+ };
184
+ }
185
+
186
+ function commentLines(lines: readonly string[]): string {
187
+ return lines
188
+ .flatMap((line) => line.split("\n"))
189
+ .map((line) => (line.trim() ? `// ${line}` : "//"))
190
+ .join("\n");
191
+ }
192
+
193
+ /**
194
+ * Render the posture as JSONC.
195
+ *
196
+ * The header states precedence and residual risk, because the file that
197
+ * carries these rules is also the file an operator will trim. A trimmed rule
198
+ * that was never explained looks like cruft, and cruft gets deleted.
199
+ */
200
+ export function renderPolicyPosture(options?: { format?: PolicyPostureFormat }): string {
201
+ const posture = buildPolicyPosture(options);
202
+ const header = commentLines([
203
+ "Workflow Guard static policy posture. Generated, never installed automatically.",
204
+ "",
205
+ `Target: OpenCode ${
206
+ posture.format === "2.0"
207
+ ? "2.0.x only (experimental.policies, action \"permission\")"
208
+ : posture.format === "next"
209
+ ? "2.1.x only (top-level policies, action \"tool.use\")"
210
+ : "2.0.x and 2.1.x (both keys; each host reads its own and ignores the other)"
211
+ }.`,
212
+ "",
213
+ "Precedence, highest first: managed preferences and Console policies, then /etc/opencode,",
214
+ "then ~/.config/opencode, then project opencode.json(c). Later rules win within one",
215
+ "ruleset, so keep these denies after any broad allow you add.",
216
+ "",
217
+ "Residual risk: policies are configuration. They do not sandbox plugin code, and any",
218
+ "process that can write the file that carries them can remove them. Place this block",
219
+ "in the system or global config, not in a project file.",
220
+ "",
221
+ "Every rule below is a deny. Nothing here grants access; runtime decisions stay with",
222
+ "the plugin's permission-evaluate and execute-before seats.",
223
+ "",
224
+ ...posture.rationale.flatMap(({ id, reason }) => [`${id}: ${reason}`, ""]),
225
+ ]);
226
+ return `${header}\n${JSON.stringify(posture.config, null, 2)}\n`;
227
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Tool pruning for read-only subagent roles.
3
+ *
4
+ * `execute.before` already denies a mutation from a read-only role, but only
5
+ * after the model has planned the call and the host has admitted it. The V2
6
+ * `session.hook("context")` sees the per-request tool snapshot, so the
7
+ * stronger invariant is to remove the mutating tools before the model can
8
+ * choose them.
9
+ *
10
+ * This is a narrowing only. The `execute.before` seat stays in place as the
11
+ * backstop: tools supplied by an ACP agent or a nested Code Mode call may not
12
+ * appear in the host snapshot, and a pruned list is not an authorization
13
+ * boundary on its own.
14
+ */
15
+
16
+ /**
17
+ * Tools removed from a read-only role's request.
18
+ *
19
+ * - `write`/`edit`/`patch`/`apply_patch` author file contents.
20
+ * - `shell`/`bash` author bytes through redirects and can mutate git state.
21
+ * - `execute` is Code Mode, which runs nested tool calls. Nested calls are not
22
+ * documented to fire the guard's hooks, so a read-only role must not reach
23
+ * them.
24
+ * - `todowrite` replaces the whole task list; read-only roles do not plan work.
25
+ * - `subagent`/`task` can delegate a mutation to a child that runs with the
26
+ * child's own role.
27
+ * - `guard_worktree_create`/`guard_worktree_cleanup` mutate repository state
28
+ * and are already denied by the dispatcher for this role.
29
+ */
30
+ export const READ_ONLY_PRUNED_TOOLS: readonly string[] = [
31
+ "write",
32
+ "edit",
33
+ "patch",
34
+ "apply_patch",
35
+ "shell",
36
+ "bash",
37
+ "execute",
38
+ "todowrite",
39
+ "subagent",
40
+ "task",
41
+ "guard_worktree_create",
42
+ "guard_worktree_cleanup",
43
+ ];
44
+
45
+ /**
46
+ * Delete the mutating tools from a per-request tool snapshot.
47
+ *
48
+ * Returns the ids actually removed, so the caller can journal the narrowing.
49
+ * The snapshot is host-owned and mutable for the duration of the request;
50
+ * deleting a key affects only the outgoing call.
51
+ */
52
+ export function pruneToolsForReadOnlyRole(tools: Record<string, unknown> | undefined): string[] {
53
+ if (!tools || typeof tools !== "object") return [];
54
+ const removed: string[] = [];
55
+ for (const id of READ_ONLY_PRUNED_TOOLS) {
56
+ if (!(id in tools)) continue;
57
+ delete tools[id];
58
+ removed.push(id);
59
+ }
60
+ return removed;
61
+ }