oc-one-guard 0.1.2 → 0.3.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.
package/README.md CHANGED
@@ -18,7 +18,9 @@ The default `noul` question asks whether the call violates exfiltration policy.
18
18
 
19
19
  ## Quick Heads-Up
20
20
 
21
- - OpenCode v1 only. Version 1.19.4 is tested; v2 is not supported.
21
+ - Branches: `main` targets the latest OpenCode (2.x) and also still loads on 1.19.4. The `opencode-1.x` branch delivers the 1.x-only line. Version numbers are plain semver and do not track OpenCode versions.
22
+ - OpenCode 2.2.x (`setup` entry) was tested end to end on 2.2.5 with a scripted model and judge: allowed and blocked calls, judge outage, staged scripts and the load check. Subagent and MCP calls are not tested yet. OpenCode 1.19.4 (`server` entry) is the tested v1 host. Unsupported before 1.19.4.
23
+ - OpenCode 2.x does not log decisions or show toasts. A load failure is logged as `failed to load plugin`, so [check it loaded](docs/installation.md#verify-it-loaded).
22
24
  - Setup is opt-in. Cloning this repo does not register the plugin or change OpenCode configuration.
23
25
  - The judge receives rendered tool arguments and, in some cases, staged file contents. Pick an endpoint you trust.
24
26
 
@@ -32,7 +34,7 @@ Published on npm as [`oc-one-guard`](https://www.npmjs.com/package/oc-one-guard)
32
34
  {
33
35
  "plugin": [
34
36
  [
35
- "oc-one-guard@0.1.1",
37
+ "oc-one-guard@0.3.0",
36
38
  {
37
39
  "endpoint": "http://127.0.0.1:8081/v1/systemone",
38
40
  "model": "h2o-lightning-4b"
@@ -42,7 +44,7 @@ Published on npm as [`oc-one-guard`](https://www.npmjs.com/package/oc-one-guard)
42
44
  }
43
45
  ```
44
46
 
45
- Use the global config instead if you want it in every project. To uninstall, remove that entry and restart. Full details and the local-checkout option: [installation](https://github.com/HumanFace-Tech/oc-one-guard/blob/main/docs/installation.md).
47
+ OpenCode 2.x uses the `plugins` key with an object entry instead; see [installation](https://github.com/HumanFace-Tech/oc-one-guard/blob/main/docs/installation.md). Use the global config instead if you want it in every project. To uninstall, remove that entry and restart. Full details and the local-checkout option: [installation](https://github.com/HumanFace-Tech/oc-one-guard/blob/main/docs/installation.md).
46
48
 
47
49
  ## How Does the Check Work?
48
50
 
@@ -66,7 +68,6 @@ An earlier synthetic guard run requested **H2O Lightning 4B** (the served model
66
68
  |---|---|
67
69
  | Benign calls scored by H2O | 17 allowed, **9 blocked** |
68
70
  | Malicious calls scored by H2O | **24 blocked**, 0 allowed |
69
- | Malicious call skipped by the prefilter | **1 allowed** without model review |
70
71
 
71
72
  The run was **incomplete**: 45 requests timed out across the direct and guard tests. Timeout blocks aren't model detections, and these small synthetic samples aren't production rates. [See the cases and full breakdown](https://github.com/HumanFace-Tech/oc-one-guard/blob/main/docs/research/h2o-synthetic-evaluation.md).
72
73
 
@@ -80,7 +81,7 @@ The run was **incomplete**: 45 requests timed out across the direct and guard te
80
81
 
81
82
  ## What's Next?
82
83
 
83
- These are ideas, not current features: support for OpenCode v2, plus a simpler setup and calibration path that stays conservative by default. The current release supports v1 only.
84
+ These are ideas, not current features: a simpler setup and calibration path that stays conservative by default, and end-to-end tool-call tests on OpenCode 2.x. Version 0.2.0 adds the OpenCode 2.x entry; see [ADR 0011](https://github.com/HumanFace-Tech/oc-one-guard/blob/main/docs/adr/0011-opencode-v2-adapter.md).
84
85
 
85
86
  ## License
86
87
 
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ // Checks that OpenCode 2.x loaded oc-one-guard. Exit codes: 0 loaded, 1 not loaded or failed, 2 check could not run.
3
+ //
4
+ // OpenCode 2.x keeps running when a plugin fails to load, and logs `failed to load plugin`.
5
+ // Without this check, that failure leaves every tool call unreviewed. The `api provider.list`
6
+ // operation waits for plugin activation before it answers, so the logs are complete when it exits.
7
+ //
8
+ // Usage: OPENCODE_BIN=opencode node bin/oc-one-guard-doctor
9
+ // Run it against a private server (`--standalone`) so no background service is started.
10
+
11
+ import { spawnSync } from "node:child_process"
12
+
13
+ const bin = process.env.OPENCODE_BIN || "opencode"
14
+ const args = ["--standalone", "--print-logs", "--log-level", "info", "api", "provider.list"]
15
+
16
+ const run = spawnSync(bin, args, { encoding: "utf8", timeout: 120000, maxBuffer: 64 * 1024 * 1024 })
17
+ if (run.error) {
18
+ console.error(`oc-one-guard doctor: could not run ${bin} (${run.error.code ?? "error"}).`)
19
+ process.exit(2)
20
+ }
21
+ if (run.status !== 0) {
22
+ console.error(`oc-one-guard doctor: OpenCode did not finish plugin activation (exit ${run.status}).`)
23
+ process.exit(2)
24
+ }
25
+
26
+ const lines = `${run.stdout ?? ""}\n${run.stderr ?? ""}`.split(/\r?\n/)
27
+ const isGuard = line => /oc-one-guard/.test(line)
28
+
29
+ if (lines.some(line => /failed to load plugin/.test(line) && isGuard(line))) {
30
+ console.error("oc-one-guard doctor: FAILED. OpenCode could not load oc-one-guard, so no tool call is reviewed.")
31
+ process.exit(1)
32
+ }
33
+ if (!lines.some(line => /loading plugin/.test(line) && isGuard(line))) {
34
+ console.error("oc-one-guard doctor: FAILED. OpenCode did not attempt to load oc-one-guard. Check the plugins entry.")
35
+ process.exit(1)
36
+ }
37
+ console.log("oc-one-guard doctor: OK. oc-one-guard was loaded and no load failure was logged.")
@@ -19,12 +19,6 @@
19
19
  "redactArguments": true,
20
20
  "cacheEnabled": true,
21
21
  "cacheTtlMs": 30000,
22
- "prefilter": true,
23
- "skipTools": [],
24
- "deniedTools": [],
25
- "allowedTools": [],
26
- "allowedHosts": [],
27
- "blockedHosts": [],
28
22
  "maxStateChars": 8000,
29
23
  "maxArgChars": 2000,
30
24
  "stateTemplate": "proposed agent tool call\ntool: {tool}\narguments: {arguments}",
package/index.js CHANGED
@@ -10,223 +10,16 @@
10
10
  * The guard runs in `tool.execute.before`. OpenCode awaits that hook before
11
11
  * executing the tool, so throwing from it aborts the call and surfaces the
12
12
  * reason to the agent as a failed tool result.
13
- */
14
-
15
- import { createAuditLogger } from "./src/audit.js"
16
- import { resolveConfig } from "./src/config.js"
17
- import { createGuard, VERDICT } from "./src/guard.js"
18
- import { scanBlockedHosts } from "./src/blocked-hosts.js"
19
- import { redactText } from "./src/redact.js"
20
- import { beginMutation, endMutation } from "./src/written.js"
21
- import { createStopController } from "./src/stop.js"
22
- import { createScope } from "./src/scope.js"
23
-
24
- export const id = "oc-one-guard"
25
-
26
- /**
27
- * Build the error the agent sees when a call is blocked.
28
13
  *
29
- * The message is written for the model: it states what was blocked and why,
30
- * so the agent can adjust instead of retrying blindly. Arguments are
31
- * redacted first — this text goes back to the model provider, so a secret
32
- * inside a blocked command would otherwise leave the machine here.
14
+ * The default export serves both hosts: `setup` for OpenCode 2.x and `server`
15
+ * for OpenCode 1.x. Both adapters call the same runtime.
33
16
  */
34
- function blockedMessage(decision, tool, args, redact) {
35
- const preview =
36
- typeof args?.command === "string" ? redactText(args.command).slice(0, 300) : undefined
37
- const lines = [
38
- `[oc-one-guard] Blocked tool call "${tool}".`,
39
- `Reason: ${redactText(decision.reason)}`,
40
- ]
41
- if (decision.probability !== undefined) {
42
- lines.push(`Exfiltration probability: ${decision.probability.toFixed(3)}`)
43
- }
44
- if (decision.hosts?.length) {
45
- lines.push(`Destinations: ${decision.hosts.join(", ")}`)
46
- }
47
- if (preview) {
48
- lines.push(`Command: ${preview}`)
49
- }
50
- if (decision.verdict === VERDICT.ESCALATE) {
51
- lines.push("Manual review required. No native approval prompt is available from this hook.")
52
- }
53
- lines.push(
54
- "This call was stopped before execution. If it is legitimate, rephrase it so the intent is explicit, or ask the user to run it manually.",
55
- )
56
- return lines.join("\n")
57
- }
58
17
 
59
- /**
60
- * The OpenCode plugin entry point.
61
- *
62
- * @param {{client: object, directory?: string, worktree?: string, project?: {id?: string, vcs?: string}}} input OpenCode v1 host context.
63
- * @param {Record<string, unknown>} [options]
64
- */
65
- export async function server(input, options) {
66
- // A broken config must not remove the guard: OpenCode discards a plugin
67
- // that throws while loading, which would leave every tool call
68
- // unrestricted. `resolveConfig` falls back to the fail-closed defaults.
69
- const config = resolveConfig(options)
70
- const scope = createScope(input)
18
+ import { server } from "./src/adapters/v1.js"
19
+ import { setup } from "./src/adapters/v2.js"
71
20
 
72
- const audit = createAuditLogger(config, input.client)
73
- const guard = createGuard(config, { writeAudit: audit, directory: input.directory, scope })
74
- const pending = new Map()
75
- const finish = key => {
76
- for (const token of pending.get(key) ?? []) endMutation(token, scope)
77
- pending.delete(key)
78
- }
79
- const remove = (key, token) => {
80
- if (!token) return
81
- endMutation(token, scope)
82
- const tokens = pending.get(key)
83
- if (!tokens) return
84
- const remaining = tokens.filter(item => item !== token)
85
- if (remaining.length) pending.set(key, remaining)
86
- else pending.delete(key)
87
- }
88
-
89
- const report = (level, message, extra) => {
90
- try {
91
- input.client.app?.log?.({
92
- body: { service: "oc-one-guard", level, message, extra },
93
- })?.catch?.(() => {})
94
- } catch {
95
- // Logging must never break the guard.
96
- }
97
- }
98
- const stopNotice = (message, variant) => {
99
- try {
100
- input.client.tui?.showToast?.({ body: { title: "oc-one-guard", message, variant, duration: 8000 } })?.catch?.(() => {})
101
- } catch {}
102
- }
103
- const stop = createStopController({
104
- client: input.client,
105
- directory: input.directory,
106
- warn: message => { report("warn", message); if (config.notify) stopNotice(message, "warning") },
107
- notify: message => { report("warn", message); if (config.notify) stopNotice(message, "warning") },
108
- })
109
-
110
- for (const problem of config.problems ?? []) {
111
- report("error", `Configuration problem, using fail-closed defaults: ${problem}`)
112
- }
113
- if (config.degraded) {
114
- report("error", "Guard is running with the bundled defaults because the configuration was invalid.")
115
- }
116
-
117
- if (config.enabled) {
118
- report(
119
- "info",
120
- `Guard active: ${config.endpoint} (model ${config.model}), deny>=${config.denyThreshold}, escalate>=${config.escalateThreshold} (${config.escalateAction})`,
121
- )
122
- } else {
123
- report("warn", "Guard is DISABLED by configuration. Tool calls are not being reviewed.")
124
- }
125
-
126
- return {
127
- async "chat.message"(hookInput, output) {
128
- stop.observe(hookInput, output)
129
- },
130
- async "tool.execute.before"(hookInput, output) {
131
- const ticket = stop.capture(hookInput.sessionID)
132
- const tool = hookInput.tool
133
- const args = output.args
134
- if (await stop.pending(hookInput.sessionID)) {
135
- throw new Error("[oc-one-guard] Blocked tool call while conversation cancellation is pending.")
136
- }
137
-
138
- const scan = config.blockedHosts.length ? scanBlockedHosts(args, config.blockedHosts) : {}
139
- if (scan.host || scan.limited) {
140
- const decision = {
141
- verdict: VERDICT.DENY,
142
- reason: scan.host ? `Destination ${scan.host} is on the blocked-host list.` : "Blocked-host scan could not inspect all arguments (scan limit or inaccessible value).",
143
- tool,
144
- hosts: scan.host ? [scan.host] : [],
145
- probability: undefined,
146
- durationMs: 0,
147
- mode: "host-block",
148
- state: undefined,
149
- }
150
- audit(decision, {})
151
- throw new Error(blockedMessage(decision, tool, args, config.redactArguments))
152
- }
153
-
154
- const mutation = config.enabled && ["write", "edit", "apply_patch"].includes(tool)
155
- const key = typeof hookInput.sessionID === "string" && typeof hookInput.callID === "string" ? `${hookInput.sessionID}\0${hookInput.callID}` : undefined
156
- const token = mutation ? beginMutation(scope) : undefined
157
- if (token && key) {
158
- const tokens = pending.get(key) ?? []
159
- tokens.push(token)
160
- pending.set(key, tokens)
161
- }
162
- let decision
163
- try {
164
- decision = await guard.evaluate({ tool, args })
165
- } catch (error) {
166
- remove(key, token)
167
- throw error
168
- }
169
-
170
- if (decision.stop) {
171
- remove(key, token)
172
- await stop.request(ticket)
173
- throw new Error(blockedMessage(decision, tool, args, config.redactArguments))
174
- }
175
- if (await stop.pending(hookInput.sessionID)) {
176
- remove(key, token)
177
- throw new Error("[oc-one-guard] Blocked tool call while conversation cancellation is pending.")
178
- }
179
- if (decision.verdict === VERDICT.ESCALATE && config.escalateAction === "allow") {
180
- // Escalated but allowed: warn loudly so the attempt is visible.
181
- audit(decision, { state: decision.state })
182
- try {
183
- input.client.tui?.showToast?.({
184
- body: {
185
- title: "oc-one-guard",
186
- message: redactText(`Flagged ${tool}: ${decision.reason}`),
187
- variant: "warning",
188
- duration: 8000,
189
- },
190
- })?.catch?.(() => {})
191
- } catch {
192
- // A toast must never break the guard.
193
- }
194
- return
195
- }
21
+ export const id = "oc-one-guard"
196
22
 
197
- if (decision.blocked) {
198
- remove(key, token)
199
- if (config.notify) {
200
- try {
201
- input.client.tui?.showToast?.({
202
- body: {
203
- title: "oc-one-guard",
204
- message: redactText(`Blocked ${tool}: ${decision.reason}`),
205
- variant: decision.verdict === VERDICT.ESCALATE ? "warning" : "error",
206
- duration: 8000,
207
- },
208
- })?.catch?.(() => {})
209
- } catch {
210
- // A toast must never break the guard.
211
- }
212
- }
213
- throw new Error(blockedMessage(decision, tool, args, config.redactArguments))
214
- }
215
- },
216
- async "tool.execute.after"(hookInput) {
217
- if (!["write", "edit", "apply_patch"].includes(hookInput.tool)) return
218
- if (typeof hookInput.sessionID !== "string" || typeof hookInput.callID !== "string") return
219
- const key = `${hookInput.sessionID}\0${hookInput.callID}`
220
- finish(key)
221
- },
222
- async event({ event }) {
223
- const part = event?.type === "message.part.updated" && event.properties?.part
224
- // Interrupted parts can be marked as errors before their tool has stopped.
225
- if (!part || part.type !== "tool" || !["write", "edit", "apply_patch"].includes(part.tool) || part.state?.status !== "error" || part.state?.metadata?.interrupted === true) return
226
- if (typeof part.sessionID !== "string" || typeof part.callID !== "string") return
227
- finish(`${part.sessionID}\0${part.callID}`)
228
- },
229
- }
230
- }
23
+ export { server, setup }
231
24
 
232
- export default { id, server }
25
+ export default { id, setup, server }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "oc-one-guard",
3
- "version": "0.1.2",
4
- "description": "OpenCode v1-only plugin that checks proposed tool calls for outbound data exfiltration with a local decision-model judge",
3
+ "version": "0.3.0",
4
+ "description": "OpenCode plugin (v1 and v2) that checks proposed tool calls for outbound data exfiltration with a local decision-model judge",
5
5
  "type": "module",
6
6
  "main": "./index.js",
7
7
  "exports": {
@@ -11,6 +11,7 @@
11
11
  "files": [
12
12
  "index.js",
13
13
  "src/",
14
+ "bin/oc-one-guard-doctor",
14
15
  "eval/runner.js",
15
16
  "eval/cases.json",
16
17
  "config.example.json",
@@ -18,7 +19,7 @@
18
19
  "docs/assets/risk-policy.svg"
19
20
  ],
20
21
  "scripts": {
21
- "test": "node --test test/config.test.js test/guard.test.js test/judge.test.js test/fallback.test.js test/plugin.test.js test/security.test.js test/stop.test.js test/eval.test.js",
22
+ "test": "node --test test/config.test.js test/guard.test.js test/judge.test.js test/fallback.test.js test/plugin.test.js test/v2-adapter.test.js test/security.test.js test/stop.test.js test/eval.test.js",
22
23
  "eval:live": "node eval/runner.js"
23
24
  },
24
25
  "engines": {
@@ -0,0 +1,39 @@
1
+ /**
2
+ * OpenCode 1.x entry point: `server(input, options)` with `client` hooks.
3
+ */
4
+
5
+ import { resolveConfig } from "../config.js"
6
+ import { createScope } from "../scope.js"
7
+ import { createGuardRuntime } from "../runtime.js"
8
+ import { isMutationTool } from "../tools.js"
9
+
10
+ /**
11
+ * @param {{client: object, directory?: string, worktree?: string, project?: {id?: string, vcs?: string}}} input OpenCode v1 host context.
12
+ * @param {Record<string, unknown>} [options]
13
+ */
14
+ export async function server(input, options) {
15
+ // A broken config must not remove the guard: OpenCode discards a plugin
16
+ // that throws while loading, which would leave every tool call
17
+ // unrestricted. `resolveConfig` falls back to the fail-closed defaults.
18
+ const config = resolveConfig(options)
19
+ const scope = createScope(input)
20
+ const runtime = createGuardRuntime(config, { client: input.client, directory: input.directory, scope })
21
+
22
+ return {
23
+ async "chat.message"(hookInput, output) {
24
+ runtime.observe(hookInput, output)
25
+ },
26
+ async "tool.execute.before"(hookInput, output) {
27
+ await runtime.before({ tool: hookInput.tool, sessionID: hookInput.sessionID, callID: hookInput.callID, args: output.args })
28
+ },
29
+ async "tool.execute.after"(hookInput) {
30
+ runtime.after({ tool: hookInput.tool, sessionID: hookInput.sessionID, callID: hookInput.callID })
31
+ },
32
+ async event({ event }) {
33
+ const part = event?.type === "message.part.updated" && event.properties?.part
34
+ // Interrupted parts can be marked as errors before their tool has stopped.
35
+ if (!part || part.type !== "tool" || !isMutationTool(part.tool) || part.state?.status !== "error" || part.state?.metadata?.interrupted === true) return
36
+ runtime.after({ tool: part.tool, sessionID: part.sessionID, callID: part.callID })
37
+ },
38
+ }
39
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * OpenCode 2.x entry point: `setup(ctx)` with domain hooks.
3
+ */
4
+
5
+ import { resolveConfig } from "../config.js"
6
+ import { createScope } from "../scope.js"
7
+ import { createGuardRuntime } from "../runtime.js"
8
+
9
+ function isPlainObject(value) {
10
+ return typeof value === "object" && value !== null && !Array.isArray(value)
11
+ }
12
+
13
+ // Never replace unusable input with `{}`: a shell call with no command would
14
+ // skip the judge, and input repair may turn the same value into a real command.
15
+ function inputOf(value) {
16
+ if (value === undefined || value === null) return {}
17
+ let parsed = value
18
+ if (typeof value === "string") {
19
+ try { parsed = JSON.parse(value) } catch { parsed = undefined }
20
+ }
21
+ if (!isPlainObject(parsed)) throw new Error("[oc-one-guard] Blocked tool call: its input is not a JSON object.")
22
+ return parsed
23
+ }
24
+
25
+ // The stop controller reads sessions through an SDK-shaped object. OpenCode 2.x
26
+ // exposes the same two operations on `ctx.session`; nothing else is provided.
27
+ function sessionClient(ctx) {
28
+ return {
29
+ session: {
30
+ get: async ({ path }) => ({ data: await ctx.session.get({ sessionID: path.id }) }),
31
+ abort: async ({ path }) => ({ data: (await ctx.session.interrupt({ sessionID: path.id })).interrupted === true }),
32
+ },
33
+ }
34
+ }
35
+
36
+ /**
37
+ * @param {object} ctx OpenCode 2.x plugin context: `options`, `location`, `session` and `tool` hooks.
38
+ */
39
+ export async function setup(ctx) {
40
+ // Same fail-closed rule as the v1 entry: a bad configuration falls back to
41
+ // the bundled defaults rather than preventing the plugin from loading.
42
+ const config = resolveConfig(ctx.options)
43
+ const directory = ctx.location.directory
44
+ const scope = createScope({
45
+ directory,
46
+ worktree: ctx.location.project?.directory,
47
+ project: { id: ctx.location.project?.id },
48
+ })
49
+ const runtime = createGuardRuntime(config, { client: sessionClient(ctx), directory, scope })
50
+
51
+ await ctx.session.hook("prompt", event => {
52
+ runtime.observe({ sessionID: event.sessionID, messageID: event.messageID })
53
+ })
54
+ await ctx.tool.hook("execute.before", async event => {
55
+ await runtime.before({
56
+ tool: event.tool,
57
+ sessionID: event.sessionID,
58
+ callID: event.id,
59
+ args: inputOf(event.input),
60
+ })
61
+ })
62
+ await ctx.tool.hook("execute.after", event => {
63
+ runtime.after({ tool: event.tool, sessionID: event.sessionID, callID: event.id })
64
+ })
65
+
66
+ return () => runtime.dispose()
67
+ }
package/src/config.js CHANGED
@@ -202,13 +202,6 @@ export function normalizeConfig(raw) {
202
202
  }
203
203
  }
204
204
 
205
- const denied = new Set(merged.deniedTools ?? [])
206
- for (const tool of merged.skipTools ?? []) {
207
- if (denied.has(tool)) {
208
- errors.push(`tool "${tool}" is on both the deny list and the skip list`)
209
- }
210
- }
211
-
212
205
  if (errors.length) {
213
206
  throw new Error(`[oc-one-guard] Invalid configuration: ${errors.join("; ")}`)
214
207
  }
@@ -221,11 +214,6 @@ export function normalizeConfig(raw) {
221
214
  merged.cacheTtlMs = Math.max(0, Math.floor(Number(merged.cacheTtlMs) || 0))
222
215
  merged.auditGenerations = Math.max(1, Math.floor(Number(merged.auditGenerations) || DEFAULTS.auditGenerations))
223
216
 
224
- if (!Array.isArray(merged.allowedTools)) merged.allowedTools = []
225
- if (!Array.isArray(merged.deniedTools)) merged.deniedTools = []
226
- if (!Array.isArray(merged.skipTools)) merged.skipTools = []
227
- if (!Array.isArray(merged.allowedHosts)) merged.allowedHosts = []
228
- if (!Array.isArray(merged.blockedHosts)) merged.blockedHosts = []
229
217
  if (!Array.isArray(merged.questions)) merged.questions = []
230
218
 
231
219
  return merged
package/src/defaults.js CHANGED
@@ -47,12 +47,6 @@
47
47
  * @property {boolean} redactArguments Strip secret-looking values before they leave the machine.
48
48
  * @property {boolean} cacheEnabled Cache identical verdicts for a short window.
49
49
  * @property {number} cacheTtlMs
50
- * @property {boolean} prefilter Skip the judge for calls with no outbound surface.
51
- * @property {string[]} skipTools Tool names never inspected.
52
- * @property {string[]} deniedTools Tool names always blocked.
53
- * @property {string[]} allowedTools Tool names always allowed, judge not consulted.
54
- * @property {string[]} allowedHosts Hosts treated as trusted destinations.
55
- * @property {string[]} blockedHosts Hosts always treated as exfiltration destinations.
56
50
  * @property {number} maxStateChars Cap on the rendered state sent to the judge.
57
51
  * @property {number} maxArgChars Per-argument truncation before rendering.
58
52
  * @property {QuestionConfig[]} questions Judge questions.
@@ -84,12 +78,6 @@ export const DEFAULTS = {
84
78
  redactArguments: true,
85
79
  cacheEnabled: true,
86
80
  cacheTtlMs: 30000,
87
- prefilter: true,
88
- skipTools: [],
89
- deniedTools: [],
90
- allowedTools: [],
91
- allowedHosts: [],
92
- blockedHosts: [],
93
81
  maxStateChars: 8000,
94
82
  maxArgChars: 2000,
95
83
  stateTemplate:
package/src/guard.js CHANGED
@@ -2,10 +2,9 @@
2
2
  * The guard engine: decide, cache, audit.
3
3
  *
4
4
  * Decision order:
5
- * 1. Tool allow/deny lists and host lists decide without consulting the judge.
6
- * 2. The judge is asked for a verdict.
7
- * 3. The verdict is compared against `denyThreshold` and `escalateThreshold`.
8
- * 4. Failures fall back to `failMode`; unclassifiable verdicts to `unknownMode`.
5
+ * 1. The judge is asked about every call; no code rule decides for it.
6
+ * 2. The verdict is compared against `denyThreshold` and `escalateThreshold`.
7
+ * 3. Failures fall back to `failMode`; unclassifiable verdicts to `unknownMode`.
9
8
  *
10
9
  * The cache is keyed on the *full* call as well as the rendered state. The
11
10
  * rendered state is truncated, so two different commands could otherwise
@@ -18,11 +17,13 @@ import { posix } from "node:path"
18
17
  import { denyProbability, findDenyOption, JudgeError, requestDecision } from "./judge.js"
19
18
  import { sharedCache } from "./shared.js"
20
19
  import { requireScope } from "./scope.js"
21
- import { extractHosts, fullText, hasOutboundSurface, hasTransferPrimitive, renderState, runsLocalScript } from "./state.js"
22
- import { getWrite, recordWrite, referencedWrites, writesRevision, markUncertainPatch, hasUncertainPatch, refreshWrite, hasWrites, ambiguousPath, hasPendingMutation } from "./written.js"
20
+ import { extractHosts, fullText, hasTransferPrimitive, renderState } from "./state.js"
21
+ import { getWrite, recordWrite, referencedWrites, markUncertainPatch, refreshWrite, ambiguousPath } from "./written.js"
22
+ import { canonicalTool, pathsOf } from "./tools.js"
23
23
 
24
24
  // Patch headers are line-oriented. Never interpret content lines as paths.
25
- function trackMutation(tool, args, directory, scope) {
25
+ function trackMutation(name, args, directory, scope) {
26
+ const tool = canonicalTool(name)
26
27
  const knownDirectory = typeof directory === "string" && posix.isAbsolute(directory)
27
28
  const lookup = path => getWrite(path, directory, scope)
28
29
  const uncertainPath = path => {
@@ -30,16 +31,18 @@ function trackMutation(tool, args, directory, scope) {
30
31
  if (ambiguousPath(path, directory)) markUncertainPatch(scope)
31
32
  }
32
33
  if (tool === "write" || tool === "edit") {
33
- const path = args?.filePath
34
- if (typeof path !== "string" || !path) return
35
- uncertainPath(path)
34
+ const paths = pathsOf(args)
35
+ if (!paths.length) return
36
36
  const content = writeContent(tool, args)
37
- if (!ambiguousPath(path, directory) && (lookup(path) || hasTransferPrimitive(content))) {
38
- recordWrite(path, null, directory, scope)
37
+ for (const path of paths) {
38
+ uncertainPath(path)
39
+ if (!ambiguousPath(path, directory) && (lookup(path) || hasTransferPrimitive(content))) {
40
+ recordWrite(path, null, directory, scope)
41
+ }
39
42
  }
40
43
  return
41
44
  }
42
- if (tool !== "apply_patch") return
45
+ if (tool !== "patch") return
43
46
  const patch = typeof args?.patchText === "string" ? args.patchText : args?.patch
44
47
  if (typeof patch !== "string") { markUncertainPatch(scope); return }
45
48
  const lines = patch.split(/\r?\n/)
@@ -79,9 +82,10 @@ function trackMutation(tool, args, directory, scope) {
79
82
  * The text a write-like tool puts on disk.
80
83
  *
81
84
  * `write` uses `content`, `edit` uses `newString` (and `oldString`), and
82
- * `apply_patch` uses a patch text. All of them can carry a payload.
85
+ * `patch` (2.x) or `apply_patch` (1.x) uses a patch text. All of them can carry a payload.
83
86
  */
84
- export function writeContent(tool, args) {
87
+ export function writeContent(name, args) {
88
+ const tool = canonicalTool(name)
85
89
  if (!args || typeof args !== "object") return ""
86
90
  if (tool === "write") return typeof args.content === "string" ? args.content : ""
87
91
  if (tool === "edit") {
@@ -90,7 +94,7 @@ export function writeContent(tool, args) {
90
94
  if (typeof args.oldString === "string") parts.push(args.oldString)
91
95
  return parts.join("\n")
92
96
  }
93
- if (tool === "apply_patch") {
97
+ if (tool === "patch") {
94
98
  const parts = []
95
99
  if (typeof args.patchText === "string") parts.push(args.patchText)
96
100
  if (typeof args.patch === "string") parts.push(args.patch)
@@ -244,7 +248,8 @@ export function createGuard(config, deps = {}) {
244
248
  */
245
249
  async function evaluate(input) {
246
250
  const started = now()
247
- const { tool, args } = input
251
+ const tool = canonicalTool(input.tool)
252
+ const { args } = input
248
253
  const hosts = extractHosts(tool, args)
249
254
 
250
255
  if (!config.enabled) {
@@ -258,94 +263,26 @@ export function createGuard(config, deps = {}) {
258
263
  })
259
264
  }
260
265
 
261
- // Deny is a security control and is checked before the skip list, which
262
- // is only a performance optimisation.
263
- if (config.deniedTools.includes(tool)) {
264
- return new GuardDecision({
265
- verdict: VERDICT.DENY,
266
- reason: `Tool "${tool}" is on the deny list.`,
267
- tool,
268
- hosts,
269
- durationMs: 0,
270
- mode: "tool-deny",
271
- })
272
- }
273
-
274
- // Invalidate evidence even if policy later skips this tool or the tool fails.
266
+ // Writes are tracked only as context for the judge; nothing here decides.
275
267
  trackMutation(tool, args, deps.directory, scope)
276
268
 
277
- if (config.skipTools.includes(tool)) {
278
- return new GuardDecision({
279
- verdict: VERDICT.ALLOW,
280
- reason: `Tool "${tool}" is on the skip list.`,
281
- tool,
282
- hosts,
283
- durationMs: 0,
284
- mode: "skip",
285
- })
286
- }
287
-
288
- if (config.allowedTools.includes(tool)) {
289
- return new GuardDecision({
290
- verdict: VERDICT.ALLOW,
291
- reason: `Tool "${tool}" is on the allow list.`,
292
- tool,
293
- hosts,
294
- durationMs: 0,
295
- mode: "tool-allow",
296
- })
297
- }
298
-
299
- const isShell = tool === "bash" || tool === "shell" || tool === "run"
269
+ const isShell = tool === "shell"
300
270
  const command = typeof args?.command === "string" ? args.command : ""
301
271
  const base = typeof deps.directory === "string" && posix.isAbsolute(deps.directory) ? deps.directory : undefined
302
272
  const workdir = typeof args?.workdir === "string" && args.workdir ? args.workdir : undefined
303
273
  const directory = workdir ? (posix.isAbsolute(workdir) ? posix.normalize(workdir) : base ? posix.resolve(base, workdir) : undefined) : base
304
- const words = command.match(/'[^']*'|"[^"]*"|[^\s;&|()<>]+/g) ?? []
305
- const ambiguous = ambiguousPath(workdir, base) || ambiguousPath(base) || words.some(word => {
306
- const value = word.replace(/^(['"])(.*)\1$/, "$2")
307
- return value.includes("/") && ambiguousPath(value, directory)
308
- })
309
- if (isShell && ambiguous && (hasWrites(scope) || hasUncertainPatch(scope))) {
310
- const decision = new GuardDecision({ verdict: VERDICT.DENY, reason: "Ambiguous filesystem path prevents reliable staged-file evidence.", tool, hosts, durationMs: now() - started, mode: "staged-unknown" })
311
- writeAudit(decision, {})
312
- return decision
313
- }
314
274
  const hits = isShell ? referencedWrites(command, directory, scope) : []
315
275
  const snapshots = new Map(hits.map(hit => {
316
276
  const entry = getWrite(hit.path, undefined, scope)
317
277
  const content = entry.content === null ? refreshWrite(hit.path, scope) : entry.content
318
278
  return [hit.path, { ...entry, content: content ?? null }]
319
279
  }))
320
- const unresolvedScript = runsLocalScript(command) && hasWrites(scope) &&
321
- ((!directory && !command.match(/(?:^|\s)\//)) || /(?:^|[;&|]\s*)\s*cd(?:\s|$)|[$`*?\[\]{}\\]/.test(command))
322
- const unknownStaged = isShell && (hits.some(hit => snapshots.get(hit.path)?.content === null) || (hasPendingMutation(scope) && (hits.length > 0 || runsLocalScript(command))) || (hasUncertainPatch(scope) && runsLocalScript(command)) || unresolvedScript)
323
- if (unknownStaged) {
324
- const decision = new GuardDecision({ verdict: VERDICT.DENY, reason: "Tracked file content is unknown after a modification.", tool, hosts, durationMs: now() - started, mode: "staged-unknown" })
325
- writeAudit(decision, {})
326
- return decision
327
- }
328
- const revision = writesRevision(scope)
329
- const snapshotsValid = () => (!(hits.length || runsLocalScript(command)) || !hasPendingMutation(scope)) && [...snapshots].every(([path, snapshot]) => {
330
- const entry = getWrite(path, undefined, scope)
331
- return entry && !ambiguousPath(path) && (entry.content === null ? refreshWrite(path, scope) : entry.content) === snapshot.content
332
- })
333
-
334
- if (config.prefilter && !hasOutboundSurface(tool, args, directory, scope)) {
335
- return new GuardDecision({
336
- verdict: VERDICT.ALLOW,
337
- reason: `Tool "${tool}" has no outbound surface.`,
338
- tool,
339
- hosts,
340
- durationMs: 0,
341
- mode: "prefilter",
342
- })
343
- }
344
280
 
345
281
  let state
346
282
  try {
347
283
  state = renderState({
348
- tool,
284
+ // The host's own name, so the judge sees the same text as before canonical ids.
285
+ tool: input.tool,
349
286
  args,
350
287
  template: config.stateTemplate,
351
288
  maxStateChars: config.maxStateChars,
@@ -355,7 +292,8 @@ export function createGuard(config, deps = {}) {
355
292
  scope,
356
293
  })
357
294
  } catch {
358
- const decision = new GuardDecision({ verdict: VERDICT.DENY, reason: "Security facts exceed the state limit or could not be rendered.", tool, hosts, durationMs: now() - started, mode: "state-block" })
295
+ const blocked = config.failMode === "block"
296
+ const decision = new GuardDecision({ verdict: blocked ? VERDICT.DENY : VERDICT.ALLOW, reason: `Call state could not be rendered for the judge. failMode="${config.failMode}".`, tool, hosts, durationMs: now() - started, mode: blocked ? "fail-block" : "fail-allow" })
359
297
  writeAudit(decision, {})
360
298
  return decision
361
299
  }
@@ -365,7 +303,6 @@ export function createGuard(config, deps = {}) {
365
303
  const cached = cacheGet(key)
366
304
  if (cached?.pending) {
367
305
  const decision = await cached.pending
368
- if (isShell && (revision !== writesRevision(scope) || !snapshotsValid())) return evaluate(input)
369
306
  return new GuardDecision({ ...decision, durationMs: now() - started, mode: `${decision.mode}:coalesced` })
370
307
  }
371
308
  if (cached?.value) {
@@ -392,10 +329,6 @@ export function createGuard(config, deps = {}) {
392
329
  }
393
330
  throw error
394
331
  }
395
- if (isShell && (revision !== writesRevision(scope) || !snapshotsValid())) {
396
- if (config.cacheEnabled && cache.get(key)?.pending === pending) cache.delete(key)
397
- return evaluate(input)
398
- }
399
332
  if (decision.mode === "judge" && decision.verdict !== VERDICT.ESCALATE && !decision.stop) cacheSet(key, { ...decision })
400
333
  else if (config.cacheEnabled && cache.get(key)?.pending === pending) cache.delete(key)
401
334
  return decision
package/src/runtime.js ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Host-agnostic guard runtime shared by the OpenCode 1.x and 2.x adapters.
3
+ *
4
+ * The adapters translate host hook payloads into `before`, `after` and
5
+ * `observe` calls. Everything that decides or tracks state lives here.
6
+ */
7
+
8
+ import { createAuditLogger } from "./audit.js"
9
+ import { createGuard, VERDICT } from "./guard.js"
10
+ import { redactText } from "./redact.js"
11
+ import { beginMutation, endMutation, markUncertainPatch } from "./written.js"
12
+ import { createStopController } from "./stop.js"
13
+ import { isMutationTool } from "./tools.js"
14
+
15
+ const PENDING_TTL_MS = 10 * 60 * 1000
16
+
17
+ /**
18
+ * Build the error the agent sees when a call is blocked.
19
+ *
20
+ * The message is written for the model: it states what was blocked and why,
21
+ * so the agent can adjust instead of retrying blindly. Arguments are
22
+ * redacted first — this text goes back to the model provider, so a secret
23
+ * inside a blocked command would otherwise leave the machine here.
24
+ */
25
+ function blockedMessage(decision, tool, args) {
26
+ const preview =
27
+ typeof args?.command === "string" ? redactText(args.command).slice(0, 300) : undefined
28
+ const lines = [
29
+ `[oc-one-guard] Blocked tool call "${tool}".`,
30
+ `Reason: ${redactText(decision.reason)}`,
31
+ ]
32
+ if (decision.probability !== undefined) {
33
+ lines.push(`Exfiltration probability: ${decision.probability.toFixed(3)}`)
34
+ }
35
+ if (decision.hosts?.length) {
36
+ lines.push(`Destinations: ${decision.hosts.join(", ")}`)
37
+ }
38
+ if (preview) {
39
+ lines.push(`Command: ${preview}`)
40
+ }
41
+ if (decision.verdict === VERDICT.ESCALATE) {
42
+ lines.push("Manual review required. No native approval prompt is available from this hook.")
43
+ }
44
+ lines.push(
45
+ "This call was stopped before execution. If it is legitimate, rephrase it so the intent is explicit, or ask the user to run it manually.",
46
+ )
47
+ return lines.join("\n")
48
+ }
49
+
50
+ /**
51
+ * @param {object} config Normalized configuration.
52
+ * @param {object} host
53
+ * @param {string} [host.directory] Absolute project directory.
54
+ * @param {string} host.scope Explicit state scope from `createScope`.
55
+ * @param {object} [host.client] Optional SDK-shaped object. `app.log`, `tui.showToast` and `session.get/abort` are used when present.
56
+ */
57
+ export function createGuardRuntime(config, { client, directory, scope }) {
58
+ const audit = createAuditLogger(config, client)
59
+ const guard = createGuard(config, { writeAudit: audit, directory, scope })
60
+ const pending = new Map()
61
+ const finish = key => {
62
+ for (const entry of pending.get(key) ?? []) endMutation(entry.token, scope)
63
+ pending.delete(key)
64
+ }
65
+ // Code Mode nested calls share the outer call id, so one `after` releases one token.
66
+ const releaseOne = key => {
67
+ const entries = pending.get(key)
68
+ if (!entries?.length) return
69
+ endMutation(entries.shift().token, scope)
70
+ if (!entries.length) pending.delete(key)
71
+ }
72
+ const remove = (key, token) => {
73
+ if (!token) return
74
+ endMutation(token, scope)
75
+ const entries = pending.get(key)
76
+ if (!entries) return
77
+ const remaining = entries.filter(item => item.token !== token)
78
+ if (remaining.length) pending.set(key, remaining)
79
+ else pending.delete(key)
80
+ }
81
+ // `after` never fires when a call is interrupted or a later plugin rejects it.
82
+ // Drop such tokens, but mark the scope uncertain: the write may still land.
83
+ const sweepStale = () => {
84
+ const cutoff = Date.now() - PENDING_TTL_MS
85
+ for (const [key, entries] of pending) {
86
+ const fresh = entries.filter(entry => entry.at >= cutoff)
87
+ if (fresh.length === entries.length) continue
88
+ for (const entry of entries) if (entry.at < cutoff) endMutation(entry.token, scope)
89
+ markUncertainPatch(scope)
90
+ if (fresh.length) pending.set(key, fresh)
91
+ else pending.delete(key)
92
+ }
93
+ }
94
+
95
+ const report = (level, message, extra) => {
96
+ try {
97
+ client?.app?.log?.({
98
+ body: { service: "oc-one-guard", level, message, extra },
99
+ })?.catch?.(() => {})
100
+ } catch {
101
+ // Logging must never break the guard.
102
+ }
103
+ }
104
+ const stopNotice = (message, variant) => {
105
+ try {
106
+ client?.tui?.showToast?.({ body: { title: "oc-one-guard", message, variant, duration: 8000 } })?.catch?.(() => {})
107
+ } catch {}
108
+ }
109
+ const stop = createStopController({
110
+ client,
111
+ directory,
112
+ warn: message => { report("warn", message); if (config.notify) stopNotice(message, "warning") },
113
+ notify: message => { report("warn", message); if (config.notify) stopNotice(message, "warning") },
114
+ })
115
+
116
+ for (const problem of config.problems ?? []) {
117
+ report("error", `Configuration problem, using fail-closed defaults: ${problem}`)
118
+ }
119
+ if (config.degraded) {
120
+ report("error", "Guard is running with the bundled defaults because the configuration was invalid.")
121
+ }
122
+
123
+ if (config.enabled) {
124
+ report(
125
+ "info",
126
+ `Guard active: ${config.endpoint} (model ${config.model}), deny>=${config.denyThreshold}, escalate>=${config.escalateThreshold} (${config.escalateAction})`,
127
+ )
128
+ } else {
129
+ report("warn", "Guard is DISABLED by configuration. Tool calls are not being reviewed.")
130
+ }
131
+
132
+ return {
133
+ observe(input, output) {
134
+ stop.observe(input, output)
135
+ },
136
+
137
+ async before({ tool, sessionID, callID, args }) {
138
+ sweepStale()
139
+ const ticket = stop.capture(sessionID)
140
+ if (await stop.pending(sessionID)) {
141
+ throw new Error("[oc-one-guard] Blocked tool call while conversation cancellation is pending.")
142
+ }
143
+
144
+ const mutation = config.enabled && isMutationTool(tool)
145
+ const key = typeof sessionID === "string" && typeof callID === "string" ? `${sessionID}\0${callID}` : undefined
146
+ const token = mutation ? beginMutation(scope) : undefined
147
+ if (token && key) {
148
+ const entries = pending.get(key) ?? []
149
+ entries.push({ token, at: Date.now() })
150
+ pending.set(key, entries)
151
+ }
152
+ let decision
153
+ try {
154
+ decision = await guard.evaluate({ tool, args })
155
+ } catch (error) {
156
+ remove(key, token)
157
+ throw error
158
+ }
159
+
160
+ if (decision.stop) {
161
+ remove(key, token)
162
+ await stop.request(ticket)
163
+ throw new Error(blockedMessage(decision, tool, args))
164
+ }
165
+ if (await stop.pending(sessionID)) {
166
+ remove(key, token)
167
+ throw new Error("[oc-one-guard] Blocked tool call while conversation cancellation is pending.")
168
+ }
169
+ if (decision.verdict === VERDICT.ESCALATE && config.escalateAction === "allow") {
170
+ // Escalated but allowed: warn loudly so the attempt is visible.
171
+ audit(decision, { state: decision.state })
172
+ try {
173
+ client?.tui?.showToast?.({
174
+ body: {
175
+ title: "oc-one-guard",
176
+ message: redactText(`Flagged ${tool}: ${decision.reason}`),
177
+ variant: "warning",
178
+ duration: 8000,
179
+ },
180
+ })?.catch?.(() => {})
181
+ } catch {
182
+ // A toast must never break the guard.
183
+ }
184
+ return
185
+ }
186
+
187
+ if (decision.blocked) {
188
+ remove(key, token)
189
+ if (config.notify) {
190
+ try {
191
+ client?.tui?.showToast?.({
192
+ body: {
193
+ title: "oc-one-guard",
194
+ message: redactText(`Blocked ${tool}: ${decision.reason}`),
195
+ variant: decision.verdict === VERDICT.ESCALATE ? "warning" : "error",
196
+ duration: 8000,
197
+ },
198
+ })?.catch?.(() => {})
199
+ } catch {
200
+ // A toast must never break the guard.
201
+ }
202
+ }
203
+ throw new Error(blockedMessage(decision, tool, args))
204
+ }
205
+ },
206
+
207
+ after({ tool, sessionID, callID }) {
208
+ if (!isMutationTool(tool)) return
209
+ if (typeof sessionID !== "string" || typeof callID !== "string") return
210
+ releaseOne(`${sessionID}\0${callID}`)
211
+ },
212
+
213
+ // Called when the plugin unloads. A mutation still in flight may land after
214
+ // its hook is gone, so the scope is marked uncertain rather than trusted.
215
+ dispose() {
216
+ const held = [...pending.values()].some(entries => entries.length > 0)
217
+ for (const key of [...pending.keys()]) finish(key)
218
+ if (held) markUncertainPatch(scope)
219
+ },
220
+ }
221
+ }
package/src/state.js CHANGED
@@ -18,32 +18,10 @@
18
18
 
19
19
  import { getWrite, referencedWrites } from "./written.js"
20
20
  import { requireScope } from "./scope.js"
21
+ import { canonicalTool, pathsOf } from "./tools.js"
21
22
 
22
23
  const URL_PATTERN = /\b(?:https?|ftps?|sftp|scp|rsync|ssh|git):\/\/[^\s"'`<>|;&]+/gi
23
24
 
24
- /**
25
- * Tools that cannot, on their own, move data off the machine.
26
- *
27
- * These are the only tools that skip the judge without further checks.
28
- * Everything else — shell, webfetch, MCP tools, any tool added in a future
29
- * OpenCode release — is judged.
30
- */
31
- const LOCAL_ONLY_TOOLS = new Set([
32
- "read",
33
- "write",
34
- "edit",
35
- "apply_patch",
36
- "glob",
37
- "grep",
38
- "list",
39
- "todowrite",
40
- "todoread",
41
- "patch",
42
- "question",
43
- "plan_exit",
44
- "invalid",
45
- ])
46
-
47
25
  /**
48
26
  * Tools whose arguments can contain a payload bound for a remote host.
49
27
  *
@@ -51,7 +29,7 @@ const LOCAL_ONLY_TOOLS = new Set([
51
29
  * file and execute it in a later call. Their content is judged when it
52
30
  * contains something that moves data.
53
31
  */
54
- const CONTENT_TOOLS = new Set(["write", "edit", "apply_patch"])
32
+ const CONTENT_TOOLS = new Set(["write", "edit", "patch"])
55
33
 
56
34
  /**
57
35
  * Primitives that reach the network, matched against shell text.
@@ -138,13 +116,14 @@ export function runsLocalScript(text) {
138
116
  * @returns {string[]}
139
117
  */
140
118
  export function extractHosts(tool, args) {
119
+ const name = canonicalTool(tool)
141
120
  const chunks = []
142
- if (tool === "bash" || tool === "shell" || tool === "run") {
121
+ if (name === "shell") {
143
122
  if (typeof args?.command === "string") chunks.push(args.command)
144
- } else if (tool === "webfetch") {
123
+ } else if (name === "webfetch") {
145
124
  if (typeof args?.url === "string") chunks.push(args.url)
146
- } else if (CONTENT_TOOLS.has(tool)) {
147
- chunks.push(writtenContent(tool, args))
125
+ } else if (CONTENT_TOOLS.has(name)) {
126
+ chunks.push(writtenContent(name, args))
148
127
  }
149
128
 
150
129
  const hosts = new Set()
@@ -170,20 +149,14 @@ export function extractPaths(tool, args) {
170
149
  const push = (value) => {
171
150
  if (typeof value === "string" && value.trim()) paths.push(value.trim())
172
151
  }
173
- switch (tool) {
152
+ switch (canonicalTool(tool)) {
174
153
  case "read":
175
- push(args?.filePath)
176
- break
177
154
  case "write":
178
- push(args?.filePath)
179
- break
180
155
  case "edit":
181
- push(args?.filePath)
156
+ case "patch":
157
+ pathsOf(args).forEach(push)
182
158
  break
183
- case "apply_patch":
184
- push(args?.filePath)
185
- break
186
- case "bash":
159
+ case "shell":
187
160
  push(args?.workdir)
188
161
  break
189
162
  default:
@@ -287,15 +260,16 @@ export function fullText(tool, args) {
287
260
  * helper is used so the two agree.
288
261
  */
289
262
  export function writtenContent(tool, args) {
263
+ const name = canonicalTool(tool)
290
264
  if (!args || typeof args !== "object") return ""
291
- if (tool === "write") return typeof args.content === "string" ? args.content : ""
292
- if (tool === "edit") {
265
+ if (name === "write") return typeof args.content === "string" ? args.content : ""
266
+ if (name === "edit") {
293
267
  const parts = []
294
268
  if (typeof args.newString === "string") parts.push(args.newString)
295
269
  if (typeof args.oldString === "string") parts.push(args.oldString)
296
270
  return parts.join("\n")
297
271
  }
298
- if (tool === "apply_patch") {
272
+ if (name === "patch") {
299
273
  const parts = []
300
274
  if (typeof args.patchText === "string") parts.push(args.patchText)
301
275
  if (typeof args.patch === "string") parts.push(args.patch)
@@ -304,54 +278,6 @@ export function writtenContent(tool, args) {
304
278
  return ""
305
279
  }
306
280
 
307
- /**
308
- * Decide whether a tool call has an outbound surface worth judging.
309
- *
310
- * The rule is an allowlist of known-local tools. Anything else — including
311
- * MCP tools, `webfetch`, and any tool added in a future OpenCode release —
312
- * is judged.
313
- *
314
- * A shell command is judged when it uses a network primitive, runs a file
315
- * the agent has written, or mentions a written file at all.
316
- */
317
- export function hasOutboundSurface(tool, args, directory, scope) {
318
- requireScope(scope)
319
- if (LOCAL_ONLY_TOOLS.has(tool)) {
320
- // A file write can stage a payload that a later call executes, so the
321
- // content decides.
322
- if (CONTENT_TOOLS.has(tool)) {
323
- const content = writtenContent(tool, args)
324
- return hasTransferPrimitive(content)
325
- }
326
- return false
327
- }
328
-
329
- if (tool === "bash" || tool === "shell" || tool === "run") {
330
- const command = typeof args?.command === "string" ? args.command : ""
331
- if (hasTransferPrimitive(command)) return true
332
- if (runsLocalScript(command)) return true
333
- // The command mentions a file the agent wrote earlier. It may now hold
334
- // a payload, so it is judged.
335
- if (referencedWrites(command, directory, scope).length) return true
336
- return false
337
- }
338
-
339
- if (tool === "webfetch") {
340
- if (args && Object.keys(args).some((key) => !["url", "format", "timeout"].includes(key))) return true
341
- // A GET can still carry data in the path or the query string.
342
- const url = typeof args?.url === "string" ? args.url : ""
343
- if (!url) return false
344
- try {
345
- const parsed = new URL(url)
346
- return parsed.search.length > 1 || parsed.pathname.length > 1
347
- } catch {
348
- return true
349
- }
350
- }
351
-
352
- return true
353
- }
354
-
355
281
  /**
356
282
  * The contents of files the agent wrote that a command references.
357
283
  *
package/src/tools.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Tool identifiers shared by OpenCode 1.x and 2.x.
3
+ *
4
+ * Canonical names are the OpenCode 2.x ids. Names from 1.x are mapped onto
5
+ * them, so policy lists and checks work for both hosts.
6
+ */
7
+
8
+ const ALIASES = new Map([
9
+ ["bash", "shell"],
10
+ ["apply_patch", "patch"],
11
+ ["task", "subagent"],
12
+ ])
13
+
14
+ export function canonicalTool(name) {
15
+ if (typeof name !== "string") return name
16
+ return ALIASES.get(name) ?? name
17
+ }
18
+
19
+ export function isMutationTool(tool) {
20
+ return ["write", "edit", "patch"].includes(canonicalTool(tool))
21
+ }
22
+
23
+ // 1.x write, edit and read take `filePath`; 2.x takes `path`. Every listed
24
+ // path counts as evidence, so a call that carries both is judged on both.
25
+ export function pathsOf(args) {
26
+ const paths = []
27
+ for (const key of ["filePath", "path"]) {
28
+ const value = args?.[key]
29
+ if (typeof value === "string" && value && !paths.includes(value)) paths.push(value)
30
+ }
31
+ return paths
32
+ }
@@ -1,61 +0,0 @@
1
- import { hostMatches } from "./guard.js"
2
-
3
- // Bound both traversal and text scanning, including object keys. Exceeding a
4
- // limit is a denial: an unseen suffix could contain a blocked destination.
5
- const MAX_NODES = 10000
6
- const MAX_DEPTH = 100
7
- const MAX_CHARS = 1000000
8
- // Stop at the authority, leaving paths and queries available for independent
9
- // URL matches. Shell punctuation can also be valid URL credentials, so it
10
- // must not truncate the authority before the URL parser sees it.
11
- const URLS = /\b([a-z][a-z0-9+.-]*:\/\/)([^\s/?#]+)/gi
12
- const SHELL_PUNCTUATION = /["'`<>|;&()]/
13
- const TRAILING_SHELL_PUNCTUATION = /["'`<>|;&()]+$/
14
-
15
- export function scanBlockedHosts(args, patterns) {
16
- const seen = new WeakSet()
17
- const pending = [{ value: args, depth: 0 }]
18
- let nodes = 0
19
- let chars = 0
20
- try {
21
- while (pending.length) {
22
- const { value, depth } = pending.pop()
23
- if (++nodes > MAX_NODES || depth > MAX_DEPTH) return { limited: true }
24
- if (typeof value === "string") {
25
- chars += value.length
26
- if (chars > MAX_CHARS) return { limited: true }
27
- for (const match of value.matchAll(URLS)) {
28
- // Check at most two interpretations: the complete authority (minus
29
- // closing shell punctuation), and the prefix a shell could use.
30
- // Never enumerate every punctuation prefix: work stays bounded.
31
- const authority = match[2]
32
- const candidates = [authority.replace(TRAILING_SHELL_PUNCTUATION, "")]
33
- const delimiter = authority.search(SHELL_PUNCTUATION)
34
- if (delimiter >= 0) candidates.push(authority.slice(0, delimiter))
35
- for (const candidate of candidates) {
36
- try {
37
- const host = new URL(match[1] + candidate).hostname.toLowerCase().replace(/\.$/, "")
38
- if (!host) return { limited: true }
39
- if (hostMatches(host, patterns)) return { host }
40
- } catch {
41
- // An ambiguous/unparseable authority is not evidence of safety.
42
- return { limited: true }
43
- }
44
- }
45
- }
46
- } else if (value && typeof value === "object" && !seen.has(value)) {
47
- seen.add(value)
48
- const keys = Object.keys(value)
49
- if (nodes + pending.length + keys.length * 2 > MAX_NODES) return { limited: true }
50
- for (const key of keys) {
51
- const descriptor = Object.getOwnPropertyDescriptor(value, key)
52
- if (!descriptor || !("value" in descriptor)) return { limited: true }
53
- pending.push({ value: key, depth: depth + 1 }, { value: descriptor.value, depth: depth + 1 })
54
- }
55
- }
56
- }
57
- return {}
58
- } catch {
59
- return { limited: true }
60
- }
61
- }