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 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
- MIT. See [LICENSE](LICENSE).
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 { resetWrites(); await rm(root, { recursive: true, force: true }) }
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 (only consumed fields).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-one-guard",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "OpenCode v1-only plugin that checks proposed tool calls for outbound data exfiltration with a local decision-model judge",
5
5
  "type": "module",
6
6
  "main": "./index.js",
package/src/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
- * Only the cache is shared. The configuration is not: each instance keeps
11
- * its own resolved settings, so a project-level override is never silently
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, so both copies of a doubly-loaded plugin see the same writes.
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 FILES_KEY = Symbol.for("oc-one-guard.written-files")
23
- const REVISION_KEY = Symbol.for("oc-one-guard.written-revision")
24
- const UNCERTAIN_KEY = Symbol.for("oc-one-guard.uncertain-patch")
25
- const PENDING_KEY = Symbol.for("oc-one-guard.pending-mutations")
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 = globalThis[PENDING_KEY] ??= new Set()
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) { globalThis[PENDING_KEY]?.delete(token) }
34
- export function hasPendingMutation() { return (globalThis[PENDING_KEY]?.size ?? 0) > 0 }
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
- globalThis[REVISION_KEY] = (globalThis[REVISION_KEY] ?? 0) + 1
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 globalThis[REVISION_KEY] ?? 0 }
99
- export function markUncertainPatch() {
100
- globalThis[UNCERTAIN_KEY] = true
101
- globalThis[REVISION_KEY] = writesRevision() + 1
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 globalThis[UNCERTAIN_KEY] === true }
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
- store().clear()
213
- globalThis[UNCERTAIN_KEY] = false
214
- globalThis[PENDING_KEY]?.clear()
215
- globalThis[REVISION_KEY] = writesRevision() + 1
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
  }