oc-one-guard 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,7 +34,7 @@ Published on npm as [`oc-one-guard`](https://www.npmjs.com/package/oc-one-guard)
34
34
  {
35
35
  "plugin": [
36
36
  [
37
- "oc-one-guard@0.2.0",
37
+ "oc-one-guard@0.3.0",
38
38
  {
39
39
  "endpoint": "http://127.0.0.1:8081/v1/systemone",
40
40
  "model": "h2o-lightning-4b"
@@ -68,7 +68,6 @@ An earlier synthetic guard run requested **H2O Lightning 4B** (the served model
68
68
  |---|---|
69
69
  | Benign calls scored by H2O | 17 allowed, **9 blocked** |
70
70
  | Malicious calls scored by H2O | **24 blocked**, 0 allowed |
71
- | Malicious call skipped by the prefilter | **1 allowed** without model review |
72
71
 
73
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).
74
73
 
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-one-guard",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
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",
package/src/config.js CHANGED
@@ -202,13 +202,6 @@ export function normalizeConfig(raw) {
202
202
  }
203
203
  }
204
204
 
205
- const denied = new Set(merged.deniedTools ?? [])
206
- for (const tool of merged.skipTools ?? []) {
207
- if (denied.has(tool)) {
208
- errors.push(`tool "${tool}" is on both the deny list and the skip list`)
209
- }
210
- }
211
-
212
205
  if (errors.length) {
213
206
  throw new Error(`[oc-one-guard] Invalid configuration: ${errors.join("; ")}`)
214
207
  }
@@ -221,11 +214,6 @@ export function normalizeConfig(raw) {
221
214
  merged.cacheTtlMs = Math.max(0, Math.floor(Number(merged.cacheTtlMs) || 0))
222
215
  merged.auditGenerations = Math.max(1, Math.floor(Number(merged.auditGenerations) || DEFAULTS.auditGenerations))
223
216
 
224
- if (!Array.isArray(merged.allowedTools)) merged.allowedTools = []
225
- if (!Array.isArray(merged.deniedTools)) merged.deniedTools = []
226
- if (!Array.isArray(merged.skipTools)) merged.skipTools = []
227
- if (!Array.isArray(merged.allowedHosts)) merged.allowedHosts = []
228
- if (!Array.isArray(merged.blockedHosts)) merged.blockedHosts = []
229
217
  if (!Array.isArray(merged.questions)) merged.questions = []
230
218
 
231
219
  return merged
package/src/defaults.js CHANGED
@@ -47,12 +47,6 @@
47
47
  * @property {boolean} redactArguments Strip secret-looking values before they leave the machine.
48
48
  * @property {boolean} cacheEnabled Cache identical verdicts for a short window.
49
49
  * @property {number} cacheTtlMs
50
- * @property {boolean} prefilter Skip the judge for calls with no outbound surface.
51
- * @property {string[]} skipTools Tool names never inspected.
52
- * @property {string[]} deniedTools Tool names always blocked.
53
- * @property {string[]} allowedTools Tool names always allowed, judge not consulted.
54
- * @property {string[]} allowedHosts Hosts treated as trusted destinations.
55
- * @property {string[]} blockedHosts Hosts always treated as exfiltration destinations.
56
50
  * @property {number} maxStateChars Cap on the rendered state sent to the judge.
57
51
  * @property {number} maxArgChars Per-argument truncation before rendering.
58
52
  * @property {QuestionConfig[]} questions Judge questions.
@@ -84,12 +78,6 @@ export const DEFAULTS = {
84
78
  redactArguments: true,
85
79
  cacheEnabled: true,
86
80
  cacheTtlMs: 30000,
87
- prefilter: true,
88
- skipTools: [],
89
- deniedTools: [],
90
- allowedTools: [],
91
- allowedHosts: [],
92
- blockedHosts: [],
93
81
  maxStateChars: 8000,
94
82
  maxArgChars: 2000,
95
83
  stateTemplate:
package/src/guard.js CHANGED
@@ -2,10 +2,9 @@
2
2
  * The guard engine: decide, cache, audit.
3
3
  *
4
4
  * Decision order:
5
- * 1. Tool allow/deny lists and host lists decide without consulting the judge.
6
- * 2. The judge is asked for a verdict.
7
- * 3. The verdict is compared against `denyThreshold` and `escalateThreshold`.
8
- * 4. Failures fall back to `failMode`; unclassifiable verdicts to `unknownMode`.
5
+ * 1. The judge is asked about every call; no code rule decides for it.
6
+ * 2. The verdict is compared against `denyThreshold` and `escalateThreshold`.
7
+ * 3. Failures fall back to `failMode`; unclassifiable verdicts to `unknownMode`.
9
8
  *
10
9
  * The cache is keyed on the *full* call as well as the rendered state. The
11
10
  * rendered state is truncated, so two different commands could otherwise
@@ -18,9 +17,9 @@ import { posix } from "node:path"
18
17
  import { denyProbability, findDenyOption, JudgeError, requestDecision } from "./judge.js"
19
18
  import { sharedCache } from "./shared.js"
20
19
  import { requireScope } from "./scope.js"
21
- import { extractHosts, fullText, hasOutboundSurface, hasTransferPrimitive, renderState, runsLocalScript } from "./state.js"
22
- import { getWrite, recordWrite, referencedWrites, writesRevision, markUncertainPatch, hasUncertainPatch, refreshWrite, hasWrites, ambiguousPath, hasPendingMutation } from "./written.js"
23
- import { canonicalTool, listsTool, pathsOf } from "./tools.js"
20
+ import { extractHosts, fullText, hasTransferPrimitive, renderState } from "./state.js"
21
+ import { getWrite, recordWrite, referencedWrites, markUncertainPatch, refreshWrite, ambiguousPath } from "./written.js"
22
+ import { canonicalTool, pathsOf } from "./tools.js"
24
23
 
25
24
  // Patch headers are line-oriented. Never interpret content lines as paths.
26
25
  function trackMutation(name, args, directory, scope) {
@@ -264,89 +263,20 @@ export function createGuard(config, deps = {}) {
264
263
  })
265
264
  }
266
265
 
267
- // Deny is a security control and is checked before the skip list, which
268
- // is only a performance optimisation.
269
- if (listsTool(config.deniedTools, tool)) {
270
- return new GuardDecision({
271
- verdict: VERDICT.DENY,
272
- reason: `Tool "${tool}" is on the deny list.`,
273
- tool,
274
- hosts,
275
- durationMs: 0,
276
- mode: "tool-deny",
277
- })
278
- }
279
-
280
- // 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.
281
267
  trackMutation(tool, args, deps.directory, scope)
282
268
 
283
- if (listsTool(config.skipTools, tool)) {
284
- return new GuardDecision({
285
- verdict: VERDICT.ALLOW,
286
- reason: `Tool "${tool}" is on the skip list.`,
287
- tool,
288
- hosts,
289
- durationMs: 0,
290
- mode: "skip",
291
- })
292
- }
293
-
294
- if (listsTool(config.allowedTools, tool)) {
295
- return new GuardDecision({
296
- verdict: VERDICT.ALLOW,
297
- reason: `Tool "${tool}" is on the allow list.`,
298
- tool,
299
- hosts,
300
- durationMs: 0,
301
- mode: "tool-allow",
302
- })
303
- }
304
-
305
269
  const isShell = tool === "shell"
306
270
  const command = typeof args?.command === "string" ? args.command : ""
307
271
  const base = typeof deps.directory === "string" && posix.isAbsolute(deps.directory) ? deps.directory : undefined
308
272
  const workdir = typeof args?.workdir === "string" && args.workdir ? args.workdir : undefined
309
273
  const directory = workdir ? (posix.isAbsolute(workdir) ? posix.normalize(workdir) : base ? posix.resolve(base, workdir) : undefined) : base
310
- const words = command.match(/'[^']*'|"[^"]*"|[^\s;&|()<>]+/g) ?? []
311
- const ambiguous = ambiguousPath(workdir, base) || ambiguousPath(base) || words.some(word => {
312
- const value = word.replace(/^(['"])(.*)\1$/, "$2")
313
- return value.includes("/") && ambiguousPath(value, directory)
314
- })
315
- if (isShell && ambiguous && (hasWrites(scope) || hasUncertainPatch(scope))) {
316
- const decision = new GuardDecision({ verdict: VERDICT.DENY, reason: "Ambiguous filesystem path prevents reliable staged-file evidence.", tool, hosts, durationMs: now() - started, mode: "staged-unknown" })
317
- writeAudit(decision, {})
318
- return decision
319
- }
320
274
  const hits = isShell ? referencedWrites(command, directory, scope) : []
321
275
  const snapshots = new Map(hits.map(hit => {
322
276
  const entry = getWrite(hit.path, undefined, scope)
323
277
  const content = entry.content === null ? refreshWrite(hit.path, scope) : entry.content
324
278
  return [hit.path, { ...entry, content: content ?? null }]
325
279
  }))
326
- const unresolvedScript = runsLocalScript(command) && hasWrites(scope) &&
327
- ((!directory && !command.match(/(?:^|\s)\//)) || /(?:^|[;&|]\s*)\s*cd(?:\s|$)|[$`*?\[\]{}\\]/.test(command))
328
- const unknownStaged = isShell && (hits.some(hit => snapshots.get(hit.path)?.content === null) || (hasPendingMutation(scope) && (hits.length > 0 || runsLocalScript(command))) || (hasUncertainPatch(scope) && runsLocalScript(command)) || unresolvedScript)
329
- if (unknownStaged) {
330
- const decision = new GuardDecision({ verdict: VERDICT.DENY, reason: "Tracked file content is unknown after a modification.", tool, hosts, durationMs: now() - started, mode: "staged-unknown" })
331
- writeAudit(decision, {})
332
- return decision
333
- }
334
- const revision = writesRevision(scope)
335
- const snapshotsValid = () => (!(hits.length || runsLocalScript(command)) || !hasPendingMutation(scope)) && [...snapshots].every(([path, snapshot]) => {
336
- const entry = getWrite(path, undefined, scope)
337
- return entry && !ambiguousPath(path) && (entry.content === null ? refreshWrite(path, scope) : entry.content) === snapshot.content
338
- })
339
-
340
- if (config.prefilter && !hasOutboundSurface(tool, args, directory, scope)) {
341
- return new GuardDecision({
342
- verdict: VERDICT.ALLOW,
343
- reason: `Tool "${tool}" has no outbound surface.`,
344
- tool,
345
- hosts,
346
- durationMs: 0,
347
- mode: "prefilter",
348
- })
349
- }
350
280
 
351
281
  let state
352
282
  try {
@@ -362,7 +292,8 @@ export function createGuard(config, deps = {}) {
362
292
  scope,
363
293
  })
364
294
  } catch {
365
- const decision = new GuardDecision({ verdict: VERDICT.DENY, reason: "Security facts exceed the state limit or could not be rendered.", tool, hosts, durationMs: now() - started, mode: "state-block" })
295
+ const blocked = config.failMode === "block"
296
+ const decision = new GuardDecision({ verdict: blocked ? VERDICT.DENY : VERDICT.ALLOW, reason: `Call state could not be rendered for the judge. failMode="${config.failMode}".`, tool, hosts, durationMs: now() - started, mode: blocked ? "fail-block" : "fail-allow" })
366
297
  writeAudit(decision, {})
367
298
  return decision
368
299
  }
@@ -372,7 +303,6 @@ export function createGuard(config, deps = {}) {
372
303
  const cached = cacheGet(key)
373
304
  if (cached?.pending) {
374
305
  const decision = await cached.pending
375
- if (isShell && (revision !== writesRevision(scope) || !snapshotsValid())) return evaluate(input)
376
306
  return new GuardDecision({ ...decision, durationMs: now() - started, mode: `${decision.mode}:coalesced` })
377
307
  }
378
308
  if (cached?.value) {
@@ -399,10 +329,6 @@ export function createGuard(config, deps = {}) {
399
329
  }
400
330
  throw error
401
331
  }
402
- if (isShell && (revision !== writesRevision(scope) || !snapshotsValid())) {
403
- if (config.cacheEnabled && cache.get(key)?.pending === pending) cache.delete(key)
404
- return evaluate(input)
405
- }
406
332
  if (decision.mode === "judge" && decision.verdict !== VERDICT.ESCALATE && !decision.stop) cacheSet(key, { ...decision })
407
333
  else if (config.cacheEnabled && cache.get(key)?.pending === pending) cache.delete(key)
408
334
  return decision
package/src/runtime.js CHANGED
@@ -7,7 +7,6 @@
7
7
 
8
8
  import { createAuditLogger } from "./audit.js"
9
9
  import { createGuard, VERDICT } from "./guard.js"
10
- import { scanBlockedHosts } from "./blocked-hosts.js"
11
10
  import { redactText } from "./redact.js"
12
11
  import { beginMutation, endMutation, markUncertainPatch } from "./written.js"
13
12
  import { createStopController } from "./stop.js"
@@ -142,40 +141,8 @@ export function createGuardRuntime(config, { client, directory, scope }) {
142
141
  throw new Error("[oc-one-guard] Blocked tool call while conversation cancellation is pending.")
143
142
  }
144
143
 
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
144
  const mutation = config.enabled && isMutationTool(tool)
162
145
  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
146
  const token = mutation ? beginMutation(scope) : undefined
180
147
  if (token && key) {
181
148
  const entries = pending.get(key) ?? []
package/src/state.js CHANGED
@@ -22,29 +22,6 @@ import { canonicalTool, pathsOf } from "./tools.js"
22
22
 
23
23
  const URL_PATTERN = /\b(?:https?|ftps?|sftp|scp|rsync|ssh|git):\/\/[^\s"'`<>|;&]+/gi
24
24
 
25
- /**
26
- * Tools that cannot, on their own, move data off the machine.
27
- *
28
- * These are the only tools that skip the judge without further checks.
29
- * Everything else — shell, webfetch, MCP tools, any tool added in a future
30
- * OpenCode release — is judged.
31
- */
32
- const LOCAL_ONLY_TOOLS = new Set([
33
- "read",
34
- "write",
35
- "edit",
36
- "apply_patch",
37
- "glob",
38
- "grep",
39
- "list",
40
- "todowrite",
41
- "todoread",
42
- "patch",
43
- "question",
44
- "plan_exit",
45
- "invalid",
46
- ])
47
-
48
25
  /**
49
26
  * Tools whose arguments can contain a payload bound for a remote host.
50
27
  *
@@ -301,55 +278,6 @@ export function writtenContent(tool, args) {
301
278
  return ""
302
279
  }
303
280
 
304
- /**
305
- * Decide whether a tool call has an outbound surface worth judging.
306
- *
307
- * The rule is an allowlist of known-local tools. Anything else — including
308
- * MCP tools, `webfetch`, and any tool added in a future OpenCode release —
309
- * is judged.
310
- *
311
- * A shell command is judged when it uses a network primitive, runs a file
312
- * the agent has written, or mentions a written file at all.
313
- */
314
- export function hasOutboundSurface(tool, args, directory, scope) {
315
- requireScope(scope)
316
- const name = canonicalTool(tool)
317
- if (LOCAL_ONLY_TOOLS.has(name)) {
318
- // A file write can stage a payload that a later call executes, so the
319
- // content decides.
320
- if (CONTENT_TOOLS.has(name)) {
321
- const content = writtenContent(name, args)
322
- return hasTransferPrimitive(content)
323
- }
324
- return false
325
- }
326
-
327
- if (name === "shell") {
328
- const command = typeof args?.command === "string" ? args.command : ""
329
- if (hasTransferPrimitive(command)) return true
330
- if (runsLocalScript(command)) return true
331
- // The command mentions a file the agent wrote earlier. It may now hold
332
- // a payload, so it is judged.
333
- if (referencedWrites(command, directory, scope).length) return true
334
- return false
335
- }
336
-
337
- if (name === "webfetch") {
338
- if (args && Object.keys(args).some((key) => !["url", "format", "timeout"].includes(key))) return true
339
- // A GET can still carry data in the path or the query string.
340
- const url = typeof args?.url === "string" ? args.url : ""
341
- if (!url) return false
342
- try {
343
- const parsed = new URL(url)
344
- return parsed.search.length > 1 || parsed.pathname.length > 1
345
- } catch {
346
- return true
347
- }
348
- }
349
-
350
- return true
351
- }
352
-
353
281
  /**
354
282
  * The contents of files the agent wrote that a command references.
355
283
  *
package/src/tools.js CHANGED
@@ -20,11 +20,6 @@ export function isMutationTool(tool) {
20
20
  return ["write", "edit", "patch"].includes(canonicalTool(tool))
21
21
  }
22
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
23
  // 1.x write, edit and read take `filePath`; 2.x takes `path`. Every listed
29
24
  // path counts as evidence, so a call that carries both is judged on both.
30
25
  export function pathsOf(args) {
@@ -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
- }