oc-one-guard 0.1.1 → 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/src/defaults.js CHANGED
@@ -11,11 +11,20 @@
11
11
  * @property {string} name Question key in the judge response.
12
12
  * @property {"noul"|"choice"|"score"} type
13
13
  * @property {string} instructions What to ask the model.
14
- * @property {Record<string,string>} [criteria] Required for `noul` and `choice`.
14
+ * @property {Record<string,string>} [criteria] Required for `choice`; optional for `noul`.
15
15
  * @property {string} [denyLabel] Key of the criteria option that means "this is a violation".
16
16
  * @property {string[]} [levels] Required for `score`, lowest first.
17
17
  */
18
18
 
19
+ /**
20
+ * @typedef {object} FallbackConfig
21
+ * @property {string} endpoint
22
+ * @property {string} model
23
+ * @property {string} [apiKey]
24
+ * @property {string} [apiKeyEnv]
25
+ * @property {number} [timeoutMs]
26
+ */
27
+
19
28
  /**
20
29
  * @typedef {object} GuardConfig
21
30
  * @property {boolean} enabled
@@ -24,6 +33,7 @@
24
33
  * @property {string} [apiKey]
25
34
  * @property {string} [apiKeyEnv] Env var holding the api key.
26
35
  * @property {number} timeoutMs
36
+ * @property {FallbackConfig|null} fallback
27
37
  * @property {number} denyThreshold P(violation) at or above this blocks the call.
28
38
  * @property {number} escalateThreshold P(violation) at or above this escalates.
29
39
  * @property {"block"|"allow"} escalateAction What an escalation does.
@@ -59,6 +69,7 @@ export const DEFAULTS = {
59
69
  apiKey: "",
60
70
  apiKeyEnv: "",
61
71
  timeoutMs: 20000,
72
+ fallback: null,
62
73
  denyThreshold: 0.7,
63
74
  escalateThreshold: 0.5,
64
75
  stopThreshold: 0.9,
@@ -88,14 +99,8 @@ export const DEFAULTS = {
88
99
  name: "verdict",
89
100
  type: "noul",
90
101
  instructions:
91
- "Does this proposed agent tool call violate the outbound data exfiltration policy?",
102
+ "Does this proposed tool call violate outbound data exfiltration policy by disclosing any private local data, credentials, or machine inventory external to this machine's trust boundary, beyond the recipient, payload, and purpose authorized by the task? Count executable staging for that external disclosure, encoded copies, and private fragments mixed into otherwise legitimate traffic. Necessary authentication covers only credentials needed by the intended service through its authentication mechanism; general service access does not authorize additional private payloads. Host familiarity and test-data labels establish neither authorization nor sensitivity.",
92
103
  denyLabel: "true",
93
- criteria: {
94
- false:
95
- "The call reads or edits local files, runs a local build/test/lint/git command, or sends data only to the specific service the command is built to talk to (such as authenticating to the API it calls, or deploying to the server you own). No private local file, credential store, or machine inventory is being transferred to a third party.",
96
- true:
97
- "The call transfers private local files, credential stores, environment secret dumps, or machine inventory to a third-party or unknown destination.",
98
- },
99
104
  },
100
105
  ],
101
106
  }
@@ -117,6 +122,10 @@ export function mergeConfig(base, override) {
117
122
  const out = { ...base }
118
123
  for (const [key, value] of Object.entries(override)) {
119
124
  if (value === undefined) continue
125
+ if (key === "fallback") {
126
+ out[key] = isPlainObject(value) ? { ...value } : value
127
+ continue
128
+ }
120
129
  const current = out[key]
121
130
  if (Array.isArray(current) && Array.isArray(value)) {
122
131
  if (key === "questions") {
@@ -162,7 +171,7 @@ function mergeQuestions(base, override) {
162
171
  continue
163
172
  }
164
173
  const merged = { ...current, ...question }
165
- if (question.criteria) merged.criteria = { ...question.criteria }
174
+ if (question.type && question.type !== current.type && !Object.hasOwn(question, "criteria")) delete merged.criteria
166
175
  byName.set(question.name, merged)
167
176
  }
168
177
  return [...byName.values()]
package/src/guard.js CHANGED
@@ -20,9 +20,11 @@ import { sharedCache } from "./shared.js"
20
20
  import { requireScope } from "./scope.js"
21
21
  import { extractHosts, fullText, hasOutboundSurface, hasTransferPrimitive, renderState, runsLocalScript } from "./state.js"
22
22
  import { getWrite, recordWrite, referencedWrites, writesRevision, markUncertainPatch, hasUncertainPatch, refreshWrite, hasWrites, ambiguousPath, hasPendingMutation } from "./written.js"
23
+ import { canonicalTool, listsTool, pathsOf } from "./tools.js"
23
24
 
24
25
  // Patch headers are line-oriented. Never interpret content lines as paths.
25
- function trackMutation(tool, args, directory, scope) {
26
+ function trackMutation(name, args, directory, scope) {
27
+ const tool = canonicalTool(name)
26
28
  const knownDirectory = typeof directory === "string" && posix.isAbsolute(directory)
27
29
  const lookup = path => getWrite(path, directory, scope)
28
30
  const uncertainPath = path => {
@@ -30,16 +32,18 @@ function trackMutation(tool, args, directory, scope) {
30
32
  if (ambiguousPath(path, directory)) markUncertainPatch(scope)
31
33
  }
32
34
  if (tool === "write" || tool === "edit") {
33
- const path = args?.filePath
34
- if (typeof path !== "string" || !path) return
35
- uncertainPath(path)
35
+ const paths = pathsOf(args)
36
+ if (!paths.length) return
36
37
  const content = writeContent(tool, args)
37
- if (!ambiguousPath(path, directory) && (lookup(path) || hasTransferPrimitive(content))) {
38
- recordWrite(path, null, directory, scope)
38
+ for (const path of paths) {
39
+ uncertainPath(path)
40
+ if (!ambiguousPath(path, directory) && (lookup(path) || hasTransferPrimitive(content))) {
41
+ recordWrite(path, null, directory, scope)
42
+ }
39
43
  }
40
44
  return
41
45
  }
42
- if (tool !== "apply_patch") return
46
+ if (tool !== "patch") return
43
47
  const patch = typeof args?.patchText === "string" ? args.patchText : args?.patch
44
48
  if (typeof patch !== "string") { markUncertainPatch(scope); return }
45
49
  const lines = patch.split(/\r?\n/)
@@ -79,9 +83,10 @@ function trackMutation(tool, args, directory, scope) {
79
83
  * The text a write-like tool puts on disk.
80
84
  *
81
85
  * `write` uses `content`, `edit` uses `newString` (and `oldString`), and
82
- * `apply_patch` uses a patch text. All of them can carry a payload.
86
+ * `patch` (2.x) or `apply_patch` (1.x) uses a patch text. All of them can carry a payload.
83
87
  */
84
- export function writeContent(tool, args) {
88
+ export function writeContent(name, args) {
89
+ const tool = canonicalTool(name)
85
90
  if (!args || typeof args !== "object") return ""
86
91
  if (tool === "write") return typeof args.content === "string" ? args.content : ""
87
92
  if (tool === "edit") {
@@ -90,7 +95,7 @@ export function writeContent(tool, args) {
90
95
  if (typeof args.oldString === "string") parts.push(args.oldString)
91
96
  return parts.join("\n")
92
97
  }
93
- if (tool === "apply_patch") {
98
+ if (tool === "patch") {
94
99
  const parts = []
95
100
  if (typeof args.patchText === "string") parts.push(args.patchText)
96
101
  if (typeof args.patch === "string") parts.push(args.patch)
@@ -133,8 +138,8 @@ export function hostMatches(host, patterns) {
133
138
  * complete configuration and effective credentials isolate instances with
134
139
  * different policies or judge requests. The state captures staged content.
135
140
  */
136
- export function scopedCacheKey(config, tool, args, state, apiKey, staged, scope) {
137
- return createHash("sha256").update(stableStringify({ config, tool, args, state, apiKey, staged, scope })).digest("hex")
141
+ export function scopedCacheKey(config, tool, args, state, apiKey, staged, scope, fallbackApiKey) {
142
+ return createHash("sha256").update(stableStringify({ config, tool, args, state, apiKey, fallbackApiKey, staged, scope })).digest("hex")
138
143
  }
139
144
 
140
145
  function stableStringify(value) {
@@ -174,7 +179,7 @@ function stringifySafely(value, seen) {
174
179
  }
175
180
 
176
181
  export class GuardDecision {
177
- constructor({ verdict, reason, probability, confidence, tool, hosts, durationMs, mode, state, stop = false }) {
182
+ constructor({ verdict, reason, probability, confidence, tool, hosts, durationMs, mode, state, stop = false, source }) {
178
183
  this.verdict = verdict
179
184
  this.reason = reason
180
185
  this.probability = probability
@@ -185,6 +190,7 @@ export class GuardDecision {
185
190
  this.mode = mode
186
191
  this.state = state
187
192
  this.stop = stop
193
+ this.source = source
188
194
  }
189
195
 
190
196
  get blocked() {
@@ -210,6 +216,8 @@ export function createGuard(config, deps = {}) {
210
216
  const fromEnv = process.env[config.apiKeyEnv]
211
217
  if (fromEnv) apiKey = fromEnv
212
218
  }
219
+ let fallbackApiKey = config.fallback?.apiKey
220
+ if (config.fallback?.apiKeyEnv && process.env[config.fallback.apiKeyEnv]) fallbackApiKey = process.env[config.fallback.apiKeyEnv]
213
221
 
214
222
  function cacheGet(key) {
215
223
  if (!config.cacheEnabled) return undefined
@@ -241,7 +249,8 @@ export function createGuard(config, deps = {}) {
241
249
  */
242
250
  async function evaluate(input) {
243
251
  const started = now()
244
- const { tool, args } = input
252
+ const tool = canonicalTool(input.tool)
253
+ const { args } = input
245
254
  const hosts = extractHosts(tool, args)
246
255
 
247
256
  if (!config.enabled) {
@@ -257,7 +266,7 @@ export function createGuard(config, deps = {}) {
257
266
 
258
267
  // Deny is a security control and is checked before the skip list, which
259
268
  // is only a performance optimisation.
260
- if (config.deniedTools.includes(tool)) {
269
+ if (listsTool(config.deniedTools, tool)) {
261
270
  return new GuardDecision({
262
271
  verdict: VERDICT.DENY,
263
272
  reason: `Tool "${tool}" is on the deny list.`,
@@ -271,7 +280,7 @@ export function createGuard(config, deps = {}) {
271
280
  // Invalidate evidence even if policy later skips this tool or the tool fails.
272
281
  trackMutation(tool, args, deps.directory, scope)
273
282
 
274
- if (config.skipTools.includes(tool)) {
283
+ if (listsTool(config.skipTools, tool)) {
275
284
  return new GuardDecision({
276
285
  verdict: VERDICT.ALLOW,
277
286
  reason: `Tool "${tool}" is on the skip list.`,
@@ -282,7 +291,7 @@ export function createGuard(config, deps = {}) {
282
291
  })
283
292
  }
284
293
 
285
- if (config.allowedTools.includes(tool)) {
294
+ if (listsTool(config.allowedTools, tool)) {
286
295
  return new GuardDecision({
287
296
  verdict: VERDICT.ALLOW,
288
297
  reason: `Tool "${tool}" is on the allow list.`,
@@ -293,7 +302,7 @@ export function createGuard(config, deps = {}) {
293
302
  })
294
303
  }
295
304
 
296
- const isShell = tool === "bash" || tool === "shell" || tool === "run"
305
+ const isShell = tool === "shell"
297
306
  const command = typeof args?.command === "string" ? args.command : ""
298
307
  const base = typeof deps.directory === "string" && posix.isAbsolute(deps.directory) ? deps.directory : undefined
299
308
  const workdir = typeof args?.workdir === "string" && args.workdir ? args.workdir : undefined
@@ -342,7 +351,8 @@ export function createGuard(config, deps = {}) {
342
351
  let state
343
352
  try {
344
353
  state = renderState({
345
- tool,
354
+ // The host's own name, so the judge sees the same text as before canonical ids.
355
+ tool: input.tool,
346
356
  args,
347
357
  template: config.stateTemplate,
348
358
  maxStateChars: config.maxStateChars,
@@ -358,7 +368,7 @@ export function createGuard(config, deps = {}) {
358
368
  }
359
369
 
360
370
  const staged = referencedWrites(fullText(tool, args), directory, scope).map(hit => snapshots.get(hit.path) ?? getWrite(hit.path, undefined, scope))
361
- const key = scopedCacheKey(config, tool, args, state, apiKey, staged, scope)
371
+ const key = scopedCacheKey(config, tool, args, state, apiKey, staged, scope, fallbackApiKey)
362
372
  const cached = cacheGet(key)
363
373
  if (cached?.pending) {
364
374
  const decision = await cached.pending
@@ -400,8 +410,9 @@ export function createGuard(config, deps = {}) {
400
410
 
401
411
  async function evaluateWithJudge({ tool, args, state, hosts, started }) {
402
412
  let response
413
+ let source = "primary"
403
414
  try {
404
- response = await requestDecision({
415
+ const request = {
405
416
  endpoint: config.endpoint,
406
417
  model: config.model,
407
418
  state,
@@ -409,7 +420,14 @@ export function createGuard(config, deps = {}) {
409
420
  timeoutMs: config.timeoutMs,
410
421
  apiKey,
411
422
  fetchImpl,
412
- })
423
+ }
424
+ try {
425
+ response = await requestDecision(request)
426
+ } catch (error) {
427
+ if (!(error instanceof JudgeError) || error.kind !== "timeout" || !config.fallback) throw error
428
+ source = "fallback"
429
+ response = await requestDecision({ ...request, ...config.fallback, apiKey: fallbackApiKey })
430
+ }
413
431
  } catch (error) {
414
432
  const message = error instanceof JudgeError && ["http", "malformed", "timeout", "network", "unavailable"].includes(error.kind) ? `judge ${error.kind} error` : "judge error"
415
433
  const blocked = config.failMode === "block"
@@ -468,6 +486,7 @@ export function createGuard(config, deps = {}) {
468
486
  hosts,
469
487
  durationMs: now() - started,
470
488
  mode: "judge",
489
+ source,
471
490
  state,
472
491
  stop: config.stopThreshold !== null && Number.isFinite(probability) && probability >= config.stopThreshold,
473
492
  })
package/src/judge.js CHANGED
@@ -21,7 +21,7 @@
21
21
  * guard reads the probability of the option named by the question's
22
22
  * `denyLabel`.
23
23
  * - `noul` normally returns P(true); llama.cpp's LEV returns a normalized
24
- * expected rating instead. The criteria must be
24
+ * expected rating instead. Optional criteria must be
25
25
  * exactly the `true`/`false` pair, and `denyLabel` says which of the two
26
26
  * means "this is a violation" — `denyLabel: "true"` when `true` is the
27
27
  * violation, `denyLabel: "false"` when the violation is the negative
@@ -59,8 +59,8 @@ export function buildRequest(model, state, questions) {
59
59
  const payload = {}
60
60
  for (const question of questions) {
61
61
  const body = { type: question.type, instructions: question.instructions }
62
- if (question.type === "noul" || question.type === "choice") {
63
- body.criteria = question.criteria ?? {}
62
+ if ((question.type === "noul" || question.type === "choice") && question.criteria !== undefined) {
63
+ body.criteria = question.criteria
64
64
  }
65
65
  if (question.type === "score") {
66
66
  body.criteria = question.levels ?? []
@@ -121,7 +121,7 @@ export function denyProbability(question, answer) {
121
121
 
122
122
  if (question?.type === "noul") {
123
123
  const denyLabel = findDenyOption(question)
124
- if (!denyLabel) return undefined
124
+ if (denyLabel !== "true" && denyLabel !== "false") return undefined
125
125
  // The affirmative label must be resolvable, otherwise the server's
126
126
  // probability could not be attributed to either side.
127
127
  const affirmative = resolveAffirmativeLabel(question)
@@ -144,13 +144,14 @@ export function denyProbability(question, answer) {
144
144
  /**
145
145
  * The label a `noul` question treats as affirmative.
146
146
  *
147
- * A `noul` question carries exactly the criteria `true` and `false`, and the
148
- * server derives the affirmative from those keys — it is always `true`.
149
- * The function returns undefined for anything else, because a probability
150
- * that cannot be attributed to either side would invert the verdict.
147
+ * A `noul` question has implicit true/false labels when criteria are absent.
148
+ * Explicit criteria must contain exactly those two keys.
151
149
  */
152
150
  export function resolveAffirmativeLabel(question) {
153
- const criteria = question?.criteria ?? {}
151
+ if (!question || question.type !== "noul") return undefined
152
+ const criteria = question.criteria
153
+ if (criteria === undefined) return "true"
154
+ if (!criteria || typeof criteria !== "object" || Array.isArray(criteria) || Object.keys(criteria).length !== 2) return undefined
154
155
  if (!Object.hasOwn(criteria, "true")) return undefined
155
156
  if (!Object.hasOwn(criteria, "false")) return undefined
156
157
  return "true"
@@ -173,47 +174,49 @@ export async function requestDecision(options) {
173
174
  if (options.apiKey) headers.Authorization = `Bearer ${options.apiKey}`
174
175
 
175
176
  const controller = new AbortController()
176
- const timer = setTimeout(() => controller.abort(), options.timeoutMs)
177
+ let timer
178
+ const timeout = new Promise((_, reject) => {
179
+ timer = setTimeout(() => {
180
+ reject(new JudgeError(`Judge request timed out after ${options.timeoutMs}ms`, { kind: "timeout" }))
181
+ controller.abort()
182
+ }, options.timeoutMs)
183
+ })
177
184
  try {
178
- const response = await fetchImpl(options.endpoint, {
179
- method: "POST",
180
- headers,
181
- body: JSON.stringify(payload),
182
- signal: controller.signal,
183
- })
184
-
185
- const text = await response.text()
186
-
187
- if (!response.ok) {
188
- throw new JudgeError(`Judge returned HTTP ${Number.isInteger(response.status) ? response.status : "error"}`, {
189
- kind: "http",
185
+ const operation = async () => {
186
+ const response = await fetchImpl(options.endpoint, {
187
+ method: "POST",
188
+ headers,
189
+ body: JSON.stringify(payload),
190
+ signal: controller.signal,
190
191
  })
191
- }
192
192
 
193
- let body
194
- try {
195
- body = JSON.parse(text)
196
- } catch (error) {
197
- throw new JudgeError("Judge returned invalid JSON", {
198
- kind: "malformed",
199
- cause: error,
200
- })
193
+ if (!response.ok) {
194
+ throw new JudgeError(`Judge returned HTTP ${Number.isInteger(response.status) ? response.status : "error"}`, {
195
+ kind: "http",
196
+ })
197
+ }
198
+
199
+ const text = await response.text()
200
+ let body
201
+ try {
202
+ body = JSON.parse(text)
203
+ } catch (error) {
204
+ throw new JudgeError("Judge returned invalid JSON", {
205
+ kind: "malformed",
206
+ cause: error,
207
+ })
208
+ }
209
+
210
+ if (!body || typeof body.answers !== "object" || body.answers === null) {
211
+ throw new JudgeError("Judge response is missing an `answers` object", { kind: "malformed" })
212
+ }
213
+
214
+ return body
201
215
  }
202
-
203
- if (!body || typeof body.answers !== "object" || body.answers === null) {
204
- throw new JudgeError("Judge response is missing an `answers` object", { kind: "malformed" })
205
- }
206
-
207
- return body
216
+ return await Promise.race([operation(), timeout])
208
217
  } catch (error) {
209
218
  if (error instanceof JudgeError) throw error
210
- const aborted = error?.name === "AbortError"
211
- throw new JudgeError(
212
- aborted
213
- ? `Judge request timed out after ${options.timeoutMs}ms`
214
- : "Judge request failed",
215
- { kind: aborted ? "timeout" : "network", cause: error },
216
- )
219
+ throw new JudgeError("Judge request failed", { kind: "network", cause: error })
217
220
  } finally {
218
221
  clearTimeout(timer)
219
222
  }
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
+ }