oc-one-guard 0.1.0 → 0.1.1
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 +3 -1
- package/eval/runner.js +8 -7
- package/index.js +7 -5
- package/package.json +1 -1
- package/src/guard.js +30 -27
- package/src/scope.js +18 -0
- package/src/shared.js +2 -3
- package/src/state.js +12 -8
- package/src/written.js +45 -36
package/README.md
CHANGED
|
@@ -51,4 +51,6 @@ The run was **incomplete**: 45 requests timed out across the direct and guard te
|
|
|
51
51
|
|
|
52
52
|
These are ideas, not current features: support for OpenCode v2, plus a simpler setup and calibration path that stays conservative by default. The current release supports v1 only.
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
## License
|
|
55
|
+
|
|
56
|
+
Open source under the [MIT License](LICENSE). Copyright (c) humanfacetech.
|
package/eval/runner.js
CHANGED
|
@@ -7,7 +7,8 @@ import { normalizeConfig } from "../src/config.js"
|
|
|
7
7
|
import { createGuard } from "../src/guard.js"
|
|
8
8
|
import { denyProbability, requestDecision } from "../src/judge.js"
|
|
9
9
|
import { renderState } from "../src/state.js"
|
|
10
|
-
import { resetWrites } from "../src/written.js"
|
|
10
|
+
import { resetWrites, releaseWrites } from "../src/written.js"
|
|
11
|
+
import { createScope } from "../src/scope.js"
|
|
11
12
|
|
|
12
13
|
const hash = value => createHash("sha256").update(value).digest("hex")
|
|
13
14
|
const labels = ["benign", "malicious", "ambiguous"]
|
|
@@ -167,26 +168,26 @@ function isolate(call, root) {
|
|
|
167
168
|
return { call: { tool: call.tool, args }, isolatedPaths }
|
|
168
169
|
}
|
|
169
170
|
|
|
170
|
-
export async function runEvaluation({ cases, endpoint, model, mode = "both", limit, apiKey = "", fetchImpl = globalThis.fetch, timeoutMs }) {
|
|
171
|
+
export async function runEvaluation({ cases, endpoint, model, mode = "both", limit, apiKey = "", fetchImpl = globalThis.fetch, timeoutMs, scope = createScope() }) {
|
|
171
172
|
const counts = validateCases(cases)
|
|
172
173
|
parseArgs(["--endpoint", endpoint, "--model", model, "--mode", mode, ...(limit === undefined ? [] : ["--limit", String(limit)])])
|
|
173
174
|
const root = await mkdtemp(join(tmpdir(), "oc-one-eval-"))
|
|
174
175
|
try {
|
|
175
176
|
const config = normalizeConfig({ endpoint, model, apiKey, apiKeyEnv: "", cacheEnabled: false, auditLog: false, auditFile: "", notify: false, ...(timeoutMs ? { timeoutMs } : {}) })
|
|
176
177
|
const safeFetch = controlledFetch(endpoint, fetchImpl, config.timeoutMs)
|
|
177
|
-
const guard = createGuard(config, { directory: root, fetchImpl: safeFetch, cache: new Map(), writeAudit: () => {} })
|
|
178
|
+
const guard = createGuard(config, { directory: root, scope, fetchImpl: safeFetch, cache: new Map(), writeAudit: () => {} })
|
|
178
179
|
const rows = []
|
|
179
180
|
for (const item of cases.slice(0, limit ?? cases.length)) {
|
|
180
181
|
const states = {}
|
|
181
182
|
for (const arm of mode === "both" ? ["direct", "guard"] : [mode]) {
|
|
182
|
-
resetWrites()
|
|
183
|
+
resetWrites(scope)
|
|
183
184
|
const start = performance.now()
|
|
184
185
|
let source, score = null, outcome = "deny", stop = false, guardMode = null, reason = null, call = item.call, isolatedPaths = [], evaluatedArgs = null
|
|
185
186
|
try {
|
|
186
187
|
if (arm === "guard") ({ call, isolatedPaths } = isolate(item.call, root))
|
|
187
188
|
evaluatedArgs = call.args
|
|
188
189
|
if (arm === "direct") {
|
|
189
|
-
const state = renderState({ ...call, directory: root, template: config.stateTemplate, maxStateChars: config.maxStateChars, maxArgChars: config.maxArgChars })
|
|
190
|
+
const state = renderState({ ...call, directory: root, scope, template: config.stateTemplate, maxStateChars: config.maxStateChars, maxArgChars: config.maxArgChars })
|
|
190
191
|
states.direct = state
|
|
191
192
|
const response = await requestDecision({ endpoint, model, state, questions: config.questions, timeoutMs: config.timeoutMs, apiKey, fetchImpl: safeFetch })
|
|
192
193
|
score = denyProbability(config.questions[0], response.answers?.[config.questions[0].name]) ?? null
|
|
@@ -206,7 +207,7 @@ export async function runEvaluation({ cases, endpoint, model, mode = "both", lim
|
|
|
206
207
|
source = decision.mode === "judge" ? "judge" : decision.mode === "prefilter" ? "prefilter" : decision.mode === "state-block" ? "state-block" : decision.mode.startsWith("unknown-") ? "unknown" : decision.mode.startsWith("fail-") ? (decision.reason.includes("timeout") ? "timeout" : "error") : "policy"
|
|
207
208
|
}
|
|
208
209
|
} catch (error) { source = error?.message === "fixture-isolation" ? "fixture-isolation" : error?.kind === "timeout" ? "timeout" : "error" }
|
|
209
|
-
finally { resetWrites() }
|
|
210
|
+
finally { resetWrites(scope) }
|
|
210
211
|
rows.push({ id: item.id, family: item.family, tags: item.tags, expected: item.label, rationale: item.rationale, tool: item.call.tool, originalArgs: item.call.args, evaluatedArgs, isolatedPaths, judgeState: states[arm] ?? null, mode: arm, source, guardMode, reason, score, outcome, blocked: outcome !== "allow", stopSignal: stop, latencyMs: Math.round((performance.now() - start) * 100) / 100 })
|
|
211
212
|
}
|
|
212
213
|
if (mode === "both") {
|
|
@@ -236,7 +237,7 @@ export async function runEvaluation({ cases, endpoint, model, mode = "both", lim
|
|
|
236
237
|
const selectedCounts = Object.fromEntries(labels.map(label => [label, selectedCases.filter(item => item.label === label).length]))
|
|
237
238
|
const comparativeCounts = { pairs: agreement.length, bothScored: agreement.filter(pair => pair.bothScored).length, statesComparable: agreement.filter(pair => pair.stateMatch !== null).length, statesMatching: agreement.filter(pair => pair.stateMatch === true).length, outcomesAgree: agreement.filter(pair => pair.outcomeAgreement === true).length }
|
|
238
239
|
return { status: failures || !rows.some(row => row.source === "judge") ? "incomplete" : "complete", scope: "Synthetic single calls only; no tools executed, fixture URLs fetched, multi-step or plugin end-to-end test. Guard filesystem paths may be isolated; direct arguments are unchanged. Guard mutation tracking may change state. Independent model samples and different state limit comparisons. Scores are not calibrated probabilities.", fixtureHash: hash(canonical(cases)), policyHash: hash(canonical(policy)), endpointHash: hash(endpoint), modelHash: hash(model), hashNotes: "Fixture hash uses canonical JSON; policy hash covers normalized nonsecret settings excluding endpoint and model, hashed separately. Model hash identifies its name, not weights or server version. Hashes do not prove reproducibility.", thresholds: { escalate: config.escalateThreshold, deny: config.denyThreshold, stop: config.stopThreshold }, fixtureCounts: counts, selectedCases: selectedCases.length, selectedCounts, summaries, agreement, comparativeCounts, cases: rows }
|
|
239
|
-
} finally {
|
|
240
|
+
} finally { releaseWrites(scope); await rm(root, { recursive: true, force: true }) }
|
|
240
241
|
}
|
|
241
242
|
|
|
242
243
|
async function main() {
|
package/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { scanBlockedHosts } from "./src/blocked-hosts.js"
|
|
|
19
19
|
import { redactText } from "./src/redact.js"
|
|
20
20
|
import { beginMutation, endMutation } from "./src/written.js"
|
|
21
21
|
import { createStopController } from "./src/stop.js"
|
|
22
|
+
import { createScope } from "./src/scope.js"
|
|
22
23
|
|
|
23
24
|
export const id = "oc-one-guard"
|
|
24
25
|
|
|
@@ -58,7 +59,7 @@ function blockedMessage(decision, tool, args, redact) {
|
|
|
58
59
|
/**
|
|
59
60
|
* The OpenCode plugin entry point.
|
|
60
61
|
*
|
|
61
|
-
* @param {{client: object, directory?: string}} input OpenCode v1 host context
|
|
62
|
+
* @param {{client: object, directory?: string, worktree?: string, project?: {id?: string, vcs?: string}}} input OpenCode v1 host context.
|
|
62
63
|
* @param {Record<string, unknown>} [options]
|
|
63
64
|
*/
|
|
64
65
|
export async function server(input, options) {
|
|
@@ -66,17 +67,18 @@ export async function server(input, options) {
|
|
|
66
67
|
// that throws while loading, which would leave every tool call
|
|
67
68
|
// unrestricted. `resolveConfig` falls back to the fail-closed defaults.
|
|
68
69
|
const config = resolveConfig(options)
|
|
70
|
+
const scope = createScope(input)
|
|
69
71
|
|
|
70
72
|
const audit = createAuditLogger(config, input.client)
|
|
71
|
-
const guard = createGuard(config, { writeAudit: audit, directory: input.directory })
|
|
73
|
+
const guard = createGuard(config, { writeAudit: audit, directory: input.directory, scope })
|
|
72
74
|
const pending = new Map()
|
|
73
75
|
const finish = key => {
|
|
74
|
-
for (const token of pending.get(key) ?? []) endMutation(token)
|
|
76
|
+
for (const token of pending.get(key) ?? []) endMutation(token, scope)
|
|
75
77
|
pending.delete(key)
|
|
76
78
|
}
|
|
77
79
|
const remove = (key, token) => {
|
|
78
80
|
if (!token) return
|
|
79
|
-
endMutation(token)
|
|
81
|
+
endMutation(token, scope)
|
|
80
82
|
const tokens = pending.get(key)
|
|
81
83
|
if (!tokens) return
|
|
82
84
|
const remaining = tokens.filter(item => item !== token)
|
|
@@ -151,7 +153,7 @@ export async function server(input, options) {
|
|
|
151
153
|
|
|
152
154
|
const mutation = config.enabled && ["write", "edit", "apply_patch"].includes(tool)
|
|
153
155
|
const key = typeof hookInput.sessionID === "string" && typeof hookInput.callID === "string" ? `${hookInput.sessionID}\0${hookInput.callID}` : undefined
|
|
154
|
-
const token = mutation ? beginMutation() : undefined
|
|
156
|
+
const token = mutation ? beginMutation(scope) : undefined
|
|
155
157
|
if (token && key) {
|
|
156
158
|
const tokens = pending.get(key) ?? []
|
|
157
159
|
tokens.push(token)
|
package/package.json
CHANGED
package/src/guard.js
CHANGED
|
@@ -17,16 +17,17 @@ import { posix } from "node:path"
|
|
|
17
17
|
|
|
18
18
|
import { denyProbability, findDenyOption, JudgeError, requestDecision } from "./judge.js"
|
|
19
19
|
import { sharedCache } from "./shared.js"
|
|
20
|
+
import { requireScope } from "./scope.js"
|
|
20
21
|
import { extractHosts, fullText, hasOutboundSurface, hasTransferPrimitive, renderState, runsLocalScript } from "./state.js"
|
|
21
22
|
import { getWrite, recordWrite, referencedWrites, writesRevision, markUncertainPatch, hasUncertainPatch, refreshWrite, hasWrites, ambiguousPath, hasPendingMutation } from "./written.js"
|
|
22
23
|
|
|
23
24
|
// Patch headers are line-oriented. Never interpret content lines as paths.
|
|
24
|
-
function trackMutation(tool, args, directory) {
|
|
25
|
+
function trackMutation(tool, args, directory, scope) {
|
|
25
26
|
const knownDirectory = typeof directory === "string" && posix.isAbsolute(directory)
|
|
26
|
-
const lookup = path => getWrite(path, directory)
|
|
27
|
+
const lookup = path => getWrite(path, directory, scope)
|
|
27
28
|
const uncertainPath = path => {
|
|
28
|
-
if (!knownDirectory && typeof path === "string" && !posix.isAbsolute(path)) markUncertainPatch()
|
|
29
|
-
if (ambiguousPath(path, directory)) markUncertainPatch()
|
|
29
|
+
if (!knownDirectory && typeof path === "string" && !posix.isAbsolute(path)) markUncertainPatch(scope)
|
|
30
|
+
if (ambiguousPath(path, directory)) markUncertainPatch(scope)
|
|
30
31
|
}
|
|
31
32
|
if (tool === "write" || tool === "edit") {
|
|
32
33
|
const path = args?.filePath
|
|
@@ -34,13 +35,13 @@ function trackMutation(tool, args, directory) {
|
|
|
34
35
|
uncertainPath(path)
|
|
35
36
|
const content = writeContent(tool, args)
|
|
36
37
|
if (!ambiguousPath(path, directory) && (lookup(path) || hasTransferPrimitive(content))) {
|
|
37
|
-
recordWrite(path, null, directory)
|
|
38
|
+
recordWrite(path, null, directory, scope)
|
|
38
39
|
}
|
|
39
40
|
return
|
|
40
41
|
}
|
|
41
42
|
if (tool !== "apply_patch") return
|
|
42
43
|
const patch = typeof args?.patchText === "string" ? args.patchText : args?.patch
|
|
43
|
-
if (typeof patch !== "string") { markUncertainPatch(); return }
|
|
44
|
+
if (typeof patch !== "string") { markUncertainPatch(scope); return }
|
|
44
45
|
const lines = patch.split(/\r?\n/)
|
|
45
46
|
if (lines.at(-1) === "") lines.pop()
|
|
46
47
|
let current
|
|
@@ -62,14 +63,14 @@ function trackMutation(tool, args, directory) {
|
|
|
62
63
|
} else valid = false
|
|
63
64
|
}
|
|
64
65
|
if (!operations.length) valid = false
|
|
65
|
-
if (!valid) markUncertainPatch()
|
|
66
|
+
if (!valid) markUncertainPatch(scope)
|
|
66
67
|
for (const op of operations) {
|
|
67
68
|
uncertainPath(op.path)
|
|
68
69
|
if (op.move) uncertainPath(op.move)
|
|
69
70
|
const relevant = lookup(op.path) || (op.move && lookup(op.move)) || hasTransferPrimitive(op.lines.join("\n"))
|
|
70
71
|
if (relevant) {
|
|
71
|
-
if (!ambiguousPath(op.path, directory)) recordWrite(op.path, null, directory)
|
|
72
|
-
if (op.move && !ambiguousPath(op.move, directory)) recordWrite(op.move, null, directory)
|
|
72
|
+
if (!ambiguousPath(op.path, directory)) recordWrite(op.path, null, directory, scope)
|
|
73
|
+
if (op.move && !ambiguousPath(op.move, directory)) recordWrite(op.move, null, directory, scope)
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
}
|
|
@@ -132,8 +133,8 @@ export function hostMatches(host, patterns) {
|
|
|
132
133
|
* complete configuration and effective credentials isolate instances with
|
|
133
134
|
* different policies or judge requests. The state captures staged content.
|
|
134
135
|
*/
|
|
135
|
-
export function scopedCacheKey(config, tool, args, state, apiKey, staged) {
|
|
136
|
-
return createHash("sha256").update(stableStringify({ config, tool, args, state, apiKey, staged })).digest("hex")
|
|
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")
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
function stableStringify(value) {
|
|
@@ -198,6 +199,7 @@ export class GuardDecision {
|
|
|
198
199
|
* @param {object} deps Injectable dependencies for testing.
|
|
199
200
|
*/
|
|
200
201
|
export function createGuard(config, deps = {}) {
|
|
202
|
+
const scope = requireScope(deps.scope)
|
|
201
203
|
const now = deps.now ?? (() => Date.now())
|
|
202
204
|
const fetchImpl = deps.fetchImpl
|
|
203
205
|
const writeAudit = deps.writeAudit ?? (() => {})
|
|
@@ -267,7 +269,7 @@ export function createGuard(config, deps = {}) {
|
|
|
267
269
|
}
|
|
268
270
|
|
|
269
271
|
// Invalidate evidence even if policy later skips this tool or the tool fails.
|
|
270
|
-
trackMutation(tool, args, deps.directory)
|
|
272
|
+
trackMutation(tool, args, deps.directory, scope)
|
|
271
273
|
|
|
272
274
|
if (config.skipTools.includes(tool)) {
|
|
273
275
|
return new GuardDecision({
|
|
@@ -301,32 +303,32 @@ export function createGuard(config, deps = {}) {
|
|
|
301
303
|
const value = word.replace(/^(['"])(.*)\1$/, "$2")
|
|
302
304
|
return value.includes("/") && ambiguousPath(value, directory)
|
|
303
305
|
})
|
|
304
|
-
if (isShell && ambiguous && (hasWrites() || hasUncertainPatch())) {
|
|
306
|
+
if (isShell && ambiguous && (hasWrites(scope) || hasUncertainPatch(scope))) {
|
|
305
307
|
const decision = new GuardDecision({ verdict: VERDICT.DENY, reason: "Ambiguous filesystem path prevents reliable staged-file evidence.", tool, hosts, durationMs: now() - started, mode: "staged-unknown" })
|
|
306
308
|
writeAudit(decision, {})
|
|
307
309
|
return decision
|
|
308
310
|
}
|
|
309
|
-
const hits = isShell ? referencedWrites(command, directory) : []
|
|
311
|
+
const hits = isShell ? referencedWrites(command, directory, scope) : []
|
|
310
312
|
const snapshots = new Map(hits.map(hit => {
|
|
311
|
-
const entry = getWrite(hit.path)
|
|
312
|
-
const content = entry.content === null ? refreshWrite(hit.path) : entry.content
|
|
313
|
+
const entry = getWrite(hit.path, undefined, scope)
|
|
314
|
+
const content = entry.content === null ? refreshWrite(hit.path, scope) : entry.content
|
|
313
315
|
return [hit.path, { ...entry, content: content ?? null }]
|
|
314
316
|
}))
|
|
315
|
-
const unresolvedScript = runsLocalScript(command) && hasWrites() &&
|
|
317
|
+
const unresolvedScript = runsLocalScript(command) && hasWrites(scope) &&
|
|
316
318
|
((!directory && !command.match(/(?:^|\s)\//)) || /(?:^|[;&|]\s*)\s*cd(?:\s|$)|[$`*?\[\]{}\\]/.test(command))
|
|
317
|
-
const unknownStaged = isShell && (hits.some(hit => snapshots.get(hit.path)?.content === null) || (hasPendingMutation() && (hits.length > 0 || runsLocalScript(command))) || (hasUncertainPatch() && runsLocalScript(command)) || unresolvedScript)
|
|
319
|
+
const unknownStaged = isShell && (hits.some(hit => snapshots.get(hit.path)?.content === null) || (hasPendingMutation(scope) && (hits.length > 0 || runsLocalScript(command))) || (hasUncertainPatch(scope) && runsLocalScript(command)) || unresolvedScript)
|
|
318
320
|
if (unknownStaged) {
|
|
319
321
|
const decision = new GuardDecision({ verdict: VERDICT.DENY, reason: "Tracked file content is unknown after a modification.", tool, hosts, durationMs: now() - started, mode: "staged-unknown" })
|
|
320
322
|
writeAudit(decision, {})
|
|
321
323
|
return decision
|
|
322
324
|
}
|
|
323
|
-
const revision = writesRevision()
|
|
324
|
-
const snapshotsValid = () => (!(hits.length || runsLocalScript(command)) || !hasPendingMutation()) && [...snapshots].every(([path, snapshot]) => {
|
|
325
|
-
const entry = getWrite(path)
|
|
326
|
-
return entry && !ambiguousPath(path) && (entry.content === null ? refreshWrite(path) : entry.content) === snapshot.content
|
|
325
|
+
const revision = writesRevision(scope)
|
|
326
|
+
const snapshotsValid = () => (!(hits.length || runsLocalScript(command)) || !hasPendingMutation(scope)) && [...snapshots].every(([path, snapshot]) => {
|
|
327
|
+
const entry = getWrite(path, undefined, scope)
|
|
328
|
+
return entry && !ambiguousPath(path) && (entry.content === null ? refreshWrite(path, scope) : entry.content) === snapshot.content
|
|
327
329
|
})
|
|
328
330
|
|
|
329
|
-
if (config.prefilter && !hasOutboundSurface(tool, args, directory)) {
|
|
331
|
+
if (config.prefilter && !hasOutboundSurface(tool, args, directory, scope)) {
|
|
330
332
|
return new GuardDecision({
|
|
331
333
|
verdict: VERDICT.ALLOW,
|
|
332
334
|
reason: `Tool "${tool}" has no outbound surface.`,
|
|
@@ -347,6 +349,7 @@ export function createGuard(config, deps = {}) {
|
|
|
347
349
|
maxArgChars: config.maxArgChars,
|
|
348
350
|
directory,
|
|
349
351
|
staged: snapshots,
|
|
352
|
+
scope,
|
|
350
353
|
})
|
|
351
354
|
} catch {
|
|
352
355
|
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" })
|
|
@@ -354,12 +357,12 @@ export function createGuard(config, deps = {}) {
|
|
|
354
357
|
return decision
|
|
355
358
|
}
|
|
356
359
|
|
|
357
|
-
const staged = referencedWrites(fullText(tool, args), directory).map(hit => snapshots.get(hit.path) ?? getWrite(hit.path))
|
|
358
|
-
const key = scopedCacheKey(config, tool, args, state, apiKey, staged)
|
|
360
|
+
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)
|
|
359
362
|
const cached = cacheGet(key)
|
|
360
363
|
if (cached?.pending) {
|
|
361
364
|
const decision = await cached.pending
|
|
362
|
-
if (isShell && (revision !== writesRevision() || !snapshotsValid())) return evaluate(input)
|
|
365
|
+
if (isShell && (revision !== writesRevision(scope) || !snapshotsValid())) return evaluate(input)
|
|
363
366
|
return new GuardDecision({ ...decision, durationMs: now() - started, mode: `${decision.mode}:coalesced` })
|
|
364
367
|
}
|
|
365
368
|
if (cached?.value) {
|
|
@@ -386,7 +389,7 @@ export function createGuard(config, deps = {}) {
|
|
|
386
389
|
}
|
|
387
390
|
throw error
|
|
388
391
|
}
|
|
389
|
-
if (isShell && (revision !== writesRevision() || !snapshotsValid())) {
|
|
392
|
+
if (isShell && (revision !== writesRevision(scope) || !snapshotsValid())) {
|
|
390
393
|
if (config.cacheEnabled && cache.get(key)?.pending === pending) cache.delete(key)
|
|
391
394
|
return evaluate(input)
|
|
392
395
|
}
|
package/src/scope.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { posix } from "node:path"
|
|
2
|
+
import { randomUUID } from "node:crypto"
|
|
3
|
+
|
|
4
|
+
export function requireScope(scope) {
|
|
5
|
+
if (typeof scope !== "string" || !scope) throw new TypeError("An explicit scope is required")
|
|
6
|
+
return scope
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createScope(input = {}) {
|
|
10
|
+
const absolute = value => typeof value === "string" && posix.isAbsolute(value) ? posix.resolve(value) : undefined
|
|
11
|
+
const directory = absolute(input.directory)
|
|
12
|
+
if (!directory) return `anonymous:${randomUUID()}`
|
|
13
|
+
const id = typeof input.project?.id === "string" && input.project.id ? input.project.id : undefined
|
|
14
|
+
const worktree = absolute(input.worktree)
|
|
15
|
+
return id && worktree && (id !== "global" || input.project?.vcs === "git")
|
|
16
|
+
? JSON.stringify(["project", id, worktree])
|
|
17
|
+
: JSON.stringify(["directory", directory])
|
|
18
|
+
}
|
package/src/shared.js
CHANGED
|
@@ -7,9 +7,8 @@
|
|
|
7
7
|
* the URL. Both instances then answer the same tool call, and the judge
|
|
8
8
|
* costs seconds per request, so the verdict must not be computed twice.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* its own resolved settings
|
|
12
|
-
* discarded because of module load order.
|
|
10
|
+
* Cache keys include the project scope and effective configuration. Each
|
|
11
|
+
* instance keeps its own resolved settings.
|
|
13
12
|
*
|
|
14
13
|
* The state is stored on `globalThis` under a `Symbol.for` key so it is
|
|
15
14
|
* shared even when the module itself is instantiated twice.
|
package/src/state.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { getWrite, referencedWrites } from "./written.js"
|
|
20
|
+
import { requireScope } from "./scope.js"
|
|
20
21
|
|
|
21
22
|
const URL_PATTERN = /\b(?:https?|ftps?|sftp|scp|rsync|ssh|git):\/\/[^\s"'`<>|;&]+/gi
|
|
22
23
|
|
|
@@ -313,7 +314,8 @@ export function writtenContent(tool, args) {
|
|
|
313
314
|
* A shell command is judged when it uses a network primitive, runs a file
|
|
314
315
|
* the agent has written, or mentions a written file at all.
|
|
315
316
|
*/
|
|
316
|
-
export function hasOutboundSurface(tool, args, directory) {
|
|
317
|
+
export function hasOutboundSurface(tool, args, directory, scope) {
|
|
318
|
+
requireScope(scope)
|
|
317
319
|
if (LOCAL_ONLY_TOOLS.has(tool)) {
|
|
318
320
|
// A file write can stage a payload that a later call executes, so the
|
|
319
321
|
// content decides.
|
|
@@ -330,7 +332,7 @@ export function hasOutboundSurface(tool, args, directory) {
|
|
|
330
332
|
if (runsLocalScript(command)) return true
|
|
331
333
|
// The command mentions a file the agent wrote earlier. It may now hold
|
|
332
334
|
// a payload, so it is judged.
|
|
333
|
-
if (referencedWrites(command, directory).length) return true
|
|
335
|
+
if (referencedWrites(command, directory, scope).length) return true
|
|
334
336
|
return false
|
|
335
337
|
}
|
|
336
338
|
|
|
@@ -357,11 +359,12 @@ export function hasOutboundSurface(tool, args, directory) {
|
|
|
357
359
|
* cannot tell a staging step from a harmless script unless it is shown what
|
|
358
360
|
* the file contains — and the guard already recorded that.
|
|
359
361
|
*/
|
|
360
|
-
export function stagedContents(text, maxChars, directory, staged) {
|
|
362
|
+
export function stagedContents(text, maxChars, directory, staged, scope) {
|
|
363
|
+
requireScope(scope)
|
|
361
364
|
if (typeof text !== "string" || !text) return []
|
|
362
365
|
const entries = []
|
|
363
|
-
for (const hit of referencedWrites(text, directory)) {
|
|
364
|
-
const written = staged?.get(hit.path) ?? getWrite(hit.path)
|
|
366
|
+
for (const hit of referencedWrites(text, directory, scope)) {
|
|
367
|
+
const written = staged?.get(hit.path) ?? getWrite(hit.path, undefined, scope)
|
|
365
368
|
if (!written) continue
|
|
366
369
|
entries.push({
|
|
367
370
|
path: written.path,
|
|
@@ -379,15 +382,16 @@ export function stagedContents(text, maxChars, directory, staged) {
|
|
|
379
382
|
* a destination, a file reference or a sink.
|
|
380
383
|
*/
|
|
381
384
|
export function renderState(input) {
|
|
385
|
+
requireScope(input.scope)
|
|
382
386
|
const hosts = new Set(extractHosts(input.tool, input.args))
|
|
383
387
|
const paths = extractPaths(input.tool, input.args)
|
|
384
388
|
const text = fullText(input.tool, input.args)
|
|
385
389
|
const references = new Set(extractFileReferences(text))
|
|
386
390
|
const argumentsText = renderArgs(input.args, input.maxArgChars)
|
|
387
|
-
const staged = stagedContents(text, input.maxArgChars, input.directory, input.staged)
|
|
391
|
+
const staged = stagedContents(text, input.maxArgChars, input.directory, input.staged, input.scope)
|
|
388
392
|
let transfer = hasTransferPrimitive(text)
|
|
389
|
-
for (const hit of referencedWrites(text, input.directory)) {
|
|
390
|
-
const content = (input.staged?.get(hit.path) ?? getWrite(hit.path))?.content ?? ""
|
|
393
|
+
for (const hit of referencedWrites(text, input.directory, input.scope)) {
|
|
394
|
+
const content = (input.staged?.get(hit.path) ?? getWrite(hit.path, undefined, input.scope))?.content ?? ""
|
|
391
395
|
for (const host of extractHosts("write", { content })) hosts.add(host)
|
|
392
396
|
for (const reference of extractFileReferences(content)) references.add(reference)
|
|
393
397
|
transfer ||= hasTransferPrimitive(content)
|
package/src/written.js
CHANGED
|
@@ -13,25 +13,29 @@
|
|
|
13
13
|
* "update index.js"` — hit the judge, and the judge costs seconds per call.
|
|
14
14
|
*
|
|
15
15
|
* The state is process-wide and stored on `globalThis` under a `Symbol.for`
|
|
16
|
-
* key,
|
|
16
|
+
* key, partitioned by scope so duplicate instances see the same writes.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { posix } from "node:path"
|
|
20
20
|
import { constants, openSync, closeSync, fstatSync, readSync, lstatSync } from "node:fs"
|
|
21
|
+
import { requireScope } from "./scope.js"
|
|
21
22
|
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
23
|
+
const STATE_KEY = Symbol.for("oc-one-guard.scoped-writes.v1")
|
|
24
|
+
function state(scope) {
|
|
25
|
+
requireScope(scope)
|
|
26
|
+
const scopes = globalThis[STATE_KEY] ??= new Map()
|
|
27
|
+
if (!scopes.has(scope)) scopes.set(scope, { files: new Map(), revision: 0, uncertain: false, pending: new Set() })
|
|
28
|
+
return scopes.get(scope)
|
|
29
|
+
}
|
|
26
30
|
|
|
27
|
-
export function beginMutation() {
|
|
28
|
-
const pending =
|
|
31
|
+
export function beginMutation(scope) {
|
|
32
|
+
const pending = state(scope).pending
|
|
29
33
|
const token = Symbol()
|
|
30
34
|
pending.add(token)
|
|
31
35
|
return token
|
|
32
36
|
}
|
|
33
|
-
export function endMutation(token) {
|
|
34
|
-
export function hasPendingMutation() { return (
|
|
37
|
+
export function endMutation(token, scope) { state(scope).pending.delete(token) }
|
|
38
|
+
export function hasPendingMutation(scope) { return state(scope).pending.size > 0 }
|
|
35
39
|
|
|
36
40
|
// Never collapse '..' across a possibly symlinked component or inspect a
|
|
37
41
|
// different lexical path as evidence. Missing components are not symlinks yet.
|
|
@@ -58,12 +62,7 @@ const MAX_TRACKED = 100
|
|
|
58
62
|
/**
|
|
59
63
|
* @returns {Map<string, {path: string, content: string}>}
|
|
60
64
|
*/
|
|
61
|
-
function store() {
|
|
62
|
-
if (!globalThis[FILES_KEY]) {
|
|
63
|
-
globalThis[FILES_KEY] = new Map()
|
|
64
|
-
}
|
|
65
|
-
return globalThis[FILES_KEY]
|
|
66
|
-
}
|
|
65
|
+
function store(scope) { return state(scope).files }
|
|
67
66
|
|
|
68
67
|
/**
|
|
69
68
|
* Normalise a path for comparison.
|
|
@@ -81,34 +80,36 @@ function normalize(path, directory) {
|
|
|
81
80
|
* @param {string} path
|
|
82
81
|
* @param {string} content
|
|
83
82
|
*/
|
|
84
|
-
export function recordWrite(path, content, directory) {
|
|
83
|
+
export function recordWrite(path, content, directory, scope) {
|
|
84
|
+
requireScope(scope)
|
|
85
85
|
const key = normalize(path, directory)
|
|
86
86
|
if (!key) return
|
|
87
|
-
const files = store()
|
|
87
|
+
const files = store(scope)
|
|
88
88
|
const relative = typeof directory === "string" && posix.isAbsolute(directory) && posix.isAbsolute(key) ? posix.relative(directory, key) : undefined
|
|
89
89
|
files.set(key, { path: key, content: typeof content === "string" ? content : null, ...(relative ? { relative } : {}) })
|
|
90
|
-
|
|
90
|
+
state(scope).revision++
|
|
91
91
|
if (files.size > MAX_TRACKED) {
|
|
92
92
|
const oldest = files.keys().next().value
|
|
93
93
|
if (oldest !== undefined) files.delete(oldest)
|
|
94
|
-
markUncertainPatch()
|
|
94
|
+
markUncertainPatch(scope)
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
export function writesRevision() { return
|
|
99
|
-
export function markUncertainPatch() {
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
export function writesRevision(scope) { return state(scope).revision }
|
|
99
|
+
export function markUncertainPatch(scope) {
|
|
100
|
+
state(scope).uncertain = true
|
|
101
|
+
state(scope).revision++
|
|
102
102
|
}
|
|
103
|
-
export function hasUncertainPatch() { return
|
|
103
|
+
export function hasUncertainPatch(scope) { return state(scope).uncertain }
|
|
104
104
|
|
|
105
105
|
/**
|
|
106
106
|
* Look up a written file by its normalised path.
|
|
107
107
|
*/
|
|
108
|
-
export function getWrite(path, directory) {
|
|
108
|
+
export function getWrite(path, directory, scope) {
|
|
109
|
+
requireScope(scope)
|
|
109
110
|
const key = normalize(path, directory)
|
|
110
111
|
if (!key) return undefined
|
|
111
|
-
return store().get(key)
|
|
112
|
+
return store(scope).get(key)
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
/**
|
|
@@ -123,9 +124,10 @@ export function getWrite(path, directory) {
|
|
|
123
124
|
* @param {string} text
|
|
124
125
|
* @returns {{path: string}[]}
|
|
125
126
|
*/
|
|
126
|
-
export function referencedWrites(text, directory) {
|
|
127
|
+
export function referencedWrites(text, directory, scope) {
|
|
128
|
+
requireScope(scope)
|
|
127
129
|
if (typeof text !== "string" || !text) return []
|
|
128
|
-
const files = store()
|
|
130
|
+
const files = store(scope)
|
|
129
131
|
if (!files.size) return []
|
|
130
132
|
const hits = []
|
|
131
133
|
// Resolve literal shell words as paths. Dynamic shell expansion and changes
|
|
@@ -154,8 +156,8 @@ export function referencedWrites(text, directory) {
|
|
|
154
156
|
|
|
155
157
|
// Refresh from the file itself, never from proposed tool arguments. Limit the
|
|
156
158
|
// read and reject symlinks and non-regular files; failures leave it unknown.
|
|
157
|
-
export function refreshWrite(path) {
|
|
158
|
-
const entry = getWrite(path)
|
|
159
|
+
export function refreshWrite(path, scope) {
|
|
160
|
+
const entry = getWrite(path, undefined, scope)
|
|
159
161
|
if (!entry || entry.content !== null || !posix.isAbsolute(entry.path) || ambiguousPath(entry.path)) return
|
|
160
162
|
let fd
|
|
161
163
|
try {
|
|
@@ -179,7 +181,7 @@ export function refreshWrite(path) {
|
|
|
179
181
|
}
|
|
180
182
|
}
|
|
181
183
|
|
|
182
|
-
export function hasWrites() { return store().size > 0 }
|
|
184
|
+
export function hasWrites(scope) { return store(scope).size > 0 }
|
|
183
185
|
|
|
184
186
|
/**
|
|
185
187
|
* File names that are too common to be a useful signal on their own.
|
|
@@ -208,9 +210,16 @@ const COMMON_NAMES = new Set([
|
|
|
208
210
|
/**
|
|
209
211
|
* Forget everything. Used by tests.
|
|
210
212
|
*/
|
|
211
|
-
export function resetWrites() {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
213
|
+
export function resetWrites(scope) {
|
|
214
|
+
const current = state(scope)
|
|
215
|
+
current.files.clear()
|
|
216
|
+
current.uncertain = false
|
|
217
|
+
current.pending.clear()
|
|
218
|
+
current.revision++
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Only use when the scope has no remaining users, such as a finished evaluation.
|
|
222
|
+
export function releaseWrites(scope) {
|
|
223
|
+
requireScope(scope)
|
|
224
|
+
globalThis[STATE_KEY]?.delete(scope)
|
|
216
225
|
}
|