opencode-workflow-guard 1.1.5 → 1.2.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.
@@ -0,0 +1,169 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { getCleanEnv, normalize } from "./utils.ts";
5
+ import { getProjectConfig } from "./state.ts";
6
+ import { normalizeGitCommands } from "../policies/git.ts";
7
+ import { liveMutationIn } from "../policies/destructive.ts";
8
+ import { isSettingsTamper } from "../policies/tamper.ts";
9
+
10
+ export function detectVerifyCommand(root: string): string | undefined {
11
+ if (process.env.WORKFLOW_GUARD_VERIFY !== undefined) {
12
+ const cmd = process.env.WORKFLOW_GUARD_VERIFY.trim();
13
+ return cmd || undefined;
14
+ }
15
+ const cfg = getProjectConfig(root);
16
+ if (typeof cfg.verifyCommand === "string") {
17
+ const cmd = cfg.verifyCommand.trim();
18
+ return cmd || undefined;
19
+ }
20
+ try {
21
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
22
+ scripts?: Record<string, string>;
23
+ };
24
+ if (pkg.scripts?.test) return "npm test";
25
+ } catch {}
26
+ return undefined;
27
+ }
28
+
29
+ /**
30
+ * Truncates and snips verification stdout/stderr for token efficiency.
31
+ * When a test suite or compiler passes, verbose log lines are trimmed to a
32
+ * concise summary. When it fails, failure context, error markers, and stack
33
+ * traces are prioritized.
34
+ */
35
+ export function snipVerifyOutput(output: string, passed: boolean, maxLines = 40): string {
36
+ if (!output) return "(no output)";
37
+ const lines = output.trim().split("\n");
38
+ if (lines.length <= maxLines) return output.trim();
39
+
40
+ if (passed) {
41
+ const head = lines.slice(0, 5).join("\n");
42
+ const tail = lines.slice(-15).join("\n");
43
+ return `${head}\n... [${lines.length - 20} lines omitted - all tests passing] ...\n${tail}`;
44
+ }
45
+
46
+ const errorLines: string[] = [];
47
+ for (const line of lines) {
48
+ if (/(?:fail|error|exception|panic|assert|reject|expected|received|err:)/i.test(line)) {
49
+ errorLines.push(line);
50
+ }
51
+ }
52
+
53
+ const head = lines.slice(0, 5).join("\n");
54
+ const tail = lines.slice(-20).join("\n");
55
+ const errorSummary = errorLines.slice(-15).join("\n");
56
+
57
+ return [
58
+ head,
59
+ `\n--- [Verification Failed: ${lines.length} total lines captured] ---`,
60
+ errorSummary ? `Key Failures:\n${errorSummary}` : "",
61
+ `Output Tail:\n${tail}`,
62
+ ]
63
+ .filter(Boolean)
64
+ .join("\n");
65
+ }
66
+
67
+ export function getCurrentGitCommitHash(root: string): string | undefined {
68
+ try {
69
+ const res = spawnSync("git", ["rev-parse", "HEAD"], {
70
+ cwd: root,
71
+ encoding: "utf8",
72
+ timeout: 5_000,
73
+ });
74
+ if (res.status === 0 && res.stdout.trim()) {
75
+ return res.stdout.trim();
76
+ }
77
+ } catch {}
78
+ return undefined;
79
+ }
80
+
81
+ export function getGitStatusSummary(root: string): string | undefined {
82
+ try {
83
+ const res = spawnSync("git", ["status", "--porcelain"], {
84
+ cwd: root,
85
+ encoding: "utf8",
86
+ timeout: 5_000,
87
+ });
88
+ if (res.status === 0) {
89
+ return res.stdout.trim();
90
+ }
91
+ } catch {}
92
+ return undefined;
93
+ }
94
+
95
+ export async function runVerify(
96
+ command: string,
97
+ root: string,
98
+ timeoutMs = 30_000,
99
+ ): Promise<{ passed: boolean; output: string; durationMs: number }> {
100
+ const start = Date.now();
101
+ const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
102
+
103
+ if (!allowLive) {
104
+ const normalized = normalizeGitCommands(normalize(command));
105
+ const liveCheck = liveMutationIn(normalized);
106
+ if (liveCheck) {
107
+ return {
108
+ passed: false,
109
+ output: `Verification command blocked: contains live destructive command (${liveCheck})`,
110
+ durationMs: 0,
111
+ };
112
+ }
113
+ if (isSettingsTamper(command)) {
114
+ return {
115
+ passed: false,
116
+ output: "Verification command blocked: contains settings tamper command",
117
+ durationMs: 0,
118
+ };
119
+ }
120
+ }
121
+
122
+ return new Promise((resolve) => {
123
+ let output = "";
124
+ let timer: NodeJS.Timeout | undefined;
125
+ let child: ReturnType<typeof spawn>;
126
+ try {
127
+ child = spawn(command, {
128
+ cwd: root,
129
+ shell: true,
130
+ env: getCleanEnv(),
131
+ stdio: ["ignore", "pipe", "pipe"],
132
+ });
133
+ } catch (err: any) {
134
+ return resolve({
135
+ passed: false,
136
+ output: `(spawn failed: ${err?.message ?? "unknown error"})`,
137
+ durationMs: Date.now() - start,
138
+ });
139
+ }
140
+
141
+ timer = setTimeout(() => {
142
+ try {
143
+ child.kill("SIGKILL");
144
+ } catch {}
145
+ resolve({
146
+ passed: false,
147
+ output: output + `\n(Verification timed out after ${Math.round(timeoutMs / 1000)}s)`,
148
+ durationMs: Date.now() - start,
149
+ });
150
+ }, timeoutMs);
151
+
152
+ child.stdout?.on("data", (d) => {
153
+ output += d.toString();
154
+ if (output.length > 50_000) output = output.slice(-50_000);
155
+ });
156
+ child.stderr?.on("data", (d) => {
157
+ output += d.toString();
158
+ if (output.length > 50_000) output = output.slice(-50_000);
159
+ });
160
+ child.on("close", (code) => {
161
+ if (timer) clearTimeout(timer);
162
+ resolve({ passed: code === 0, output, durationMs: Date.now() - start });
163
+ });
164
+ child.on("error", (err) => {
165
+ if (timer) clearTimeout(timer);
166
+ resolve({ passed: false, output: `(spawn failed: ${err.message})`, durationMs: Date.now() - start });
167
+ });
168
+ });
169
+ }
@@ -0,0 +1,216 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, join, resolve, sep } from "node:path";
5
+ import { getWorkspaceRoot } from "./state.ts";
6
+ import { isProtectedBranchName } from "../policies/git.ts";
7
+
8
+ export function getWorktreeStorageDir(root = getWorkspaceRoot()): string {
9
+ const base =
10
+ process.env.WORKFLOW_GUARD_WORKTREE_DIR ??
11
+ join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "opencode", "worktrees");
12
+ return join(base, basename(root));
13
+ }
14
+
15
+ /**
16
+ * Returns a copy of process.env with git context variables removed so spawned
17
+ * git commands resolve the repository from their cwd alone. Without this, a
18
+ * caller nested inside a git hook (which exports GIT_INDEX_FILE and friends)
19
+ * would make `git worktree add` fail: the internal checkout of the new
20
+ * worktree resolves the inherited relative GIT_INDEX_FILE against the new
21
+ * worktree root, where `.git` is a file, not a directory.
22
+ */
23
+ export function getCleanGitEnv(): Record<string, string> {
24
+ const env: Record<string, string> = { ...(process.env as Record<string, string>) };
25
+ for (const key of [
26
+ "GIT_INDEX_FILE",
27
+ "GIT_DIR",
28
+ "GIT_WORK_TREE",
29
+ "GIT_COMMON_DIR",
30
+ "GIT_OBJECT_DIRECTORY",
31
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
32
+ "GIT_PREFIX",
33
+ ]) {
34
+ delete env[key];
35
+ }
36
+ return env;
37
+ }
38
+
39
+ /**
40
+ * Validates a branch name using git itself (`git check-ref-format --branch`),
41
+ * with fast local pre-filters for names that must never be passed as arguments
42
+ * at all (a leading dash would be parsed as an option).
43
+ */
44
+ export function isValidBranchName(name: string): boolean {
45
+ if (!name || name.length > 255) return false;
46
+ if (name.startsWith("-") || name.startsWith(".") || name.endsWith(".") || name.endsWith(".lock")) return false;
47
+ if (name.includes("..") || name.includes("@{") || name.includes("//")) return false;
48
+ const res = spawnSync("git", ["check-ref-format", "--branch", name], {
49
+ encoding: "utf8",
50
+ timeout: 5_000,
51
+ env: getCleanGitEnv(),
52
+ });
53
+ return res.status === 0;
54
+ }
55
+
56
+ function localBranchExists(branch: string, root: string): boolean {
57
+ const res = spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
58
+ cwd: root,
59
+ encoding: "utf8",
60
+ timeout: 5_000,
61
+ env: getCleanGitEnv(),
62
+ });
63
+ return res.status === 0;
64
+ }
65
+
66
+ export function createGitWorktree(
67
+ branch: string,
68
+ baseBranch = "HEAD",
69
+ root = getWorkspaceRoot(),
70
+ ): { success: boolean; worktreePath?: string; error?: string } {
71
+ if (!branch || !isValidBranchName(branch)) {
72
+ return { success: false, error: `Invalid branch name '${branch}' for worktree creation.` };
73
+ }
74
+ if (isProtectedBranchName(branch, root)) {
75
+ return { success: false, error: `Cannot create worktree on protected branch '${branch}'.` };
76
+ }
77
+
78
+ const storageBase = getWorktreeStorageDir(root);
79
+ const targetPath = join(storageBase, branch.replace(/[/\\:]/g, "-"));
80
+
81
+ try {
82
+ mkdirSync(storageBase, { recursive: true });
83
+
84
+ const exists = localBranchExists(branch, root);
85
+ const gitArgs = exists
86
+ ? ["worktree", "add", targetPath, branch]
87
+ : ["worktree", "add", "-b", branch, targetPath, baseBranch];
88
+
89
+ const addRes = spawnSync("git", gitArgs, {
90
+ cwd: root,
91
+ env: getCleanGitEnv(),
92
+ encoding: "utf8",
93
+ timeout: 15_000,
94
+ });
95
+ if (addRes.status !== 0) {
96
+ return { success: false, error: addRes.stderr.trim() || `git worktree add failed` };
97
+ }
98
+
99
+ // Symlink the parent's node_modules so tooling works without a fresh install.
100
+ const nodeModulesSrc = join(root, "node_modules");
101
+ const nodeModulesDest = join(targetPath, "node_modules");
102
+ if (existsSync(nodeModulesSrc) && !existsSync(nodeModulesDest)) {
103
+ try {
104
+ symlinkSync(nodeModulesSrc, nodeModulesDest, "dir");
105
+ } catch {}
106
+ }
107
+
108
+ return { success: true, worktreePath: targetPath };
109
+ } catch (e: any) {
110
+ return { success: false, error: e?.message ?? String(e) };
111
+ }
112
+ }
113
+
114
+ function registeredWorktreePaths(root: string): string[] | undefined {
115
+ const res = spawnSync("git", ["worktree", "list", "--porcelain"], {
116
+ cwd: root,
117
+ encoding: "utf8",
118
+ timeout: 10_000,
119
+ env: getCleanGitEnv(),
120
+ });
121
+ if (res.status !== 0) return undefined;
122
+ return res.stdout
123
+ .split("\n")
124
+ .filter((line) => line.startsWith("worktree "))
125
+ .map((line) => line.slice("worktree ".length))
126
+ .filter((path) => path.length > 0);
127
+ }
128
+
129
+ export function cleanupGitWorktree(
130
+ worktreePath: string,
131
+ root = getWorkspaceRoot(),
132
+ ): { success: boolean; error?: string } {
133
+ try {
134
+ if (!existsSync(worktreePath)) {
135
+ return { success: false, error: `Worktree path '${worktreePath}' does not exist.` };
136
+ }
137
+
138
+ // Ownership validation: the path must live under the configured worktree
139
+ // storage directory AND be a registered worktree of this repository. This
140
+ // refuses arbitrary directories, other repos' worktrees, and the primary
141
+ // working tree - cleanup must never be an unrestricted delete primitive.
142
+ const resolved = resolve(worktreePath);
143
+ const storageBase = resolve(getWorktreeStorageDir(root));
144
+ if (resolved !== storageBase && !resolved.startsWith(storageBase + sep)) {
145
+ return {
146
+ success: false,
147
+ error: `Refusing to clean up '${worktreePath}': path is outside the worktree storage directory (${storageBase}).`,
148
+ };
149
+ }
150
+ const registered = registeredWorktreePaths(root);
151
+ if (!registered) {
152
+ return { success: false, error: "Failed to list registered worktrees - aborting cleanup." };
153
+ }
154
+ const resolvedRegistered = registered.map((path) => resolve(path));
155
+ if (!resolvedRegistered.includes(resolved)) {
156
+ return {
157
+ success: false,
158
+ error: `Refusing to clean up '${worktreePath}': not a registered worktree of this repository.`,
159
+ };
160
+ }
161
+ if (resolvedRegistered[0] === resolved) {
162
+ return { success: false, error: "Refusing to remove the primary working tree." };
163
+ }
164
+
165
+ // Snapshot: stage and commit remaining changes. If the snapshot cannot be
166
+ // established (hook failure, missing identity, lock error, timeout), abort -
167
+ // never destroy uncommitted work.
168
+ const addArgs = ["add", "-A", "--", "."];
169
+ try {
170
+ // Exclude the plugin-created node_modules symlink so repos that do not
171
+ // ignore it never commit a machine-local link.
172
+ if (lstatSync(join(resolved, "node_modules")).isSymbolicLink()) {
173
+ addArgs.push(":(exclude)node_modules");
174
+ }
175
+ } catch {}
176
+ const addRes = spawnSync("git", addArgs, {
177
+ cwd: worktreePath,
178
+ env: getCleanGitEnv(),
179
+ encoding: "utf8",
180
+ timeout: 5_000,
181
+ });
182
+ if (addRes.status !== 0) {
183
+ return {
184
+ success: false,
185
+ error: `Snapshot failed (git add: ${(addRes.stderr ?? "").trim()}) - worktree left intact.`,
186
+ };
187
+ }
188
+ const commitRes = spawnSync(
189
+ "git",
190
+ ["commit", "-m", "chore(worktree): auto-snapshot before cleanup", "--allow-empty"],
191
+ { cwd: worktreePath, env: getCleanGitEnv(), encoding: "utf8", timeout: 5_000 },
192
+ );
193
+ if (commitRes.status !== 0) {
194
+ return {
195
+ success: false,
196
+ error: `Snapshot failed (git commit: ${(commitRes.stderr ?? "").trim().split("\n").pop()?.trim() ?? "unknown error"}) - worktree left intact.`,
197
+ };
198
+ }
199
+
200
+ const res = spawnSync("git", ["worktree", "remove", "--force", worktreePath], {
201
+ cwd: root,
202
+ env: getCleanGitEnv(),
203
+ encoding: "utf8",
204
+ timeout: 10_000,
205
+ });
206
+ if (res.status !== 0) {
207
+ // A failed git removal is an error - never authorization for a raw
208
+ // recursive delete of the directory.
209
+ return { success: false, error: res.stderr.trim() || "git worktree remove failed." };
210
+ }
211
+
212
+ return { success: true };
213
+ } catch (e: any) {
214
+ return { success: false, error: e?.message ?? String(e) };
215
+ }
216
+ }
@@ -0,0 +1,248 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import type { ShellMutation } from "../lib/types.ts";
4
+ import {
5
+ getWorkspaceRoot,
6
+ getWorkspaceRootReal,
7
+ recordMutation,
8
+ } from "../lib/state.ts";
9
+ import { shellWords, unwrapShellCommand } from "../lib/utils.ts";
10
+ import { isProtectedPath, PROTECTED_PATH_REASON } from "./tamper.ts";
11
+ import { isSecretPath, secretIn } from "./secrets.ts";
12
+ import { onProtectedBranch, branchGuardReason } from "./git.ts";
13
+ import {
14
+ effectiveTodos,
15
+ effectiveTodoOwnerSessionID,
16
+ hasActiveTodo,
17
+ } from "./todo.ts";
18
+
19
+ export function isPathOutsideWorkspace(targetPath: string, root: string): boolean {
20
+ if (!targetPath) return false;
21
+ const resolved = resolve(root, targetPath);
22
+ const normalizedRoot = root.endsWith("/") ? root : root + "/";
23
+ if (resolved !== root && !resolved.startsWith(normalizedRoot)) {
24
+ return true;
25
+ }
26
+ const realRootVal = getWorkspaceRootReal();
27
+ try {
28
+ const real = realpathSync(resolved);
29
+ const realRoot = realRootVal.endsWith("/") ? realRootVal : realRootVal + "/";
30
+ if (real !== realRootVal && !real.startsWith(realRoot)) {
31
+ return true;
32
+ }
33
+ } catch {
34
+ let curr = resolved;
35
+ while (curr && curr !== "/" && curr !== ".") {
36
+ const parent = resolve(curr, "..");
37
+ if (parent === curr) break;
38
+ curr = parent;
39
+ try {
40
+ const realParent = realpathSync(curr);
41
+ const realRoot = realRootVal.endsWith("/") ? realRootVal : realRootVal + "/";
42
+ if (realParent !== realRootVal && !realParent.startsWith(realRoot)) {
43
+ return true;
44
+ }
45
+ break;
46
+ } catch {}
47
+ }
48
+ }
49
+ return false;
50
+ }
51
+
52
+ export function extractPatchPaths(patchText: string): string[] {
53
+ const paths: string[] = [];
54
+ const markerRe =
55
+ /^\*\*\*\s+(?:Add File|Update File|Delete File|Move to|Move from):\s*(\S+)/gm;
56
+ let match: RegExpExecArray | null;
57
+ while ((match = markerRe.exec(patchText)) !== null) {
58
+ if (match[1]) paths.push(match[1]);
59
+ }
60
+ const diffRe = /^(?:---|\+\+\+)\s+(?:[ab]\/)?(\S+)/gm;
61
+ while ((match = diffRe.exec(patchText)) !== null) {
62
+ if (match[1] && match[1] !== "/dev/null") paths.push(match[1]);
63
+ }
64
+ return paths;
65
+ }
66
+
67
+ export function shellMutationIn(segment: string): ShellMutation | undefined {
68
+ const redirectMatch = segment.match(
69
+ /(?:^|\s|>)(?:[0-9]*>>?)\s*["']?([^\s>&|;"']+)/,
70
+ );
71
+ if (redirectMatch?.[1]) {
72
+ if (/^\/dev\/(?:null|stdout|stderr|tty|fd\/\d+)$/.test(redirectMatch[1])) {
73
+ return undefined;
74
+ }
75
+ return {
76
+ kind: "redirect",
77
+ target: redirectMatch[1],
78
+ what: `file redirect to '${redirectMatch[1]}'`,
79
+ };
80
+ }
81
+ const teeMatch = segment.match(/\btee\s+(?:-a\s+)?["']?([^\s;&|"']+)/);
82
+ if (teeMatch?.[1]) {
83
+ return {
84
+ kind: "command",
85
+ target: teeMatch[1],
86
+ what: `tee to '${teeMatch[1]}'`,
87
+ };
88
+ }
89
+ const sedMatch = segment.match(/\bsed\s+(?:-(?:[a-zA-Z]*i[a-zA-Z]*|i\S*)\s+)+[^\s;&|]+/);
90
+ if (sedMatch) {
91
+ const tokens = sedMatch[0]!.split(/\s+/);
92
+ const target = tokens[tokens.length - 1];
93
+ return { kind: "command", target, what: `sed -i on '${target}'` };
94
+ }
95
+ const transfer = filesystemTransferInfo(segment);
96
+ if (transfer?.destination) {
97
+ return {
98
+ kind: "command",
99
+ target: transfer.destination,
100
+ what: `copy/move/link to '${transfer.destination}'`,
101
+ };
102
+ }
103
+ const fsMutationMatch = segment.match(
104
+ /\b(?:touch|mkdir|rm|unlink|rmdir|ln)\b[^|;&]*\s+["']?([^\s;&|"']+)["']?\s*$/,
105
+ );
106
+ if (fsMutationMatch?.[1] && !fsMutationMatch[1].startsWith("-")) {
107
+ return {
108
+ kind: "command",
109
+ target: fsMutationMatch[1],
110
+ what: `filesystem mutation of '${fsMutationMatch[1]}'`,
111
+ };
112
+ }
113
+ const copyMatch = segment.match(
114
+ /\b(?:cp|mv|rsync|install|cpio|scp|wget|curl)\b[^|;&]*?(-o\s+)?(["']?)([^\s;&|"']+)\2?\s*$/,
115
+ );
116
+ if (copyMatch?.[3] && /\b(?:cp|mv|rsync|install)\b/.test(segment)) {
117
+ return {
118
+ kind: "command",
119
+ target: copyMatch[3],
120
+ what: `copy/move to '${copyMatch[3]}'`,
121
+ };
122
+ }
123
+ if (/\bgit\s+(?:apply|am)\b/.test(segment)) {
124
+ return { kind: "command", what: "git apply/am (patch via shell)" };
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ export function simpleFilesystemMutations(segment: string): ShellMutation[] {
130
+ const words = shellWords(unwrapShellCommand(segment));
131
+ const command = words[0];
132
+ if (!command || !new Set(["touch", "mkdir", "rm", "unlink", "rmdir"]).has(command)) {
133
+ return [];
134
+ }
135
+ return words
136
+ .slice(1)
137
+ .filter((word) => !word.startsWith("-"))
138
+ .map((target) => ({
139
+ kind: "command" as const,
140
+ target,
141
+ what: `filesystem mutation of '${target}'`,
142
+ }));
143
+ }
144
+
145
+ export function filesystemTransferInfo(
146
+ segment: string,
147
+ ): { sources: string[]; destination?: string } | undefined {
148
+ const words = shellWords(unwrapShellCommand(segment));
149
+ if (!words[0] || !new Set(["cp", "mv", "ln"]).has(words[0])) return undefined;
150
+ const operands: string[] = [];
151
+ let targetDirectory: string | undefined;
152
+ for (let i = 1; i < words.length; i++) {
153
+ const word = words[i]!;
154
+ if (word === "-t" || word === "--target-directory") {
155
+ targetDirectory = words[++i];
156
+ continue;
157
+ }
158
+ if (word.startsWith("--target-directory=")) {
159
+ targetDirectory = word.slice("--target-directory=".length);
160
+ continue;
161
+ }
162
+ if (word.startsWith("-")) continue;
163
+ operands.push(word);
164
+ }
165
+ if (targetDirectory) return { sources: operands, destination: targetDirectory };
166
+ return {
167
+ sources: operands.slice(0, -1),
168
+ destination: operands.at(-1),
169
+ };
170
+ }
171
+
172
+ export function secretSourceInFilesystemCommand(segment: string): string | undefined {
173
+ const transfer = filesystemTransferInfo(segment);
174
+ if (!transfer) return undefined;
175
+ for (const source of transfer.sources) {
176
+ if (isSecretPath(source)) return source;
177
+ }
178
+ return undefined;
179
+ }
180
+
181
+ export async function guardShellMutation(
182
+ command: string,
183
+ sessionID: string | undefined,
184
+ ): Promise<string | undefined> {
185
+ const root = getWorkspaceRoot();
186
+ let hasMutation = false;
187
+ for (const segment of command.split(/[\n|;&]+/)) {
188
+ const secretSource = secretSourceInFilesystemCommand(segment);
189
+ if (secretSource) {
190
+ return `Blocked: shell command would copy, move, or link sensitive file '${secretSource}' under a non-secret name.`;
191
+ }
192
+ // mv mutates its SOURCES too (they are removed from their origin), so
193
+ // sources must respect the same boundaries as mutation targets: no
194
+ // moving files in from outside the workspace, no moving protected
195
+ // (settings/plugin) files to innocuous names.
196
+ const transfer = filesystemTransferInfo(segment);
197
+ if (transfer && shellWords(unwrapShellCommand(segment))[0] === "mv") {
198
+ for (const source of transfer.sources) {
199
+ if (isProtectedPath(source)) {
200
+ return PROTECTED_PATH_REASON;
201
+ }
202
+ if (isPathOutsideWorkspace(source, root)) {
203
+ return `Blocked: mv would remove source '${source}' from outside the workspace root (${root}). File mutations must stay within the workspace.`;
204
+ }
205
+ }
206
+ }
207
+ const simpleMutations = simpleFilesystemMutations(segment);
208
+ const fallbackMutation = shellMutationIn(segment.trim());
209
+ const mutations = simpleMutations.length > 0
210
+ ? simpleMutations
211
+ : fallbackMutation
212
+ ? [fallbackMutation]
213
+ : [];
214
+ for (const mutation of mutations) {
215
+ hasMutation = true;
216
+ const secret = secretIn(segment);
217
+ if (secret) {
218
+ return `Blocked: shell file mutation payload appears to contain a ${secret}. Secrets must not be written to disk from agent commands.`;
219
+ }
220
+ const target = mutation.target ?? "";
221
+ if (target && isProtectedPath(target)) {
222
+ return PROTECTED_PATH_REASON;
223
+ }
224
+ if (target && isPathOutsideWorkspace(target, root)) {
225
+ // The workspace boundary has no override: a write outside the
226
+ // workspace is out of bounds even with WORKFLOW_GUARD_ALLOW_LIVE
227
+ // (that override covers live-system commands, not the boundary).
228
+ return `Blocked: shell mutation '${mutation.what}' targets a path outside the workspace root (${root}). All changes must stay within the workspace.`;
229
+ }
230
+ if (onProtectedBranch(root)) {
231
+ return branchGuardReason();
232
+ }
233
+ const todos = await effectiveTodos(sessionID);
234
+ if (todos !== undefined && !hasActiveTodo(todos)) {
235
+ return (
236
+ "Blocked: shell file mutation with no active todo item. " +
237
+ "Break the request down with todowrite first, then apply " +
238
+ "changes (the same gates apply to shell redirects, tee, " +
239
+ "sed -i, cp/mv and git apply as to the edit tools)."
240
+ );
241
+ }
242
+ }
243
+ }
244
+ if (hasMutation) {
245
+ recordMutation(await effectiveTodoOwnerSessionID(sessionID));
246
+ }
247
+ return undefined;
248
+ }