opencode-workflow-guard 1.15.5 → 1.16.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,86 @@
1
+ /**
2
+ * Host MCP server inventory for the guard's MCP policy.
3
+ *
4
+ * The MCP policy identifies a guarded live-system call by name tokens
5
+ * (`github`, `azure`, `ado`, ...). That static list covers the common servers,
6
+ * but it cannot learn a server the operator renamed, and it cannot tell when
7
+ * the host's roster changed.
8
+ *
9
+ * `ctx.mcp.list()` reports the servers for this location, and the
10
+ * `mcp.tools.changed` event says when that roster went stale. Together they
11
+ * widen the guarded token set from host-declared names only.
12
+ *
13
+ * Two deliberate limits:
14
+ *
15
+ * - **Widen-only.** A server the operator removed must not silently unguard a
16
+ * tool that still names it. Removal therefore contributes nothing; the
17
+ * static defaults in `policies/mcp.ts` remain the floor.
18
+ * - **Never a replacement for the policy.** A token learned here only reaches
19
+ * the same `mcpMutationTool` decision the static list already used. Nothing
20
+ * here can allow a call the policy would otherwise block.
21
+ */
22
+
23
+ import { addGuardedMcpTokens, GUARDED_MCP_SERVERS } from "../policies/mcp.ts";
24
+
25
+ /** A server name counts as guarded when it names a known live-system product. */
26
+ const GUARDED_SERVER_TOKEN_RE =
27
+ /(?:^|[^a-z0-9])(?:github|gh|azure|azmcp|ado|devops|azuredevops)(?:[^a-z0-9]|$)/;
28
+
29
+ /**
30
+ * Turn a host server name into tokens for the policy's token scan.
31
+ *
32
+ * Host names are free-form (`github`, `github-enterprise`, `Azure DevOps`), so
33
+ * a guarded server contributes its tokens rather than a single literal. A name
34
+ * that does not look guarded contributes nothing.
35
+ */
36
+ export function guardedTokensFromServerName(serverName: string): string[] {
37
+ if (!serverName || !GUARDED_SERVER_TOKEN_RE.test(serverName.toLowerCase())) return [];
38
+ return serverName
39
+ .toLowerCase()
40
+ .replace(/[^a-z0-9]+/g, " ")
41
+ .trim()
42
+ .split(" ")
43
+ .filter(Boolean);
44
+ }
45
+
46
+ /**
47
+ * Read the MCP servers the host reports.
48
+ *
49
+ * `undefined` means "the host could not tell us", which is different from an
50
+ * empty roster. Callers must not treat unknown as "nothing is guarded".
51
+ *
52
+ * The 2.0.10 MCP domain types `list` with a location filter and returns
53
+ * `{ data: [...] }`; the duck-typed read tolerates a bare array too, because a
54
+ * shape mismatch must degrade to unknown rather than throw.
55
+ */
56
+ export async function listHostMcpServerNames(ctx: unknown): Promise<string[] | undefined> {
57
+ const api = (ctx as { mcp?: { list?: (input?: unknown) => Promise<unknown> } } | undefined)?.mcp;
58
+ if (!api || typeof api.list !== "function") return undefined;
59
+ try {
60
+ const result = await api.list.call(api, {});
61
+ const data = (result as { data?: unknown } | undefined)?.data;
62
+ const entries = Array.isArray(data) ? data : Array.isArray(result) ? result : undefined;
63
+ if (!entries) return undefined;
64
+ return entries
65
+ .map((entry) => (typeof entry === "string" ? entry : (entry as { name?: unknown })?.name))
66
+ .filter((name): name is string => typeof name === "string" && name.length > 0);
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Merge host-declared server names into the guarded token set.
74
+ *
75
+ * Returns the tokens this call added, so the caller can journal widening once
76
+ * instead of logging the whole set on every event. `undefined` input (host
77
+ * could not answer) adds nothing and is not an error.
78
+ */
79
+ export function recordHostMcpServers(serverNames: readonly string[] | undefined): string[] {
80
+ if (!serverNames) return [];
81
+ const tokens = serverNames.flatMap((name) => guardedTokensFromServerName(name));
82
+ return addGuardedMcpTokens(tokens);
83
+ }
84
+
85
+ /** Static defaults, for callers that only need to report the floor. */
86
+ export const STATIC_GUARDED_MCP_SERVERS = GUARDED_MCP_SERVERS;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * V2 permission seat: `ctx.permission.hook("evaluate")`.
3
+ *
4
+ * Two enforcement seats, deliberately complementary:
5
+ *
6
+ * - `evaluate` sees the host's normalized permission check (an `action` plus
7
+ * its `resources`) before anything runs, so a denial there means the tool
8
+ * never starts. It does NOT see raw tool input, so tool-lifecycle work
9
+ * (todo transitions, stale-write observations, post-edit snapshots) stays in
10
+ * `execute.before`.
11
+ * - `execute.before` sees the raw arguments and aborts with a specific
12
+ * remediation message, but only after the permission layer has allowed.
13
+ *
14
+ * The seat can only tighten. An explicit configured `deny` is final: the host
15
+ * never invokes the hook for it (it resolves deny first and only calls the hook
16
+ * when the configured effect is allow/ask). A hook denial is therefore always a
17
+ * response to something the host would otherwise permit.
18
+ *
19
+ * Every decision here is a simulation of the policy core: `simulate: true`
20
+ * skips mutation accounting, file claims, verification runs, and durable audit
21
+ * writes, because the same call is evaluated for real in `execute.before`.
22
+ */
23
+
24
+ import { guardToolCallImpl } from "./guard-dispatcher.ts";
25
+ import { getSessionBoundWorkspace, runWithRuntimeState } from "./state.ts";
26
+ import { mcpMutationTool } from "../policies/mcp.ts";
27
+ import type { PolicyDecision } from "./types.ts";
28
+
29
+ /** The subset of the host's permission evaluation this seat reads. */
30
+ export interface PermissionSeatEvent {
31
+ readonly sessionID: string;
32
+ readonly agent?: string;
33
+ readonly action: string;
34
+ readonly resources: readonly string[];
35
+ readonly effect: "allow" | "deny" | "ask";
36
+ }
37
+
38
+ export interface PermissionSeatDeps {
39
+ workspaceRoot: string;
40
+ client: unknown;
41
+ }
42
+
43
+ export interface PermissionSeatResult {
44
+ /** The effect the seat leaves in place. */
45
+ effect: "allow" | "deny";
46
+ /** Set only when the seat denied. */
47
+ decision?: PolicyDecision;
48
+ /** The guard tool the action was mapped onto, for attribution. */
49
+ mappedTool?: string;
50
+ }
51
+
52
+ const EDIT_ACTIONS = new Set(["edit", "write", "patch", "apply_patch"]);
53
+
54
+ function mappedToolFor(action: string): { tool: string; input: (resource: string) => unknown } | undefined {
55
+ if (action === "shell" || action === "bash") {
56
+ return { tool: "bash", input: (resource) => ({ command: resource }) };
57
+ }
58
+ if (action === "read" || action === "read_file") {
59
+ return { tool: "read", input: (resource) => ({ filePath: resource }) };
60
+ }
61
+ if (EDIT_ACTIONS.has(action)) {
62
+ // `edit` is the canonical mutation tool name: it carries the tamper,
63
+ // secret, boundary, protected-branch, and todo checks.
64
+ return { tool: "edit", input: (resource) => ({ filePath: resource }) };
65
+ }
66
+ return undefined;
67
+ }
68
+
69
+ /**
70
+ * Evaluate one host permission request. Returns the effect to leave in place.
71
+ *
72
+ * Unmapped actions (`external_directory`, `glob`, `grep`, `webfetch`,
73
+ * `websearch`, `question`, `subagent`, ...) stay with the host: config policy
74
+ * and the user prompt are the right seats for them, and the guard's shell,
75
+ * file, and MCP checks have nothing to add. The hook still journals them.
76
+ */
77
+ export async function evaluatePermissionSeat(
78
+ event: PermissionSeatEvent,
79
+ deps: PermissionSeatDeps,
80
+ ): Promise<PermissionSeatResult> {
81
+ if (event.effect === "deny") return { effect: "deny" };
82
+ const resources = event.resources.filter((resource): resource is string => typeof resource === "string" && resource.length > 0);
83
+ if (resources.length === 0) return { effect: "allow" };
84
+ const workspaceRoot = getSessionBoundWorkspace(event.sessionID) || deps.workspaceRoot;
85
+ const context = { sessionID: event.sessionID, agent: event.agent, worktree: workspaceRoot, simulate: true };
86
+ const decide = (tool: string, input: unknown): Promise<PolicyDecision> =>
87
+ runWithRuntimeState(workspaceRoot, deps.client, () => guardToolCallImpl(tool, input, context));
88
+
89
+ const mapping = mappedToolFor(event.action);
90
+ if (mapping) {
91
+ for (const resource of resources) {
92
+ const decision = await decide(mapping.tool, mapping.input(resource));
93
+ if (decision.status !== "allowed") {
94
+ return {
95
+ effect: "deny",
96
+ decision: { ...decision, message: `[workflow-guard permission.evaluate] ${decision.message}` },
97
+ mappedTool: mapping.tool,
98
+ };
99
+ }
100
+ }
101
+ return { effect: "allow", mappedTool: mapping.tool };
102
+ }
103
+
104
+ // `<server>_<tool>` MCP actions: only the guarded live-system servers are
105
+ // mapped (GitHub/Azure DevOps), matching the execute.before MCP policy.
106
+ for (const resource of resources) {
107
+ if (!mcpMutationTool(event.action) && !mcpMutationTool(resource)) continue;
108
+ const decision = await decide(event.action, {});
109
+ if (decision.status !== "allowed") {
110
+ return {
111
+ effect: "deny",
112
+ decision: { ...decision, message: `[workflow-guard permission.evaluate] ${decision.message}` },
113
+ mappedTool: event.action,
114
+ };
115
+ }
116
+ return { effect: "allow", mappedTool: event.action };
117
+ }
118
+ return { effect: "allow" };
119
+ }
@@ -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
+ }