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 +6 -4
- package/bin/oc-one-guard-doctor +37 -0
- package/index.js +7 -214
- package/package.json +4 -3
- package/src/adapters/v1.js +39 -0
- package/src/adapters/v2.js +67 -0
- package/src/guard.js +23 -16
- package/src/runtime.js +254 -0
- package/src/state.js +21 -23
- package/src/tools.js +37 -0
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
|
|
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.
|
|
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:
|
|
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
|
|
30
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "OpenCode v1
|
|
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(
|
|
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
|
|
34
|
-
if (
|
|
35
|
-
uncertainPath(path)
|
|
35
|
+
const paths = pathsOf(args)
|
|
36
|
+
if (!paths.length) return
|
|
36
37
|
const content = writeContent(tool, args)
|
|
37
|
-
|
|
38
|
-
|
|
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 !== "
|
|
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(
|
|
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 === "
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 === "
|
|
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
|
-
|
|
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", "
|
|
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 (
|
|
144
|
+
if (name === "shell") {
|
|
143
145
|
if (typeof args?.command === "string") chunks.push(args.command)
|
|
144
|
-
} else if (
|
|
146
|
+
} else if (name === "webfetch") {
|
|
145
147
|
if (typeof args?.url === "string") chunks.push(args.url)
|
|
146
|
-
} else if (CONTENT_TOOLS.has(
|
|
147
|
-
chunks.push(writtenContent(
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
case "apply_patch":
|
|
184
|
-
push(args?.filePath)
|
|
179
|
+
case "patch":
|
|
180
|
+
pathsOf(args).forEach(push)
|
|
185
181
|
break
|
|
186
|
-
case "
|
|
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 (
|
|
292
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
323
|
-
const content = writtenContent(
|
|
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 (
|
|
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 (
|
|
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
|
+
}
|