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
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { dogfoodEvidence } from "./evidence.ts";
|
|
2
|
+
import { getDogfoodDeployMatrix, getDogfoodRecordsForWorkspace, isDogfoodRequired } from "./state.ts";
|
|
3
|
+
import { getCurrentGitCommitHash, getTrackedWorktreeFingerprint } from "./verify.ts";
|
|
4
|
+
import type { DogfoodResult } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export interface DogfoodGateResult {
|
|
7
|
+
required: boolean;
|
|
8
|
+
/** Matrix hosts still missing a fresh passing record. */
|
|
9
|
+
missingHosts: string[];
|
|
10
|
+
/** The record that satisfied the gate, when one did. */
|
|
11
|
+
matched?: DogfoodResult;
|
|
12
|
+
/** Every record counted toward the gate, one per host. */
|
|
13
|
+
matchedRecords?: DogfoodResult[];
|
|
14
|
+
/** Why the gate is unsatisfied, cause-attributed. */
|
|
15
|
+
reason?: string;
|
|
16
|
+
/** Evidence id of the matched record, for audit correlation. */
|
|
17
|
+
evidenceId?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Why one record cannot count for this worktree, or undefined when it can.
|
|
22
|
+
*
|
|
23
|
+
* Each cause is named separately because they have different remedies. A
|
|
24
|
+
* missing fingerprint is an environment problem, a fingerprint mismatch is a
|
|
25
|
+
* stale run, and a commit mismatch is an unrecorded commit. Collapsing them
|
|
26
|
+
* into one message sends the agent looking for the wrong fix.
|
|
27
|
+
*/
|
|
28
|
+
function stalenessReason(record: DogfoodResult, commitHash: string | undefined, fingerprint: string | undefined): string | undefined {
|
|
29
|
+
if (!fingerprint) {
|
|
30
|
+
return "Worktree fingerprint could not be computed (git unavailable or worktree unreadable), so dogfood evidence cannot be bound to this worktree; resolve worktree readability and re-run the dogfood scenario.";
|
|
31
|
+
}
|
|
32
|
+
if (!record.worktreeFingerprint) {
|
|
33
|
+
return `The recorded dogfood run ('${record.scenarioId}') has no worktree fingerprint binding (recorded against an unreadable worktree); re-run the dogfood scenario and record it with record_dogfood.`;
|
|
34
|
+
}
|
|
35
|
+
if (record.worktreeFingerprint !== fingerprint) {
|
|
36
|
+
return `The recorded dogfood run ('${record.scenarioId}' on '${record.host.label}') does not match the current worktree contents (tracked content changed after the run); re-run the dogfood scenario and record it with record_dogfood.`;
|
|
37
|
+
}
|
|
38
|
+
if (record.commitHash && commitHash && record.commitHash !== commitHash) {
|
|
39
|
+
return `The recorded dogfood run ('${record.scenarioId}' on '${record.host.label}') is bound to commit ${record.commitHash.slice(0, 12)} but this worktree is at commit ${commitHash.slice(0, 12)}; re-run the dogfood scenario at the current commit and record it with record_dogfood.`;
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Judges the dogfood gate for one worktree.
|
|
46
|
+
*
|
|
47
|
+
* Shared by PR preflight and `guard_status` so the two can never disagree about
|
|
48
|
+
* whether the gate is satisfied. Freshness is content-scoped, matching review:
|
|
49
|
+
* a record covers the tracked tree it ran against, so untracked scratch
|
|
50
|
+
* cleanup cannot invalidate it while any tracked edit or new commit does.
|
|
51
|
+
*/
|
|
52
|
+
export function evaluateDogfoodGate(root: string): DogfoodGateResult {
|
|
53
|
+
if (!isDogfoodRequired(root)) return { required: false, missingHosts: [] };
|
|
54
|
+
const matrix = getDogfoodDeployMatrix(root);
|
|
55
|
+
const records = getDogfoodRecordsForWorkspace(root);
|
|
56
|
+
const commitHash = getCurrentGitCommitHash(root);
|
|
57
|
+
const fingerprint = getTrackedWorktreeFingerprint(root);
|
|
58
|
+
|
|
59
|
+
if (records.length === 0) {
|
|
60
|
+
return {
|
|
61
|
+
required: true,
|
|
62
|
+
missingHosts: matrix.length > 0 ? matrix : ["this host"],
|
|
63
|
+
reason: "Passing dogfood evidence is required (requireDogfood); run the dogfood runner and record the result with record_dogfood.",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Only the newest record per host can count. An older pass is superseded by
|
|
68
|
+
// a newer run of the same host, including a newer HOLD, so a stale pass
|
|
69
|
+
// cannot hide a regression the same host later found. A same-millisecond
|
|
70
|
+
// tie resolves to the HOLD regardless of arrival order: re-running is cheap,
|
|
71
|
+
// and trusting a stale pass is the failure that matters.
|
|
72
|
+
const newestPerHost = new Map<string, DogfoodResult>();
|
|
73
|
+
for (const record of records) {
|
|
74
|
+
const seen = newestPerHost.get(record.host.label);
|
|
75
|
+
if (!seen || record.timestamp > seen.timestamp) {
|
|
76
|
+
newestPerHost.set(record.host.label, record);
|
|
77
|
+
} else if (record.timestamp === seen.timestamp && seen.verdict === "pass" && record.verdict === "hold") {
|
|
78
|
+
newestPerHost.set(record.host.label, record);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const unmatched: string[] = [];
|
|
83
|
+
const held: DogfoodResult[] = [];
|
|
84
|
+
const stale: Array<{ record: DogfoodResult; reason: string }> = [];
|
|
85
|
+
const matchedRecords: DogfoodResult[] = [];
|
|
86
|
+
for (const [host, record] of newestPerHost) {
|
|
87
|
+
// A host outside the deploy matrix cannot block the gate, so its HOLD or
|
|
88
|
+
// staleness must not be reported as the reason: that would name the wrong
|
|
89
|
+
// remedy. Only in-scope hosts collect their cause.
|
|
90
|
+
const inScope = matrix.length === 0 || matrix.includes(host);
|
|
91
|
+
if (record.verdict !== "pass") {
|
|
92
|
+
if (inScope) {
|
|
93
|
+
held.push(record);
|
|
94
|
+
unmatched.push(host);
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const reason = stalenessReason(record, commitHash, fingerprint);
|
|
99
|
+
if (reason) {
|
|
100
|
+
if (inScope) {
|
|
101
|
+
stale.push({ record, reason });
|
|
102
|
+
unmatched.push(host);
|
|
103
|
+
}
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!inScope) continue;
|
|
107
|
+
matchedRecords.push(record);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (matrix.length > 0) {
|
|
111
|
+
for (const host of matrix) if (!newestPerHost.has(host)) unmatched.push(host);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (unmatched.length > 0) {
|
|
115
|
+
const missingHosts = [...new Set(unmatched)];
|
|
116
|
+
// A HOLD is more actionable than a matrix gap, so name it first.
|
|
117
|
+
if (held.length > 0) {
|
|
118
|
+
const first = held[0];
|
|
119
|
+
return {
|
|
120
|
+
required: true,
|
|
121
|
+
missingHosts,
|
|
122
|
+
reason: `The most recent dogfood run for host '${first.host.label}' ('${first.scenarioId}') returned HOLD${first.taxonomyClass ? ` with class '${first.taxonomyClass}'` : ""}; fix the finding and re-run before opening a PR.`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (stale.length > 0) {
|
|
126
|
+
return { required: true, missingHosts, reason: stale[0].reason };
|
|
127
|
+
}
|
|
128
|
+
const outsideMatrix = [...newestPerHost.keys()].filter((host) => !matrix.includes(host));
|
|
129
|
+
if (matrix.length > 0 && matchedRecords.length === 0 && outsideMatrix.length > 0) {
|
|
130
|
+
return {
|
|
131
|
+
required: true,
|
|
132
|
+
missingHosts,
|
|
133
|
+
reason: `Dogfood evidence was recorded for host(s) ${outsideMatrix.join(", ")}, which are not in the deploy matrix (${matrix.join(", ")}); run the dogfood scenario on each matrix host and record each result.`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
required: true,
|
|
138
|
+
missingHosts,
|
|
139
|
+
reason: `Dogfood evidence is missing for host(s) ${missingHosts.join(", ")} in the deploy matrix (${matrix.join(", ")}); run the dogfood scenario on each and record each result with record_dogfood.`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Satisfied. Report the newest contributing record, and keep the per-host
|
|
144
|
+
// set so the caller can journal every host that was actually covered.
|
|
145
|
+
const newest = matchedRecords.reduce<DogfoodResult | undefined>((best, record) => !best || record.timestamp > best.timestamp ? record : best, undefined);
|
|
146
|
+
return { required: true, missingHosts: [], matched: newest, matchedRecords, evidenceId: newest ? dogfoodEvidence(newest).id : undefined };
|
|
147
|
+
}
|
package/src/lib/evidence.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import type { ToolOutcome } from "./tool-outcomes.ts";
|
|
3
|
-
import type { EvidenceRecord, EvidenceSubject, PolicyDecision, ReviewResult, VerifyResult } from "./types.ts";
|
|
3
|
+
import type { DogfoodResult, EvidenceRecord, EvidenceSubject, PolicyDecision, ReviewResult, VerifyResult } from "./types.ts";
|
|
4
4
|
import { isSameGitRepo } from "./project-config.ts";
|
|
5
5
|
|
|
6
6
|
function evidenceId(kind: EvidenceRecord["kind"], observedAt: number, subject: EvidenceRecord["subject"], source: Record<string, unknown>): string {
|
|
@@ -19,6 +19,24 @@ export function reviewEvidence(result: ReviewResult): EvidenceRecord {
|
|
|
19
19
|
return { id: evidenceId("review", result.timestamp, subject, source), kind: "review", confidence: "attestation", observedAt: result.timestamp, subject, source };
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export function dogfoodEvidence(result: DogfoodResult): EvidenceRecord {
|
|
23
|
+
const subject = { workspace: result.workspace, commitHash: result.commitHash, worktreeFingerprint: result.worktreeFingerprint, sessionID: result.sessionID, actor: result.host.label };
|
|
24
|
+
// The digest pair is part of the source, so replaying a passing run onto a
|
|
25
|
+
// different scenario or taxonomy changes the evidence id.
|
|
26
|
+
const source = {
|
|
27
|
+
verdict: result.verdict,
|
|
28
|
+
scenarioId: result.scenarioId,
|
|
29
|
+
scenarioSha256: result.scenarioSha256,
|
|
30
|
+
taxonomySha256: result.taxonomySha256,
|
|
31
|
+
taxonomyClass: result.taxonomyClass,
|
|
32
|
+
checks: result.checks,
|
|
33
|
+
runCount: result.runCount,
|
|
34
|
+
host: result.host,
|
|
35
|
+
gitStatus: result.gitStatus,
|
|
36
|
+
};
|
|
37
|
+
return { id: evidenceId("dogfood", result.timestamp, subject, source), kind: "dogfood", confidence: "attestation", observedAt: result.timestamp, subject, source };
|
|
38
|
+
}
|
|
39
|
+
|
|
22
40
|
export function toolOutcomeEvidence(outcome: ToolOutcome, observedAt: number, workspace?: string): EvidenceRecord {
|
|
23
41
|
const subject = { workspace, sessionID: outcome.sessionID, callID: outcome.callID };
|
|
24
42
|
const source = { tool: outcome.tool, status: outcome.status, durationMs: outcome.durationMs, repeatedFailureCount: outcome.repeatedFailureCount };
|
|
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { audit, loadVerifyCache } from "./audit.ts";
|
|
4
4
|
import { isEvidenceFresh, reviewEvidence, verificationEvidence } from "./evidence.ts";
|
|
5
5
|
import { findGitRoot, isSameGitRepo, projectRootKey } from "./project-config.ts";
|
|
6
|
+
import { evaluateDogfoodGate } from "./dogfood.ts";
|
|
6
7
|
import {
|
|
7
8
|
getLastReviewResultForWorkspace,
|
|
8
9
|
getLiveControlPlaneRoots,
|
|
@@ -23,14 +24,16 @@ import {
|
|
|
23
24
|
import {
|
|
24
25
|
dynamicShellSyntaxIn,
|
|
25
26
|
extractCommands,
|
|
27
|
+
isLeadingEvalSegment,
|
|
28
|
+
maskDataHeredocBodies,
|
|
26
29
|
normalize,
|
|
30
|
+
planShellSegments,
|
|
27
31
|
shellWrappersChangeCwd,
|
|
28
32
|
splitShellSegments,
|
|
29
33
|
unwrapShellCommand,
|
|
30
34
|
} from "./shell.ts";
|
|
31
35
|
import { asRecord, extractTargetPath } from "./utils.ts";
|
|
32
36
|
import type { PolicyDecision } from "./types.ts";
|
|
33
|
-
import { getProjectMemoryIdentity } from "./project-memory.ts";
|
|
34
37
|
import {
|
|
35
38
|
detectVerifyCommand,
|
|
36
39
|
getCurrentGitCommitHash,
|
|
@@ -41,7 +44,7 @@ import {
|
|
|
41
44
|
resolveVerifyTimeoutMs,
|
|
42
45
|
snipVerifyOutput,
|
|
43
46
|
} from "./verify.ts";
|
|
44
|
-
import { detectShellMutation, extractPatchPaths, guardShellMutation, hasUnresolvableVariable, isEnvironmentScratchTarget, isPathOutsideWorkspace, OPENCODE_SCRATCH_DIR } from "../policies/boundary.ts";
|
|
47
|
+
import { detectShellMutation, extractPatchPaths, foreignSegmentMutationReason, guardShellMutation, hasUnresolvableVariable, isEnvironmentScratchTarget, isPathOutsideWorkspace, nestedPayloadMutationReason, OPENCODE_SCRATCH_DIR } from "../policies/boundary.ts";
|
|
45
48
|
import { branchHasChangelogChange, checkLockfileSync, hasPrCreateInvocation, prBodyHasLiteralLineBreakEscapes, prBodyIncludesChangelog } from "../policies/changelog.ts";
|
|
46
49
|
import { extractEditContent, liveMutationIn } from "../policies/destructive.ts";
|
|
47
50
|
import { branchHasDocumentationChange } from "../policies/docs.ts";
|
|
@@ -55,11 +58,14 @@ import {
|
|
|
55
58
|
checkBranchBaseIsUpToDate,
|
|
56
59
|
checkMergeConflicts,
|
|
57
60
|
currentGitBranch,
|
|
61
|
+
findWorktreeForBranch,
|
|
58
62
|
hasUnsafeGitAlias,
|
|
59
63
|
isBranchAlreadyMergedOrClosed,
|
|
64
|
+
isGitTreeWriteText,
|
|
60
65
|
normalizeGitCommands,
|
|
61
66
|
onProtectedBranch,
|
|
62
67
|
parseGitInvocation,
|
|
68
|
+
prSourceBranch,
|
|
63
69
|
pushedProtectedBranchIn,
|
|
64
70
|
tagRefspecIn,
|
|
65
71
|
} from "../policies/git.ts";
|
|
@@ -277,7 +283,7 @@ export async function guardToolCallImpl(
|
|
|
277
283
|
// that would restate the misattribution.
|
|
278
284
|
const tail = snipVerifyOutput(result.output, result.timedOut === true).slice(-500);
|
|
279
285
|
const reason = result.timedOut
|
|
280
|
-
? `Blocked todowrite: verification did not finish within ${formatTimeoutLimit(result.timeoutMs)} so the result is unknown rather than failing (${command}).
|
|
286
|
+
? `Blocked todowrite: verification did not finish within ${formatTimeoutLimit(result.timeoutMs)} so the result is unknown rather than failing (${command}). Only the user can raise the limit, in the project .opencode/workflow-guard.json or .jsonc (verifyTimeoutMs) or with WORKFLOW_GUARD_VERIFY_TIMEOUT_MS set before the guard starts; ask them, then complete the tasks again. Output tail: ${tail}`
|
|
281
287
|
: `Blocked todowrite: all tasks marked done but verification is failing (${command}). Fix the failure before finishing; output tail: ${tail}`;
|
|
282
288
|
logPolicyBlock(`[workflow-guard] ${reason}`);
|
|
283
289
|
return block("verification", result.timedOut ? "verification_timed_out" : "verification_failed", reason);
|
|
@@ -447,9 +453,13 @@ export async function guardToolCallImpl(
|
|
|
447
453
|
if (!SHELL_TOOL_NAMES.has(toolName)) return allow();
|
|
448
454
|
const shellWorkdir = asRecord(input)?.workdir;
|
|
449
455
|
const requestedShellRoot = typeof shellWorkdir === "string" ? resolve(currentRoot, shellWorkdir) : currentRoot;
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
456
|
+
// A workdir outside the workspace is only usable when it is a linked
|
|
457
|
+
// worktree of the SAME repository: those commands mutate a tree of this
|
|
458
|
+
// workspace. A genuinely foreign repository cannot be judged here at all,
|
|
459
|
+
// because the command executes there while relative targets would be
|
|
460
|
+
// validated against the workspace root.
|
|
461
|
+
const foreignShellRoot = isPathOutsideWorkspace(requestedShellRoot, currentRoot) && !isSameGitRepo(requestedShellRoot, currentRoot);
|
|
462
|
+
const shellRoot = foreignShellRoot ? currentRoot : requestedShellRoot;
|
|
453
463
|
|
|
454
464
|
for (const raw of extractCommands(input)) {
|
|
455
465
|
const dynamicSyntax = dynamicShellSyntaxIn(raw);
|
|
@@ -470,11 +480,50 @@ export async function guardToolCallImpl(
|
|
|
470
480
|
}
|
|
471
481
|
const normalizedCommand = normalizeGitCommands(command);
|
|
472
482
|
if (hasUnsafeGitAlias(command)) return block("git", "unsafe_git_alias", "Blocked: per-invocation Git aliases cannot be used because they can hide guarded Git operations.");
|
|
473
|
-
|
|
483
|
+
// Each segment is judged in the directory it actually runs in. A `cd`
|
|
484
|
+
// earlier in the command moves the ones that follow, so parsing them all
|
|
485
|
+
// against the shell's initial `workdir` hid a mutation in another tree:
|
|
486
|
+
// `cd <other-repo> && git commit` was judged as a commit in the session's
|
|
487
|
+
// own checkout. Plan from `raw`: `normalize` folds newlines into spaces,
|
|
488
|
+
// which erases the separator that makes a newline-separated `cd` a real
|
|
489
|
+
// directory change.
|
|
490
|
+
const segmentPlan = planShellSegments(raw, shellRoot);
|
|
491
|
+
const gitInvocations = segmentPlan
|
|
492
|
+
.map(({ segment, root }) => parseGitInvocation(segment.trim(), root))
|
|
493
|
+
.filter((invocation): invocation is NonNullable<ReturnType<typeof parseGitInvocation>> => invocation !== undefined);
|
|
474
494
|
const effectiveRoot = gitInvocations[0]?.repoDir ?? currentRoot;
|
|
495
|
+
// The command executes in `requestedShellRoot`, not in the workspace, so
|
|
496
|
+
// every relative target in it would be validated against the wrong tree:
|
|
497
|
+
// `rm -rf dist` in a foreign workdir would be checked as
|
|
498
|
+
// `<workspace>/dist`. Reads elsewhere are fine, mutations are not.
|
|
499
|
+
const mutatesTree = (invocation: NonNullable<ReturnType<typeof parseGitInvocation>>): boolean =>
|
|
500
|
+
isGitTreeWriteText(`git ${invocation.rest}`);
|
|
501
|
+
if (foreignShellRoot && (detectShellMutation(command) || gitInvocations.some(mutatesTree))) {
|
|
502
|
+
logPolicyBlock(`[workflow-guard] blocked mutation in a workdir outside workspace: ${requestedShellRoot}`);
|
|
503
|
+
return block(
|
|
504
|
+
"boundary",
|
|
505
|
+
"workspace_escape",
|
|
506
|
+
`Blocked: the shell's workdir '${requestedShellRoot}' is outside workspace root (${currentRoot}) and is not a linked worktree of this repository, so relative targets cannot be validated against the tree the command would actually change. Run the command from inside the workspace instead.`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
const foreignSegmentReason = foreignSegmentMutationReason(segmentPlan, currentRoot);
|
|
510
|
+
if (foreignSegmentReason) {
|
|
511
|
+
logPolicyBlock(`[workflow-guard] blocked mutation in a directory outside workspace: ${command.slice(0, 120)}`);
|
|
512
|
+
return block("boundary", "workspace_escape", foreignSegmentReason);
|
|
513
|
+
}
|
|
475
514
|
for (const invocation of gitInvocations) {
|
|
476
515
|
const normalizedInvocation = `git ${invocation.rest}`;
|
|
477
|
-
|
|
516
|
+
// A linked worktree of the SAME repository is not foreign state: the
|
|
517
|
+
// shell's workdir legitimately lives there when a session works in an
|
|
518
|
+
// isolated checkout, and judging those commands is the point of the
|
|
519
|
+
// workdir-aware parse above. A genuinely different repository stays
|
|
520
|
+
// confined, exactly as before.
|
|
521
|
+
// An indeterminate repository is foreign: `findGitRoot` walks up to
|
|
522
|
+
// the nearest existing ancestor, so `git -C $OTHER_REPO commit`
|
|
523
|
+
// resolved to the workspace's own root and read as the same repo.
|
|
524
|
+
const foreignRepo = hasUnresolvableVariable(invocation.repoDir)
|
|
525
|
+
|| (isPathOutsideWorkspace(invocation.repoDir, currentRoot) && !isSameGitRepo(invocation.repoDir, currentRoot));
|
|
526
|
+
if (foreignRepo && isGitTreeWriteText(normalizedInvocation)) {
|
|
478
527
|
logPolicyBlock(`[workflow-guard] blocked git mutation on repository outside workspace: ${invocation.repoDir}`);
|
|
479
528
|
return block(
|
|
480
529
|
"boundary",
|
|
@@ -521,13 +570,25 @@ export async function guardToolCallImpl(
|
|
|
521
570
|
logPolicyBlock(`[workflow-guard] blocked settings tamper: ${command.slice(0, 120)}`);
|
|
522
571
|
return block("tamper", "settings_tamper", PROTECTED_PATH_REASON);
|
|
523
572
|
}
|
|
524
|
-
|
|
573
|
+
// Mask from `raw`, not the normalized copy: `normalize` folds newlines
|
|
574
|
+
// into spaces, which erases the heredoc shape the mask needs to find.
|
|
575
|
+
for (const segment of maskDataHeredocBodies(raw).split(/[\n|;&]+/)) {
|
|
525
576
|
const secretFile = secretFileReadIn(segment.trim());
|
|
526
577
|
if (secretFile) {
|
|
527
578
|
logPolicyBlock(`[workflow-guard] blocked shell read of secret file: ${secretFile}`);
|
|
528
579
|
return block("secrets", "secret_read", `Blocked: reading sensitive credential/secret file '${secretFile}' via shell is not permitted. Reference environment variables by name or inspect safe templates (e.g. .env.example) instead.`);
|
|
529
580
|
}
|
|
530
581
|
}
|
|
582
|
+
// A nested payload changes a tree this session may not judge when it
|
|
583
|
+
// changes directory itself, or names a foreign `cwd`. The outer shell
|
|
584
|
+
// sees one word, so the top-level walk above cannot see it. Plan from
|
|
585
|
+
// `raw` for the same reason the top-level walk does: a heredoc body's
|
|
586
|
+
// own lines are real commands to the shell that consumes it.
|
|
587
|
+
const nestedTreeReason = nestedPayloadMutationReason(extractInterpreterPayload, writePathsInPayload, raw, shellRoot, currentRoot);
|
|
588
|
+
if (nestedTreeReason) {
|
|
589
|
+
logPolicyBlock(`[workflow-guard] blocked nested interpreter mutation outside workspace: ${command.slice(0, 120)}`);
|
|
590
|
+
return block("boundary", "workspace_escape", nestedTreeReason);
|
|
591
|
+
}
|
|
531
592
|
for (const payload of extractInterpreterPayload(raw)) {
|
|
532
593
|
const outsidePath = outsideWritePathInPayload(payload, currentRoot);
|
|
533
594
|
if (outsidePath) {
|
|
@@ -560,7 +621,13 @@ export async function guardToolCallImpl(
|
|
|
560
621
|
const secretPath = secretPathInPayload(payload);
|
|
561
622
|
if (secretPath) return block("secrets", "secret_read", `Blocked: reading sensitive credential/secret file '${secretPath}' via inline interpreter script is not permitted.`);
|
|
562
623
|
}
|
|
563
|
-
|
|
624
|
+
// `eval` runs its body in the current shell, so a `cd` inside it moves the
|
|
625
|
+
// directory of later segments. Another launcher's payload runs in a child
|
|
626
|
+
// process and cannot move this shell, so only `eval` is forwarded. The
|
|
627
|
+
// predicate lives in the shell walk so both call sites agree on which
|
|
628
|
+
// `eval` is a command.
|
|
629
|
+
const leadingEvalBody = (s: string): string | undefined => (isLeadingEvalSegment(s) ? extractInterpreterPayload(s)[0] : undefined);
|
|
630
|
+
const shellMutationReason = await guardShellMutation(raw, context?.sessionID, !context?.simulate, shellRoot, leadingEvalBody);
|
|
564
631
|
if (shellMutationReason) {
|
|
565
632
|
logPolicyBlock(`[workflow-guard] blocked shell mutation: ${command.slice(0, 120)}`);
|
|
566
633
|
return block("boundary", "shell_mutation", shellMutationReason);
|
|
@@ -604,15 +671,40 @@ export async function guardToolCallImpl(
|
|
|
604
671
|
}
|
|
605
672
|
}
|
|
606
673
|
if (hasPrCreateInvocation(raw)) {
|
|
607
|
-
|
|
674
|
+
// The PR is about a BRANCH, not about whatever the shell's checkout
|
|
675
|
+
// happens to have checked out. Resolve the branch the command names
|
|
676
|
+
// (`gh --head`, `az --source-branch`) and then the checkout that
|
|
677
|
+
// holds it: a linked worktree is the normal case, because the
|
|
678
|
+
// orchestrator session may sit in the primary checkout while the
|
|
679
|
+
// reviewed branch lives in an isolated worktree. Every preflight
|
|
680
|
+
// check then judges that tree instead of the caller's.
|
|
681
|
+
const prSegment = splitShellSegments(raw).find((segment) => hasPrCreateInvocation(segment));
|
|
682
|
+
// Quote-aware on the raw segment: `unwrapShellCommand` joins the
|
|
683
|
+
// words back without their quotes, so re-parsing the joined text
|
|
684
|
+
// read a `--head`-looking token inside a quoted `--body` as the
|
|
685
|
+
// source branch.
|
|
686
|
+
const sourceBranch = prSegment ? prSourceBranch(prSegment) : undefined;
|
|
687
|
+
let prRoot = shellRoot;
|
|
688
|
+
let branch = currentGitBranch(shellRoot);
|
|
689
|
+
let missingSourceCheckout: string | undefined;
|
|
690
|
+
if (sourceBranch) {
|
|
691
|
+
const holder = findWorktreeForBranch(shellRoot, sourceBranch) ?? findWorktreeForBranch(currentRoot, sourceBranch);
|
|
692
|
+
prRoot = holder ?? shellRoot;
|
|
693
|
+
branch = sourceBranch;
|
|
694
|
+
// Review and merge evidence bind to the CONTENT of a checkout.
|
|
695
|
+
// Substituting the caller's checkout would let an approval of one
|
|
696
|
+
// tree satisfy a PR that publishes another, so a named branch no
|
|
697
|
+
// local checkout holds is refused instead.
|
|
698
|
+
if (!holder) missingSourceCheckout = sourceBranch;
|
|
699
|
+
}
|
|
608
700
|
const isAz = /\baz\s+repos\s+pr\s+create\b/.test(normalizedCommand);
|
|
609
701
|
const prTool = isAz ? "az repos pr create" : "gh pr create";
|
|
610
702
|
const descFlag = isAz ? "--description" : "--body";
|
|
611
703
|
const preflightFailures: string[] = [];
|
|
704
|
+
if (missingSourceCheckout) preflightFailures.push(`Source branch '${missingSourceCheckout}' is not checked out in any worktree of this repository, so review and merge evidence cannot be bound to the content this PR would publish. Check that branch out (a linked worktree is fine) and re-run, or omit the source-branch flag to judge the current checkout.`);
|
|
612
705
|
if (prBodyHasLiteralLineBreakEscapes(raw)) preflightFailures.push(`PR description contains literal \\n/\\r escapes that will render as text; use real newlines, ANSI-C quoting ($'...\\n...'), or a body/description file instead.`);
|
|
613
|
-
const conflictCheck = checkMergeConflicts(prRoot);
|
|
706
|
+
const conflictCheck = checkMergeConflicts(prRoot, branch);
|
|
614
707
|
if (conflictCheck.hasConflicts) preflightFailures.push(conflictCheck.reason ?? "Branch has merge conflicts with its base branch.");
|
|
615
|
-
const branch = currentGitBranch(prRoot);
|
|
616
708
|
if (branch) {
|
|
617
709
|
if (context?.simulate) remotePrStateUnchecked = true;
|
|
618
710
|
const mergedStatus = isBranchAlreadyMergedOrClosed(prRoot, branch, !context?.simulate);
|
|
@@ -686,6 +778,35 @@ export async function guardToolCallImpl(
|
|
|
686
778
|
}
|
|
687
779
|
}
|
|
688
780
|
if (isDocumentationRequired(prRoot) && !branchHasDocumentationChange(prRoot)) preflightFailures.push("Documentation update is required (Policy 21); update README.md or relevant documentation in docs/.");
|
|
781
|
+
// Dogfood gate (pre-deployment evidence), off by default. The
|
|
782
|
+
// evaluation lives in lib/dogfood.ts so preflight and guard_status
|
|
783
|
+
// cannot disagree about whether the gate is satisfied.
|
|
784
|
+
const dogfoodGate = evaluateDogfoodGate(prRoot);
|
|
785
|
+
if (dogfoodGate.matched && !context?.simulate) {
|
|
786
|
+
audit({
|
|
787
|
+
ts: new Date().toISOString(),
|
|
788
|
+
sessionID: context?.sessionID,
|
|
789
|
+
tool: "pr-preflight.dogfood-binding",
|
|
790
|
+
decision: "allow",
|
|
791
|
+
phase: "event",
|
|
792
|
+
reason: "dogfood_evidence_matched",
|
|
793
|
+
evidence: {
|
|
794
|
+
evidenceId: dogfoodGate.evidenceId,
|
|
795
|
+
dogfoodScenarioId: dogfoodGate.matched.scenarioId,
|
|
796
|
+
dogfoodHost: dogfoodGate.matched.host.label,
|
|
797
|
+
// Journal every host the matrix required, not just the
|
|
798
|
+
// newest one. A single-host entry would understate a
|
|
799
|
+
// multi-host pass and mislead an auditor later.
|
|
800
|
+
dogfoodCoveredHosts: (dogfoodGate.matchedRecords ?? []).map((record) => record.host.label),
|
|
801
|
+
binding: {
|
|
802
|
+
workspace: projectRootKey(prRoot),
|
|
803
|
+
commitHash: getCurrentGitCommitHash(prRoot),
|
|
804
|
+
worktreeFingerprint: getTrackedWorktreeFingerprint(prRoot)?.slice(0, 16),
|
|
805
|
+
},
|
|
806
|
+
},
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
if (dogfoodGate.missingHosts.length > 0 && dogfoodGate.reason) preflightFailures.push(dogfoodGate.reason);
|
|
689
810
|
const branchChangelog = branchHasChangelogChange(prRoot);
|
|
690
811
|
let shellCwdKnown = true;
|
|
691
812
|
const prSegments: Array<{ segment: string; invocationRoot: string | null }> = [];
|
|
@@ -29,6 +29,9 @@
|
|
|
29
29
|
|
|
30
30
|
import { guardToolCallImpl } from "./guard-dispatcher.ts";
|
|
31
31
|
import { getSessionBoundWorkspace, runWithRuntimeState } from "./state.ts";
|
|
32
|
+
import { GIT_BRANCH_CREATE_RE, GIT_WRITE_RE, PUSH_TO_MAIN_RE, parseGitInvocation } from "../policies/git.ts";
|
|
33
|
+
import { hasPrCreateInvocation } from "../policies/changelog.ts";
|
|
34
|
+
import { splitShellSegments } from "./shell.ts";
|
|
32
35
|
import { mcpMutationTool } from "../policies/mcp.ts";
|
|
33
36
|
import type { PolicyDecision } from "./types.ts";
|
|
34
37
|
|
|
@@ -61,6 +64,55 @@ export interface PermissionSeatResult {
|
|
|
61
64
|
|
|
62
65
|
const EDIT_ACTIONS = new Set(["edit", "write", "patch", "apply_patch"]);
|
|
63
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Commands whose verdict depends on WHICH checkout they run in.
|
|
69
|
+
*
|
|
70
|
+
* A bare `git push`, `git commit`, or branch creation is judged against the
|
|
71
|
+
* repository the shell's working directory resolves to, and PR preflight is
|
|
72
|
+
* judged against the branch the PR publishes. This seat sees normalized
|
|
73
|
+
* `action`/`resources` only, never the tool's `workdir`, so for those commands
|
|
74
|
+
* the repository is an assumption rather than a fact: the session-bound
|
|
75
|
+
* checkout may be the primary tree while the command targets a linked
|
|
76
|
+
* worktree. Deciding on the assumption denied legitimate pushes and PRs, so
|
|
77
|
+
* the seat defers them to `execute.before`, which sees the raw arguments.
|
|
78
|
+
*
|
|
79
|
+
* Two cases stay decisive:
|
|
80
|
+
*
|
|
81
|
+
* - An explicit `-C`/`--git-dir`/`--work-tree`/`GIT_DIR` names the repository
|
|
82
|
+
* in the command text, so it is a fact the seat can judge.
|
|
83
|
+
* - A push whose destination is an explicitly named protected branch is a
|
|
84
|
+
* finding in every checkout, so no `workdir` can change the verdict.
|
|
85
|
+
*/
|
|
86
|
+
function repositoryDependent(command: string): boolean {
|
|
87
|
+
for (const segment of splitShellSegments(command)) {
|
|
88
|
+
// PR creation names no repository at all: the checkout is cwd-derived,
|
|
89
|
+
// and a `--head`/`--source-branch` may live in another worktree.
|
|
90
|
+
if (hasPrCreateInvocation(segment)) return true;
|
|
91
|
+
const parsed = parseGitInvocation(segment);
|
|
92
|
+
if (!parsed) continue;
|
|
93
|
+
const rest = `git ${parsed.rest}`;
|
|
94
|
+
if (parsed.explicitRepoDir) continue;
|
|
95
|
+
if (PUSH_TO_MAIN_RE.test(rest)) continue;
|
|
96
|
+
const repositorySensitive =
|
|
97
|
+
/\bgit\s+push\b/.test(rest) ||
|
|
98
|
+
GIT_WRITE_RE.test(rest) ||
|
|
99
|
+
GIT_BRANCH_CREATE_RE.test(rest);
|
|
100
|
+
if (repositorySensitive) return true;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Policies whose verdict is derived from the selected checkout.
|
|
107
|
+
*
|
|
108
|
+
* A block from one of these may be an artifact of judging the wrong tree: the
|
|
109
|
+
* original defect was a push denied for the protected branch of the primary
|
|
110
|
+
* checkout while the command targeted a linked feature worktree. A block from
|
|
111
|
+
* any other policy (destructive, shell-safety, secrets, tamper, todo) is a
|
|
112
|
+
* finding about the command text itself, so no working directory can change it.
|
|
113
|
+
*/
|
|
114
|
+
const CHECKOUT_DERIVED_POLICIES = new Set(["git", "pr-preflight"]);
|
|
115
|
+
|
|
64
116
|
/**
|
|
65
117
|
* Map one simulated policy decision onto the seat's effect.
|
|
66
118
|
*
|
|
@@ -117,12 +169,38 @@ export async function evaluatePermissionSeat(
|
|
|
117
169
|
|
|
118
170
|
const mapping = mappedToolFor(event.action);
|
|
119
171
|
if (mapping) {
|
|
172
|
+
let deferred: PermissionSeatResult | undefined;
|
|
120
173
|
for (const resource of resources) {
|
|
174
|
+
// Where the command runs is part of the decision for some commands,
|
|
175
|
+
// and this seat cannot see the tool's `workdir`. Both directions of a
|
|
176
|
+
// checkout-derived verdict are then unreliable: a block may be the
|
|
177
|
+
// protected branch of the wrong tree, and an allow may be a feature
|
|
178
|
+
// checkout that hides a push to a protected destination. Defer those
|
|
179
|
+
// to `execute.before`, which resolves the shell's workdir.
|
|
180
|
+
const cwdDependent = mapping.tool === "bash" && repositoryDependent(resource);
|
|
121
181
|
const decision = await decide(mapping.tool, mapping.input(resource));
|
|
122
182
|
const verdict = seatVerdict(decision, mapping.tool);
|
|
183
|
+
if (cwdDependent) {
|
|
184
|
+
// A block from any OTHER policy is a finding about the command
|
|
185
|
+
// text itself (`cat .env` or a destructive live command in the
|
|
186
|
+
// same compound command), so no working directory can change it
|
|
187
|
+
// and it keeps its denial. Everything else defers.
|
|
188
|
+
if (verdict?.effect === "deny" && !CHECKOUT_DERIVED_POLICIES.has(decision.policy ?? "")) return verdict;
|
|
189
|
+
deferred = {
|
|
190
|
+
effect: "abstain",
|
|
191
|
+
mappedTool: mapping.tool,
|
|
192
|
+
decision: {
|
|
193
|
+
status: "needs_approval",
|
|
194
|
+
policy: decision.policy ?? "git",
|
|
195
|
+
code: "repository_unidentified",
|
|
196
|
+
message: "[workflow-guard permission.evaluate] Deferred: this command's verdict depends on the directory it runs in, which the permission seat cannot see. `execute.before` resolves the shell's workdir and enforces there.",
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
123
201
|
if (verdict) return verdict;
|
|
124
202
|
}
|
|
125
|
-
return { effect: "allow", mappedTool: mapping.tool };
|
|
203
|
+
return deferred ?? { effect: "allow", mappedTool: mapping.tool };
|
|
126
204
|
}
|
|
127
205
|
|
|
128
206
|
// `<server>_<tool>` MCP actions: only the guarded live-system servers are
|