opencode-workflow-guard 1.15.1 → 1.15.3
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/docs/agents/lessons/2026-09-27-boundary-symlink-alias-escape.md +7 -0
- package/docs/agents/lessons/2026-09-27-branch-create-redirect-start-point.md +6 -0
- package/docs/agents/lessons/2026-09-27-e2e-config-sentinel-proof.md +4 -0
- package/docs/agents/lessons/2026-09-27-e2e-guard-why-policy-probes.md +6 -0
- package/docs/agents/lessons/2026-09-27-e2e-tool-surface-enumeration.md +4 -0
- package/docs/agents/lessons/2026-09-27-shell-syntax-remedy-messages.md +7 -0
- package/docs/agents/lessons/2026-09-27-stale-write-shell-seeding.md +7 -0
- package/docs/agents/lessons.md +4 -0
- package/docs/policies.md +4 -2
- package/package.json +1 -1
- package/src/lib/custom-tools.ts +58 -18
- package/src/lib/guard-dispatcher.ts +38 -3
- package/src/lib/state.ts +118 -49
- package/src/lib/tool-lifecycle.ts +15 -1
- package/src/lib/v2-plugin.ts +47 -12
- package/src/policies/boundary.ts +26 -7
- package/src/policies/changelog.ts +112 -11
- package/src/policies/git.ts +27 -1
- package/src/policies/stale-write.ts +1 -1
- package/src/workflow-guard.ts +52 -10
- package/test/test-e2e.mts +59 -4
- package/test/test.mts +334 -0
package/src/lib/state.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { resolve } from "node:path";
|
|
1
|
+
import { existsSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
3
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
4
|
import { getReviewCacheFilePath, loadReviewCache, persistReviewCache, persistVerifyCache, persistVerifyHistory } from "./audit.ts";
|
|
5
5
|
import { findGitRoot, getCachedProjectConfig, isSameGitRepo, loadProjectConfig, projectRootKey } from "./project-config.ts";
|
|
@@ -13,36 +13,21 @@ import type {
|
|
|
13
13
|
} from "./types.ts";
|
|
14
14
|
|
|
15
15
|
export let workspaceRoot = process.cwd();
|
|
16
|
-
export let workspaceRootReal = workspaceRoot;
|
|
17
16
|
interface RuntimeState {
|
|
18
17
|
workspaceRoot: string;
|
|
19
|
-
workspaceRootReal: string;
|
|
20
18
|
sdkClient: TodoSdkClient | undefined;
|
|
21
19
|
projectConfig: ProjectConfig;
|
|
22
20
|
}
|
|
23
21
|
const runtimeState = new AsyncLocalStorage<RuntimeState>();
|
|
24
22
|
|
|
25
|
-
try {
|
|
26
|
-
workspaceRootReal = realpathSync(workspaceRoot);
|
|
27
|
-
} catch {}
|
|
28
|
-
|
|
29
23
|
export function setWorkspaceRoot(root: string): void {
|
|
30
24
|
workspaceRoot = root;
|
|
31
|
-
try {
|
|
32
|
-
workspaceRootReal = realpathSync(root);
|
|
33
|
-
} catch {
|
|
34
|
-
workspaceRootReal = root;
|
|
35
|
-
}
|
|
36
25
|
}
|
|
37
26
|
|
|
38
27
|
export function getWorkspaceRoot(): string {
|
|
39
28
|
return runtimeState.getStore()?.workspaceRoot ?? workspaceRoot;
|
|
40
29
|
}
|
|
41
30
|
|
|
42
|
-
export function getWorkspaceRootReal(): string {
|
|
43
|
-
return runtimeState.getStore()?.workspaceRootReal ?? workspaceRootReal;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
31
|
export let sdkClient: TodoSdkClient | undefined;
|
|
47
32
|
|
|
48
33
|
export function setSdkClient(client: unknown): void {
|
|
@@ -54,14 +39,9 @@ export function getSdkClient(): TodoSdkClient | undefined {
|
|
|
54
39
|
}
|
|
55
40
|
|
|
56
41
|
export function runWithRuntimeState<T>(root: string, client: unknown, fn: () => T): T {
|
|
57
|
-
let realRoot = root;
|
|
58
|
-
try {
|
|
59
|
-
realRoot = realpathSync(root);
|
|
60
|
-
} catch {}
|
|
61
42
|
return runtimeState.run(
|
|
62
43
|
{
|
|
63
44
|
workspaceRoot: root,
|
|
64
|
-
workspaceRootReal: realRoot,
|
|
65
45
|
sdkClient: client as TodoSdkClient | undefined,
|
|
66
46
|
projectConfig: loadProjectConfig(root),
|
|
67
47
|
},
|
|
@@ -76,11 +56,18 @@ const workspaceMutationCounts = new Map<string, number>();
|
|
|
76
56
|
export const sessionMutationTimestamps = new Map<string, number>();
|
|
77
57
|
export const sessionMutationCounts = new Map<string, number>();
|
|
78
58
|
export const sessionWorkspaces = new Map<string, string>();
|
|
59
|
+
// Sessions whose workspace was registered from their OWN context (tool-call
|
|
60
|
+
// arguments or a host-provided per-call worktree). A session registered only
|
|
61
|
+
// from the plugin-instance default is deliberately left unmarked: concurrent
|
|
62
|
+
// loops share this process, and a value derived from the last-active plugin
|
|
63
|
+
// instance must never outrank the call's own context or the host fallback.
|
|
64
|
+
const sessionWorkspaceBound = new Set<string>();
|
|
79
65
|
|
|
80
66
|
export function setSessionWorkspace(sessionID: string, workspace: string): void {
|
|
81
67
|
if (!sessionID || !workspace) return;
|
|
82
68
|
const gitRoot = findGitRoot(workspace);
|
|
83
69
|
sessionWorkspaces.set(sessionID, gitRoot ?? projectRootKey(workspace));
|
|
70
|
+
sessionWorkspaceBound.add(sessionID);
|
|
84
71
|
}
|
|
85
72
|
|
|
86
73
|
export function getSessionWorkspace(sessionID?: string): string | undefined {
|
|
@@ -88,6 +75,75 @@ export function getSessionWorkspace(sessionID?: string): string | undefined {
|
|
|
88
75
|
return sessionWorkspaces.get(sessionID);
|
|
89
76
|
}
|
|
90
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The session's workspace only when it was registered from the session's own
|
|
80
|
+
* context (tool-call arguments or a host-provided per-call worktree), never a
|
|
81
|
+
* plugin-instance-default bootstrap. Policy decisions that bind evidence or
|
|
82
|
+
* confine boundaries must consult this variant: two concurrent loops share one
|
|
83
|
+
* guard process, and an unbound bootstrap entry may have been derived from
|
|
84
|
+
* another session's plugin instance.
|
|
85
|
+
*/
|
|
86
|
+
export function getSessionBoundWorkspace(sessionID?: string): string | undefined {
|
|
87
|
+
if (!sessionID) return undefined;
|
|
88
|
+
return sessionWorkspaceBound.has(sessionID) ? sessionWorkspaces.get(sessionID) : undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Register a session's workspace from a tool call's own context. Candidates in
|
|
93
|
+
* priority order: tool-argument paths (workdir/filePath — the call's own
|
|
94
|
+
* target), then a host-provided per-call worktree/directory. A session that is
|
|
95
|
+
* already registered is never re-anchored from the plugin-instance fallback:
|
|
96
|
+
* under concurrent loops that fallback can belong to a different session's
|
|
97
|
+
* workspace, which is exactly how workspaces cross-contaminate. Only a
|
|
98
|
+
* session with no registration at all is bootstrapped from the fallback, and
|
|
99
|
+
* such a bootstrap stays unbound (never used to bind review evidence).
|
|
100
|
+
*/
|
|
101
|
+
export function registerSessionWorkspaceFromToolCall(
|
|
102
|
+
sessionID: string | undefined,
|
|
103
|
+
call: { workdir?: string; filePath?: string; hostWorktree?: string; toolWorktree: string },
|
|
104
|
+
): void {
|
|
105
|
+
if (!sessionID) return;
|
|
106
|
+
const rawCandidate = call.workdir ?? call.filePath ?? call.hostWorktree;
|
|
107
|
+
if (rawCandidate) {
|
|
108
|
+
const candidatePath = isAbsolute(rawCandidate) ? rawCandidate : resolve(call.toolWorktree, rawCandidate);
|
|
109
|
+
const gitRoot = findGitRoot(candidatePath);
|
|
110
|
+
if (gitRoot) {
|
|
111
|
+
sessionWorkspaces.set(sessionID, gitRoot);
|
|
112
|
+
sessionWorkspaceBound.add(sessionID);
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (sessionWorkspaces.has(sessionID)) return;
|
|
117
|
+
const gitRoot = findGitRoot(call.toolWorktree);
|
|
118
|
+
sessionWorkspaces.set(sessionID, gitRoot ?? projectRootKey(call.toolWorktree));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The session's workspace resolved from its own bindings only: a directly
|
|
123
|
+
* registered bound workspace, else the nearest bound parent workspace
|
|
124
|
+
* (subagent sessions inherit their parent's binding for relay flows). Returns
|
|
125
|
+
* undefined when neither exists — callers that must not guess (review verdict
|
|
126
|
+
* binding) treat that as "resolve nothing, ask for an explicit directory".
|
|
127
|
+
*/
|
|
128
|
+
export async function resolveSessionBoundWorkspace(sessionID?: string): Promise<string | undefined> {
|
|
129
|
+
if (!sessionID) return undefined;
|
|
130
|
+
const direct = getSessionBoundWorkspace(sessionID);
|
|
131
|
+
if (direct) return direct;
|
|
132
|
+
try {
|
|
133
|
+
const client = getSdkClient();
|
|
134
|
+
const session = client?.session;
|
|
135
|
+
const get = session?.get;
|
|
136
|
+
if (typeof get === "function") {
|
|
137
|
+
const result = await get.call(session, { path: { id: sessionID } });
|
|
138
|
+
const parent = (result as { data?: { parentID?: unknown } } | undefined)?.data?.parentID;
|
|
139
|
+
if (typeof parent === "string" && parent && sessionWorkspaceBound.has(parent)) {
|
|
140
|
+
return sessionWorkspaces.get(parent);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} catch {}
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
91
147
|
export let lastVerify: VerifyResult | undefined;
|
|
92
148
|
export const sessionVerifyResults = new Map<string, NonNullable<VerifyResult>>();
|
|
93
149
|
|
|
@@ -228,10 +284,28 @@ export function getLastReviewResult(): typeof lastReview {
|
|
|
228
284
|
|
|
229
285
|
export function getLastReviewResultForWorkspace(root: string): typeof lastReview {
|
|
230
286
|
const workspace = projectRootKey(root);
|
|
231
|
-
const
|
|
232
|
-
(result): result is ReviewResult
|
|
287
|
+
const all = [lastReview, ...sessionReviews.values()].filter(
|
|
288
|
+
(result): result is ReviewResult & { workspace: string } => result?.workspace != null,
|
|
233
289
|
);
|
|
234
|
-
|
|
290
|
+
// Exact worktree binding wins: concurrent loops sharing one repository
|
|
291
|
+
// (different worktrees) must consume their own approvals independently —
|
|
292
|
+
// a newer sibling-worktree review must never displace this worktree's
|
|
293
|
+
// between record_review and PR creation.
|
|
294
|
+
const exact = all.filter((result) => projectRootKey(result.workspace) === workspace);
|
|
295
|
+
let preferred = exact;
|
|
296
|
+
if (preferred.length === 0) {
|
|
297
|
+
const sameRepo = all.filter((result) => isSameGitRepo(result.workspace, root));
|
|
298
|
+
if (sameRepo.length > 0) {
|
|
299
|
+
// Among same-repo candidates, prefer the review whose tracked
|
|
300
|
+
// content matches THIS worktree (the PR preflight consumes the
|
|
301
|
+
// approval bound to the tree it would publish); recency only
|
|
302
|
+
// breaks ties.
|
|
303
|
+
const prFingerprint = getTrackedWorktreeFingerprint(root);
|
|
304
|
+
const contentMatched = prFingerprint ? sameRepo.filter((result) => result.worktreeFingerprint === prFingerprint) : [];
|
|
305
|
+
preferred = contentMatched.length > 0 ? contentMatched : sameRepo;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const latest = preferred.reduce<ReviewResult | undefined>((best, result) => !best || result.timestamp > best.timestamp ? result : best, undefined);
|
|
235
309
|
if (latest) return latest;
|
|
236
310
|
const diskCached = loadReviewCache();
|
|
237
311
|
if (diskCached?.workspace && (projectRootKey(diskCached.workspace) === workspace || isSameGitRepo(diskCached.workspace, root))) {
|
|
@@ -254,41 +328,36 @@ export async function resolveEffectiveWorkspace(options: {
|
|
|
254
328
|
directory?: string;
|
|
255
329
|
fallback?: string;
|
|
256
330
|
}): Promise<string> {
|
|
331
|
+
// Intended precedence. Custom tools (resolveCallWorkspace) consult the
|
|
332
|
+
// explicit argument and the host per-call context BEFORE calling this, so
|
|
333
|
+
// inside this function the remaining order is: bound session workspace >
|
|
334
|
+
// host fallback > unbound bootstrap hint > global latch/cwd. A future
|
|
335
|
+
// direct caller passing a per-call `fallback` should apply the same
|
|
336
|
+
// host-context-first step that resolveCallWorkspace does.
|
|
257
337
|
if (options.directory) {
|
|
258
338
|
const gitRoot = findGitRoot(options.directory);
|
|
259
339
|
return gitRoot ?? projectRootKey(options.directory);
|
|
260
340
|
}
|
|
341
|
+
// 1. The session's own binding (direct, or inherited from a bound parent
|
|
342
|
+
// for relay flows) wins over every ambient fallback. Concurrent loops
|
|
343
|
+
// share one guard process; a fallback derived from the last-active
|
|
344
|
+
// plugin instance must never re-anchor another session's context.
|
|
345
|
+
const bound = await resolveSessionBoundWorkspace(options.sessionID);
|
|
346
|
+
if (bound) return bound;
|
|
261
347
|
const fallbackRoot = options.fallback ? projectRootKey(options.fallback) : undefined;
|
|
262
348
|
const isFallbackFsRoot = fallbackRoot ? resolve(fallbackRoot) === resolve(fallbackRoot, "..") : true;
|
|
263
349
|
|
|
350
|
+
// 2. The call's own host-provided per-call worktree/directory.
|
|
264
351
|
if (fallbackRoot && !isFallbackFsRoot) {
|
|
265
|
-
if (options.sessionID) {
|
|
266
|
-
const sessionWs = sessionWorkspaces.get(options.sessionID);
|
|
267
|
-
if (sessionWs && isSameGitRepo(sessionWs, fallbackRoot)) {
|
|
268
|
-
return sessionWs;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
352
|
return fallbackRoot;
|
|
272
353
|
}
|
|
273
354
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const get = session?.get;
|
|
281
|
-
if (typeof get === "function") {
|
|
282
|
-
const result = await get.call(session, { path: { id: options.sessionID } });
|
|
283
|
-
const parent = (result as { data?: { parentID?: unknown } } | undefined)?.data?.parentID;
|
|
284
|
-
if (typeof parent === "string" && parent && sessionWorkspaces.has(parent)) {
|
|
285
|
-
const parentWs = sessionWorkspaces.get(parent)!;
|
|
286
|
-
sessionWorkspaces.set(options.sessionID, parentWs);
|
|
287
|
-
return parentWs;
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
} catch {}
|
|
291
|
-
}
|
|
355
|
+
// 3. Unbound bootstrap hint: the session was registered only from the
|
|
356
|
+
// plugin-instance default, so it carries no per-session authority and
|
|
357
|
+
// loses to any real fallback (step 2). It is still better than the raw
|
|
358
|
+
// global latch when the fallback is absent or the filesystem root.
|
|
359
|
+
const hintRoot = options.sessionID ? sessionWorkspaces.get(options.sessionID) : undefined;
|
|
360
|
+
if (hintRoot) return hintRoot;
|
|
292
361
|
const activeRoot = getWorkspaceRoot();
|
|
293
362
|
if (activeRoot && resolve(activeRoot) !== resolve(activeRoot, "..")) {
|
|
294
363
|
const gitRoot = findGitRoot(activeRoot);
|
|
@@ -7,6 +7,9 @@ function invocationKey(sessionID: string, callID: string): string {
|
|
|
7
7
|
|
|
8
8
|
export class ToolInvocationLifecycle {
|
|
9
9
|
private readonly postEditSnapshots = new Map<string, { root: string; snapshots: FileSnapshot[] }>();
|
|
10
|
+
// Shell-redirect pre-state snapshots: they seed freshness observations
|
|
11
|
+
// only and must never run the post-edit validators.
|
|
12
|
+
private readonly shellWriteSnapshots = new Map<string, { root: string; snapshots: FileSnapshot[] }>();
|
|
10
13
|
private readonly startedAt = new Map<string, number>();
|
|
11
14
|
private readonly readObservations = new Map<string, ReadObservation>();
|
|
12
15
|
private readonly recoveryRuns = new Map<string, number>();
|
|
@@ -44,6 +47,17 @@ export class ToolInvocationLifecycle {
|
|
|
44
47
|
return snapshots;
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
setShellWriteSnapshots(sessionID: string, callID: string, root: string, snapshots: FileSnapshot[]): void {
|
|
51
|
+
this.shellWriteSnapshots.set(invocationKey(sessionID, callID), { root, snapshots });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
takeShellWriteSnapshots(sessionID: string, callID: string): { root: string; snapshots: FileSnapshot[] } | undefined {
|
|
55
|
+
const key = invocationKey(sessionID, callID);
|
|
56
|
+
const snapshots = this.shellWriteSnapshots.get(key);
|
|
57
|
+
this.shellWriteSnapshots.delete(key);
|
|
58
|
+
return snapshots;
|
|
59
|
+
}
|
|
60
|
+
|
|
47
61
|
setRecoveryRun(sessionID: string, run: number): void {
|
|
48
62
|
this.recoveryRuns.set(sessionID, run);
|
|
49
63
|
}
|
|
@@ -56,7 +70,7 @@ export class ToolInvocationLifecycle {
|
|
|
56
70
|
|
|
57
71
|
clearSession(sessionID: string): void {
|
|
58
72
|
const prefix = `${sessionID}\0`;
|
|
59
|
-
for (const map of [this.startedAt, this.readObservations, this.postEditSnapshots]) {
|
|
73
|
+
for (const map of [this.startedAt, this.readObservations, this.postEditSnapshots, this.shellWriteSnapshots]) {
|
|
60
74
|
for (const key of map.keys()) {
|
|
61
75
|
if (key.startsWith(prefix)) map.delete(key);
|
|
62
76
|
}
|
package/src/lib/v2-plugin.ts
CHANGED
|
@@ -25,8 +25,8 @@ import { findGitRoot, reloadProjectConfig } from "./project-config.ts";
|
|
|
25
25
|
import {
|
|
26
26
|
setWorkspaceRoot,
|
|
27
27
|
setSdkClient,
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
getSessionBoundWorkspace,
|
|
29
|
+
registerSessionWorkspaceFromToolCall,
|
|
30
30
|
runWithRuntimeState,
|
|
31
31
|
getSessionMutationCount,
|
|
32
32
|
sessionVerifyResults,
|
|
@@ -48,7 +48,7 @@ import {
|
|
|
48
48
|
} from "./project-memory.ts";
|
|
49
49
|
import { secretIn } from "../policies/secrets.ts";
|
|
50
50
|
import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./custom-tools.ts";
|
|
51
|
-
import { isReadOnlyRole } from "./guard-dispatcher.ts";
|
|
51
|
+
import { isReadOnlyRole, SHELL_TOOL_NAMES } from "./guard-dispatcher.ts";
|
|
52
52
|
import { ToolInvocationLifecycle } from "./tool-lifecycle.ts";
|
|
53
53
|
import { ToolOutcomeTracker, type ToolOutcomePart } from "./tool-outcomes.ts";
|
|
54
54
|
import { EDIT_TOOL_NAMES, fetchParentSession, fetchParentSessionID, effectiveTodos } from "../policies/todo.ts";
|
|
@@ -56,7 +56,9 @@ import { currentGitBranch, isProtectedBranchName } from "../policies/git.ts";
|
|
|
56
56
|
import { checkCompletionClaims } from "../policies/completion.ts";
|
|
57
57
|
import { releaseFileClaims } from "../policies/file-claims.ts";
|
|
58
58
|
import { beginReadObservation, recordMutationObservation, recordSuccessfulRead, clearReadFingerprints } from "../policies/stale-write.ts";
|
|
59
|
-
import { editTargets, runPostEditValidators, snapshotFile } from "../policies/post-edit-validation.ts";
|
|
59
|
+
import { editTargets, runPostEditValidators, snapshotFile, type FileSnapshot } from "../policies/post-edit-validation.ts";
|
|
60
|
+
import { extractCommands, splitShellSegments } from "./shell.ts";
|
|
61
|
+
import { expandShellTargetPath, isPathOutsideWorkspace, OPENCODE_SCRATCH_DIR, redirectMutationsIn } from "../policies/boundary.ts";
|
|
60
62
|
import { audit, summarizeInput } from "./audit.ts";
|
|
61
63
|
import { asRecord, showBlockToast, isSensitiveEnvKey } from "./utils.ts";
|
|
62
64
|
import { loadedPluginVersion } from "./version.ts";
|
|
@@ -193,10 +195,14 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
193
195
|
// wrap it so the V2 registry receives a real StandardSchemaV1.
|
|
194
196
|
input: z.object(definition.args as unknown as z.ZodRawShape),
|
|
195
197
|
execute: async (rawInput: unknown, toolContext: { sessionID: string; id: string }) => {
|
|
198
|
+
// Only a session-bound workspace counts as the tool's own
|
|
199
|
+
// context; an unbound entry is a plugin-instance bootstrap
|
|
200
|
+
// and must not be presented to the tools as one.
|
|
201
|
+
const boundWorktree = getSessionBoundWorkspace(toolContext.sessionID);
|
|
196
202
|
const result = await (definition.execute as (input: unknown, context: unknown) => Promise<unknown>)(rawInput, {
|
|
197
203
|
sessionID: toolContext.sessionID,
|
|
198
|
-
worktree:
|
|
199
|
-
directory: effectiveRoot,
|
|
204
|
+
...(boundWorktree ? { worktree: boundWorktree } : {}),
|
|
205
|
+
directory: boundWorktree ?? effectiveRoot,
|
|
200
206
|
});
|
|
201
207
|
const text = typeof result === "string" ? result : (result as { output?: string } | undefined)?.output;
|
|
202
208
|
return {
|
|
@@ -218,7 +224,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
218
224
|
|
|
219
225
|
// ── Deterministic enforcement ──
|
|
220
226
|
await ctx.tool.hook("execute.before", (event) => {
|
|
221
|
-
const toolWorktree =
|
|
227
|
+
const toolWorktree = getSessionBoundWorkspace(event.sessionID) || effectiveRoot;
|
|
222
228
|
return runWithRuntimeState(toolWorktree, client, async () => {
|
|
223
229
|
const args = event.input;
|
|
224
230
|
const reason = await guardToolCall(event.tool, args, {
|
|
@@ -239,11 +245,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
239
245
|
const record = asRecord(args);
|
|
240
246
|
const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
|
|
241
247
|
const filePath = typeof record?.filePath === "string" ? record.filePath : (typeof record?.path === "string" ? record.path : undefined);
|
|
242
|
-
|
|
243
|
-
if (candidatePath) {
|
|
244
|
-
const gitRoot = findGitRoot(candidatePath);
|
|
245
|
-
if (gitRoot) setSessionWorkspace(event.sessionID, gitRoot);
|
|
246
|
-
}
|
|
248
|
+
registerSessionWorkspaceFromToolCall(event.sessionID, { workdir, filePath, toolWorktree });
|
|
247
249
|
if (event.tool === "read") {
|
|
248
250
|
const target = editTargets(args, toolWorktree)[0];
|
|
249
251
|
if (target) {
|
|
@@ -255,6 +257,31 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
255
257
|
const snapshots = editTargets(args, toolWorktree).map(snapshotFile);
|
|
256
258
|
if (snapshots.length) toolLifecycle.setPostEditSnapshots(event.sessionID, event.id, toolWorktree, snapshots);
|
|
257
259
|
}
|
|
260
|
+
if (SHELL_TOOL_NAMES.has(event.tool)) {
|
|
261
|
+
// An allowed shell redirect authors the target's bytes exactly
|
|
262
|
+
// like edit/write (LL-003), so pre-register each redirect
|
|
263
|
+
// target's pre-state and seed the session's freshness
|
|
264
|
+
// observation after the call succeeds. Observation only: shell
|
|
265
|
+
// writes never run the post-edit validators. (V1 parity.)
|
|
266
|
+
const shellWorkdir = typeof record?.workdir === "string" ? record.workdir : undefined;
|
|
267
|
+
const shellCwd = shellWorkdir ? resolve(toolWorktree, shellWorkdir) : toolWorktree;
|
|
268
|
+
const snapshots: FileSnapshot[] = [];
|
|
269
|
+
for (const command of extractCommands(args)) {
|
|
270
|
+
for (const segment of splitShellSegments(command)) {
|
|
271
|
+
for (const mutation of redirectMutationsIn(segment)) {
|
|
272
|
+
const target = mutation.target ?? "";
|
|
273
|
+
if (!target || target === "/dev/null") continue;
|
|
274
|
+
const expanded = expandShellTargetPath(target);
|
|
275
|
+
if (!expanded) continue;
|
|
276
|
+
const resolvedTarget = resolve(shellCwd, expanded);
|
|
277
|
+
if (resolvedTarget === OPENCODE_SCRATCH_DIR || resolvedTarget.startsWith(OPENCODE_SCRATCH_DIR + "/")) continue;
|
|
278
|
+
if (isPathOutsideWorkspace(resolvedTarget, toolWorktree)) continue;
|
|
279
|
+
snapshots.push(snapshotFile(resolvedTarget));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (snapshots.length) toolLifecycle.setShellWriteSnapshots(event.sessionID, event.id, toolWorktree, snapshots);
|
|
284
|
+
}
|
|
258
285
|
});
|
|
259
286
|
});
|
|
260
287
|
|
|
@@ -279,6 +306,14 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
279
306
|
if (observation) recordSuccessfulRead(observation, event.sessionID);
|
|
280
307
|
}
|
|
281
308
|
if (event.status === "error") return;
|
|
309
|
+
const shellPending = toolLifecycle.takeShellWriteSnapshots(event.sessionID, event.id);
|
|
310
|
+
if (shellPending) {
|
|
311
|
+
// Seed the session's observation of the bytes its shell redirect
|
|
312
|
+
// authored (LL-003); observation-only, validators are not run for
|
|
313
|
+
// shell writes, and the digest gate keeps no-op calls from
|
|
314
|
+
// seeding. (V1 parity.)
|
|
315
|
+
for (const before of shellPending.snapshots) recordMutationObservation(before.path, event.sessionID, before.digest);
|
|
316
|
+
}
|
|
282
317
|
const pending = toolLifecycle.takePostEditSnapshots(event.sessionID, event.id);
|
|
283
318
|
if (!pending) return;
|
|
284
319
|
await runWithRuntimeState(pending.root, client, async () => {
|
package/src/policies/boundary.ts
CHANGED
|
@@ -4,7 +4,6 @@ import { dirname, join, resolve } from "node:path";
|
|
|
4
4
|
import type { ShellMutation } from "../lib/types.ts";
|
|
5
5
|
import {
|
|
6
6
|
getWorkspaceRoot,
|
|
7
|
-
getWorkspaceRootReal,
|
|
8
7
|
recordMutation,
|
|
9
8
|
} from "../lib/state.ts";
|
|
10
9
|
import { prepareRedirectResidue, shellWords, unwrapShellCommand } from "../lib/shell.ts";
|
|
@@ -86,16 +85,32 @@ export function isPathOutsideWorkspace(targetPath: string, root: string): boolea
|
|
|
86
85
|
if (expanded === null) return true;
|
|
87
86
|
const resolved = resolve(root, expanded);
|
|
88
87
|
const normalizedRoot = root.endsWith("/") ? root : root + "/";
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
88
|
+
// A path is outside only when BOTH the lexical resolution and the
|
|
89
|
+
// canonical resolution are outside. The lexical verdict is therefore
|
|
90
|
+
// held instead of returned early: symlink aliases of the workspace
|
|
91
|
+
// itself (e.g. /home -> /var/home) resolve lexically outside but
|
|
92
|
+
// canonically inside, and must be accepted, while the canonical checks
|
|
93
|
+
// below keep blocking symlinks inside the workspace that point outside.
|
|
94
|
+
// When canonicalization is indeterminate (no existing ancestor), the
|
|
95
|
+
// lexical verdict stands (fail-closed).
|
|
96
|
+
const lexicallyOutside = resolved !== root && !resolved.startsWith(normalizedRoot);
|
|
97
|
+
// The boundary is resolved against the workspace THIS call declares (the
|
|
98
|
+
// `root` argument), never against a process-global latched root: concurrent
|
|
99
|
+
// loops share one guard process, and a latched root must not redefine
|
|
100
|
+
// another session's boundary. Symlink-awareness is preserved (ancestor
|
|
101
|
+
// walk below) by canonicalizing the declared root itself.
|
|
102
|
+
let realRootVal = root;
|
|
103
|
+
try {
|
|
104
|
+
realRootVal = realpathSync(root);
|
|
105
|
+
} catch {}
|
|
106
|
+
let canonicallyInside = false;
|
|
93
107
|
try {
|
|
94
108
|
const real = realpathSync(resolved);
|
|
95
109
|
const realRoot = realRootVal.endsWith("/") ? realRootVal : realRootVal + "/";
|
|
96
110
|
if (real !== realRootVal && !real.startsWith(realRoot)) {
|
|
97
111
|
return true;
|
|
98
112
|
}
|
|
113
|
+
canonicallyInside = true;
|
|
99
114
|
} catch {
|
|
100
115
|
let curr = resolved;
|
|
101
116
|
while (curr && curr !== "/" && curr !== ".") {
|
|
@@ -108,11 +123,15 @@ export function isPathOutsideWorkspace(targetPath: string, root: string): boolea
|
|
|
108
123
|
if (realParent !== realRootVal && !realParent.startsWith(realRoot)) {
|
|
109
124
|
return true;
|
|
110
125
|
}
|
|
126
|
+
canonicallyInside = true;
|
|
111
127
|
break;
|
|
112
128
|
} catch {}
|
|
113
129
|
}
|
|
114
130
|
}
|
|
115
|
-
|
|
131
|
+
// Relocated lexical verdict: reached only when canonicalization did not
|
|
132
|
+
// find the path outside, so a lexically-outside path that canonicalized
|
|
133
|
+
// inside (a symlink alias of the workspace) is no longer blocked here.
|
|
134
|
+
return lexicallyOutside && !canonicallyInside;
|
|
116
135
|
}
|
|
117
136
|
|
|
118
137
|
export function extractPatchPaths(patchText: string): string[] {
|
|
@@ -154,7 +173,7 @@ export function teeTargetsIn(segment: string): string[] {
|
|
|
154
173
|
return targets;
|
|
155
174
|
}
|
|
156
175
|
|
|
157
|
-
function redirectMutationsIn(segment: string): ShellMutation[] {
|
|
176
|
+
export function redirectMutationsIn(segment: string): ShellMutation[] {
|
|
158
177
|
const mutations: ShellMutation[] = [];
|
|
159
178
|
// Redirect detection runs on the quote-stripped residue: quoted data
|
|
160
179
|
// spans are command data and their ">" characters are not redirects,
|
|
@@ -193,16 +193,114 @@ export function prBodyHasLiteralLineBreakEscapes(command: string): boolean {
|
|
|
193
193
|
return false;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Top-level package.json sections whose change requires a matching lockfile
|
|
198
|
+
* update: the dependency maps (including peerDependenciesMeta) plus every
|
|
199
|
+
* other section a supported package manager records in its lockfile
|
|
200
|
+
* (engines/packageManager, overrides/resolutions, the pnpm section with
|
|
201
|
+
* pnpm.overrides and pnpm.patchedDependencies, the workspace graph, and the
|
|
202
|
+
* project name/version stored in the lockfile root). A scripts-only or
|
|
203
|
+
* metadata-only manifest edit regenerates the lockfile byte-identical —
|
|
204
|
+
* demanding a lockfile change for it is unsatisfiable and must not block PR
|
|
205
|
+
* creation.
|
|
206
|
+
*/
|
|
207
|
+
const LOCKFILE_RELEVANT_MANIFEST_KEYS = new Set([
|
|
208
|
+
"name",
|
|
209
|
+
"version",
|
|
210
|
+
"dependencies",
|
|
211
|
+
"devDependencies",
|
|
212
|
+
"optionalDependencies",
|
|
213
|
+
"peerDependencies",
|
|
214
|
+
"peerDependenciesMeta",
|
|
215
|
+
"engines",
|
|
216
|
+
"packageManager",
|
|
217
|
+
"overrides",
|
|
218
|
+
"resolutions",
|
|
219
|
+
"workspaces",
|
|
220
|
+
// pnpm-specific top-level section: pnpm.overrides and
|
|
221
|
+
// pnpm.patchedDependencies are recorded in pnpm-lock.yaml.
|
|
222
|
+
"pnpm",
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
/** Order-insensitive JSON comparison: dependency maps are semantically
|
|
226
|
+
* unordered, so reordering keys alone is not a lockfile-relevant change. */
|
|
227
|
+
function canonicalJsonValue(value: unknown): string {
|
|
228
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "undefined";
|
|
229
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJsonValue).join(",")}]`;
|
|
230
|
+
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
231
|
+
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJsonValue(item)}`).join(",")}}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function manifestTouchesLockfileRelevantKeys(oldSource: string | undefined, newSource: string | undefined): boolean {
|
|
235
|
+
try {
|
|
236
|
+
const parse = (source: string | undefined): Record<string, unknown> | undefined => {
|
|
237
|
+
if (source === undefined) return undefined;
|
|
238
|
+
const parsed = JSON.parse(source) as unknown;
|
|
239
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not a manifest object");
|
|
240
|
+
return parsed as Record<string, unknown>;
|
|
241
|
+
};
|
|
242
|
+
const oldManifest = parse(oldSource);
|
|
243
|
+
const newManifest = parse(newSource);
|
|
244
|
+
const keys = new Set([...Object.keys(oldManifest ?? {}), ...Object.keys(newManifest ?? {})]);
|
|
245
|
+
for (const key of keys) {
|
|
246
|
+
if (!LOCKFILE_RELEVANT_MANIFEST_KEYS.has(key)) continue;
|
|
247
|
+
if (canonicalJsonValue(oldManifest?.[key]) !== canonicalJsonValue(newManifest?.[key])) return true;
|
|
248
|
+
}
|
|
249
|
+
return false;
|
|
250
|
+
} catch {
|
|
251
|
+
return true; // unreadable manifest: fail closed and keep demanding the lockfile
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function manifestSourceAt(root: string, revision: string, manifestPath: string): string | undefined {
|
|
256
|
+
const res = spawnSync("git", ["show", `${revision}:${manifestPath}`], { cwd: root, encoding: "utf8", timeout: 10_000 });
|
|
257
|
+
if (res.status !== 0) return undefined;
|
|
258
|
+
return res.stdout;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function workingTreeManifestSource(root: string, manifestPath: string): string | undefined {
|
|
262
|
+
try {
|
|
263
|
+
return readFileSync(resolve(root, manifestPath), "utf8");
|
|
264
|
+
} catch {
|
|
265
|
+
return undefined; // deleted from the working tree
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Whether a manifest change demands a lockfile update. package.json is
|
|
271
|
+
* compared section-by-section (scripts-only edits pass); Cargo.toml and
|
|
272
|
+
* go.mod have no dependency-section parser here, so any change to them
|
|
273
|
+
* conservatively demands the lockfile (unchanged behavior).
|
|
274
|
+
*/
|
|
275
|
+
function manifestChangeRequiresLockfile(root: string, mergeBaseCommit: string | undefined, manifestPath: string): boolean {
|
|
276
|
+
if (!/(?:^|\/)package\.json$/.test(manifestPath)) return true;
|
|
277
|
+
let oldSource: string | undefined;
|
|
278
|
+
if (mergeBaseCommit) {
|
|
279
|
+
// Absent at the merge base means the manifest was introduced on this
|
|
280
|
+
// branch (or is untracked): the pre-branch state had no manifest, so
|
|
281
|
+
// every lockfile-relevant key it has counts as new.
|
|
282
|
+
oldSource = manifestSourceAt(root, mergeBaseCommit, manifestPath);
|
|
283
|
+
} else {
|
|
284
|
+
oldSource = manifestSourceAt(root, "HEAD", manifestPath);
|
|
285
|
+
}
|
|
286
|
+
const newSource = workingTreeManifestSource(root, manifestPath);
|
|
287
|
+
if (oldSource === undefined && newSource === undefined) return true;
|
|
288
|
+
return manifestTouchesLockfileRelevantKeys(oldSource, newSource);
|
|
289
|
+
}
|
|
290
|
+
|
|
196
291
|
export function checkLockfileSync(root: string): { isOutOfSync: boolean; manifest?: string; lockfile?: string; reason?: string } {
|
|
197
292
|
try {
|
|
198
293
|
let modifiedFiles: string[] = [];
|
|
294
|
+
let mergeBaseCommit: string | undefined;
|
|
199
295
|
const baseCandidates = ["origin/HEAD", "origin/main", "origin/master", "main", "master"];
|
|
200
296
|
for (const base of baseCandidates) {
|
|
201
297
|
const mergeBase = spawnSync("git", ["merge-base", "HEAD", base], { cwd: root, encoding: "utf8", timeout: 10_000 });
|
|
202
298
|
if (mergeBase.status !== 0 || !mergeBase.stdout.trim()) continue;
|
|
203
|
-
const
|
|
299
|
+
const baseCommit = mergeBase.stdout.trim();
|
|
300
|
+
const diff = spawnSync("git", ["diff", "--name-only", `${baseCommit}...HEAD`], { cwd: root, encoding: "utf8", timeout: 10_000 });
|
|
204
301
|
if (diff.status === 0 && diff.stdout.trim()) {
|
|
205
302
|
modifiedFiles = diff.stdout.split("\n").map((f) => f.trim()).filter(Boolean);
|
|
303
|
+
mergeBaseCommit = baseCommit;
|
|
206
304
|
break;
|
|
207
305
|
}
|
|
208
306
|
}
|
|
@@ -236,16 +334,19 @@ export function checkLockfileSync(root: string): { isOutOfSync: boolean; manifes
|
|
|
236
334
|
];
|
|
237
335
|
|
|
238
336
|
for (const item of manifestToLockfiles) {
|
|
239
|
-
const
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
337
|
+
const manifestPaths = modifiedFiles.filter((f) => item.manifest.test(f));
|
|
338
|
+
if (manifestPaths.length === 0) continue;
|
|
339
|
+
const hasLockfile = modifiedFiles.some((f) => item.lockfiles.test(f));
|
|
340
|
+
if (hasLockfile) continue;
|
|
341
|
+
// Only a lockfile-relevant manifest change demands the lockfile: a
|
|
342
|
+
// scripts-only edit regenerates it byte-identical, so demanding one
|
|
343
|
+
// is unsatisfiable.
|
|
344
|
+
if (manifestPaths.some((manifestPath) => manifestChangeRequiresLockfile(root, mergeBaseCommit, manifestPath))) {
|
|
345
|
+
return {
|
|
346
|
+
isOutOfSync: true,
|
|
347
|
+
manifest: item.name,
|
|
348
|
+
reason: `Package manifest '${item.name}' was modified without updating its corresponding lockfile. Run the package manager install/lock command before opening a PR.`,
|
|
349
|
+
};
|
|
249
350
|
}
|
|
250
351
|
}
|
|
251
352
|
} catch {}
|
package/src/policies/git.ts
CHANGED
|
@@ -346,6 +346,24 @@ const BRANCH_CREATION_VALUELESS_FLAGS = new Set([
|
|
|
346
346
|
"--no-track",
|
|
347
347
|
]);
|
|
348
348
|
|
|
349
|
+
// Shell redirections never determine the branch start point. Exact tokens
|
|
350
|
+
// cover separated operators (`2> /dev/null`); the attached pattern covers
|
|
351
|
+
// operators fused with their target (`2>/dev/null`, `2>&1`, `&>/dev/null`).
|
|
352
|
+
// Valid refs cannot contain `<` or `>`, so no start point can be mistaken
|
|
353
|
+
// for a redirection token.
|
|
354
|
+
const BRANCH_REDIRECTION_OPERATORS = new Set([
|
|
355
|
+
"<",
|
|
356
|
+
">",
|
|
357
|
+
"<<",
|
|
358
|
+
">>",
|
|
359
|
+
"<<<",
|
|
360
|
+
"<&",
|
|
361
|
+
">&",
|
|
362
|
+
"&>",
|
|
363
|
+
"&>>",
|
|
364
|
+
]);
|
|
365
|
+
const BRANCH_REDIRECTION_ATTACHED_RE = /^(?:&|\d{1,2})?(?:>>|>|<<<|<<|>&|<&|<)[^<>]*$/;
|
|
366
|
+
|
|
349
367
|
// Classifies the optional start-point operand of an already-matched branch
|
|
350
368
|
// creation invocation (`git switch -c|--create` / `git checkout -b`). Returns
|
|
351
369
|
// the explicit start point when present, `{ start: undefined }` when the
|
|
@@ -357,7 +375,15 @@ export function branchCreationStartPoint(rest: string): { start?: string } | und
|
|
|
357
375
|
const tokens = rest.split(/\s+/).filter(Boolean);
|
|
358
376
|
let sawCreate = false;
|
|
359
377
|
let named = false;
|
|
360
|
-
for (
|
|
378
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
379
|
+
const token = tokens[i]!;
|
|
380
|
+
if (BRANCH_REDIRECTION_OPERATORS.has(token) || /^\d{1,2}(?:>>|>|<<<|<<|>&|<&|<)$/.test(token)) {
|
|
381
|
+
// Separated redirection: its target word follows and is equally
|
|
382
|
+
// irrelevant to the start point.
|
|
383
|
+
i += 1;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (BRANCH_REDIRECTION_ATTACHED_RE.test(token)) continue;
|
|
361
387
|
const indeterminate = /[$`"']/.test(token);
|
|
362
388
|
if (!sawCreate) {
|
|
363
389
|
if (token === "switch" || token === "checkout") continue;
|
|
@@ -74,7 +74,7 @@ export function staleWriteReason(path: string, sessionID: string): string | unde
|
|
|
74
74
|
const observed = reads.get(sessionID)?.get(canonical);
|
|
75
75
|
if (!observed) {
|
|
76
76
|
if (!current) return undefined;
|
|
77
|
-
return `Blocked: existing file '${canonical}' has not been read by this session. Re-read it before editing or overwriting it.`;
|
|
77
|
+
return `Blocked: existing file '${canonical}' has not been read by this session. Re-read it before editing or overwriting it. If another session may own it, coordinate before writing.`;
|
|
78
78
|
}
|
|
79
79
|
if (!current || !equal(observed, current)) {
|
|
80
80
|
return `Blocked: file '${canonical}' changed since this session read it. Re-read it before editing or overwriting it.`;
|