oc-one-guard 0.1.2 → 0.2.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.2.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
 
@@ -80,7 +82,7 @@ The run was **incomplete**: 45 requests timed out across the direct and guard te
80
82
 
81
83
  ## What's Next?
82
84
 
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.
85
+ 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
86
 
85
87
  ## License
86
88
 
@@ -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.")
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.2.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/guard.js CHANGED
@@ -20,9 +20,11 @@ import { sharedCache } from "./shared.js"
20
20
  import { requireScope } from "./scope.js"
21
21
  import { extractHosts, fullText, hasOutboundSurface, hasTransferPrimitive, renderState, runsLocalScript } from "./state.js"
22
22
  import { getWrite, recordWrite, referencedWrites, writesRevision, markUncertainPatch, hasUncertainPatch, refreshWrite, hasWrites, ambiguousPath, hasPendingMutation } from "./written.js"
23
+ import { canonicalTool, listsTool, pathsOf } from "./tools.js"
23
24
 
24
25
  // Patch headers are line-oriented. Never interpret content lines as paths.
25
- function trackMutation(tool, args, directory, scope) {
26
+ function trackMutation(name, args, directory, scope) {
27
+ const tool = canonicalTool(name)
26
28
  const knownDirectory = typeof directory === "string" && posix.isAbsolute(directory)
27
29
  const lookup = path => getWrite(path, directory, scope)
28
30
  const uncertainPath = path => {
@@ -30,16 +32,18 @@ function trackMutation(tool, args, directory, scope) {
30
32
  if (ambiguousPath(path, directory)) markUncertainPatch(scope)
31
33
  }
32
34
  if (tool === "write" || tool === "edit") {
33
- const path = args?.filePath
34
- if (typeof path !== "string" || !path) return
35
- uncertainPath(path)
35
+ const paths = pathsOf(args)
36
+ if (!paths.length) return
36
37
  const content = writeContent(tool, args)
37
- if (!ambiguousPath(path, directory) && (lookup(path) || hasTransferPrimitive(content))) {
38
- recordWrite(path, null, directory, scope)
38
+ for (const path of paths) {
39
+ uncertainPath(path)
40
+ if (!ambiguousPath(path, directory) && (lookup(path) || hasTransferPrimitive(content))) {
41
+ recordWrite(path, null, directory, scope)
42
+ }
39
43
  }
40
44
  return
41
45
  }
42
- if (tool !== "apply_patch") return
46
+ if (tool !== "patch") return
43
47
  const patch = typeof args?.patchText === "string" ? args.patchText : args?.patch
44
48
  if (typeof patch !== "string") { markUncertainPatch(scope); return }
45
49
  const lines = patch.split(/\r?\n/)
@@ -79,9 +83,10 @@ function trackMutation(tool, args, directory, scope) {
79
83
  * The text a write-like tool puts on disk.
80
84
  *
81
85
  * `write` uses `content`, `edit` uses `newString` (and `oldString`), and
82
- * `apply_patch` uses a patch text. All of them can carry a payload.
86
+ * `patch` (2.x) or `apply_patch` (1.x) uses a patch text. All of them can carry a payload.
83
87
  */
84
- export function writeContent(tool, args) {
88
+ export function writeContent(name, args) {
89
+ const tool = canonicalTool(name)
85
90
  if (!args || typeof args !== "object") return ""
86
91
  if (tool === "write") return typeof args.content === "string" ? args.content : ""
87
92
  if (tool === "edit") {
@@ -90,7 +95,7 @@ export function writeContent(tool, args) {
90
95
  if (typeof args.oldString === "string") parts.push(args.oldString)
91
96
  return parts.join("\n")
92
97
  }
93
- if (tool === "apply_patch") {
98
+ if (tool === "patch") {
94
99
  const parts = []
95
100
  if (typeof args.patchText === "string") parts.push(args.patchText)
96
101
  if (typeof args.patch === "string") parts.push(args.patch)
@@ -244,7 +249,8 @@ export function createGuard(config, deps = {}) {
244
249
  */
245
250
  async function evaluate(input) {
246
251
  const started = now()
247
- const { tool, args } = input
252
+ const tool = canonicalTool(input.tool)
253
+ const { args } = input
248
254
  const hosts = extractHosts(tool, args)
249
255
 
250
256
  if (!config.enabled) {
@@ -260,7 +266,7 @@ export function createGuard(config, deps = {}) {
260
266
 
261
267
  // Deny is a security control and is checked before the skip list, which
262
268
  // is only a performance optimisation.
263
- if (config.deniedTools.includes(tool)) {
269
+ if (listsTool(config.deniedTools, tool)) {
264
270
  return new GuardDecision({
265
271
  verdict: VERDICT.DENY,
266
272
  reason: `Tool "${tool}" is on the deny list.`,
@@ -274,7 +280,7 @@ export function createGuard(config, deps = {}) {
274
280
  // Invalidate evidence even if policy later skips this tool or the tool fails.
275
281
  trackMutation(tool, args, deps.directory, scope)
276
282
 
277
- if (config.skipTools.includes(tool)) {
283
+ if (listsTool(config.skipTools, tool)) {
278
284
  return new GuardDecision({
279
285
  verdict: VERDICT.ALLOW,
280
286
  reason: `Tool "${tool}" is on the skip list.`,
@@ -285,7 +291,7 @@ export function createGuard(config, deps = {}) {
285
291
  })
286
292
  }
287
293
 
288
- if (config.allowedTools.includes(tool)) {
294
+ if (listsTool(config.allowedTools, tool)) {
289
295
  return new GuardDecision({
290
296
  verdict: VERDICT.ALLOW,
291
297
  reason: `Tool "${tool}" is on the allow list.`,
@@ -296,7 +302,7 @@ export function createGuard(config, deps = {}) {
296
302
  })
297
303
  }
298
304
 
299
- const isShell = tool === "bash" || tool === "shell" || tool === "run"
305
+ const isShell = tool === "shell"
300
306
  const command = typeof args?.command === "string" ? args.command : ""
301
307
  const base = typeof deps.directory === "string" && posix.isAbsolute(deps.directory) ? deps.directory : undefined
302
308
  const workdir = typeof args?.workdir === "string" && args.workdir ? args.workdir : undefined
@@ -345,7 +351,8 @@ export function createGuard(config, deps = {}) {
345
351
  let state
346
352
  try {
347
353
  state = renderState({
348
- tool,
354
+ // The host's own name, so the judge sees the same text as before canonical ids.
355
+ tool: input.tool,
349
356
  args,
350
357
  template: config.stateTemplate,
351
358
  maxStateChars: config.maxStateChars,
package/src/runtime.js ADDED
@@ -0,0 +1,254 @@
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 { scanBlockedHosts } from "./blocked-hosts.js"
11
+ import { redactText } from "./redact.js"
12
+ import { beginMutation, endMutation, markUncertainPatch } from "./written.js"
13
+ import { createStopController } from "./stop.js"
14
+ import { isMutationTool } from "./tools.js"
15
+
16
+ const PENDING_TTL_MS = 10 * 60 * 1000
17
+
18
+ /**
19
+ * Build the error the agent sees when a call is blocked.
20
+ *
21
+ * The message is written for the model: it states what was blocked and why,
22
+ * so the agent can adjust instead of retrying blindly. Arguments are
23
+ * redacted first — this text goes back to the model provider, so a secret
24
+ * inside a blocked command would otherwise leave the machine here.
25
+ */
26
+ function blockedMessage(decision, tool, args) {
27
+ const preview =
28
+ typeof args?.command === "string" ? redactText(args.command).slice(0, 300) : undefined
29
+ const lines = [
30
+ `[oc-one-guard] Blocked tool call "${tool}".`,
31
+ `Reason: ${redactText(decision.reason)}`,
32
+ ]
33
+ if (decision.probability !== undefined) {
34
+ lines.push(`Exfiltration probability: ${decision.probability.toFixed(3)}`)
35
+ }
36
+ if (decision.hosts?.length) {
37
+ lines.push(`Destinations: ${decision.hosts.join(", ")}`)
38
+ }
39
+ if (preview) {
40
+ lines.push(`Command: ${preview}`)
41
+ }
42
+ if (decision.verdict === VERDICT.ESCALATE) {
43
+ lines.push("Manual review required. No native approval prompt is available from this hook.")
44
+ }
45
+ lines.push(
46
+ "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.",
47
+ )
48
+ return lines.join("\n")
49
+ }
50
+
51
+ /**
52
+ * @param {object} config Normalized configuration.
53
+ * @param {object} host
54
+ * @param {string} [host.directory] Absolute project directory.
55
+ * @param {string} host.scope Explicit state scope from `createScope`.
56
+ * @param {object} [host.client] Optional SDK-shaped object. `app.log`, `tui.showToast` and `session.get/abort` are used when present.
57
+ */
58
+ export function createGuardRuntime(config, { client, directory, scope }) {
59
+ const audit = createAuditLogger(config, client)
60
+ const guard = createGuard(config, { writeAudit: audit, directory, scope })
61
+ const pending = new Map()
62
+ const finish = key => {
63
+ for (const entry of pending.get(key) ?? []) endMutation(entry.token, scope)
64
+ pending.delete(key)
65
+ }
66
+ // Code Mode nested calls share the outer call id, so one `after` releases one token.
67
+ const releaseOne = key => {
68
+ const entries = pending.get(key)
69
+ if (!entries?.length) return
70
+ endMutation(entries.shift().token, scope)
71
+ if (!entries.length) pending.delete(key)
72
+ }
73
+ const remove = (key, token) => {
74
+ if (!token) return
75
+ endMutation(token, scope)
76
+ const entries = pending.get(key)
77
+ if (!entries) return
78
+ const remaining = entries.filter(item => item.token !== token)
79
+ if (remaining.length) pending.set(key, remaining)
80
+ else pending.delete(key)
81
+ }
82
+ // `after` never fires when a call is interrupted or a later plugin rejects it.
83
+ // Drop such tokens, but mark the scope uncertain: the write may still land.
84
+ const sweepStale = () => {
85
+ const cutoff = Date.now() - PENDING_TTL_MS
86
+ for (const [key, entries] of pending) {
87
+ const fresh = entries.filter(entry => entry.at >= cutoff)
88
+ if (fresh.length === entries.length) continue
89
+ for (const entry of entries) if (entry.at < cutoff) endMutation(entry.token, scope)
90
+ markUncertainPatch(scope)
91
+ if (fresh.length) pending.set(key, fresh)
92
+ else pending.delete(key)
93
+ }
94
+ }
95
+
96
+ const report = (level, message, extra) => {
97
+ try {
98
+ client?.app?.log?.({
99
+ body: { service: "oc-one-guard", level, message, extra },
100
+ })?.catch?.(() => {})
101
+ } catch {
102
+ // Logging must never break the guard.
103
+ }
104
+ }
105
+ const stopNotice = (message, variant) => {
106
+ try {
107
+ client?.tui?.showToast?.({ body: { title: "oc-one-guard", message, variant, duration: 8000 } })?.catch?.(() => {})
108
+ } catch {}
109
+ }
110
+ const stop = createStopController({
111
+ client,
112
+ directory,
113
+ warn: message => { report("warn", message); if (config.notify) stopNotice(message, "warning") },
114
+ notify: message => { report("warn", message); if (config.notify) stopNotice(message, "warning") },
115
+ })
116
+
117
+ for (const problem of config.problems ?? []) {
118
+ report("error", `Configuration problem, using fail-closed defaults: ${problem}`)
119
+ }
120
+ if (config.degraded) {
121
+ report("error", "Guard is running with the bundled defaults because the configuration was invalid.")
122
+ }
123
+
124
+ if (config.enabled) {
125
+ report(
126
+ "info",
127
+ `Guard active: ${config.endpoint} (model ${config.model}), deny>=${config.denyThreshold}, escalate>=${config.escalateThreshold} (${config.escalateAction})`,
128
+ )
129
+ } else {
130
+ report("warn", "Guard is DISABLED by configuration. Tool calls are not being reviewed.")
131
+ }
132
+
133
+ return {
134
+ observe(input, output) {
135
+ stop.observe(input, output)
136
+ },
137
+
138
+ async before({ tool, sessionID, callID, args }) {
139
+ sweepStale()
140
+ const ticket = stop.capture(sessionID)
141
+ if (await stop.pending(sessionID)) {
142
+ throw new Error("[oc-one-guard] Blocked tool call while conversation cancellation is pending.")
143
+ }
144
+
145
+ const scan = config.blockedHosts.length ? scanBlockedHosts(args, config.blockedHosts) : {}
146
+ if (scan.host || scan.limited) {
147
+ const decision = {
148
+ verdict: VERDICT.DENY,
149
+ 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).",
150
+ tool,
151
+ hosts: scan.host ? [scan.host] : [],
152
+ probability: undefined,
153
+ durationMs: 0,
154
+ mode: "host-block",
155
+ state: undefined,
156
+ }
157
+ audit(decision, {})
158
+ throw new Error(blockedMessage(decision, tool, args))
159
+ }
160
+
161
+ const mutation = config.enabled && isMutationTool(tool)
162
+ const key = typeof sessionID === "string" && typeof callID === "string" ? `${sessionID}\0${callID}` : undefined
163
+ // A token without a key can never be released, so it would deny later
164
+ // script runs until restart. Refuse the call instead.
165
+ if (mutation && !key) {
166
+ const decision = {
167
+ verdict: VERDICT.DENY,
168
+ reason: "The call has no session or call identifier, so its pending state cannot be tracked.",
169
+ tool,
170
+ hosts: [],
171
+ probability: undefined,
172
+ durationMs: 0,
173
+ mode: "uncorrelated-block",
174
+ state: undefined,
175
+ }
176
+ audit(decision, {})
177
+ throw new Error(blockedMessage(decision, tool, args))
178
+ }
179
+ const token = mutation ? beginMutation(scope) : undefined
180
+ if (token && key) {
181
+ const entries = pending.get(key) ?? []
182
+ entries.push({ token, at: Date.now() })
183
+ pending.set(key, entries)
184
+ }
185
+ let decision
186
+ try {
187
+ decision = await guard.evaluate({ tool, args })
188
+ } catch (error) {
189
+ remove(key, token)
190
+ throw error
191
+ }
192
+
193
+ if (decision.stop) {
194
+ remove(key, token)
195
+ await stop.request(ticket)
196
+ throw new Error(blockedMessage(decision, tool, args))
197
+ }
198
+ if (await stop.pending(sessionID)) {
199
+ remove(key, token)
200
+ throw new Error("[oc-one-guard] Blocked tool call while conversation cancellation is pending.")
201
+ }
202
+ if (decision.verdict === VERDICT.ESCALATE && config.escalateAction === "allow") {
203
+ // Escalated but allowed: warn loudly so the attempt is visible.
204
+ audit(decision, { state: decision.state })
205
+ try {
206
+ client?.tui?.showToast?.({
207
+ body: {
208
+ title: "oc-one-guard",
209
+ message: redactText(`Flagged ${tool}: ${decision.reason}`),
210
+ variant: "warning",
211
+ duration: 8000,
212
+ },
213
+ })?.catch?.(() => {})
214
+ } catch {
215
+ // A toast must never break the guard.
216
+ }
217
+ return
218
+ }
219
+
220
+ if (decision.blocked) {
221
+ remove(key, token)
222
+ if (config.notify) {
223
+ try {
224
+ client?.tui?.showToast?.({
225
+ body: {
226
+ title: "oc-one-guard",
227
+ message: redactText(`Blocked ${tool}: ${decision.reason}`),
228
+ variant: decision.verdict === VERDICT.ESCALATE ? "warning" : "error",
229
+ duration: 8000,
230
+ },
231
+ })?.catch?.(() => {})
232
+ } catch {
233
+ // A toast must never break the guard.
234
+ }
235
+ }
236
+ throw new Error(blockedMessage(decision, tool, args))
237
+ }
238
+ },
239
+
240
+ after({ tool, sessionID, callID }) {
241
+ if (!isMutationTool(tool)) return
242
+ if (typeof sessionID !== "string" || typeof callID !== "string") return
243
+ releaseOne(`${sessionID}\0${callID}`)
244
+ },
245
+
246
+ // Called when the plugin unloads. A mutation still in flight may land after
247
+ // its hook is gone, so the scope is marked uncertain rather than trusted.
248
+ dispose() {
249
+ const held = [...pending.values()].some(entries => entries.length > 0)
250
+ for (const key of [...pending.keys()]) finish(key)
251
+ if (held) markUncertainPatch(scope)
252
+ },
253
+ }
254
+ }
package/src/state.js CHANGED
@@ -18,6 +18,7 @@
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
 
@@ -51,7 +52,7 @@ const LOCAL_ONLY_TOOLS = new Set([
51
52
  * file and execute it in a later call. Their content is judged when it
52
53
  * contains something that moves data.
53
54
  */
54
- const CONTENT_TOOLS = new Set(["write", "edit", "apply_patch"])
55
+ const CONTENT_TOOLS = new Set(["write", "edit", "patch"])
55
56
 
56
57
  /**
57
58
  * Primitives that reach the network, matched against shell text.
@@ -138,13 +139,14 @@ export function runsLocalScript(text) {
138
139
  * @returns {string[]}
139
140
  */
140
141
  export function extractHosts(tool, args) {
142
+ const name = canonicalTool(tool)
141
143
  const chunks = []
142
- if (tool === "bash" || tool === "shell" || tool === "run") {
144
+ if (name === "shell") {
143
145
  if (typeof args?.command === "string") chunks.push(args.command)
144
- } else if (tool === "webfetch") {
146
+ } else if (name === "webfetch") {
145
147
  if (typeof args?.url === "string") chunks.push(args.url)
146
- } else if (CONTENT_TOOLS.has(tool)) {
147
- chunks.push(writtenContent(tool, args))
148
+ } else if (CONTENT_TOOLS.has(name)) {
149
+ chunks.push(writtenContent(name, args))
148
150
  }
149
151
 
150
152
  const hosts = new Set()
@@ -170,20 +172,14 @@ export function extractPaths(tool, args) {
170
172
  const push = (value) => {
171
173
  if (typeof value === "string" && value.trim()) paths.push(value.trim())
172
174
  }
173
- switch (tool) {
175
+ switch (canonicalTool(tool)) {
174
176
  case "read":
175
- push(args?.filePath)
176
- break
177
177
  case "write":
178
- push(args?.filePath)
179
- break
180
178
  case "edit":
181
- push(args?.filePath)
182
- break
183
- case "apply_patch":
184
- push(args?.filePath)
179
+ case "patch":
180
+ pathsOf(args).forEach(push)
185
181
  break
186
- case "bash":
182
+ case "shell":
187
183
  push(args?.workdir)
188
184
  break
189
185
  default:
@@ -287,15 +283,16 @@ export function fullText(tool, args) {
287
283
  * helper is used so the two agree.
288
284
  */
289
285
  export function writtenContent(tool, args) {
286
+ const name = canonicalTool(tool)
290
287
  if (!args || typeof args !== "object") return ""
291
- if (tool === "write") return typeof args.content === "string" ? args.content : ""
292
- if (tool === "edit") {
288
+ if (name === "write") return typeof args.content === "string" ? args.content : ""
289
+ if (name === "edit") {
293
290
  const parts = []
294
291
  if (typeof args.newString === "string") parts.push(args.newString)
295
292
  if (typeof args.oldString === "string") parts.push(args.oldString)
296
293
  return parts.join("\n")
297
294
  }
298
- if (tool === "apply_patch") {
295
+ if (name === "patch") {
299
296
  const parts = []
300
297
  if (typeof args.patchText === "string") parts.push(args.patchText)
301
298
  if (typeof args.patch === "string") parts.push(args.patch)
@@ -316,17 +313,18 @@ export function writtenContent(tool, args) {
316
313
  */
317
314
  export function hasOutboundSurface(tool, args, directory, scope) {
318
315
  requireScope(scope)
319
- if (LOCAL_ONLY_TOOLS.has(tool)) {
316
+ const name = canonicalTool(tool)
317
+ if (LOCAL_ONLY_TOOLS.has(name)) {
320
318
  // A file write can stage a payload that a later call executes, so the
321
319
  // content decides.
322
- if (CONTENT_TOOLS.has(tool)) {
323
- const content = writtenContent(tool, args)
320
+ if (CONTENT_TOOLS.has(name)) {
321
+ const content = writtenContent(name, args)
324
322
  return hasTransferPrimitive(content)
325
323
  }
326
324
  return false
327
325
  }
328
326
 
329
- if (tool === "bash" || tool === "shell" || tool === "run") {
327
+ if (name === "shell") {
330
328
  const command = typeof args?.command === "string" ? args.command : ""
331
329
  if (hasTransferPrimitive(command)) return true
332
330
  if (runsLocalScript(command)) return true
@@ -336,7 +334,7 @@ export function hasOutboundSurface(tool, args, directory, scope) {
336
334
  return false
337
335
  }
338
336
 
339
- if (tool === "webfetch") {
337
+ if (name === "webfetch") {
340
338
  if (args && Object.keys(args).some((key) => !["url", "format", "timeout"].includes(key))) return true
341
339
  // A GET can still carry data in the path or the query string.
342
340
  const url = typeof args?.url === "string" ? args.url : ""
package/src/tools.js ADDED
@@ -0,0 +1,37 @@
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
+ export function listsTool(list, tool) {
24
+ const wanted = canonicalTool(tool)
25
+ return list.some(entry => typeof entry === "string" && canonicalTool(entry) === wanted)
26
+ }
27
+
28
+ // 1.x write, edit and read take `filePath`; 2.x takes `path`. Every listed
29
+ // path counts as evidence, so a call that carries both is judged on both.
30
+ export function pathsOf(args) {
31
+ const paths = []
32
+ for (const key of ["filePath", "path"]) {
33
+ const value = args?.[key]
34
+ if (typeof value === "string" && value && !paths.includes(value)) paths.push(value)
35
+ }
36
+ return paths
37
+ }