opencode-workflow-guard 1.14.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/agents/lessons.md +12 -0
- package/docs/installation.md +6 -0
- package/docs/policies.md +10 -4
- package/docs/troubleshooting.md +15 -0
- package/package.json +1 -1
- package/src/lib/custom-tools.ts +60 -16
- package/src/lib/guard-dispatcher.ts +95 -24
- package/src/lib/hygiene.ts +66 -0
- package/src/lib/state.ts +20 -11
- package/src/lib/types.ts +29 -1
- package/src/lib/v2-plugin.ts +6 -2
- package/src/lib/verify.ts +33 -13
- package/src/lib/version.ts +21 -0
- package/src/lib/worktree.ts +123 -9
- package/src/policies/boundary.ts +36 -0
- package/src/policies/file-claims.ts +68 -2
- package/src/policies/shell-safety.ts +32 -12
- package/src/policies/stale-write.ts +17 -0
- package/src/workflow-guard.ts +17 -2
- package/test/test.mts +759 -9
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { checkBranchBaseIsUpToDate, currentGitBranch } from "../policies/git.ts";
|
|
3
|
+
import { getCleanGitEnv } from "./worktree.ts";
|
|
4
|
+
|
|
5
|
+
const MERGED_BASES = ["origin/main", "origin/master", "main", "master"];
|
|
6
|
+
const MAX_LISTED_BRANCHES = 10;
|
|
7
|
+
|
|
8
|
+
export interface GitHygieneSnapshot {
|
|
9
|
+
/** Local branches fully merged into the mainline, capped for bounded output. */
|
|
10
|
+
mergedLocalBranches: string[];
|
|
11
|
+
/** True count of merged local branches (the list above may be capped). */
|
|
12
|
+
mergedLocalBranchCount: number;
|
|
13
|
+
/** Worktree admin entries whose directory is missing (clearable via cleanupGitWorktree or git worktree prune). */
|
|
14
|
+
prunableWorktreeCount: number;
|
|
15
|
+
/** Commits the current branch's base line is ahead by, when a remote base exists. */
|
|
16
|
+
baseBehind?: number;
|
|
17
|
+
baseRef?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function gitOut(args: string[], root: string): string | undefined {
|
|
21
|
+
const res = spawnSync("git", args, {
|
|
22
|
+
cwd: root,
|
|
23
|
+
encoding: "utf8",
|
|
24
|
+
timeout: 10_000,
|
|
25
|
+
env: getCleanGitEnv(),
|
|
26
|
+
});
|
|
27
|
+
if (res.status !== 0) return undefined;
|
|
28
|
+
return res.stdout;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read-only accumulation snapshot for guard_status: local branches already
|
|
33
|
+
* merged into the mainline, prunable worktree admin entries, and how far the
|
|
34
|
+
* current branch's base line is ahead. Hygiene itself stays manual — the
|
|
35
|
+
* guard reports, it never prunes or deletes branches on its own.
|
|
36
|
+
*/
|
|
37
|
+
export function gitHygieneSnapshot(root: string): GitHygieneSnapshot | undefined {
|
|
38
|
+
const current = currentGitBranch(root);
|
|
39
|
+
if (current === undefined) {
|
|
40
|
+
// Not a git repository (or git unavailable): no hygiene snapshot.
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const snapshot: GitHygieneSnapshot = { mergedLocalBranches: [], mergedLocalBranchCount: 0, prunableWorktreeCount: 0 };
|
|
44
|
+
for (const base of MERGED_BASES) {
|
|
45
|
+
const out = gitOut(["branch", "--merged", base, "--format=%(refname:short)"], root);
|
|
46
|
+
if (out === undefined) continue;
|
|
47
|
+
const branches = out
|
|
48
|
+
.split("\n")
|
|
49
|
+
.map((line) => line.trim())
|
|
50
|
+
.filter((name) => name.length > 0 && name !== base && name !== current)
|
|
51
|
+
.sort();
|
|
52
|
+
snapshot.mergedLocalBranchCount = branches.length;
|
|
53
|
+
snapshot.mergedLocalBranches = branches.slice(0, MAX_LISTED_BRANCHES);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
const worktreeOut = gitOut(["worktree", "list", "--porcelain"], root);
|
|
57
|
+
if (worktreeOut !== undefined) {
|
|
58
|
+
snapshot.prunableWorktreeCount = worktreeOut.split("\n").filter((line) => line.startsWith("prunable")).length;
|
|
59
|
+
}
|
|
60
|
+
const behind = checkBranchBaseIsUpToDate(root);
|
|
61
|
+
if (behind.isBehind) {
|
|
62
|
+
snapshot.baseBehind = behind.count;
|
|
63
|
+
snapshot.baseRef = behind.baseRef;
|
|
64
|
+
}
|
|
65
|
+
return snapshot;
|
|
66
|
+
}
|
package/src/lib/state.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { 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";
|
|
6
|
-
import { snipVerifyOutput, getCurrentGitCommitHash, getGitStatusSummary, getGitWorktreeFingerprint } from "./verify.ts";
|
|
6
|
+
import { snipVerifyOutput, getCurrentGitCommitHash, getGitStatusSummary, getGitWorktreeFingerprint, getTrackedWorktreeFingerprint } from "./verify.ts";
|
|
7
7
|
import { normalizeLiveControlPlaneRoots } from "./live-control-plane.ts";
|
|
8
8
|
import type {
|
|
9
9
|
TodoSdkClient,
|
|
@@ -104,15 +104,14 @@ export function recordMutation(sessionID?: string, actorSessionID?: string): voi
|
|
|
104
104
|
sessionMutationTimestamps.set(id, lastMutationTimestamp);
|
|
105
105
|
sessionMutationCounts.set(id, (sessionMutationCounts.get(id) ?? 0) + 1);
|
|
106
106
|
sessionVerifyResults.delete(id);
|
|
107
|
-
sessionReviews.delete(id);
|
|
108
|
-
}
|
|
109
|
-
if (!lastReview?.targetSessionID || lastReview.targetSessionID === sessionID || lastReview.targetSessionID === actorSessionID) {
|
|
110
|
-
lastReview = undefined;
|
|
111
|
-
try {
|
|
112
|
-
const p = getReviewCacheFilePath();
|
|
113
|
-
if (existsSync(p)) unlinkSync(p);
|
|
114
|
-
} catch {}
|
|
115
107
|
}
|
|
108
|
+
// Review evidence is intentionally NOT erased here. Recorded approvals bind
|
|
109
|
+
// to the reviewed tracked content (commit hash + tracked worktree
|
|
110
|
+
// fingerprint), so freshness is re-evaluated against content at
|
|
111
|
+
// consumption time. Erasing on any mutation made an unrelated untracked
|
|
112
|
+
// scratch-file deletion (or a mutation in a sibling worktree) invalidate a
|
|
113
|
+
// valid approval and let a re-record silently fail to stick; any change to
|
|
114
|
+
// the reviewed tracked content still makes the evidence stale.
|
|
116
115
|
}
|
|
117
116
|
|
|
118
117
|
export function getMutationCount(sessionID?: string): number {
|
|
@@ -195,7 +194,7 @@ export function recordReviewResult(
|
|
|
195
194
|
passed: boolean,
|
|
196
195
|
targetSessionID?: string,
|
|
197
196
|
workspace?: string,
|
|
198
|
-
):
|
|
197
|
+
): ReviewResult {
|
|
199
198
|
const root = projectRootKey(workspace ?? getWorkspaceRoot());
|
|
200
199
|
lastReview = {
|
|
201
200
|
reviewer,
|
|
@@ -206,12 +205,15 @@ export function recordReviewResult(
|
|
|
206
205
|
workspace: root,
|
|
207
206
|
commitHash: getCurrentGitCommitHash(root),
|
|
208
207
|
gitStatus: getGitStatusSummary(root),
|
|
209
|
-
|
|
208
|
+
// Bind to tracked repository content only: the review covered the
|
|
209
|
+
// reviewed diff, and untracked scratch files are not part of it.
|
|
210
|
+
worktreeFingerprint: getTrackedWorktreeFingerprint(root),
|
|
210
211
|
};
|
|
211
212
|
if (targetSessionID) sessionReviews.set(targetSessionID, lastReview);
|
|
212
213
|
if (passed) {
|
|
213
214
|
persistReviewCache(lastReview);
|
|
214
215
|
}
|
|
216
|
+
return lastReview;
|
|
215
217
|
}
|
|
216
218
|
|
|
217
219
|
export function getLastReviewResult(): typeof lastReview {
|
|
@@ -328,6 +330,13 @@ export function isReviewRequired(root: string): boolean {
|
|
|
328
330
|
return cfg.requireReview !== false;
|
|
329
331
|
}
|
|
330
332
|
|
|
333
|
+
export function isSubagentReviewRequired(root: string): boolean {
|
|
334
|
+
const env = process.env.WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW?.toLowerCase();
|
|
335
|
+
if (env === "0" || env === "false" || env === "off") return false;
|
|
336
|
+
if (env === "1" || env === "true" || env === "on") return true;
|
|
337
|
+
return getProjectConfig(root).requireSubagentReview === true;
|
|
338
|
+
}
|
|
339
|
+
|
|
331
340
|
export function isDocumentationRequired(root: string): boolean {
|
|
332
341
|
if (process.env.WORKFLOW_GUARD_REQUIRE_DOCS === "1") return true;
|
|
333
342
|
const cfg = getProjectConfig(root);
|
package/src/lib/types.ts
CHANGED
|
@@ -9,7 +9,14 @@ export interface TodoSdkClient {
|
|
|
9
9
|
};
|
|
10
10
|
session?: {
|
|
11
11
|
todo?: (opts: { path: { id: string } }) => Promise<{ data?: unknown }>;
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The returned record's `data.time.idle` (ms epoch, server-recorded
|
|
14
|
+
* when a session's turn ends; absent/zero while mid-turn) is the
|
|
15
|
+
* stale-claim staleness boundary in src/policies/file-claims.ts. The
|
|
16
|
+
* field is assumption-typed because the minimal SDK surface here does
|
|
17
|
+
* not declare it; a missing, zero, or non-numeric value fails closed.
|
|
18
|
+
*/
|
|
19
|
+
get?: (opts: { path: { id: string } }) => Promise<{ data?: { parentID?: unknown; title?: unknown; time?: { idle?: unknown } } }>;
|
|
13
20
|
promptAsync?: (opts: {
|
|
14
21
|
path: { id: string };
|
|
15
22
|
body: { messageID?: string; parts: Array<{ type: "text"; text: string; synthetic?: boolean }> };
|
|
@@ -34,6 +41,14 @@ export interface ProjectConfig {
|
|
|
34
41
|
timeoutMs?: number;
|
|
35
42
|
}>;
|
|
36
43
|
requireReview?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Opt-in strict mode for secondary reviews: when true, record_review
|
|
46
|
+
* rejects approvals recorded by the root session itself, so a passing
|
|
47
|
+
* verdict must come from a subagent session with a parent. Unset or false
|
|
48
|
+
* keeps the documented relay flow where any session may record a verdict
|
|
49
|
+
* that is then bound to the reviewed content.
|
|
50
|
+
*/
|
|
51
|
+
requireSubagentReview?: boolean;
|
|
37
52
|
requireDocumentation?: boolean;
|
|
38
53
|
/**
|
|
39
54
|
* Absolute paths of the LIVE control plane (runtime `~/.config/opencode`,
|
|
@@ -122,6 +137,8 @@ export interface AuditEntry {
|
|
|
122
137
|
evidence?: {
|
|
123
138
|
mutation?: boolean;
|
|
124
139
|
targetPath?: string;
|
|
140
|
+
/** Owner of a released stale file claim (stale_claim_takeover audits). */
|
|
141
|
+
claimOwnerSessionID?: string;
|
|
125
142
|
verification?: {
|
|
126
143
|
command: string;
|
|
127
144
|
passed: boolean;
|
|
@@ -130,6 +147,17 @@ export interface AuditEntry {
|
|
|
130
147
|
};
|
|
131
148
|
allowLive?: boolean;
|
|
132
149
|
reviewVerdict?: "approved" | "changes_requested" | "rejected";
|
|
150
|
+
evidenceId?: string;
|
|
151
|
+
reviewer?: string;
|
|
152
|
+
reviewTimestamp?: number;
|
|
153
|
+
binding?: {
|
|
154
|
+
workspace?: string;
|
|
155
|
+
commitHash?: string;
|
|
156
|
+
worktreeFingerprint?: string;
|
|
157
|
+
recorderSessionID?: string;
|
|
158
|
+
recorderRole?: "root" | "subagent";
|
|
159
|
+
targetSessionID?: string;
|
|
160
|
+
};
|
|
133
161
|
};
|
|
134
162
|
}
|
|
135
163
|
|
package/src/lib/v2-plugin.ts
CHANGED
|
@@ -55,10 +55,11 @@ import { EDIT_TOOL_NAMES, fetchParentSession, fetchParentSessionID, effectiveTod
|
|
|
55
55
|
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
|
-
import { beginReadObservation, recordSuccessfulRead, clearReadFingerprints } from "../policies/stale-write.ts";
|
|
58
|
+
import { beginReadObservation, recordMutationObservation, recordSuccessfulRead, clearReadFingerprints } from "../policies/stale-write.ts";
|
|
59
59
|
import { editTargets, runPostEditValidators, snapshotFile } from "../policies/post-edit-validation.ts";
|
|
60
60
|
import { audit, summarizeInput } from "./audit.ts";
|
|
61
61
|
import { asRecord, showBlockToast, isSensitiveEnvKey } from "./utils.ts";
|
|
62
|
+
import { loadedPluginVersion } from "./version.ts";
|
|
62
63
|
import { clearContinuationState, continueUnfinishedSession, isGeneratedContinuationMessage, recordUserMessage } from "../policies/continuation.ts";
|
|
63
64
|
import { createRecoveryCheckpoint, finalizeRecoveryCheckpoint, nextRecoveryRun } from "./checkpoint.ts";
|
|
64
65
|
import { buildCompactionContext, guardToolCall } from "../workflow-guard.ts";
|
|
@@ -175,7 +176,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
175
176
|
}
|
|
176
177
|
|
|
177
178
|
try {
|
|
178
|
-
console.log(`[workflow-guard] info: Workflow Guard plugin initialized for ${effectiveRoot}; provenance is plugin-local in V2`);
|
|
179
|
+
console.log(`[workflow-guard] info: Workflow Guard v${loadedPluginVersion() ?? "unknown"} plugin initialized for ${effectiveRoot}; provenance is plugin-local in V2`);
|
|
179
180
|
} catch {}
|
|
180
181
|
|
|
181
182
|
const toolLifecycle = new ToolInvocationLifecycle();
|
|
@@ -281,6 +282,9 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
|
|
|
281
282
|
const pending = toolLifecycle.takePostEditSnapshots(event.sessionID, event.id);
|
|
282
283
|
if (!pending) return;
|
|
283
284
|
await runWithRuntimeState(pending.root, client, async () => {
|
|
285
|
+
// Seed the session's observation of the bytes it just mutated so a
|
|
286
|
+
// follow-up edit needs no redundant re-read (V1 parity).
|
|
287
|
+
for (const before of pending.snapshots) recordMutationObservation(before.path, event.sessionID, before.digest);
|
|
284
288
|
const reports = await Promise.all(pending.snapshots.map((before) => runPostEditValidators(pending.root, before)));
|
|
285
289
|
const report = reports.filter((value): value is string => Boolean(value)).join("\n\n");
|
|
286
290
|
if (!report) return;
|
package/src/lib/verify.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
2
|
+
import { createHash, type Hash } from "node:crypto";
|
|
3
3
|
import { lstatSync, readFileSync, readlinkSync, existsSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { dynamicShellSyntaxIn, normalize } from "./shell.ts";
|
|
@@ -141,6 +141,35 @@ function warnFingerprintSkip(root: string, file: string, detail: string): void {
|
|
|
141
141
|
} catch {}
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Content hash over tracked repository content only: the index listing
|
|
146
|
+
* (staged/tracked blob hashes) plus the worktree-vs-index diff (unstaged
|
|
147
|
+
* edits). Review evidence binds to this fingerprint, not to the full one:
|
|
148
|
+
* untracked scratch files are not part of the reviewed diff, so creating,
|
|
149
|
+
* modifying, or deleting them must never invalidate a recorded approval.
|
|
150
|
+
*/
|
|
151
|
+
function trackedContentHash(root: string): Hash | undefined {
|
|
152
|
+
const staged = spawnSync("git", ["ls-files", "--stage", "-z"], {
|
|
153
|
+
cwd: root,
|
|
154
|
+
encoding: "utf8",
|
|
155
|
+
timeout: 5_000,
|
|
156
|
+
});
|
|
157
|
+
const unstaged = spawnSync("git", ["diff", "--no-ext-diff", "--"], {
|
|
158
|
+
cwd: root,
|
|
159
|
+
encoding: "utf8",
|
|
160
|
+
timeout: 5_000,
|
|
161
|
+
});
|
|
162
|
+
if (staged.status !== 0 || unstaged.status !== 0) return undefined;
|
|
163
|
+
return createHash("sha256").update(staged.stdout).update(unstaged.stdout);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function getTrackedWorktreeFingerprint(root: string): string | undefined {
|
|
167
|
+
try {
|
|
168
|
+
return trackedContentHash(root)?.digest("hex");
|
|
169
|
+
} catch {}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
144
173
|
export function getGitWorktreeFingerprint(root: string): string | undefined {
|
|
145
174
|
try {
|
|
146
175
|
// Index listing captures the exact staged/tracked content (blob hashes);
|
|
@@ -148,23 +177,14 @@ export function getGitWorktreeFingerprint(root: string): string | undefined {
|
|
|
148
177
|
// contents cover files git does not yet track. Together these bind the
|
|
149
178
|
// fingerprint to file contents regardless of whether HEAD exists, so
|
|
150
179
|
// freshly initialized (commitless) repositories are covered too.
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
encoding: "utf8",
|
|
154
|
-
timeout: 5_000,
|
|
155
|
-
});
|
|
156
|
-
const unstaged = spawnSync("git", ["diff", "--no-ext-diff", "--"], {
|
|
157
|
-
cwd: root,
|
|
158
|
-
encoding: "utf8",
|
|
159
|
-
timeout: 5_000,
|
|
160
|
-
});
|
|
180
|
+
const hash = trackedContentHash(root);
|
|
181
|
+
if (!hash) return undefined;
|
|
161
182
|
const untracked = spawnSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], {
|
|
162
183
|
cwd: root,
|
|
163
184
|
encoding: "utf8",
|
|
164
185
|
timeout: 5_000,
|
|
165
186
|
});
|
|
166
|
-
if (
|
|
167
|
-
const hash = createHash("sha256").update(staged.stdout).update(unstaged.stdout);
|
|
187
|
+
if (untracked.status !== 0) return undefined;
|
|
168
188
|
for (const file of untracked.stdout.split("\0").filter(Boolean).sort()) {
|
|
169
189
|
// The path itself is always part of the fingerprint, so a skipped
|
|
170
190
|
// entry still binds its presence even when its contents do not.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The version of the package this module was loaded from, read from the
|
|
6
|
+
* adjacent package.json (the package ships TypeScript source, so the manifest
|
|
7
|
+
* always sits two levels above src/). Fail-soft: an unreadable or malformed
|
|
8
|
+
* manifest (for example a bundled or relocated copy without package.json)
|
|
9
|
+
* yields undefined, and callers surface that absence rather than guessing.
|
|
10
|
+
* This exists because OpenCode resolves configured bare package names through
|
|
11
|
+
* Node's parent-directory lookup from its configuration location, so the
|
|
12
|
+
* loaded copy can silently differ from a globally npm-installed one.
|
|
13
|
+
*/
|
|
14
|
+
export function loadedPluginVersion(moduleUrl: string = import.meta.url): string | undefined {
|
|
15
|
+
try {
|
|
16
|
+
const manifest = JSON.parse(readFileSync(fileURLToPath(new URL("../../package.json", moduleUrl)), "utf8")) as { version?: unknown };
|
|
17
|
+
return typeof manifest.version === "string" && manifest.version ? manifest.version : undefined;
|
|
18
|
+
} catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/lib/worktree.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs";
|
|
1
|
+
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { basename, join, resolve, sep } from "node:path";
|
|
5
5
|
import { getWorkspaceRoot } from "./state.ts";
|
|
@@ -63,6 +63,89 @@ function localBranchExists(branch: string, root: string): boolean {
|
|
|
63
63
|
return res.status === 0;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Worktree timeout: WORKFLOW_GUARD_WORKTREE_TIMEOUT_MS env wins, then the
|
|
68
|
+
* 15s default. Large checkouts can exceed 15s; without this the killed
|
|
69
|
+
* `git worktree add` left the branch behind with only its stderr progress
|
|
70
|
+
* text as the reported reason.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveWorktreeTimeoutMs(): number {
|
|
73
|
+
const envValue = Number(process.env.WORKFLOW_GUARD_WORKTREE_TIMEOUT_MS);
|
|
74
|
+
return Number.isFinite(envValue) && envValue > 0 ? envValue : 15_000;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** git exit status is the failure signal; stderr is only context (progress lines land there too). */
|
|
78
|
+
function describeGitFailure(operation: string, res: SpawnSyncReturns<string>): string {
|
|
79
|
+
const status = res.status === null
|
|
80
|
+
? `null (killed${res.signal ? ` via ${res.signal}` : ""}${res.error ? `: ${res.error.message}` : ""})`
|
|
81
|
+
: String(res.status);
|
|
82
|
+
const stderrTail = (res.stderr ?? "").trim().split("\n").filter(Boolean).slice(-3).join("; ");
|
|
83
|
+
return `git ${operation} failed (exit status ${status})${stderrTail ? `: ${stderrTail}` : ""}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Rolls back the partial state of a failed `git worktree add`. git creates
|
|
88
|
+
* the branch ref before the worktree checkout, so a failure after that point
|
|
89
|
+
* (target path collision, killed checkout, hook error) leaves a dangling
|
|
90
|
+
* branch and possibly stale worktree admin entries behind. Deletion of the
|
|
91
|
+
* branch only happens when this call created it.
|
|
92
|
+
*/
|
|
93
|
+
function rollbackFailedWorktreeAdd(options: { branch: string; branchCreatedByUs: boolean; targetPath: string; existedBefore: boolean; root: string }): string {
|
|
94
|
+
const { branch, branchCreatedByUs, targetPath, existedBefore, root } = options;
|
|
95
|
+
const notes: string[] = [];
|
|
96
|
+
try {
|
|
97
|
+
const registered = (registeredWorktreePaths(root) ?? []).map((path) => resolve(path));
|
|
98
|
+
const targetRegistered = registered.includes(resolve(targetPath));
|
|
99
|
+
if (targetRegistered && !existedBefore && existsSync(targetPath)) {
|
|
100
|
+
// Only a worktree this call registered may be force-removed. A
|
|
101
|
+
// pre-existing registered worktree at the same sanitized path
|
|
102
|
+
// (duplicate name, concurrent subagents, feat/x vs feat:x) is the
|
|
103
|
+
// reason the add failed - it must never be destroyed by rollback.
|
|
104
|
+
spawnSync("git", ["worktree", "remove", "--force", targetPath], {
|
|
105
|
+
cwd: root,
|
|
106
|
+
env: getCleanGitEnv(),
|
|
107
|
+
encoding: "utf8",
|
|
108
|
+
timeout: 10_000,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (!existedBefore) {
|
|
112
|
+
// Clear stale worktree admin entries left by an interrupted add for
|
|
113
|
+
// this new path. Scoped to the new-path case so unrelated
|
|
114
|
+
// worktrees on temporarily unavailable paths are never pruned.
|
|
115
|
+
spawnSync("git", ["worktree", "prune"], {
|
|
116
|
+
cwd: root,
|
|
117
|
+
env: getCleanGitEnv(),
|
|
118
|
+
encoding: "utf8",
|
|
119
|
+
timeout: 10_000,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (!existedBefore && existsSync(targetPath)) {
|
|
123
|
+
// git may have partially populated the directory before failing.
|
|
124
|
+
// It did not exist before this call, so removing it is safe; a
|
|
125
|
+
// pre-existing path (e.g. the reason the add failed) is left alone.
|
|
126
|
+
try { rmSync(targetPath, { recursive: true, force: true }); } catch {}
|
|
127
|
+
}
|
|
128
|
+
if (branchCreatedByUs) {
|
|
129
|
+
// After the deregistration above: a branch cannot be deleted while
|
|
130
|
+
// still checked out in a worktree.
|
|
131
|
+
const deleteRes = spawnSync("git", ["branch", "-D", branch], {
|
|
132
|
+
cwd: root,
|
|
133
|
+
env: getCleanGitEnv(),
|
|
134
|
+
encoding: "utf8",
|
|
135
|
+
timeout: 5_000,
|
|
136
|
+
});
|
|
137
|
+
if (deleteRes.status !== 0) {
|
|
138
|
+
notes.push(`the created branch '${branch}' could not be rolled back and was left in place; delete it manually if it is not needed`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
if (branchCreatedByUs) {
|
|
143
|
+
notes.push(`the created branch '${branch}' may remain; delete it manually if it is not needed`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return notes.join("; ");
|
|
147
|
+
}
|
|
148
|
+
|
|
66
149
|
export function createGitWorktree(
|
|
67
150
|
branch: string,
|
|
68
151
|
baseBranch = "HEAD",
|
|
@@ -77,12 +160,13 @@ export function createGitWorktree(
|
|
|
77
160
|
|
|
78
161
|
const storageBase = getWorktreeStorageDir(root);
|
|
79
162
|
const targetPath = join(storageBase, branch.replace(/[/\\:]/g, "-"));
|
|
163
|
+
const existedBefore = existsSync(targetPath);
|
|
80
164
|
|
|
81
165
|
try {
|
|
82
166
|
mkdirSync(storageBase, { recursive: true });
|
|
83
167
|
|
|
84
|
-
const
|
|
85
|
-
const gitArgs =
|
|
168
|
+
const branchAlreadyExisted = localBranchExists(branch, root);
|
|
169
|
+
const gitArgs = branchAlreadyExisted
|
|
86
170
|
? ["worktree", "add", targetPath, branch]
|
|
87
171
|
: ["worktree", "add", "-b", branch, targetPath, baseBranch];
|
|
88
172
|
|
|
@@ -90,10 +174,12 @@ export function createGitWorktree(
|
|
|
90
174
|
cwd: root,
|
|
91
175
|
env: getCleanGitEnv(),
|
|
92
176
|
encoding: "utf8",
|
|
93
|
-
timeout:
|
|
177
|
+
timeout: resolveWorktreeTimeoutMs(),
|
|
94
178
|
});
|
|
95
179
|
if (addRes.status !== 0) {
|
|
96
|
-
|
|
180
|
+
const rollbackNote = rollbackFailedWorktreeAdd({ branch, branchCreatedByUs: !branchAlreadyExisted, targetPath, existedBefore, root });
|
|
181
|
+
const error = describeGitFailure("worktree add", addRes) + (rollbackNote ? `; ${rollbackNote}` : "");
|
|
182
|
+
return { success: false, error };
|
|
97
183
|
}
|
|
98
184
|
|
|
99
185
|
// Symlink the parent's node_modules so tooling works without a fresh install.
|
|
@@ -131,16 +217,44 @@ export function cleanupGitWorktree(
|
|
|
131
217
|
root = getWorkspaceRoot(),
|
|
132
218
|
): { success: boolean; error?: string } {
|
|
133
219
|
try {
|
|
220
|
+
const resolved = resolve(worktreePath);
|
|
221
|
+
const storageBase = resolve(getWorktreeStorageDir(root));
|
|
134
222
|
if (!existsSync(worktreePath)) {
|
|
135
|
-
|
|
223
|
+
// A registered worktree whose directory is gone (manual removal, a
|
|
224
|
+
// crashed add, external cleanup) still holds a stale admin entry that
|
|
225
|
+
// keeps it in `git worktree list` as prunable, and must still be
|
|
226
|
+
// cleanable. Anything outside storage or unregistered is refused.
|
|
227
|
+
// The primary working tree is never at risk: prune only removes
|
|
228
|
+
// admin dirs under .git/worktrees/<id>, which the main tree lacks.
|
|
229
|
+
if (resolved !== storageBase && !resolved.startsWith(storageBase + sep)) {
|
|
230
|
+
return {
|
|
231
|
+
success: false,
|
|
232
|
+
error: `Refusing to clean up '${worktreePath}': path is outside the worktree storage directory (${storageBase}).`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
const registeredMissing = registeredWorktreePaths(root);
|
|
236
|
+
if (!registeredMissing) {
|
|
237
|
+
return { success: false, error: "Failed to list registered worktrees - aborting cleanup." };
|
|
238
|
+
}
|
|
239
|
+
if (!registeredMissing.map((path) => resolve(path)).includes(resolved)) {
|
|
240
|
+
return { success: false, error: `Worktree path '${worktreePath}' does not exist.` };
|
|
241
|
+
}
|
|
242
|
+
const prune = spawnSync("git", ["worktree", "prune"], {
|
|
243
|
+
cwd: root,
|
|
244
|
+
env: getCleanGitEnv(),
|
|
245
|
+
encoding: "utf8",
|
|
246
|
+
timeout: 10_000,
|
|
247
|
+
});
|
|
248
|
+
if (prune.status !== 0) {
|
|
249
|
+
return { success: false, error: describeGitFailure("worktree prune", prune) };
|
|
250
|
+
}
|
|
251
|
+
return { success: true };
|
|
136
252
|
}
|
|
137
253
|
|
|
138
254
|
// Ownership validation: the path must live under the configured worktree
|
|
139
255
|
// storage directory AND be a registered worktree of this repository. This
|
|
140
256
|
// refuses arbitrary directories, other repos' worktrees, and the primary
|
|
141
257
|
// working tree - cleanup must never be an unrestricted delete primitive.
|
|
142
|
-
const resolved = resolve(worktreePath);
|
|
143
|
-
const storageBase = resolve(getWorktreeStorageDir(root));
|
|
144
258
|
if (resolved !== storageBase && !resolved.startsWith(storageBase + sep)) {
|
|
145
259
|
return {
|
|
146
260
|
success: false,
|
package/src/policies/boundary.ts
CHANGED
|
@@ -53,6 +53,33 @@ export function expandShellTargetPath(targetPath: string): string | null {
|
|
|
53
53
|
return out;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* A mutation/redirect target whose expansion is indeterminate (it contains an
|
|
58
|
+
* unresolvable `$VARIABLE` reference): the boundary treats such destinations
|
|
59
|
+
* as outside the workspace, and block messages attribute that cause instead
|
|
60
|
+
* of the generic escape message.
|
|
61
|
+
*/
|
|
62
|
+
export function hasUnresolvableVariable(targetPath: string): boolean {
|
|
63
|
+
return expandShellTargetPath(targetPath) === null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The OpenCode harness designates this directory as the agents' scratch
|
|
68
|
+
* space ("pre-created and approved for external access"), but the workspace
|
|
69
|
+
* boundary is workspace-absolute and does not cover it. Targets under it get
|
|
70
|
+
* a cause-attributed block message so agents stop treating the block as a
|
|
71
|
+
* guard bug and write scratch as untracked files inside the workspace
|
|
72
|
+
* instead. Message-only: the boundary decision itself is unchanged.
|
|
73
|
+
*/
|
|
74
|
+
export const OPENCODE_SCRATCH_DIR = "/tmp/opencode";
|
|
75
|
+
|
|
76
|
+
export function isEnvironmentScratchTarget(targetPath: string, root: string): boolean {
|
|
77
|
+
const expanded = expandShellTargetPath(targetPath);
|
|
78
|
+
if (expanded === null) return false;
|
|
79
|
+
const resolved = resolve(root, expanded);
|
|
80
|
+
return resolved === OPENCODE_SCRATCH_DIR || resolved.startsWith(OPENCODE_SCRATCH_DIR + "/");
|
|
81
|
+
}
|
|
82
|
+
|
|
56
83
|
export function isPathOutsideWorkspace(targetPath: string, root: string): boolean {
|
|
57
84
|
if (!targetPath) return false;
|
|
58
85
|
const expanded = expandShellTargetPath(targetPath);
|
|
@@ -372,6 +399,9 @@ export async function guardShellMutation(
|
|
|
372
399
|
return PROTECTED_PATH_REASON;
|
|
373
400
|
}
|
|
374
401
|
if (isPathOutsideWorkspace(source, root)) {
|
|
402
|
+
if (hasUnresolvableVariable(source)) {
|
|
403
|
+
return `Blocked: mv source '${source}' contains an unresolvable variable reference; indeterminate destinations are treated as outside the workspace root (${root}). Use a literal workspace-relative path.`;
|
|
404
|
+
}
|
|
375
405
|
return `Blocked: mv would remove source '${source}' from outside the workspace root (${root}). File mutations must stay within the workspace.`;
|
|
376
406
|
}
|
|
377
407
|
}
|
|
@@ -403,6 +433,12 @@ export async function guardShellMutation(
|
|
|
403
433
|
// The workspace boundary has no override: a write outside the
|
|
404
434
|
// workspace is out of bounds even with WORKFLOW_GUARD_ALLOW_LIVE
|
|
405
435
|
// (that override covers live-system commands, not the boundary).
|
|
436
|
+
if (hasUnresolvableVariable(target)) {
|
|
437
|
+
return `Blocked: shell mutation '${mutation.what}' targets '${target}', which contains an unresolvable variable reference; indeterminate destinations are treated as outside the workspace root (${root}). Use a literal workspace-relative path.`;
|
|
438
|
+
}
|
|
439
|
+
if (isEnvironmentScratchTarget(target, root)) {
|
|
440
|
+
return `Blocked: shell mutation '${mutation.what}' targets '${target}' under ${OPENCODE_SCRATCH_DIR}, the environment scratch directory the workspace boundary does not cover; write scratch as an untracked file inside the workspace root (${root}) instead (for example .tmp-notes.md).`;
|
|
441
|
+
}
|
|
406
442
|
return `Blocked: shell mutation '${mutation.what}' targets a path outside the workspace root (${root}). All changes must stay within the workspace.`;
|
|
407
443
|
}
|
|
408
444
|
if (onProtectedBranch(root)) {
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { readlinkSync, realpathSync } from "node:fs";
|
|
2
2
|
import { dirname, relative, resolve } from "node:path";
|
|
3
|
+
import { audit } from "../lib/audit.ts";
|
|
4
|
+
import { getSdkClient } from "../lib/state.ts";
|
|
3
5
|
|
|
4
6
|
interface FileClaim {
|
|
5
7
|
sessionID: string;
|
|
6
8
|
callID: string;
|
|
9
|
+
/** Creation timestamp; a claim older than its owner's last idle is stale. */
|
|
10
|
+
ts: number;
|
|
7
11
|
}
|
|
8
12
|
|
|
9
13
|
const claims = new Map<string, FileClaim>();
|
|
@@ -36,8 +40,14 @@ export function claimFiles(paths: string[], sessionID: string, callID: string):
|
|
|
36
40
|
const conflict = fileClaimConflictReason(canonical, sessionID);
|
|
37
41
|
if (conflict) return conflict;
|
|
38
42
|
for (const path of canonical) {
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
const existing = claims.get(path);
|
|
44
|
+
if (!existing) {
|
|
45
|
+
claims.set(path, { sessionID, callID, ts: Date.now() });
|
|
46
|
+
} else if (existing.sessionID === sessionID) {
|
|
47
|
+
// The same session re-claiming the path refreshes the claim so the
|
|
48
|
+
// timestamp (and callID) reflect the in-flight call: stale-takeover
|
|
49
|
+
// never fires for it, and the after-hook release matches.
|
|
50
|
+
claims.set(path, { sessionID, callID, ts: Date.now() });
|
|
41
51
|
}
|
|
42
52
|
}
|
|
43
53
|
return undefined;
|
|
@@ -54,6 +64,62 @@ export function fileClaimConflictReason(paths: string[], sessionID: string): str
|
|
|
54
64
|
return undefined;
|
|
55
65
|
}
|
|
56
66
|
|
|
67
|
+
/**
|
|
68
|
+
* A claim is normally released by its owning tool call (after-hook) or by the
|
|
69
|
+
* owner's session.idle/session.deleted events. When an idle release is missed,
|
|
70
|
+
* the claim would otherwise block every other session forever. A claim created
|
|
71
|
+
* BEFORE its owner's last recorded idle timestamp cannot belong to an
|
|
72
|
+
* in-flight tool call — that call finished before the turn ended — so it is
|
|
73
|
+
* provably stale and is released with an audited takeover. Without an SDK
|
|
74
|
+
* client, or when the owner record cannot be read, nothing is released:
|
|
75
|
+
* fail-closed keeps live two-session protection intact. Clock skew between
|
|
76
|
+
* this process and the server is not expected for a local (same-host)
|
|
77
|
+
* service: negative skew fails closed; forward skew could make fresh claims
|
|
78
|
+
* look stale and is accepted for that deployment. Concurrent claimants can
|
|
79
|
+
* emit duplicate takeover audits (append-only, harmless). Release is
|
|
80
|
+
* per-claim: only claims whose ts is provably stale are dropped, re-read
|
|
81
|
+
* AFTER the lookup round-trip so an owner that re-claimed (and refreshed) a
|
|
82
|
+
* path meanwhile keeps its live claim.
|
|
83
|
+
*/
|
|
84
|
+
export async function releaseStaleFileClaims(paths: string[], claimantSessionID: string): Promise<void> {
|
|
85
|
+
const canonical = [...new Set(paths.map((path) => canonicalPath(path)))];
|
|
86
|
+
const staleOwners = new Map<string, string>();
|
|
87
|
+
for (const path of canonical) {
|
|
88
|
+
const existing = claims.get(path);
|
|
89
|
+
if (existing && existing.sessionID !== claimantSessionID && !staleOwners.has(existing.sessionID)) {
|
|
90
|
+
staleOwners.set(existing.sessionID, path);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (staleOwners.size === 0) return;
|
|
94
|
+
const client = getSdkClient();
|
|
95
|
+
const session = client?.session;
|
|
96
|
+
const get = session?.get;
|
|
97
|
+
if (!session || typeof get !== "function") return;
|
|
98
|
+
for (const [ownerSessionID] of staleOwners) {
|
|
99
|
+
try {
|
|
100
|
+
const result = await get.call(session, { path: { id: ownerSessionID } });
|
|
101
|
+
const idle = (result as { data?: { time?: { idle?: unknown } } } | undefined)?.data?.time?.idle;
|
|
102
|
+
if (typeof idle !== "number" || idle <= 0) continue;
|
|
103
|
+
// Release every claim of this owner that is provably stale. Each
|
|
104
|
+
// claim is re-read after the lookup round-trip so an owner that
|
|
105
|
+
// re-claimed (and refreshed) a path meanwhile keeps its live claim.
|
|
106
|
+
for (const [claimPath, claim] of [...claims]) {
|
|
107
|
+
if (claim.sessionID !== ownerSessionID || claim.ts >= idle) continue;
|
|
108
|
+
claims.delete(claimPath);
|
|
109
|
+
audit({
|
|
110
|
+
ts: new Date().toISOString(),
|
|
111
|
+
sessionID: claimantSessionID,
|
|
112
|
+
tool: "file-claims",
|
|
113
|
+
decision: "allow",
|
|
114
|
+
phase: "event",
|
|
115
|
+
reason: "stale_claim_takeover",
|
|
116
|
+
evidence: { mutation: false, targetPath: claimPath, claimOwnerSessionID: ownerSessionID },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
} catch {}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
57
123
|
export function releaseFileClaims(sessionID: string, callID?: string): void {
|
|
58
124
|
for (const [path, claim] of claims) {
|
|
59
125
|
if (claim.sessionID === sessionID && (callID === undefined || claim.callID === callID)) {
|