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
|
@@ -1,25 +1,102 @@
|
|
|
1
1
|
import { isSecretPath } from "./secrets.ts";
|
|
2
2
|
import { isPathOutsideWorkspace } from "./boundary.ts";
|
|
3
|
+
import { heredocBodyIsCode, heredocRanges, maskDataHeredocBodies } from "../lib/shell.ts";
|
|
4
|
+
|
|
5
|
+
// `eval` runs its argument as shell text, so its payload is scanned like an
|
|
6
|
+
// inline interpreter body. It is the one launcher with no option word, so the
|
|
7
|
+
// quoted body follows the command directly.
|
|
8
|
+
const LAUNCHER_RE = /\b(?:python3?|node|perl|ruby|osascript|bash|sh|zsh|dash|ksh)\s+(?:-[a-zA-Z]*[ce])\s*|\beval\s+/i;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The quoted shell word that follows an inline interpreter launcher.
|
|
12
|
+
*
|
|
13
|
+
* A shell word is one argument however many quoted spans it concatenates, so
|
|
14
|
+
* `'bash -c '\''cd /other && rm -rf x'\'''` is a single payload. Stopping at
|
|
15
|
+
* the first closing quote truncated that word to `bash -c `, and the nested
|
|
16
|
+
* command escaped the payload scan entirely.
|
|
17
|
+
*/
|
|
18
|
+
function inlinePayloadWord(segment: string, start: number): string | undefined {
|
|
19
|
+
let out = "";
|
|
20
|
+
let i = start;
|
|
21
|
+
while (i < segment.length) {
|
|
22
|
+
const char = segment[i]!;
|
|
23
|
+
if (char === "'") {
|
|
24
|
+
i += 1;
|
|
25
|
+
let closed = false;
|
|
26
|
+
while (i < segment.length) {
|
|
27
|
+
const inner = segment[i]!;
|
|
28
|
+
// `'\''` is the idiom for a literal quote inside a single-quoted
|
|
29
|
+
// span: the span closes, an escaped quote follows, a new span opens.
|
|
30
|
+
if (inner === "'" && segment[i + 1] === "\\" && segment[i + 2] === "'" && segment[i + 3] === "'") {
|
|
31
|
+
out += "'";
|
|
32
|
+
i += 4;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (inner === "'") {
|
|
36
|
+
closed = true;
|
|
37
|
+
i += 1;
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
out += inner;
|
|
41
|
+
i += 1;
|
|
42
|
+
}
|
|
43
|
+
if (!closed) return undefined;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (char === '"') {
|
|
47
|
+
i += 1;
|
|
48
|
+
let closed = false;
|
|
49
|
+
while (i < segment.length) {
|
|
50
|
+
const inner = segment[i]!;
|
|
51
|
+
if (inner === "\\" && segment[i + 1] !== undefined) {
|
|
52
|
+
out += segment[i + 1];
|
|
53
|
+
i += 2;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (inner === '"') {
|
|
57
|
+
closed = true;
|
|
58
|
+
i += 1;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
out += inner;
|
|
62
|
+
i += 1;
|
|
63
|
+
}
|
|
64
|
+
if (!closed) return undefined;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
// Unquoted text ends the word at whitespace or a shell operator.
|
|
68
|
+
if (/\s/.test(char) || "|&;()<>".includes(char)) break;
|
|
69
|
+
if (char === "\\" && segment[i + 1] !== undefined) {
|
|
70
|
+
out += segment[i + 1];
|
|
71
|
+
i += 2;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
out += char;
|
|
75
|
+
i += 1;
|
|
76
|
+
}
|
|
77
|
+
return out === "" ? undefined : out;
|
|
78
|
+
}
|
|
3
79
|
|
|
4
80
|
export function extractInterpreterPayload(segment: string): string[] {
|
|
5
81
|
const payloads: string[] = [];
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
)
|
|
9
|
-
|
|
10
|
-
|
|
82
|
+
// A heredoc body is prose unless the command reading it executes it, so the
|
|
83
|
+
// launcher scan runs on the masked text instead of reading documentation as
|
|
84
|
+
// code (`bash -c 'cd /other && rm -rf x'` quoted inside a `cat` body).
|
|
85
|
+
const masked = maskDataHeredocBodies(segment);
|
|
86
|
+
const launcher = LAUNCHER_RE.exec(masked);
|
|
87
|
+
if (launcher) {
|
|
88
|
+
const payload = inlinePayloadWord(segment, (launcher.index ?? 0) + launcher[0].length);
|
|
89
|
+
if (payload !== undefined) payloads.push(payload);
|
|
11
90
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
const terminator = lines.findIndex((line) => (heredocHeader[1] ? line.replace(/^\t+/, "") : line) === delimiter);
|
|
20
|
-
if (terminator >= 0) payloads.push(lines.slice(0, terminator).join("\n"));
|
|
91
|
+
// A heredoc body is code when the command that reads it executes it. An
|
|
92
|
+
// unterminated body still runs, so it is scanned to end of input. The
|
|
93
|
+
// terminator's own newline is not part of the body.
|
|
94
|
+
for (const range of heredocRanges(segment)) {
|
|
95
|
+
if (!heredocBodyIsCode(segment, range.start)) continue;
|
|
96
|
+
const body = segment.slice(range.bodyStart, range.bodyEnd).replace(/\n$/, "");
|
|
97
|
+
if (body !== "") payloads.push(body);
|
|
21
98
|
}
|
|
22
|
-
const psMatch =
|
|
99
|
+
const psMatch = masked.match(
|
|
23
100
|
/\b(?:powershell|pwsh)\s+(?:-[a-zA-Z]*enc[a-zA-Z]*\s+)([A-Za-z0-9+/=]+)/i,
|
|
24
101
|
);
|
|
25
102
|
if (psMatch?.[1]) {
|
|
@@ -28,7 +105,7 @@ export function extractInterpreterPayload(segment: string): string[] {
|
|
|
28
105
|
payloads.push(buf.toString("utf8"), buf.toString("utf16le"));
|
|
29
106
|
} catch {}
|
|
30
107
|
}
|
|
31
|
-
const b64PipeMatch =
|
|
108
|
+
const b64PipeMatch = masked.match(
|
|
32
109
|
/echo\s+["']?([A-Za-z0-9+/=]{4,})["']?\s*\|\s*base64\s+(?:-[a-zA-Z]*d[a-zA-Z]*|--decode)\s*\|\s*(?:bash|sh|zsh)/i,
|
|
33
110
|
);
|
|
34
111
|
if (b64PipeMatch?.[1]) {
|
package/src/workflow-guard.ts
CHANGED
|
@@ -17,16 +17,18 @@ import { join } from "node:path";
|
|
|
17
17
|
import { loadedPluginVersion } from "./lib/version.ts";
|
|
18
18
|
import { editTargets, runPostEditValidators, snapshotFile, type FileSnapshot } from "./policies/post-edit-validation.ts";
|
|
19
19
|
import { claimFiles, releaseFileClaims } from "./policies/file-claims.ts";
|
|
20
|
-
import { extractCommands,
|
|
20
|
+
import { extractCommands, planShellSegments } from "./lib/shell.ts";
|
|
21
21
|
import { expandShellTargetPath, isPathOutsideWorkspace, OPENCODE_SCRATCH_DIR, redirectMutationsIn } from "./policies/boundary.ts";
|
|
22
22
|
import { beginReadObservation, clearReadFingerprints, recordMutationObservation, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.ts";
|
|
23
23
|
import { ToolInvocationLifecycle } from "./lib/tool-lifecycle.ts";
|
|
24
24
|
import { ToolOutcomeTracker, type ToolOutcomePart } from "./lib/tool-outcomes.ts";
|
|
25
25
|
import { guardToolCallImpl, isMutatingSurfaceTool, isReadOnlyRole, SHELL_TOOL_NAMES } from "./lib/guard-dispatcher.ts";
|
|
26
26
|
import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./lib/custom-tools.ts";
|
|
27
|
+
import { evaluateDogfoodGate } from "./lib/dogfood.ts";
|
|
27
28
|
import type { PolicyDecision, TodoSdkClient } from "./lib/types.ts";
|
|
28
29
|
export { isReadOnlyRole } from "./lib/guard-dispatcher.ts";
|
|
29
30
|
export { extractReviewFollowups, buildWorkflowGuardSystemGuidance } from "./lib/custom-tools.ts";
|
|
31
|
+
export { evaluateDogfoodGate } from "./lib/dogfood.ts";
|
|
30
32
|
|
|
31
33
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
32
34
|
export type {
|
|
@@ -47,7 +49,7 @@ export type {
|
|
|
47
49
|
LearningOpportunity,
|
|
48
50
|
} from "./lib/types.ts";
|
|
49
51
|
export type { TodoSdkClient };
|
|
50
|
-
export { verificationEvidence, reviewEvidence, toolOutcomeEvidence, policyDecisionEvidence, lifecycleStateEvidence, agentAssertionEvidence, evidenceMatchesSubject, isEvidenceFresh } from "./lib/evidence.ts";
|
|
52
|
+
export { verificationEvidence, reviewEvidence, dogfoodEvidence, toolOutcomeEvidence, policyDecisionEvidence, lifecycleStateEvidence, agentAssertionEvidence, evidenceMatchesSubject, isEvidenceFresh } from "./lib/evidence.ts";
|
|
51
53
|
export { getRalphOutcome } from "./policies/continuation.ts";
|
|
52
54
|
|
|
53
55
|
// ── State & runtime context ──────────────────────────────────────────────────
|
|
@@ -76,6 +78,10 @@ import {
|
|
|
76
78
|
isReviewRequired,
|
|
77
79
|
isSubagentReviewRequired,
|
|
78
80
|
isDocumentationRequired,
|
|
81
|
+
isDogfoodRequired,
|
|
82
|
+
getDogfoodDeployMatrix,
|
|
83
|
+
getDogfoodDepth,
|
|
84
|
+
isDogfoodActor,
|
|
79
85
|
isLiveDocsRequired,
|
|
80
86
|
getLiveDocFailureThreshold,
|
|
81
87
|
getResearchToolPatterns,
|
|
@@ -88,9 +94,15 @@ import {
|
|
|
88
94
|
getLastReviewResultForWorkspace,
|
|
89
95
|
resetReviewState,
|
|
90
96
|
sessionReviews,
|
|
97
|
+
recordDogfoodResult,
|
|
98
|
+
getLastDogfoodResultForWorkspace,
|
|
99
|
+
getDogfoodRecordsForWorkspace,
|
|
100
|
+
resetDogfoodState,
|
|
91
101
|
setSessionWorkspace,
|
|
92
102
|
getSessionWorkspace,
|
|
93
103
|
getSessionBoundWorkspace,
|
|
104
|
+
rebindSessionWorkspaceFromHost,
|
|
105
|
+
forgetSessionWorkspace,
|
|
94
106
|
registerSessionWorkspaceFromToolCall,
|
|
95
107
|
resolveInheritedWorkspace,
|
|
96
108
|
resolveSessionBoundWorkspace,
|
|
@@ -117,6 +129,10 @@ export {
|
|
|
117
129
|
isReviewRequired,
|
|
118
130
|
isSubagentReviewRequired,
|
|
119
131
|
isDocumentationRequired,
|
|
132
|
+
isDogfoodRequired,
|
|
133
|
+
getDogfoodDeployMatrix,
|
|
134
|
+
getDogfoodDepth,
|
|
135
|
+
isDogfoodActor,
|
|
120
136
|
isLiveDocsRequired,
|
|
121
137
|
getLiveDocFailureThreshold,
|
|
122
138
|
getResearchToolPatterns,
|
|
@@ -127,9 +143,15 @@ export {
|
|
|
127
143
|
getLastReviewResult,
|
|
128
144
|
getLastReviewResultForWorkspace,
|
|
129
145
|
resetReviewState,
|
|
146
|
+
recordDogfoodResult,
|
|
147
|
+
getLastDogfoodResultForWorkspace,
|
|
148
|
+
getDogfoodRecordsForWorkspace,
|
|
149
|
+
resetDogfoodState,
|
|
130
150
|
setSessionWorkspace,
|
|
131
151
|
getSessionWorkspace,
|
|
132
152
|
getSessionBoundWorkspace,
|
|
153
|
+
rebindSessionWorkspaceFromHost,
|
|
154
|
+
forgetSessionWorkspace,
|
|
133
155
|
registerSessionWorkspaceFromToolCall,
|
|
134
156
|
resolveInheritedWorkspace,
|
|
135
157
|
resolveSessionBoundWorkspace,
|
|
@@ -194,7 +216,7 @@ import {
|
|
|
194
216
|
summarizeInput,
|
|
195
217
|
} from "./lib/audit.ts";
|
|
196
218
|
|
|
197
|
-
export { getAuditFilePath, getVerifyCacheFilePath, getReviewCacheFilePath, getVerifyHistoryFilePath, persistVerifyCache, loadVerifyCache, persistReviewCache, loadReviewCache, getRecentAuditEntries, getRecentVerifyHistory } from "./lib/audit.ts";
|
|
219
|
+
export { getAuditFilePath, getVerifyCacheFilePath, getReviewCacheFilePath, getDogfoodCacheFilePath, getVerifyHistoryFilePath, persistVerifyCache, loadVerifyCache, persistReviewCache, loadReviewCache, persistDogfoodCache, loadDogfoodCache, getRecentAuditEntries, getRecentVerifyHistory } from "./lib/audit.ts";
|
|
198
220
|
export { summarizeInput };
|
|
199
221
|
|
|
200
222
|
export function managedConfigDiagnostic(platform = process.platform, env: NodeJS.ProcessEnv = process.env): string {
|
|
@@ -233,6 +255,7 @@ export {
|
|
|
233
255
|
getGitWorktreeFingerprint,
|
|
234
256
|
getTrackedWorktreeFingerprint,
|
|
235
257
|
};
|
|
258
|
+
export type { VerifyRun } from "./lib/verify.ts";
|
|
236
259
|
|
|
237
260
|
// ── Secondary review rubric ──────────────────────────────────────────────────
|
|
238
261
|
import { buildReviewRubric } from "./lib/review.ts";
|
|
@@ -291,6 +314,9 @@ import {
|
|
|
291
314
|
isBranchAlreadyMergedOrClosed,
|
|
292
315
|
checkMergeConflicts,
|
|
293
316
|
checkBranchBaseIsUpToDate,
|
|
317
|
+
findWorktreeForBranch,
|
|
318
|
+
parseGitInvocation,
|
|
319
|
+
prSourceBranch,
|
|
294
320
|
} from "./policies/git.ts";
|
|
295
321
|
|
|
296
322
|
export {
|
|
@@ -298,6 +324,9 @@ export {
|
|
|
298
324
|
isBranchAlreadyMergedOrClosed,
|
|
299
325
|
checkMergeConflicts,
|
|
300
326
|
checkBranchBaseIsUpToDate,
|
|
327
|
+
findWorktreeForBranch,
|
|
328
|
+
parseGitInvocation,
|
|
329
|
+
prSourceBranch,
|
|
301
330
|
};
|
|
302
331
|
|
|
303
332
|
import {
|
|
@@ -578,13 +607,13 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
578
607
|
const shellCwd = shellWorkdir ? resolve(toolWorktree, shellWorkdir) : toolWorktree;
|
|
579
608
|
const snapshots: FileSnapshot[] = [];
|
|
580
609
|
for (const command of extractCommands(args)) {
|
|
581
|
-
for (const segment of
|
|
610
|
+
for (const { segment, root } of planShellSegments(command, shellCwd)) {
|
|
582
611
|
for (const mutation of redirectMutationsIn(segment)) {
|
|
583
612
|
const target = mutation.target ?? "";
|
|
584
613
|
if (!target || target === "/dev/null") continue;
|
|
585
614
|
const expanded = expandShellTargetPath(target);
|
|
586
615
|
if (!expanded) continue;
|
|
587
|
-
const resolvedTarget = resolve(shellCwd, expanded);
|
|
616
|
+
const resolvedTarget = resolve(root ?? shellCwd, expanded);
|
|
588
617
|
if (resolvedTarget === OPENCODE_SCRATCH_DIR || resolvedTarget.startsWith(OPENCODE_SCRATCH_DIR + "/")) continue;
|
|
589
618
|
if (isPathOutsideWorkspace(resolvedTarget, toolWorktree)) continue;
|
|
590
619
|
snapshots.push(snapshotFile(resolvedTarget));
|
|
@@ -694,6 +723,14 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
694
723
|
`- Secondary Review: ${lastR.passed ? "APPROVED" : "CHANGES REQUESTED"} by ${lastR.reviewer}`,
|
|
695
724
|
);
|
|
696
725
|
}
|
|
726
|
+
const dogfoodCtxGate = evaluateDogfoodGate(effectiveRoot);
|
|
727
|
+
if (dogfoodCtxGate.required) {
|
|
728
|
+
stateLines.push(
|
|
729
|
+
dogfoodCtxGate.missingHosts.length === 0
|
|
730
|
+
? `- Dogfood Evidence: PASS on ${(dogfoodCtxGate.matchedRecords ?? []).map((record) => record.host.label).join(", ") || "recorded host"}`
|
|
731
|
+
: `- Dogfood Evidence: MISSING for ${dogfoodCtxGate.missingHosts.join(", ")} (required before PR creation)`,
|
|
732
|
+
);
|
|
733
|
+
}
|
|
697
734
|
let openFollowups: ReturnType<typeof listReviewFollowups> = [];
|
|
698
735
|
try { openFollowups = followupStore ? listReviewFollowups(followupStore, "open", 8) : []; } catch {}
|
|
699
736
|
if (openFollowups.length > 0) {
|
|
@@ -838,6 +875,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
838
875
|
projectMemoryEnabled,
|
|
839
876
|
learningEnabled,
|
|
840
877
|
recoveryCheckpointsEnabled: isRecoveryCheckpointsEnabled(effectiveRoot),
|
|
878
|
+
dogfoodRequired: isDogfoodRequired(effectiveRoot),
|
|
841
879
|
isReadOnly,
|
|
842
880
|
});
|
|
843
881
|
output.system.push(guidance);
|
|
@@ -916,6 +954,18 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
916
954
|
await runWithRuntimeState(effectiveRoot, ctx.client, () => continueUnfinishedSession(sessionID, settleTitle));
|
|
917
955
|
}
|
|
918
956
|
}
|
|
957
|
+
if (event?.type === "session.updated") {
|
|
958
|
+
// `session_move` relocates a session between checkouts, and the
|
|
959
|
+
// host re-emits `session.updated` for the moved session. Its
|
|
960
|
+
// `directory` is the authoritative post-move location; without
|
|
961
|
+
// this rebind the session kept judging the tree it left. The
|
|
962
|
+
// rebind only takes a genuine move, so a title or cost update on
|
|
963
|
+
// a session bound to a linked worktree cannot drag it back.
|
|
964
|
+
const info = (event.properties as { info?: { id?: unknown; directory?: unknown } })?.info;
|
|
965
|
+
if (typeof info?.id === "string" && typeof info.directory === "string") {
|
|
966
|
+
rebindSessionWorkspaceFromHost(info.id, info.directory);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
919
969
|
if (event?.type === "message.updated") {
|
|
920
970
|
const info = (event.properties as { info?: { id?: unknown; role?: unknown; sessionID?: unknown; error?: { name?: unknown } } })?.info;
|
|
921
971
|
if (info?.role === "user" && typeof info.sessionID === "string") {
|
|
@@ -945,6 +995,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
|
945
995
|
toolLifecycle.takeRecoveryRun(sessionID);
|
|
946
996
|
clearLiveDocsFloor(sessionID);
|
|
947
997
|
await runWithRuntimeState(effectiveRoot, ctx.client, () => clearContinuationState(sessionID));
|
|
998
|
+
forgetSessionWorkspace(sessionID);
|
|
948
999
|
}
|
|
949
1000
|
}
|
|
950
1001
|
if (
|
package/test/test-e2e.mts
CHANGED
|
@@ -453,10 +453,13 @@ if (isOpenCodeV2) {
|
|
|
453
453
|
headlessAccountability?.seat?.deny?.decision?.policy === "secrets" &&
|
|
454
454
|
headlessAccountability?.seat?.deny?.decision?.code === "secret_read",
|
|
455
455
|
);
|
|
456
|
+
// A bare `git push` names no repository. Its checkout comes from the tool's
|
|
457
|
+
// workdir, which the seat cannot see, so the seat defers it as
|
|
458
|
+
// cwd-dependent and `execute.before` enforces with the resolved workdir.
|
|
456
459
|
check(
|
|
457
460
|
"V2 permission seat abstains on an undecidable push instead of denying it",
|
|
458
461
|
headlessAccountability?.seat?.push?.effect === "abstain" &&
|
|
459
|
-
headlessAccountability?.seat?.push?.decision?.code === "
|
|
462
|
+
headlessAccountability?.seat?.push?.decision?.code === "repository_unidentified",
|
|
460
463
|
);
|
|
461
464
|
}
|
|
462
465
|
|