opencode-workflow-guard 1.19.0 → 1.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/policies.md +11 -3
- package/docs/testing.md +2 -1
- package/package.json +1 -1
- package/src/lib/guard-dispatcher.ts +104 -13
- package/src/lib/permission-seat.ts +79 -1
- package/src/lib/shell.ts +702 -5
- package/src/lib/state.ts +80 -1
- package/src/lib/types.ts +7 -0
- package/src/lib/v2-plugin.ts +20 -0
- package/src/policies/boundary.ts +307 -24
- package/src/policies/git.ts +144 -22
- package/src/policies/interpreter.ts +93 -16
- package/src/workflow-guard.ts +27 -3
- package/test/test-e2e.mts +4 -1
- package/test/test.mts +660 -20
package/src/lib/state.ts
CHANGED
|
@@ -81,6 +81,10 @@ export function clearTodoSessionState(sessionID: string): void {
|
|
|
81
81
|
// instance must never outrank the call's own context or the host fallback.
|
|
82
82
|
const sessionWorkspaceBound = new Set<string>();
|
|
83
83
|
|
|
84
|
+
// Last directory the HOST named per session, so a repeated `session.updated`
|
|
85
|
+
// costs one string comparison instead of a `git rev-parse` child process.
|
|
86
|
+
const sessionHostDirectories = new Map<string, string>();
|
|
87
|
+
|
|
84
88
|
export function setSessionWorkspace(sessionID: string, workspace: string): void {
|
|
85
89
|
if (!sessionID || !workspace) return;
|
|
86
90
|
const gitRoot = findGitRoot(workspace);
|
|
@@ -88,6 +92,18 @@ export function setSessionWorkspace(sessionID: string, workspace: string): void
|
|
|
88
92
|
sessionWorkspaceBound.add(sessionID);
|
|
89
93
|
}
|
|
90
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Drop a deleted session's workspace binding and host-directory cache entry.
|
|
97
|
+
*
|
|
98
|
+
* Both are keyed by session id, so a deleted session can never read them again.
|
|
99
|
+
* Leaving them would grow both maps for the life of a long-lived server.
|
|
100
|
+
*/
|
|
101
|
+
export function forgetSessionWorkspace(sessionID: string): void {
|
|
102
|
+
sessionWorkspaces.delete(sessionID);
|
|
103
|
+
sessionWorkspaceBound.delete(sessionID);
|
|
104
|
+
sessionHostDirectories.delete(sessionID);
|
|
105
|
+
}
|
|
106
|
+
|
|
91
107
|
export function getSessionWorkspace(sessionID?: string): string | undefined {
|
|
92
108
|
if (!sessionID) return undefined;
|
|
93
109
|
return sessionWorkspaces.get(sessionID);
|
|
@@ -236,11 +252,74 @@ function auditWorkspaceRebind(sessionID: string, from: string | undefined, to: s
|
|
|
236
252
|
tool: "workspace.binding",
|
|
237
253
|
decision: "allow",
|
|
238
254
|
phase: "event",
|
|
239
|
-
reason: "workspace_rebind_host_worktree",
|
|
255
|
+
reason: source === "session-move" ? "workspace_rebind_session_move" : "workspace_rebind_host_worktree",
|
|
240
256
|
input: { from, to, source },
|
|
241
257
|
});
|
|
242
258
|
}
|
|
243
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Rebind a session to the workspace the HOST says it now occupies.
|
|
262
|
+
*
|
|
263
|
+
* `session_move` (and the equivalent host move command) relocates a session
|
|
264
|
+
* between checkouts. Without this the binding stayed on the old checkout, so
|
|
265
|
+
* every later boundary, branch, and PR decision judged the tree the session had
|
|
266
|
+
* left: the move looked like it had no effect. A host move is an explicit
|
|
267
|
+
* boundary statement from the host, in the same class as a per-call worktree,
|
|
268
|
+
* so it may widen an already-bound session. It is journaled for that reason.
|
|
269
|
+
*
|
|
270
|
+
* The hard part is telling a move from a routine update. `session.updated` is a
|
|
271
|
+
* hot event, emitted for title and cost changes, and it names the session's
|
|
272
|
+
* creation directory. A session bound to a LINKED WORKTREE by its own tool-call
|
|
273
|
+
* `workdir` legitimately differs from that directory, so treating every update
|
|
274
|
+
* as a move pulled the session back to the checkout it had deliberately left.
|
|
275
|
+
* Three signals therefore count, and nothing else does:
|
|
276
|
+
*
|
|
277
|
+
* - The host announced the relocation itself (`moved: true`, from
|
|
278
|
+
* `session.moved`).
|
|
279
|
+
* - The directory the host names now differs from the one it named last for
|
|
280
|
+
* this session.
|
|
281
|
+
* - The named repository is a DIFFERENT repository from the bound workspace. A
|
|
282
|
+
* linked worktree is the same repository, so this catches the cross-repository
|
|
283
|
+
* move that a first observation would otherwise miss on OpenCode 1.x, which
|
|
284
|
+
* has no move event.
|
|
285
|
+
*
|
|
286
|
+
* The last directory is remembered so a repeated update costs one string
|
|
287
|
+
* comparison instead of a `git rev-parse` child process. The cache stores a
|
|
288
|
+
* directory that actually resolved to a repository root: an unresolved
|
|
289
|
+
* directory is not cached, so a later event for the same path retries instead
|
|
290
|
+
* of being suppressed forever.
|
|
291
|
+
*/
|
|
292
|
+
export function rebindSessionWorkspaceFromHost(
|
|
293
|
+
sessionID: string | undefined,
|
|
294
|
+
directory: string | undefined,
|
|
295
|
+
options: { moved?: boolean } = {},
|
|
296
|
+
): void {
|
|
297
|
+
if (!sessionID || !directory) return;
|
|
298
|
+
const gitRoot = findGitRoot(directory);
|
|
299
|
+
if (!gitRoot) return;
|
|
300
|
+
const previousHostDirectory = sessionHostDirectories.get(sessionID);
|
|
301
|
+
sessionHostDirectories.set(sessionID, directory);
|
|
302
|
+
const previous = sessionWorkspaces.get(sessionID);
|
|
303
|
+
const moved =
|
|
304
|
+
options.moved === true
|
|
305
|
+
|| (previousHostDirectory !== undefined && previousHostDirectory !== directory)
|
|
306
|
+
|| (previous !== undefined && gitRoot !== previous && !isSameGitRepo(gitRoot, previous));
|
|
307
|
+
if (!moved) {
|
|
308
|
+
if (sessionWorkspaceBound.has(sessionID)) return;
|
|
309
|
+
// A bootstrap entry that was never bound still needs binding: the host
|
|
310
|
+
// just named the session's real directory.
|
|
311
|
+
sessionWorkspaces.set(sessionID, gitRoot);
|
|
312
|
+
sessionWorkspaceBound.add(sessionID);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (previous === gitRoot) {
|
|
316
|
+
sessionWorkspaceBound.add(sessionID);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
setSessionWorkspace(sessionID, gitRoot);
|
|
320
|
+
auditWorkspaceRebind(sessionID, previous, gitRoot, "session-move");
|
|
321
|
+
}
|
|
322
|
+
|
|
244
323
|
/**
|
|
245
324
|
* Durable record of a refused widening. The refusal itself is the boundary
|
|
246
325
|
* policy's decision; this entry exists so an operator can tell whether a
|
package/src/lib/types.ts
CHANGED
|
@@ -247,6 +247,13 @@ export interface AuditEntry {
|
|
|
247
247
|
export interface GitInvocation {
|
|
248
248
|
repoDir: string;
|
|
249
249
|
rest: string;
|
|
250
|
+
/**
|
|
251
|
+
* True when the command text itself names the repository (`-C`,
|
|
252
|
+
* `--git-dir`, `--work-tree`, `GIT_DIR=`). False means `repoDir` is only
|
|
253
|
+
* the invocation's assumed base directory, which is the shell's working
|
|
254
|
+
* directory and not a fact the command string carries.
|
|
255
|
+
*/
|
|
256
|
+
explicitRepoDir?: boolean;
|
|
250
257
|
}
|
|
251
258
|
|
|
252
259
|
export interface ShellMutation {
|
package/src/lib/v2-plugin.ts
CHANGED
|
@@ -28,6 +28,8 @@ import {
|
|
|
28
28
|
setWorkspaceRoot,
|
|
29
29
|
setSdkClient,
|
|
30
30
|
getSessionBoundWorkspace,
|
|
31
|
+
rebindSessionWorkspaceFromHost,
|
|
32
|
+
forgetSessionWorkspace,
|
|
31
33
|
registerSessionWorkspaceFromToolCall,
|
|
32
34
|
runWithRuntimeState,
|
|
33
35
|
getSessionMutationCount,
|
|
@@ -841,6 +843,23 @@ async function handleV2Event(
|
|
|
841
843
|
return;
|
|
842
844
|
}
|
|
843
845
|
|
|
846
|
+
if (event.type === "session.moved") {
|
|
847
|
+
// `session_move` relocates a session between checkouts, and the host
|
|
848
|
+
// announces it. On the pinned 2.2.0 SDK the V2 union carries
|
|
849
|
+
// `session.moved` and no `session.updated`, and the move event itself
|
|
850
|
+
// names the destination: `data.sessionID` plus
|
|
851
|
+
// `data.location.directory`. Without this rebind the session kept
|
|
852
|
+
// judging the tree it left.
|
|
853
|
+
const data = event.data as {
|
|
854
|
+
sessionID?: unknown;
|
|
855
|
+
location?: { directory?: unknown };
|
|
856
|
+
} | undefined;
|
|
857
|
+
const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
|
|
858
|
+
const directory = typeof data?.location?.directory === "string" ? data.location.directory : undefined;
|
|
859
|
+
rebindSessionWorkspaceFromHost(sessionID, directory, { moved: true });
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
|
|
844
863
|
if (event.type === "session.idle") {
|
|
845
864
|
const data = event.data as { sessionID?: unknown } | undefined;
|
|
846
865
|
const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
|
|
@@ -870,6 +889,7 @@ async function handleV2Event(
|
|
|
870
889
|
clearTodoSessionState(sessionID);
|
|
871
890
|
toolLifecycle.takeRecoveryRun(sessionID);
|
|
872
891
|
await runWithRuntimeState(effectiveRoot, client, () => clearContinuationState(sessionID));
|
|
892
|
+
forgetSessionWorkspace(sessionID);
|
|
873
893
|
}
|
|
874
894
|
return;
|
|
875
895
|
}
|
package/src/policies/boundary.ts
CHANGED
|
@@ -2,14 +2,16 @@ import { realpathSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import type { ShellMutation } from "../lib/types.ts";
|
|
5
|
+
import type { ShellSegmentPlan } from "../lib/shell.ts";
|
|
5
6
|
import {
|
|
6
7
|
getWorkspaceRoot,
|
|
7
8
|
recordMutation,
|
|
8
9
|
} from "../lib/state.ts";
|
|
9
|
-
import { prepareRedirectResidue, shellWords, unwrapShellCommand } from "../lib/shell.ts";
|
|
10
|
+
import { isLeadingEvalSegment, maskDataHeredocBodies, prepareRedirectResidue, planShellSegments, shellWords, unwrapShellCommand } from "../lib/shell.ts";
|
|
10
11
|
import { isCollaborationInvocation, isProtectedPath, PROTECTED_PATH_REASON } from "./tamper.ts";
|
|
11
12
|
import { isSecretPath, secretIn } from "./secrets.ts";
|
|
12
|
-
import {
|
|
13
|
+
import { isGitTreeWriteText, branchGuardReason, onProtectedBranch, parseGitInvocation } from "./git.ts";
|
|
14
|
+
import { isSameGitRepo } from "../lib/project-config.ts";
|
|
13
15
|
import {
|
|
14
16
|
effectiveTodos,
|
|
15
17
|
effectiveTodoOwnerSessionID,
|
|
@@ -372,8 +374,265 @@ function stripQuotedSpans(segment: string): string {
|
|
|
372
374
|
return segment.replace(/'[^'\n]*'/g, " ").replace(/"[^"\n]*"/g, " ");
|
|
373
375
|
}
|
|
374
376
|
|
|
377
|
+
/**
|
|
378
|
+
* A segment that changes a tree this session may not judge: a mutation or git
|
|
379
|
+
* write whose directory could not be resolved (`cd $DIR && git commit`) or
|
|
380
|
+
* whose directory is a foreign repository (`cd /other-repo && rm -rf dist`).
|
|
381
|
+
*
|
|
382
|
+
* Relative targets in such a segment would be validated against the session's
|
|
383
|
+
* own checkout while the command changes another tree. That is the defect a
|
|
384
|
+
* shell-level `cd` used to hide, so it is refused rather than judged.
|
|
385
|
+
*/
|
|
386
|
+
export function foreignSegmentMutationReason(
|
|
387
|
+
segments: ShellSegmentPlan[],
|
|
388
|
+
workspaceRoot: string,
|
|
389
|
+
): string | undefined {
|
|
390
|
+
for (const { segment, root } of segments) {
|
|
391
|
+
const mutation = detectShellMutation(segment);
|
|
392
|
+
const gitWrite = isGitTreeWrite(segment, root ?? workspaceRoot);
|
|
393
|
+
if (!mutation && !gitWrite) continue;
|
|
394
|
+
if (root === undefined) {
|
|
395
|
+
return `Blocked: the command changes directory to a target that cannot be determined without executing shell expansion, then runs a mutation (${mutation?.what ?? "git write"}). Indeterminate destinations are treated as outside the workspace root (${workspaceRoot}). Use a literal directory inside the workspace.`;
|
|
396
|
+
}
|
|
397
|
+
if (!isPathOutsideWorkspace(root, workspaceRoot) || isSameGitRepo(root, workspaceRoot)) continue;
|
|
398
|
+
return `Blocked: the command changes into '${root}' and mutates it (${mutation?.what ?? "git write"}), but that directory is outside workspace root (${workspaceRoot}) and is not a linked worktree of this repository. Relative targets there cannot be validated against the tree the command would actually change. Run the command from inside the workspace instead.`;
|
|
399
|
+
}
|
|
400
|
+
return undefined;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function isGitTreeWrite(segment: string, baseDir: string): boolean {
|
|
404
|
+
const invocation = parseGitInvocation(segment.trim(), baseDir);
|
|
405
|
+
if (!invocation) return false;
|
|
406
|
+
return isGitTreeWriteText(`git ${invocation.rest}`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The working directory an inline interpreter payload names for the commands it
|
|
411
|
+
* runs.
|
|
412
|
+
*
|
|
413
|
+
* `execSync('git commit', {cwd: '<dir>'})` carries the tree directly instead of
|
|
414
|
+
* changing directory first, so the payload walk alone cannot see it. The match
|
|
415
|
+
* is anchored to a spawn-family call or `process.chdir`, because a bare `cwd:`
|
|
416
|
+
* key in an unrelated object literal does not choose a working directory.
|
|
417
|
+
*
|
|
418
|
+
* A named directory that is not a literal (`{cwd: process.env.OTHER}`) is
|
|
419
|
+
* reported as indeterminate rather than ignored, matching the shell rule that
|
|
420
|
+
* an unresolvable destination fails closed for a mutation.
|
|
421
|
+
*/
|
|
422
|
+
export type PayloadWorkingRoot =
|
|
423
|
+
| { status: "none" }
|
|
424
|
+
| { status: "resolved"; root: string }
|
|
425
|
+
| { status: "indeterminate"; raw: string };
|
|
426
|
+
|
|
427
|
+
// Only a call site that actually spawns a process names a working directory.
|
|
428
|
+
// `execSync`, `spawn`, and friends are unambiguous. A bare `exec` is guarded by
|
|
429
|
+
// its preceding token so `regex.exec(str, {cwd: …})` is not a spawn, while
|
|
430
|
+
// `child_process.exec(...)` still is. A bare `cwd:` key in an unrelated object
|
|
431
|
+
// literal names no working directory at all.
|
|
432
|
+
const SPAWN_CALLEE = String.raw`(?:(?:child_process|cp)\.)?(?:execSync|execFileSync|spawnSync|execFile|spawn|fork)|(?:child_process|cp)\.exec|(?<![.\w$])exec`;
|
|
433
|
+
const PROCESS_CHDIR = /\bprocess\s*\.\s*chdir\s*\(\s*([^)]+?)\s*\)/;
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The argument text of every spawn-family call, found with a balanced scan.
|
|
437
|
+
*
|
|
438
|
+
* A flat `[^)]*` stopped at the first closing paren, so
|
|
439
|
+
* `execSync(require('path').join('a','b'), {cwd: '/other'})` matched nothing and
|
|
440
|
+
* its foreign `cwd` went unchecked.
|
|
441
|
+
*/
|
|
442
|
+
function spawnCallArguments(payload: string): string[] {
|
|
443
|
+
const spans: string[] = [];
|
|
444
|
+
const callee = new RegExp(String.raw`(?:${SPAWN_CALLEE})\s*\(`, "g");
|
|
445
|
+
for (let match = callee.exec(payload); match; match = callee.exec(payload)) {
|
|
446
|
+
let depth = 0;
|
|
447
|
+
let quote: string | undefined;
|
|
448
|
+
const start = match.index + match[0].length - 1;
|
|
449
|
+
for (let i = start; i < payload.length; i += 1) {
|
|
450
|
+
const char = payload[i]!;
|
|
451
|
+
if (quote) {
|
|
452
|
+
if (char === quote && payload[i - 1] !== "\\") quote = undefined;
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (char === "'" || char === '"' || char === "`") {
|
|
456
|
+
quote = char;
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (char === "(") depth += 1;
|
|
460
|
+
else if (char === ")") {
|
|
461
|
+
depth -= 1;
|
|
462
|
+
if (depth === 0) {
|
|
463
|
+
spans.push(payload.slice(start + 1, i));
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
callee.lastIndex = Math.max(callee.lastIndex, start + 1);
|
|
469
|
+
}
|
|
470
|
+
return spans;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* The `cwd` of the call's own options object, ignoring a nested one.
|
|
475
|
+
*
|
|
476
|
+
* The argument text is split at top-level commas, and only an argument that is
|
|
477
|
+
* itself an object literal contributes. Without that, `execSync(fn({cwd: '<a>'}),
|
|
478
|
+
* {cwd: '<b>'})` read the nested object's key first and reported a workspace
|
|
479
|
+
* directory for a call that actually ran in `<b>`.
|
|
480
|
+
*/
|
|
481
|
+
function callOwnCwd(args: string): string | undefined {
|
|
482
|
+
let depth = 0;
|
|
483
|
+
let quote: string | undefined;
|
|
484
|
+
const topLevel: string[] = [];
|
|
485
|
+
let current = "";
|
|
486
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
487
|
+
const char = args[i]!;
|
|
488
|
+
if (quote) {
|
|
489
|
+
if (char === quote && args[i - 1] !== "\\") quote = undefined;
|
|
490
|
+
current += char;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
if (char === "'" || char === '"' || char === "`") {
|
|
494
|
+
quote = char;
|
|
495
|
+
current += char;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (char === "{" || char === "[" || char === "(") depth += 1;
|
|
499
|
+
else if (char === "}" || char === "]" || char === ")") depth -= 1;
|
|
500
|
+
if (char === "," && depth === 0) {
|
|
501
|
+
topLevel.push(current);
|
|
502
|
+
current = "";
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
current += char;
|
|
506
|
+
}
|
|
507
|
+
topLevel.push(current);
|
|
508
|
+
for (const argument of topLevel) {
|
|
509
|
+
const trimmed = argument.trim();
|
|
510
|
+
if (!trimmed.startsWith("{")) continue;
|
|
511
|
+
const match = /\bcwd\s*:\s*([^,}]+)/.exec(trimmed);
|
|
512
|
+
if (match?.[1]) return match[1].trim();
|
|
513
|
+
}
|
|
514
|
+
return undefined;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function payloadWorkingRoot(payload: string): PayloadWorkingRoot {
|
|
518
|
+
const cwdValues = spawnCallArguments(payload)
|
|
519
|
+
.map(callOwnCwd)
|
|
520
|
+
.filter((value): value is string => value !== undefined);
|
|
521
|
+
const chdir = PROCESS_CHDIR.exec(payload)?.[1]?.trim();
|
|
522
|
+
if (chdir) cwdValues.push(chdir);
|
|
523
|
+
// Every call's directory has to agree. Two different trees, or one that is
|
|
524
|
+
// not a literal, means the payload's real directory is not determinable, and
|
|
525
|
+
// a mutation then fails closed rather than trusting the first one found.
|
|
526
|
+
const roots = new Set<string>();
|
|
527
|
+
for (const raw of cwdValues) {
|
|
528
|
+
const literal = /^(["'`])([^"'`]*)\1$/.exec(raw);
|
|
529
|
+
if (!literal?.[2]) return { status: "indeterminate", raw };
|
|
530
|
+
roots.add(literal[2]);
|
|
531
|
+
}
|
|
532
|
+
if (roots.size === 0) return { status: "none" };
|
|
533
|
+
if (roots.size > 1) return { status: "indeterminate", raw: [...roots].join(", ") };
|
|
534
|
+
return { status: "resolved", root: [...roots][0]! };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function payloadMutates(payload: string): boolean {
|
|
538
|
+
if (detectShellMutation(payload)) return true;
|
|
539
|
+
return isGitTreeWriteText(payload);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* A segment-level git invocation whose repository is outside the workspace.
|
|
544
|
+
*
|
|
545
|
+
* The dispatcher applies this check to top-level commands. A payload was never
|
|
546
|
+
* routed through it, so `bash -c 'git -C <other> commit'` reached a foreign
|
|
547
|
+
* repository: the `-C` form carries no `cd`, and `isGitTreeWriteText` reads the
|
|
548
|
+
* `git <verb>` text without resolving the flag.
|
|
549
|
+
*/
|
|
550
|
+
function foreignGitRepositoryIn(segment: string, baseDir: string, workspaceRoot: string): string | undefined {
|
|
551
|
+
for (const raw of segment.split(/[\n|;&]+/)) {
|
|
552
|
+
const invocation = parseGitInvocation(raw.trim(), baseDir);
|
|
553
|
+
if (!invocation) continue;
|
|
554
|
+
if (!isGitTreeWriteText(`git ${invocation.rest}`)) continue;
|
|
555
|
+
const repoDir = invocation.repoDir;
|
|
556
|
+
const foreign = hasUnresolvableVariable(repoDir)
|
|
557
|
+
|| (isPathOutsideWorkspace(repoDir, workspaceRoot) && !isSameGitRepo(repoDir, workspaceRoot));
|
|
558
|
+
if (foreign) return repoDir;
|
|
559
|
+
}
|
|
560
|
+
return undefined;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* The tree a nested interpreter payload changes, when that tree is one this
|
|
565
|
+
* session may not judge.
|
|
566
|
+
*
|
|
567
|
+
* A payload never reaches the top-level segment walk: `bash -c 'cd <other> &&
|
|
568
|
+
* git commit'` is one shell word to the outer shell, so its nested `cd` and
|
|
569
|
+
* mutation were invisible. `extractPayloads` and `writesIn` are injected so
|
|
570
|
+
* this policy does not import the interpreter scanner back.
|
|
571
|
+
*/
|
|
572
|
+
export function nestedPayloadMutationReason(
|
|
573
|
+
extractPayloads: (segment: string) => string[],
|
|
574
|
+
writesIn: (payload: string) => string[],
|
|
575
|
+
command: string,
|
|
576
|
+
baseDir: string,
|
|
577
|
+
workspaceRoot: string,
|
|
578
|
+
depth = 0,
|
|
579
|
+
): string | undefined {
|
|
580
|
+
// Nesting deeper than this is not modelled. A launcher chain that deep is
|
|
581
|
+
// refused rather than skipped, so the cap cannot become a way through.
|
|
582
|
+
if (depth > 8) {
|
|
583
|
+
return `Blocked: inline interpreter scripts are nested deeper than the ${8} levels this check models, so the tree they mutate cannot be determined. Run the command from a single script file inside the workspace instead.`;
|
|
584
|
+
}
|
|
585
|
+
for (const { segment, root } of planShellSegments(command, baseDir, (s) => {
|
|
586
|
+
// `eval` runs in the current shell, so its body's `cd` moves later
|
|
587
|
+
// segments. Only an `eval` body counts here; another launcher's payload
|
|
588
|
+
// runs in a child process and cannot move this shell.
|
|
589
|
+
return isLeadingEvalSegment(s) ? extractPayloads(s)[0] : undefined;
|
|
590
|
+
})) {
|
|
591
|
+
const payloads = extractPayloads(segment);
|
|
592
|
+
if (payloads.length === 0) continue;
|
|
593
|
+
// A launcher whose directory is indeterminate must not be assumed to be
|
|
594
|
+
// the workspace root: `cd $DIR && node -e "writeFileSync('x')"` would then
|
|
595
|
+
// read as a workspace write. The payload's own relative targets cannot be
|
|
596
|
+
// validated against a tree this walk could not resolve, so a mutation
|
|
597
|
+
// fails closed, matching the shell rule for the same shape.
|
|
598
|
+
const launcherRoot = root;
|
|
599
|
+
if (launcherRoot === undefined) {
|
|
600
|
+
const mutates = payloads.some((payload) => writesIn(payload).length > 0 || payloadMutates(payload));
|
|
601
|
+
if (mutates) {
|
|
602
|
+
return `Blocked: the command changes directory to a target that cannot be determined without executing shell expansion, then runs an inline interpreter script that mutates. Indeterminate destinations are treated as outside the workspace root (${workspaceRoot}). Use a literal directory inside the workspace.`;
|
|
603
|
+
}
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
for (const payload of payloads) {
|
|
607
|
+
const foreignRepo = foreignGitRepositoryIn(payload, launcherRoot, workspaceRoot);
|
|
608
|
+
if (foreignRepo) {
|
|
609
|
+
return `Blocked: the inline interpreter script runs a git write against '${foreignRepo}', outside workspace root (${workspaceRoot}) and not a linked worktree of this repository. Run the command from inside the workspace instead.`;
|
|
610
|
+
}
|
|
611
|
+
const nested = foreignSegmentMutationReason(planShellSegments(payload, launcherRoot), workspaceRoot);
|
|
612
|
+
if (nested) return nested;
|
|
613
|
+
// A payload can launch another interpreter: `bash -c "bash -c 'cd
|
|
614
|
+
// <other> && git commit'"` only extracts its own quoted body. Re-plan
|
|
615
|
+
// each payload until the nesting bottoms out.
|
|
616
|
+
const inner = nestedPayloadMutationReason(extractPayloads, writesIn, payload, launcherRoot, workspaceRoot, depth + 1);
|
|
617
|
+
if (inner) return inner;
|
|
618
|
+
const declared = payloadWorkingRoot(payload);
|
|
619
|
+
if (declared.status === "none") continue;
|
|
620
|
+
if (writesIn(payload).length === 0 && !payloadMutates(payload)) continue;
|
|
621
|
+
if (declared.status === "indeterminate") {
|
|
622
|
+
return `Blocked: the inline interpreter script runs a mutating command in a working directory that cannot be determined without executing the script (${declared.raw}). Indeterminate destinations are treated as outside the workspace root (${workspaceRoot}). Use a literal directory inside the workspace.`;
|
|
623
|
+
}
|
|
624
|
+
const targetRoot = resolve(launcherRoot, declared.root);
|
|
625
|
+
if (!isPathOutsideWorkspace(targetRoot, workspaceRoot) || isSameGitRepo(targetRoot, workspaceRoot)) continue;
|
|
626
|
+
return `Blocked: the inline interpreter script runs a mutating command in '${targetRoot}', outside workspace root (${workspaceRoot}) and not a linked worktree of this repository. Relative targets there cannot be validated against the tree the command would actually change. Run the command from inside the workspace instead.`;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return undefined;
|
|
630
|
+
}
|
|
631
|
+
|
|
375
632
|
export function detectShellMutation(command: string): ShellMutation | undefined {
|
|
376
|
-
|
|
633
|
+
// A heredoc body no command executes is prose. Splitting it on its newlines
|
|
634
|
+
// read a documented command shape as a real one.
|
|
635
|
+
for (const rawSegment of maskDataHeredocBodies(command).split(/[\n|;&]+/)) {
|
|
377
636
|
const segment = isCollaborationInvocation(rawSegment) ? stripQuotedSpans(rawSegment) : rawSegment;
|
|
378
637
|
const simpleMutations = simpleFilesystemMutations(segment);
|
|
379
638
|
if (simpleMutations.length > 0) return simpleMutations[0];
|
|
@@ -395,15 +654,39 @@ export async function guardShellMutation(
|
|
|
395
654
|
command: string,
|
|
396
655
|
sessionID: string | undefined,
|
|
397
656
|
record = true,
|
|
657
|
+
shellRoot?: string,
|
|
658
|
+
evalBody?: (segment: string) => string | undefined,
|
|
398
659
|
): Promise<string | undefined> {
|
|
399
|
-
|
|
660
|
+
// Judge the tree the command RUNS in. A linked worktree of this repository
|
|
661
|
+
// is a legitimate workdir, and its branch or relative targets are not the
|
|
662
|
+
// session checkout's: validating against that checkout blocked a mutation
|
|
663
|
+
// in a feature worktree as if it happened on the primary `main`.
|
|
664
|
+
const workspaceRoot = getWorkspaceRoot();
|
|
665
|
+
const root = shellRoot ?? workspaceRoot;
|
|
666
|
+
const plan = planShellSegments(command, root, evalBody);
|
|
667
|
+
// The foreign-tree gate runs here too, so this function is safe when called
|
|
668
|
+
// on its own: an indeterminate or foreign `cd` is refused instead of silently
|
|
669
|
+
// falling back to judging relative targets against the wrong tree.
|
|
670
|
+
const foreignReason = foreignSegmentMutationReason(plan, workspaceRoot);
|
|
671
|
+
if (foreignReason) return foreignReason;
|
|
400
672
|
let hasMutation = false;
|
|
401
|
-
for (const
|
|
673
|
+
for (const { segment, root: segmentRoot } of plan) {
|
|
674
|
+
// `cd` earlier in the command moves this segment, so its relative targets
|
|
675
|
+
// resolve against the tree it actually changes. An indeterminate `cd` was
|
|
676
|
+
// already refused above.
|
|
677
|
+
const runRoot = segmentRoot ?? root;
|
|
678
|
+
// Both trees are this workspace: the checkout the command runs in and the
|
|
679
|
+
// session's own checkout. Absolute paths into either are legitimate, so a
|
|
680
|
+
// target is outside only when it leaves both.
|
|
681
|
+
const outsideWorkspace = (target: string): boolean =>
|
|
682
|
+
isPathOutsideWorkspace(target, runRoot) && isPathOutsideWorkspace(target, workspaceRoot);
|
|
402
683
|
// Same residue analysis as detectShellMutation: quoted arguments of
|
|
403
684
|
// gh/glab/az PR/issue commands are command data, not shell redirects,
|
|
404
|
-
// while unquoted redirects still get full validation below.
|
|
405
|
-
|
|
406
|
-
const
|
|
685
|
+
// while unquoted redirects still get full validation below. A heredoc body
|
|
686
|
+
// that no command executes is prose, so its lines are not judged either.
|
|
687
|
+
const masked = maskDataHeredocBodies(segment);
|
|
688
|
+
const cleanSegment = isCollaborationInvocation(masked) ? stripQuotedSpans(masked) : masked;
|
|
689
|
+
const secretSource = secretSourceInFilesystemCommand(cleanSegment);
|
|
407
690
|
if (secretSource) {
|
|
408
691
|
return `Blocked: shell command would copy, move, or link sensitive file '${secretSource}' under a non-secret name.`;
|
|
409
692
|
}
|
|
@@ -411,36 +694,36 @@ export async function guardShellMutation(
|
|
|
411
694
|
// sources must respect the same boundaries as mutation targets: no
|
|
412
695
|
// moving files in from outside the workspace, no moving protected
|
|
413
696
|
// (settings/plugin) files to innocuous names.
|
|
414
|
-
const transfer = filesystemTransferInfo(
|
|
415
|
-
if (transfer && shellWords(unwrapShellCommand(
|
|
697
|
+
const transfer = filesystemTransferInfo(cleanSegment);
|
|
698
|
+
if (transfer && shellWords(unwrapShellCommand(cleanSegment))[0] === "mv") {
|
|
416
699
|
for (const source of transfer.sources) {
|
|
417
700
|
if (isProtectedPath(source)) {
|
|
418
701
|
return PROTECTED_PATH_REASON;
|
|
419
702
|
}
|
|
420
|
-
if (
|
|
703
|
+
if (outsideWorkspace(source)) {
|
|
421
704
|
if (hasUnresolvableVariable(source)) {
|
|
422
|
-
return `Blocked: mv source '${source}' contains an unresolvable variable reference; indeterminate destinations are treated as outside the workspace root (${
|
|
705
|
+
return `Blocked: mv source '${source}' contains an unresolvable variable reference; indeterminate destinations are treated as outside the workspace root (${workspaceRoot}). Use a literal workspace-relative path.`;
|
|
423
706
|
}
|
|
424
|
-
return `Blocked: mv would remove source '${source}' from outside the workspace root (${
|
|
707
|
+
return `Blocked: mv would remove source '${source}' from outside the workspace root (${workspaceRoot}). File mutations must stay within the workspace.`;
|
|
425
708
|
}
|
|
426
709
|
}
|
|
427
710
|
}
|
|
428
|
-
const simpleMutations = simpleFilesystemMutations(
|
|
429
|
-
const teeTargets = teeTargetsIn(
|
|
711
|
+
const simpleMutations = simpleFilesystemMutations(cleanSegment);
|
|
712
|
+
const teeTargets = teeTargetsIn(cleanSegment);
|
|
430
713
|
const teeMutations: ShellMutation[] = teeTargets.map((target) => ({
|
|
431
714
|
kind: "command" as const,
|
|
432
715
|
target,
|
|
433
716
|
what: `tee to '${target}'`,
|
|
434
717
|
}));
|
|
435
|
-
const redirectMutations = redirectMutationsIn(
|
|
718
|
+
const redirectMutations = redirectMutationsIn(cleanSegment.trim());
|
|
436
719
|
const mutations = [...simpleMutations, ...teeMutations, ...redirectMutations];
|
|
437
720
|
if (mutations.length === 0) {
|
|
438
|
-
const fallbackMutation = shellMutationIn(
|
|
721
|
+
const fallbackMutation = shellMutationIn(cleanSegment.trim());
|
|
439
722
|
if (fallbackMutation) mutations.push(fallbackMutation);
|
|
440
723
|
}
|
|
441
724
|
for (const mutation of mutations) {
|
|
442
725
|
hasMutation = true;
|
|
443
|
-
const secret = secretIn(
|
|
726
|
+
const secret = secretIn(cleanSegment);
|
|
444
727
|
if (secret) {
|
|
445
728
|
return `Blocked: shell file mutation payload appears to contain a ${secret}. Secrets must not be written to disk from agent commands.`;
|
|
446
729
|
}
|
|
@@ -448,19 +731,19 @@ export async function guardShellMutation(
|
|
|
448
731
|
if (target && isProtectedPath(target)) {
|
|
449
732
|
return PROTECTED_PATH_REASON;
|
|
450
733
|
}
|
|
451
|
-
if (target &&
|
|
734
|
+
if (target && outsideWorkspace(target)) {
|
|
452
735
|
// The workspace boundary has no override: a write outside the
|
|
453
736
|
// workspace is out of bounds even with WORKFLOW_GUARD_ALLOW_LIVE
|
|
454
737
|
// (that override covers live-system commands, not the boundary).
|
|
455
738
|
if (hasUnresolvableVariable(target)) {
|
|
456
|
-
return `Blocked: shell mutation '${mutation.what}' targets '${target}', which contains an unresolvable variable reference; indeterminate destinations are treated as outside the workspace root (${
|
|
739
|
+
return `Blocked: shell mutation '${mutation.what}' targets '${target}', which contains an unresolvable variable reference; indeterminate destinations are treated as outside the workspace root (${workspaceRoot}). Use a literal workspace-relative path.`;
|
|
457
740
|
}
|
|
458
|
-
if (isEnvironmentScratchTarget(target,
|
|
459
|
-
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 (${
|
|
741
|
+
if (isEnvironmentScratchTarget(target, runRoot)) {
|
|
742
|
+
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 (${workspaceRoot}) instead (for example .tmp-notes.md).`;
|
|
460
743
|
}
|
|
461
|
-
return `Blocked: shell mutation '${mutation.what}' targets a path outside the workspace root (${
|
|
744
|
+
return `Blocked: shell mutation '${mutation.what}' targets a path outside the workspace root (${workspaceRoot}). All changes must stay within the workspace.`;
|
|
462
745
|
}
|
|
463
|
-
if (onProtectedBranch(
|
|
746
|
+
if (onProtectedBranch(runRoot)) {
|
|
464
747
|
return branchGuardReason();
|
|
465
748
|
}
|
|
466
749
|
const todos = await effectiveTodos(sessionID);
|