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 +6 -5
- package/bin/oc-one-guard-doctor +37 -0
- package/config.example.json +0 -6
- 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/config.js +0 -12
- package/src/defaults.js +0 -12
- package/src/guard.js +28 -95
- package/src/runtime.js +221 -0
- package/src/state.js +15 -89
- package/src/tools.js +32 -0
- package/src/blocked-hosts.js +0 -61
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.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:
|
|
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.")
|
package/config.example.json
CHANGED
|
@@ -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
|
|
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.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.
|
|
6
|
-
* 2. The
|
|
7
|
-
* 3.
|
|
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,
|
|
22
|
-
import { getWrite, recordWrite, referencedWrites,
|
|
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(
|
|
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
|
|
34
|
-
if (
|
|
35
|
-
uncertainPath(path)
|
|
34
|
+
const paths = pathsOf(args)
|
|
35
|
+
if (!paths.length) return
|
|
36
36
|
const content = writeContent(tool, args)
|
|
37
|
-
|
|
38
|
-
|
|
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 !== "
|
|
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(
|
|
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 === "
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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", "
|
|
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 (
|
|
121
|
+
if (name === "shell") {
|
|
143
122
|
if (typeof args?.command === "string") chunks.push(args.command)
|
|
144
|
-
} else if (
|
|
123
|
+
} else if (name === "webfetch") {
|
|
145
124
|
if (typeof args?.url === "string") chunks.push(args.url)
|
|
146
|
-
} else if (CONTENT_TOOLS.has(
|
|
147
|
-
chunks.push(writtenContent(
|
|
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
|
-
|
|
156
|
+
case "patch":
|
|
157
|
+
pathsOf(args).forEach(push)
|
|
182
158
|
break
|
|
183
|
-
case "
|
|
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 (
|
|
292
|
-
if (
|
|
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 (
|
|
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
|
+
}
|
package/src/blocked-hosts.js
DELETED
|
@@ -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
|
-
}
|