opencode-workflow-guard 1.18.1 → 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 +4 -4
- package/docs/installation.md +23 -0
- package/docs/policies.md +31 -3
- package/docs/testing.md +3 -1
- package/package.json +2 -2
- package/src/lib/audit.ts +52 -2
- package/src/lib/custom-tools.ts +154 -3
- package/src/lib/dogfood.ts +147 -0
- package/src/lib/evidence.ts +19 -1
- package/src/lib/guard-dispatcher.ts +134 -13
- package/src/lib/permission-seat.ts +79 -1
- package/src/lib/shell.ts +702 -5
- package/src/lib/state.ts +301 -2
- package/src/lib/types.ts +71 -1
- package/src/lib/v2-plugin.ts +32 -1
- 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 +56 -5
- package/test/test-e2e.mts +4 -1
- package/test/test.mts +1286 -20
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);
|
package/src/policies/git.ts
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
import {
|
|
10
10
|
unwrapShellWords,
|
|
11
11
|
splitShellSegments,
|
|
12
|
+
shellPrefixAssignments,
|
|
13
|
+
resolveGitRepoSelector,
|
|
12
14
|
} from "../lib/shell.ts";
|
|
13
15
|
|
|
14
16
|
export const PROTECTED_BRANCHES = new Set(["main", "master"]);
|
|
@@ -45,6 +47,10 @@ const GIT_DIR_OPTION_TAKES_VALUE = new Set([
|
|
|
45
47
|
]);
|
|
46
48
|
const GIT_DIR_OPTION_PREFIXED =
|
|
47
49
|
/^(--git-dir=|--work-tree=|--namespace=|-c\S|--config-env=)/;
|
|
50
|
+
// Git also accepts the attached short forms (`-C/path`, `-C~`). An unparsed
|
|
51
|
+
// `-C` leaves the repository at the assumed base, which judges the write in the
|
|
52
|
+
// wrong checkout, so the attached form is read as a claim like the spaced one.
|
|
53
|
+
const GIT_DIR_SHORT_ATTACHED = /^-C(.+)$/;
|
|
48
54
|
const GIT_GLOBAL_BOOLEAN_OPTIONS = new Set([
|
|
49
55
|
"--version",
|
|
50
56
|
"--help",
|
|
@@ -60,44 +66,88 @@ const GIT_GLOBAL_BOOLEAN_OPTIONS = new Set([
|
|
|
60
66
|
"--exec-path",
|
|
61
67
|
]);
|
|
62
68
|
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
// Repository selection from the environment. It is read from the assignments
|
|
70
|
+
// that precede the command word, never from the rest of the command text. A
|
|
71
|
+
// bare `GIT_DIR=` used to be matched anywhere in the string, so an unquoted
|
|
72
|
+
// commit message such as `git commit -m GIT_DIR=/repo` claimed an explicit
|
|
73
|
+
// repository. That was harmless while the value only picked a base directory,
|
|
74
|
+
// but `explicitRepoDir` is now a fact the permission seat trusts, so the claim
|
|
75
|
+
// must be real. A `-c` config override is deliberately not a repository
|
|
76
|
+
// selector: `GIT_DIR` is an environment variable, not a git config key.
|
|
77
|
+
const GIT_REPO_ENV_ASSIGNMENT = /^(?:GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR)=(.*)$/s;
|
|
65
78
|
|
|
66
|
-
|
|
67
|
-
|
|
79
|
+
/**
|
|
80
|
+
* The repository an environment assignment selects, or undefined.
|
|
81
|
+
*
|
|
82
|
+
* Only assignments that the shell unwrapper actually consumed before the
|
|
83
|
+
* command word count. A value that merely appears inside an argument cannot be
|
|
84
|
+
* mistaken for one.
|
|
85
|
+
*/
|
|
86
|
+
function repoEnvironmentOverride(command: string): string | undefined {
|
|
87
|
+
for (const assignment of shellPrefixAssignments(command)) {
|
|
88
|
+
const match = GIT_REPO_ENV_ASSIGNMENT.exec(assignment);
|
|
89
|
+
if (match?.[1]) return match[1];
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Parse one git invocation.
|
|
96
|
+
*
|
|
97
|
+
* `baseDir` is the directory the command actually runs in: a shell call's
|
|
98
|
+
* `workdir`, resolved against the call's workspace by the caller. It defaults
|
|
99
|
+
* to the ambient workspace root for callers that only classify command text.
|
|
100
|
+
* Without it, `git push` inside a linked worktree was resolved against the
|
|
101
|
+
* session-bound checkout, so every branch-state check judged the wrong tree.
|
|
102
|
+
*
|
|
103
|
+
* `explicitRepoDir` records whether the command text named the repository
|
|
104
|
+
* itself. A caller that cannot see `workdir` uses that flag to tell a fact
|
|
105
|
+
* from an assumption.
|
|
106
|
+
*/
|
|
107
|
+
export function parseGitInvocation(command: string, baseDir?: string): GitInvocation | undefined {
|
|
108
|
+
const root = baseDir ?? getWorkspaceRoot();
|
|
68
109
|
const tokens = unwrapShellWords(command);
|
|
69
110
|
if (tokens[0] !== "git") return undefined;
|
|
70
111
|
let i = 1;
|
|
71
112
|
let repoDir = root;
|
|
72
113
|
let sawDirOption = false;
|
|
73
114
|
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
repoDir = resolve(root, envVal);
|
|
79
|
-
sawDirOption = true;
|
|
80
|
-
}
|
|
115
|
+
const envDirValue = repoEnvironmentOverride(command);
|
|
116
|
+
if (envDirValue) {
|
|
117
|
+
repoDir = resolveGitRepoSelector(root, envDirValue);
|
|
118
|
+
sawDirOption = true;
|
|
81
119
|
}
|
|
82
120
|
|
|
83
121
|
while (i < tokens.length) {
|
|
84
122
|
const tok = tokens[i]!;
|
|
85
123
|
if (GIT_DIR_OPTION_TAKES_VALUE.has(tok)) {
|
|
86
124
|
const value = tokens[i + 1];
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
125
|
+
const namesRepository = tok === "-C" || tok === "--git-dir" || tok === "--work-tree";
|
|
126
|
+
// A trailing `-C` with no value names no repository, but the flag is
|
|
127
|
+
// still present in the text. Report it as a claim so both seats agree
|
|
128
|
+
// the command is unclassifiable rather than one judging it and the
|
|
129
|
+
// other deferring it. `-c` and `--config-env` name no repository.
|
|
130
|
+
if (value === undefined) return { repoDir, rest: "", explicitRepoDir: sawDirOption || namesRepository };
|
|
131
|
+
if (namesRepository) {
|
|
132
|
+
repoDir = resolveGitRepoSelector(root, value);
|
|
90
133
|
sawDirOption = true;
|
|
91
134
|
}
|
|
92
135
|
i += 2;
|
|
93
136
|
continue;
|
|
94
137
|
}
|
|
138
|
+
const attached = GIT_DIR_SHORT_ATTACHED.exec(tok);
|
|
139
|
+
if (attached) {
|
|
140
|
+
repoDir = resolveGitRepoSelector(root, attached[1]!);
|
|
141
|
+
sawDirOption = true;
|
|
142
|
+
i += 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
95
145
|
if (GIT_DIR_OPTION_PREFIXED.test(tok)) {
|
|
96
146
|
if (tok.startsWith("--git-dir=")) {
|
|
97
|
-
repoDir =
|
|
147
|
+
repoDir = resolveGitRepoSelector(root, tok.slice("--git-dir=".length));
|
|
98
148
|
sawDirOption = true;
|
|
99
149
|
} else if (tok.startsWith("--work-tree=")) {
|
|
100
|
-
repoDir =
|
|
150
|
+
repoDir = resolveGitRepoSelector(root, tok.slice("--work-tree=".length));
|
|
101
151
|
sawDirOption = true;
|
|
102
152
|
}
|
|
103
153
|
i += 1;
|
|
@@ -109,8 +159,7 @@ export function parseGitInvocation(command: string): GitInvocation | undefined {
|
|
|
109
159
|
}
|
|
110
160
|
break;
|
|
111
161
|
}
|
|
112
|
-
|
|
113
|
-
return { repoDir, rest: tokens.slice(i).join(" ") };
|
|
162
|
+
return { repoDir, rest: tokens.slice(i).join(" "), explicitRepoDir: sawDirOption };
|
|
114
163
|
}
|
|
115
164
|
|
|
116
165
|
export function normalizeGitCommands(command: string): string {
|
|
@@ -125,6 +174,17 @@ export function normalizeGitCommands(command: string): string {
|
|
|
125
174
|
export const GIT_WRITE_RE =
|
|
126
175
|
/\bgit\s+(add|rm|mv|commit|merge|rebase|cherry-pick|revert|stash\s+pop|apply|am|restore|reset|update-ref|filter-branch)\b|\bgit\s+tag\s+(?!--?list\b|-l\b)(?:[^|;&]*\s)?(?:-d\b|--delete\b)|\bgit\s+checkout\s+(?!-b\b)|\bgit\s+branch\s+(?:[^|;&]*\s)?-[dDM]\b/;
|
|
127
176
|
|
|
177
|
+
/**
|
|
178
|
+
* True when `text` invokes git in a way that changes a tree's refs or content.
|
|
179
|
+
*
|
|
180
|
+
* One predicate for every caller: the shell segment walk, the nested payload
|
|
181
|
+
* check, and the foreign-workdir gate. Keeping copies is how the payload check
|
|
182
|
+
* drifted wider than the segment check once already.
|
|
183
|
+
*/
|
|
184
|
+
export function isGitTreeWriteText(text: string): boolean {
|
|
185
|
+
return GIT_WRITE_RE.test(text) || GIT_BRANCH_CREATE_RE.test(text) || /\bgit\s+push\b/.test(text);
|
|
186
|
+
}
|
|
187
|
+
|
|
128
188
|
export function currentGitBranch(root: string): string | undefined {
|
|
129
189
|
const result = spawnSync("git", ["branch", "--show-current"], {
|
|
130
190
|
cwd: root,
|
|
@@ -304,14 +364,76 @@ export function isBranchAlreadyMergedOrClosed(
|
|
|
304
364
|
return { merged: false };
|
|
305
365
|
}
|
|
306
366
|
|
|
307
|
-
|
|
367
|
+
/**
|
|
368
|
+
* The source branch a PR-creation command names, if any.
|
|
369
|
+
*
|
|
370
|
+
* `gh` names it with `--head`/`-H`, and Azure DevOps with `--source-branch`.
|
|
371
|
+
* Fork forms carry an `owner:` prefix; a ref cannot contain `:`, so the last
|
|
372
|
+
* segment is the local branch name the caller looks up. Callers fall back to
|
|
373
|
+
* the invoking checkout when the branch is not checked out anywhere locally.
|
|
374
|
+
*/
|
|
375
|
+
export function prSourceBranch(command: string): string | undefined {
|
|
376
|
+
const words = unwrapShellWords(command);
|
|
377
|
+
let source: string | undefined;
|
|
378
|
+
for (let i = 0; i < words.length; i++) {
|
|
379
|
+
const word = words[i]!;
|
|
380
|
+
let raw: string | undefined;
|
|
381
|
+
if (word === "--head" || word === "-H" || word === "--source-branch") {
|
|
382
|
+
raw = words[i + 1];
|
|
383
|
+
} else if (/^--(?:head|source-branch)=/.test(word)) {
|
|
384
|
+
raw = word.slice(word.indexOf("=") + 1);
|
|
385
|
+
} else if (/^-H\S/.test(word)) {
|
|
386
|
+
raw = word.slice(2);
|
|
387
|
+
}
|
|
388
|
+
if (raw === undefined) continue;
|
|
389
|
+
const branch = raw.includes(":") ? raw.slice(raw.lastIndexOf(":") + 1) : raw;
|
|
390
|
+
if (branch) source = branch;
|
|
391
|
+
}
|
|
392
|
+
return source;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Which checkout of this repository has `branch` checked out, if any.
|
|
397
|
+
*
|
|
398
|
+
* A PR is evaluated against the tree the branch actually lives in, not against
|
|
399
|
+
* whatever the caller's checkout happens to hold. Linked worktrees are the
|
|
400
|
+
* normal case: a session may sit in the primary checkout while the reviewed
|
|
401
|
+
* branch is checked out in an isolated worktree.
|
|
402
|
+
*/
|
|
403
|
+
export function findWorktreeForBranch(root: string, branch: string): string | undefined {
|
|
404
|
+
const target = branch.replace(/^refs\/heads\//, "").trim();
|
|
405
|
+
if (!target) return undefined;
|
|
406
|
+
const res = spawnSync("git", ["worktree", "list", "--porcelain"], {
|
|
407
|
+
cwd: root,
|
|
408
|
+
encoding: "utf8",
|
|
409
|
+
timeout: 10_000,
|
|
410
|
+
});
|
|
411
|
+
if (res.status !== 0) return undefined;
|
|
412
|
+
let current: string | undefined;
|
|
413
|
+
for (const line of res.stdout.split("\n")) {
|
|
414
|
+
if (line.startsWith("worktree ")) {
|
|
415
|
+
current = line.slice("worktree ".length).trim();
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (line.startsWith("branch ") && current) {
|
|
419
|
+
if (line.slice("branch ".length).trim().replace(/^refs\/heads\//, "") === target) return current;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function checkMergeConflicts(root: string, ref?: string): {
|
|
308
426
|
hasConflicts: boolean;
|
|
309
427
|
baseBranch?: string;
|
|
310
428
|
reason?: string;
|
|
311
429
|
} {
|
|
430
|
+
// A branch that is not checked out anywhere still has to be judged; the
|
|
431
|
+
// caller passes its ref, and HEAD stands in only when no ref was named.
|
|
432
|
+
const subject = typeof ref === "string" && ref.trim() ? ref.trim() : "HEAD";
|
|
433
|
+
const subjectLabel = subject === "HEAD" ? "" : `Branch '${subject}'`;
|
|
312
434
|
const candidates = ["origin/HEAD", "origin/main", "origin/master", "main", "master"];
|
|
313
435
|
for (const base of candidates) {
|
|
314
|
-
const mergeBaseRes = spawnSync("git", ["merge-base",
|
|
436
|
+
const mergeBaseRes = spawnSync("git", ["merge-base", subject, base], {
|
|
315
437
|
cwd: root,
|
|
316
438
|
encoding: "utf8",
|
|
317
439
|
timeout: 5_000,
|
|
@@ -319,7 +441,7 @@ export function checkMergeConflicts(root: string): {
|
|
|
319
441
|
if (mergeBaseRes.status !== 0 || !mergeBaseRes.stdout.trim()) continue;
|
|
320
442
|
const mergeBase = mergeBaseRes.stdout.trim();
|
|
321
443
|
|
|
322
|
-
const treeRes = spawnSync("git", ["merge-tree", mergeBase,
|
|
444
|
+
const treeRes = spawnSync("git", ["merge-tree", mergeBase, subject, base], {
|
|
323
445
|
cwd: root,
|
|
324
446
|
encoding: "utf8",
|
|
325
447
|
timeout: 10_000,
|
|
@@ -328,7 +450,7 @@ export function checkMergeConflicts(root: string): {
|
|
|
328
450
|
return {
|
|
329
451
|
hasConflicts: true,
|
|
330
452
|
baseBranch: base,
|
|
331
|
-
reason:
|
|
453
|
+
reason: `${subjectLabel || "Branch"} has merge conflicts with base branch '${base}'. Rebase or merge '${base}' to resolve all conflicts before opening a PR or handing off.`,
|
|
332
454
|
};
|
|
333
455
|
}
|
|
334
456
|
}
|