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.
@@ -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
- const inlineMatch = segment.match(
7
- /\b(?:python3?|node|perl|ruby|osascript|bash|sh|zsh|dash|ksh)\s+(?:-[a-zA-Z]*[ce])\s*(?:"([^"]*)"|'([^']*)')/i,
8
- );
9
- if (inlineMatch?.[1] || inlineMatch?.[2]) {
10
- payloads.push(inlineMatch[1] ?? inlineMatch[2] ?? "");
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
- const heredocHeader = segment.match(
13
- /\b(?:python3?|node|perl|ruby|osascript|bash|sh|zsh|dash|ksh)\b[^\n]*<<(-?)\s*(?:'([^'\n]+)'|"([^"\n]+)"|([^\s'";|&()<>\n]+))[^\n]*\n/i,
14
- );
15
- if (heredocHeader) {
16
- const delimiter = heredocHeader[2] ?? heredocHeader[3] ?? heredocHeader[4];
17
- const bodyStart = (heredocHeader.index ?? 0) + heredocHeader[0].length;
18
- const lines = segment.slice(bodyStart).split("\n");
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 = segment.match(
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 = segment.match(
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]) {
@@ -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, splitShellSegments } from "./lib/shell.ts";
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 splitShellSegments(command)) {
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 === "remote_state_unchecked",
462
+ headlessAccountability?.seat?.push?.decision?.code === "repository_unidentified",
460
463
  );
461
464
  }
462
465