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/README.md +36 -3
- package/bin/oc-one-guard-doctor +37 -0
- package/config.example.json +2 -6
- package/docs/README.md +3 -1
- package/eval/runner.js +2 -2
- 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/audit.js +2 -0
- package/src/config.js +24 -20
- package/src/defaults.js +18 -9
- package/src/guard.js +41 -22
- package/src/judge.js +47 -44
- package/src/runtime.js +254 -0
- package/src/state.js +21 -23
- package/src/tools.js +37 -0
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 `
|
|
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
|
|
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.
|
|
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(
|
|
26
|
+
function trackMutation(name, args, directory, scope) {
|
|
27
|
+
const tool = canonicalTool(name)
|
|
26
28
|
const knownDirectory = typeof directory === "string" && posix.isAbsolute(directory)
|
|
27
29
|
const lookup = path => getWrite(path, directory, scope)
|
|
28
30
|
const uncertainPath = path => {
|
|
@@ -30,16 +32,18 @@ function trackMutation(tool, args, directory, scope) {
|
|
|
30
32
|
if (ambiguousPath(path, directory)) markUncertainPatch(scope)
|
|
31
33
|
}
|
|
32
34
|
if (tool === "write" || tool === "edit") {
|
|
33
|
-
const
|
|
34
|
-
if (
|
|
35
|
-
uncertainPath(path)
|
|
35
|
+
const paths = pathsOf(args)
|
|
36
|
+
if (!paths.length) return
|
|
36
37
|
const content = writeContent(tool, args)
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
for (const path of paths) {
|
|
39
|
+
uncertainPath(path)
|
|
40
|
+
if (!ambiguousPath(path, directory) && (lookup(path) || hasTransferPrimitive(content))) {
|
|
41
|
+
recordWrite(path, null, directory, scope)
|
|
42
|
+
}
|
|
39
43
|
}
|
|
40
44
|
return
|
|
41
45
|
}
|
|
42
|
-
if (tool !== "
|
|
46
|
+
if (tool !== "patch") return
|
|
43
47
|
const patch = typeof args?.patchText === "string" ? args.patchText : args?.patch
|
|
44
48
|
if (typeof patch !== "string") { markUncertainPatch(scope); return }
|
|
45
49
|
const lines = patch.split(/\r?\n/)
|
|
@@ -79,9 +83,10 @@ function trackMutation(tool, args, directory, scope) {
|
|
|
79
83
|
* The text a write-like tool puts on disk.
|
|
80
84
|
*
|
|
81
85
|
* `write` uses `content`, `edit` uses `newString` (and `oldString`), and
|
|
82
|
-
* `apply_patch` uses a patch text. All of them can carry a payload.
|
|
86
|
+
* `patch` (2.x) or `apply_patch` (1.x) uses a patch text. All of them can carry a payload.
|
|
83
87
|
*/
|
|
84
|
-
export function writeContent(
|
|
88
|
+
export function writeContent(name, args) {
|
|
89
|
+
const tool = canonicalTool(name)
|
|
85
90
|
if (!args || typeof args !== "object") return ""
|
|
86
91
|
if (tool === "write") return typeof args.content === "string" ? args.content : ""
|
|
87
92
|
if (tool === "edit") {
|
|
@@ -90,7 +95,7 @@ export function writeContent(tool, args) {
|
|
|
90
95
|
if (typeof args.oldString === "string") parts.push(args.oldString)
|
|
91
96
|
return parts.join("\n")
|
|
92
97
|
}
|
|
93
|
-
if (tool === "
|
|
98
|
+
if (tool === "patch") {
|
|
94
99
|
const parts = []
|
|
95
100
|
if (typeof args.patchText === "string") parts.push(args.patchText)
|
|
96
101
|
if (typeof args.patch === "string") parts.push(args.patch)
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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 === "
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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
|
|
148
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
+
}
|