opencode-workflow-guard 1.8.6 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/lib/audit.ts +6 -4
- package/src/lib/custom-tools.ts +36 -13
- package/src/lib/evidence.ts +54 -0
- package/src/lib/guard-dispatcher.ts +123 -109
- package/src/lib/project-config.ts +14 -6
- package/src/lib/state.ts +56 -5
- package/src/lib/types.ts +35 -0
- package/src/policies/boundary.ts +2 -1
- package/src/policies/completion.ts +10 -2
- package/src/policies/continuation.ts +45 -11
- package/src/policies/file-claims.ts +12 -5
- package/src/policies/git.ts +2 -0
- package/src/workflow-guard-ui.ts +8 -77
- package/src/workflow-guard.ts +27 -8
package/src/lib/state.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { realpathSync } from "node:fs";
|
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
4
|
import { persistVerifyCache, persistVerifyHistory } from "./audit.ts";
|
|
5
|
-
import { getCachedProjectConfig, loadProjectConfig } from "./project-config.ts";
|
|
5
|
+
import { getCachedProjectConfig, loadProjectConfig, projectRootKey } from "./project-config.ts";
|
|
6
6
|
import { snipVerifyOutput, getCurrentGitCommitHash, getGitStatusSummary, getGitWorktreeFingerprint } from "./verify.ts";
|
|
7
7
|
import type {
|
|
8
8
|
TodoSdkClient,
|
|
@@ -70,6 +70,8 @@ export function runWithRuntimeState<T>(root: string, client: unknown, fn: () =>
|
|
|
70
70
|
|
|
71
71
|
export let lastMutationTimestamp = 0;
|
|
72
72
|
export let mutationCount = 0;
|
|
73
|
+
const workspaceMutationTimestamps = new Map<string, number>();
|
|
74
|
+
const workspaceMutationCounts = new Map<string, number>();
|
|
73
75
|
export const sessionMutationTimestamps = new Map<string, number>();
|
|
74
76
|
export const sessionMutationCounts = new Map<string, number>();
|
|
75
77
|
|
|
@@ -82,6 +84,9 @@ export const sessionReviews = new Map<string, ReviewResult>();
|
|
|
82
84
|
export function recordMutation(sessionID?: string, actorSessionID?: string): void {
|
|
83
85
|
lastMutationTimestamp = Date.now();
|
|
84
86
|
mutationCount++;
|
|
87
|
+
const workspace = projectRootKey(runtimeState.getStore()?.workspaceRoot ?? getWorkspaceRoot());
|
|
88
|
+
workspaceMutationTimestamps.set(workspace, lastMutationTimestamp);
|
|
89
|
+
workspaceMutationCounts.set(workspace, (workspaceMutationCounts.get(workspace) ?? 0) + 1);
|
|
85
90
|
for (const id of new Set([sessionID, actorSessionID].filter((value): value is string => Boolean(value)))) {
|
|
86
91
|
sessionMutationTimestamps.set(id, lastMutationTimestamp);
|
|
87
92
|
sessionMutationCounts.set(id, (sessionMutationCounts.get(id) ?? 0) + 1);
|
|
@@ -108,10 +113,26 @@ export function getLastMutationTimestamp(): number {
|
|
|
108
113
|
return lastMutationTimestamp;
|
|
109
114
|
}
|
|
110
115
|
|
|
116
|
+
export function getWorkspaceMutationTimestamp(root: string): number {
|
|
117
|
+
return workspaceMutationTimestamps.get(projectRootKey(root)) ?? 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function getWorkspaceMutationCount(root: string): number {
|
|
121
|
+
return workspaceMutationCounts.get(projectRootKey(root)) ?? 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
111
124
|
export function getLastVerifyResult(): typeof lastVerify {
|
|
112
125
|
return lastVerify;
|
|
113
126
|
}
|
|
114
127
|
|
|
128
|
+
export function getLastVerifyResultForWorkspace(root: string): typeof lastVerify {
|
|
129
|
+
const workspace = projectRootKey(root);
|
|
130
|
+
const candidates = [lastVerify, ...sessionVerifyResults.values()].filter(
|
|
131
|
+
(result): result is NonNullable<VerifyResult> => result?.workspaceRoot != null && projectRootKey(result.workspaceRoot) === workspace,
|
|
132
|
+
);
|
|
133
|
+
return candidates.reduce<NonNullable<VerifyResult> | undefined>((latest, result) => !latest || result.timestamp > latest.timestamp ? result : latest, undefined);
|
|
134
|
+
}
|
|
135
|
+
|
|
115
136
|
export function recordVerifyResult(
|
|
116
137
|
command: string,
|
|
117
138
|
result: { passed: boolean; output: string; durationMs?: number },
|
|
@@ -126,7 +147,8 @@ export function recordVerifyResult(
|
|
|
126
147
|
durationMs: result.durationMs,
|
|
127
148
|
commitHash: getCurrentGitCommitHash(root),
|
|
128
149
|
gitStatus: getGitStatusSummary(root),
|
|
129
|
-
workspaceRoot:
|
|
150
|
+
workspaceRoot: projectRootKey(root),
|
|
151
|
+
worktreeFingerprint: getGitWorktreeFingerprint(root),
|
|
130
152
|
};
|
|
131
153
|
if (sessionID && lastVerify) sessionVerifyResults.set(sessionID, lastVerify);
|
|
132
154
|
persistVerifyHistory(lastVerify);
|
|
@@ -145,6 +167,8 @@ export function resetVerifyState(): void {
|
|
|
145
167
|
lastVerify = undefined;
|
|
146
168
|
sessionMutationTimestamps.clear();
|
|
147
169
|
sessionMutationCounts.clear();
|
|
170
|
+
workspaceMutationTimestamps.clear();
|
|
171
|
+
workspaceMutationCounts.clear();
|
|
148
172
|
sessionVerifyResults.clear();
|
|
149
173
|
}
|
|
150
174
|
|
|
@@ -155,7 +179,7 @@ export function recordReviewResult(
|
|
|
155
179
|
targetSessionID?: string,
|
|
156
180
|
workspace?: string,
|
|
157
181
|
): void {
|
|
158
|
-
const root =
|
|
182
|
+
const root = projectRootKey(workspace ?? getWorkspaceRoot());
|
|
159
183
|
lastReview = {
|
|
160
184
|
reviewer,
|
|
161
185
|
summary: summary.slice(-4000),
|
|
@@ -174,6 +198,14 @@ export function getLastReviewResult(): typeof lastReview {
|
|
|
174
198
|
return lastReview;
|
|
175
199
|
}
|
|
176
200
|
|
|
201
|
+
export function getLastReviewResultForWorkspace(root: string): typeof lastReview {
|
|
202
|
+
const workspace = projectRootKey(root);
|
|
203
|
+
const candidates = [lastReview, ...sessionReviews.values()].filter(
|
|
204
|
+
(result): result is ReviewResult => result?.workspace != null && projectRootKey(result.workspace) === workspace,
|
|
205
|
+
);
|
|
206
|
+
return candidates.reduce<ReviewResult | undefined>((latest, result) => !latest || result.timestamp > latest.timestamp ? result : latest, undefined);
|
|
207
|
+
}
|
|
208
|
+
|
|
177
209
|
export function resetReviewState(): void {
|
|
178
210
|
lastReview = undefined;
|
|
179
211
|
sessionReviews.clear();
|
|
@@ -181,8 +213,8 @@ export function resetReviewState(): void {
|
|
|
181
213
|
|
|
182
214
|
export function getProjectConfig(root: string): ProjectConfig {
|
|
183
215
|
const active = runtimeState.getStore();
|
|
184
|
-
if (active
|
|
185
|
-
return getCachedProjectConfig() ?? loadProjectConfig(root);
|
|
216
|
+
if (active && projectRootKey(active.workspaceRoot) === projectRootKey(root)) return active.projectConfig;
|
|
217
|
+
return getCachedProjectConfig(root) ?? loadProjectConfig(root);
|
|
186
218
|
}
|
|
187
219
|
|
|
188
220
|
export function isReviewRequired(root: string): boolean {
|
|
@@ -197,6 +229,25 @@ export function isDocumentationRequired(root: string): boolean {
|
|
|
197
229
|
return cfg.requireDocumentation === true;
|
|
198
230
|
}
|
|
199
231
|
|
|
232
|
+
export function getOperationProfile(root: string): "interactive" | "autonomous" {
|
|
233
|
+
return getProjectConfig(root).profile === "autonomous" ? "autonomous" : "interactive";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function isRecoveryCheckpointsEnabled(root: string): boolean {
|
|
237
|
+
const cfg = getProjectConfig(root);
|
|
238
|
+
return cfg.recoveryCheckpoints ?? getOperationProfile(root) === "autonomous";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function isRalphModeEnabled(root: string): boolean {
|
|
242
|
+
return getProjectConfig(root).ralphMode === true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function getRalphMaxIterations(root: string): number {
|
|
246
|
+
const configured = getProjectConfig(root).ralphMaxIterations;
|
|
247
|
+
if (typeof configured === "number" && Number.isInteger(configured) && configured > 0 && configured <= 100) return configured;
|
|
248
|
+
return 10;
|
|
249
|
+
}
|
|
250
|
+
|
|
200
251
|
export function getSubagentMutationBudget(root: string): number {
|
|
201
252
|
if (process.env.WORKFLOW_GUARD_MAX_SUBAGENT_MUTATIONS) {
|
|
202
253
|
const parsed = parseInt(process.env.WORKFLOW_GUARD_MAX_SUBAGENT_MUTATIONS, 10);
|
package/src/lib/types.ts
CHANGED
|
@@ -24,6 +24,7 @@ export interface TodoSdkClient {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export interface ProjectConfig {
|
|
27
|
+
profile?: "interactive" | "autonomous";
|
|
27
28
|
protectedBranches?: string[];
|
|
28
29
|
verifyCommand?: string;
|
|
29
30
|
postEditValidators?: Array<{
|
|
@@ -37,10 +38,22 @@ export interface ProjectConfig {
|
|
|
37
38
|
projectMemory?: boolean;
|
|
38
39
|
learning?: boolean;
|
|
39
40
|
titleSettleWorkaround?: boolean;
|
|
41
|
+
ralphMode?: boolean;
|
|
42
|
+
ralphMaxIterations?: number;
|
|
40
43
|
maxSubagentMutations?: number;
|
|
41
44
|
maxLearningInterventions?: number;
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
export type PolicyDecisionStatus = "allowed" | "blocked" | "needs_approval" | "verification_failed";
|
|
48
|
+
|
|
49
|
+
export interface PolicyDecision {
|
|
50
|
+
status: PolicyDecisionStatus;
|
|
51
|
+
code: string;
|
|
52
|
+
policy?: string;
|
|
53
|
+
message: string;
|
|
54
|
+
details?: Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
export interface VerifyResult {
|
|
45
58
|
passed: boolean;
|
|
46
59
|
command: string;
|
|
@@ -50,6 +63,7 @@ export interface VerifyResult {
|
|
|
50
63
|
commitHash?: string;
|
|
51
64
|
gitStatus?: string;
|
|
52
65
|
workspaceRoot?: string;
|
|
66
|
+
worktreeFingerprint?: string;
|
|
53
67
|
}
|
|
54
68
|
|
|
55
69
|
export interface ReviewResult {
|
|
@@ -64,12 +78,33 @@ export interface ReviewResult {
|
|
|
64
78
|
worktreeFingerprint?: string;
|
|
65
79
|
}
|
|
66
80
|
|
|
81
|
+
export type EvidenceConfidence = "deterministic_observation" | "attestation" | "derived_state" | "agent_assertion";
|
|
82
|
+
|
|
83
|
+
export interface EvidenceRecord {
|
|
84
|
+
id: string;
|
|
85
|
+
kind: "verification" | "review" | "tool_outcome" | "policy_evaluation" | "lifecycle_state" | "agent_assertion";
|
|
86
|
+
confidence: EvidenceConfidence;
|
|
87
|
+
observedAt: number;
|
|
88
|
+
subject: {
|
|
89
|
+
workspace?: string;
|
|
90
|
+
commitHash?: string;
|
|
91
|
+
worktreeFingerprint?: string;
|
|
92
|
+
sessionID?: string;
|
|
93
|
+
callID?: string;
|
|
94
|
+
actor?: string;
|
|
95
|
+
};
|
|
96
|
+
source: Record<string, unknown>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type EvidenceSubject = EvidenceRecord["subject"];
|
|
100
|
+
|
|
67
101
|
export interface AuditEntry {
|
|
68
102
|
ts: string;
|
|
69
103
|
sessionID?: string;
|
|
70
104
|
callID?: string;
|
|
71
105
|
tool: string;
|
|
72
106
|
decision: "allow" | "block";
|
|
107
|
+
policyDecision?: PolicyDecision;
|
|
73
108
|
phase?: "decision" | "outcome" | "event";
|
|
74
109
|
durationMs?: number;
|
|
75
110
|
reason?: string;
|
package/src/policies/boundary.ts
CHANGED
|
@@ -317,6 +317,7 @@ export function detectShellMutation(command: string): ShellMutation | undefined
|
|
|
317
317
|
export async function guardShellMutation(
|
|
318
318
|
command: string,
|
|
319
319
|
sessionID: string | undefined,
|
|
320
|
+
record = true,
|
|
320
321
|
): Promise<string | undefined> {
|
|
321
322
|
const root = getWorkspaceRoot();
|
|
322
323
|
let hasMutation = false;
|
|
@@ -383,7 +384,7 @@ export async function guardShellMutation(
|
|
|
383
384
|
}
|
|
384
385
|
}
|
|
385
386
|
}
|
|
386
|
-
if (hasMutation) {
|
|
387
|
+
if (hasMutation && record) {
|
|
387
388
|
recordMutation(await effectiveTodoOwnerSessionID(sessionID), sessionID);
|
|
388
389
|
}
|
|
389
390
|
return undefined;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isEvidenceFresh, verificationEvidence } from "../lib/evidence.ts";
|
|
2
|
+
import { projectRootKey } from "../lib/project-config.ts";
|
|
3
|
+
import { getWorkspaceRoot, sessionVerifyResults, lastVerify, sessionMutationTimestamps, lastMutationTimestamp } from "../lib/state.ts";
|
|
2
4
|
import type { VerifyResult } from "../lib/types.ts";
|
|
5
|
+
import { getCurrentGitCommitHash, getGitWorktreeFingerprint } from "../lib/verify.ts";
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
8
|
* Claims-vs-evidence check (Policy 24): when the assistant's final text
|
|
@@ -60,7 +63,12 @@ export function checkCompletionClaims(
|
|
|
60
63
|
} else if (!verifyResult.passed) {
|
|
61
64
|
evidenceState = "failing";
|
|
62
65
|
} else if (
|
|
63
|
-
verifyResult
|
|
66
|
+
!isEvidenceFresh(verificationEvidence(verifyResult, context?.sessionID), {
|
|
67
|
+
workspace: projectRootKey(getWorkspaceRoot()),
|
|
68
|
+
commitHash: getCurrentGitCommitHash(getWorkspaceRoot()),
|
|
69
|
+
worktreeFingerprint: getGitWorktreeFingerprint(getWorkspaceRoot()),
|
|
70
|
+
sessionID: context?.sessionID,
|
|
71
|
+
}, mutationTimestamp) ||
|
|
64
72
|
Date.now() - verifyResult.timestamp > VERIFICATION_FRESH_WINDOW_MS
|
|
65
73
|
) {
|
|
66
74
|
evidenceState = "stale-pass";
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { getSdkClient } from "../lib/state.ts";
|
|
2
|
+
import { getRalphMaxIterations, getSdkClient, getWorkspaceRoot, isRalphModeEnabled } from "../lib/state.ts";
|
|
3
|
+
import { projectRootKey } from "../lib/project-config.ts";
|
|
3
4
|
import { effectiveTodosWithOwner, hasActiveTodo } from "./todo.ts";
|
|
4
5
|
|
|
5
6
|
const MAX_CONSECUTIVE_CONTINUATIONS = 3;
|
|
7
|
+
const MAX_GENERATED_MESSAGE_IDS = 100;
|
|
6
8
|
const TITLE_SETTLE_MS = 250;
|
|
7
9
|
const TITLE_SETTLE_ATTEMPTS = 8;
|
|
8
10
|
const DEFAULT_SESSION_TITLE = /^(?:New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
9
11
|
type ContinuationState = {
|
|
10
12
|
counts: Map<string, number>;
|
|
13
|
+
ralphOutcomes: Map<string, RalphOutcome>;
|
|
11
14
|
generatedMessageIDs: Map<string, Set<string>>;
|
|
15
|
+
lastUserMessageIDs: Map<string, string>;
|
|
12
16
|
inFlight: Set<string>;
|
|
13
17
|
};
|
|
14
|
-
|
|
18
|
+
export type RalphOutcome = "running" | "completed" | "blocked" | "budget_exhausted" | "user_stopped";
|
|
19
|
+
const states = new WeakMap<object, Map<string, ContinuationState>>();
|
|
15
20
|
|
|
16
21
|
export async function waitForSessionTitle(sessionID: string): Promise<void> {
|
|
17
22
|
const session = getSdkClient()?.session;
|
|
@@ -30,10 +35,16 @@ export async function waitForSessionTitle(sessionID: string): Promise<void> {
|
|
|
30
35
|
function getState(): ContinuationState | undefined {
|
|
31
36
|
const client = getSdkClient();
|
|
32
37
|
if (!client || (typeof client !== "object" && typeof client !== "function")) return undefined;
|
|
33
|
-
let
|
|
38
|
+
let clientStates = states.get(client as object);
|
|
39
|
+
if (!clientStates) {
|
|
40
|
+
clientStates = new Map();
|
|
41
|
+
states.set(client as object, clientStates);
|
|
42
|
+
}
|
|
43
|
+
const root = projectRootKey(getWorkspaceRoot());
|
|
44
|
+
let state = clientStates.get(root);
|
|
34
45
|
if (!state) {
|
|
35
|
-
state = { counts: new Map(), generatedMessageIDs: new Map(), inFlight: new Set() };
|
|
36
|
-
|
|
46
|
+
state = { counts: new Map(), ralphOutcomes: new Map(), generatedMessageIDs: new Map(), lastUserMessageIDs: new Map(), inFlight: new Set() };
|
|
47
|
+
clientStates.set(root, state);
|
|
37
48
|
}
|
|
38
49
|
return state;
|
|
39
50
|
}
|
|
@@ -43,19 +54,33 @@ export async function continueUnfinishedSession(sessionID: string, settleTitle =
|
|
|
43
54
|
if (!state || state.inFlight.has(sessionID)) return false;
|
|
44
55
|
state.inFlight.add(sessionID);
|
|
45
56
|
try {
|
|
57
|
+
const ralph = isRalphModeEnabled(getWorkspaceRoot());
|
|
58
|
+
if (ralph && state.ralphOutcomes.get(sessionID) === "user_stopped") return false;
|
|
46
59
|
const effective = await effectiveTodosWithOwner(sessionID);
|
|
47
|
-
if (!effective || effective.ownerSessionID !== sessionID || !hasActiveTodo(effective.todos))
|
|
60
|
+
if (!effective || effective.ownerSessionID !== sessionID || !hasActiveTodo(effective.todos)) {
|
|
61
|
+
if (ralph && state.ralphOutcomes.has(sessionID)) state.ralphOutcomes.set(sessionID, "completed");
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
48
64
|
if (settleTitle) await waitForSessionTitle(sessionID);
|
|
49
65
|
|
|
50
66
|
const count = state.counts.get(sessionID) ?? 0;
|
|
51
|
-
|
|
67
|
+
const maxContinuations = ralph ? getRalphMaxIterations(getWorkspaceRoot()) : MAX_CONSECUTIVE_CONTINUATIONS;
|
|
68
|
+
if (count >= maxContinuations) {
|
|
69
|
+
if (ralph) state.ralphOutcomes.set(sessionID, "budget_exhausted");
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
52
72
|
|
|
53
73
|
const session = getSdkClient()?.session;
|
|
54
74
|
if (typeof session?.promptAsync !== "function") return false;
|
|
55
75
|
|
|
56
76
|
const messageID = `workflow-guard-${randomUUID()}`;
|
|
57
77
|
state.counts.set(sessionID, count + 1);
|
|
78
|
+
if (ralph) state.ralphOutcomes.set(sessionID, "running");
|
|
58
79
|
const generatedIDs = state.generatedMessageIDs.get(sessionID) ?? new Set<string>();
|
|
80
|
+
if (generatedIDs.size >= MAX_GENERATED_MESSAGE_IDS) {
|
|
81
|
+
const oldestID = generatedIDs.values().next().value;
|
|
82
|
+
if (oldestID) generatedIDs.delete(oldestID);
|
|
83
|
+
}
|
|
59
84
|
generatedIDs.add(messageID);
|
|
60
85
|
state.generatedMessageIDs.set(sessionID, generatedIDs);
|
|
61
86
|
try {
|
|
@@ -86,11 +111,18 @@ export function recordUserMessage(sessionID: string, messageID?: string): void {
|
|
|
86
111
|
const state = getState();
|
|
87
112
|
if (!state) return;
|
|
88
113
|
const generatedIDs = state.generatedMessageIDs.get(sessionID);
|
|
89
|
-
if (messageID && generatedIDs?.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
114
|
+
if (messageID && generatedIDs?.has(messageID)) return;
|
|
115
|
+
if (messageID && state.lastUserMessageIDs.get(sessionID) === messageID) return;
|
|
116
|
+
if (messageID) state.lastUserMessageIDs.set(sessionID, messageID);
|
|
93
117
|
state.counts.delete(sessionID);
|
|
118
|
+
if (isRalphModeEnabled(getWorkspaceRoot()) && state.ralphOutcomes.has(sessionID)) {
|
|
119
|
+
if (state.ralphOutcomes.get(sessionID) === "user_stopped") state.ralphOutcomes.delete(sessionID);
|
|
120
|
+
else state.ralphOutcomes.set(sessionID, "user_stopped");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function getRalphOutcome(sessionID: string): RalphOutcome | undefined {
|
|
125
|
+
return getState()?.ralphOutcomes.get(sessionID);
|
|
94
126
|
}
|
|
95
127
|
|
|
96
128
|
export function isGeneratedContinuationMessage(sessionID: string, messageID?: string): boolean {
|
|
@@ -102,6 +134,8 @@ export function clearContinuationState(sessionID: string): void {
|
|
|
102
134
|
const state = getState();
|
|
103
135
|
if (!state) return;
|
|
104
136
|
state.counts.delete(sessionID);
|
|
137
|
+
state.ralphOutcomes.delete(sessionID);
|
|
105
138
|
state.generatedMessageIDs.delete(sessionID);
|
|
139
|
+
state.lastUserMessageIDs.delete(sessionID);
|
|
106
140
|
state.inFlight.delete(sessionID);
|
|
107
141
|
}
|
|
@@ -33,15 +33,22 @@ export function canonicalPath(path: string, seen = new Set<string>()): string {
|
|
|
33
33
|
|
|
34
34
|
export function claimFiles(paths: string[], sessionID: string, callID: string): string | undefined {
|
|
35
35
|
const canonical = [...new Set(paths.map((path) => canonicalPath(path)))];
|
|
36
|
+
const conflict = fileClaimConflictReason(canonical, sessionID);
|
|
37
|
+
if (conflict) return conflict;
|
|
36
38
|
for (const path of canonical) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return `Blocked: file '${path}' is claimed by another active session ('${existing.sessionID}'). Wait for that edit to finish or use an isolated worktree.`;
|
|
39
|
+
if (!claims.has(path)) {
|
|
40
|
+
claims.set(path, { sessionID, callID });
|
|
40
41
|
}
|
|
41
42
|
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function fileClaimConflictReason(paths: string[], sessionID: string): string | undefined {
|
|
47
|
+
const canonical = [...new Set(paths.map((path) => canonicalPath(path)))];
|
|
42
48
|
for (const path of canonical) {
|
|
43
|
-
|
|
44
|
-
|
|
49
|
+
const existing = claims.get(path);
|
|
50
|
+
if (existing && existing.sessionID !== sessionID) {
|
|
51
|
+
return `Blocked: file '${path}' is claimed by another active session ('${existing.sessionID}'). Wait for that edit to finish or use an isolated worktree.`;
|
|
45
52
|
}
|
|
46
53
|
}
|
|
47
54
|
return undefined;
|
package/src/policies/git.ts
CHANGED
|
@@ -195,6 +195,7 @@ export function branchGuardReason(): string {
|
|
|
195
195
|
export function isBranchAlreadyMergedOrClosed(
|
|
196
196
|
root: string,
|
|
197
197
|
branch: string,
|
|
198
|
+
checkRemotePrs = true,
|
|
198
199
|
): { merged: boolean; reason?: string } {
|
|
199
200
|
if (!branch || PROTECTED_BRANCHES.has(branch)) {
|
|
200
201
|
return { merged: false };
|
|
@@ -220,6 +221,7 @@ export function isBranchAlreadyMergedOrClosed(
|
|
|
220
221
|
}
|
|
221
222
|
}
|
|
222
223
|
}
|
|
224
|
+
if (!checkRemotePrs) return { merged: false };
|
|
223
225
|
|
|
224
226
|
try {
|
|
225
227
|
const ghRes = spawnSync(
|
package/src/workflow-guard-ui.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui";
|
|
2
|
-
import type { BaseRenderable } from "@opentui/core";
|
|
3
2
|
import type { JSX } from "@opentui/solid";
|
|
4
3
|
import { createElement, insert, setProp } from "@opentui/solid";
|
|
5
4
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -31,11 +30,8 @@ function text(props: Record<string, unknown>, children: Child[]) {
|
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
const BADGE_ACTIVE = "Workflow Guard 🛡️";
|
|
34
|
-
const BLOCKED_BADGE_DURATION_MS = 5_000;
|
|
35
|
-
let lastBlockedReason: string | undefined;
|
|
36
|
-
const sessionBlockedReasons = new Map<string, string>();
|
|
37
33
|
|
|
38
|
-
type ProjectToggle = "recoveryCheckpoints" | "projectMemory" | "learning" | "titleSettleWorkaround";
|
|
34
|
+
type ProjectToggle = "recoveryCheckpoints" | "projectMemory" | "learning" | "titleSettleWorkaround" | "ralphMode";
|
|
39
35
|
|
|
40
36
|
export function readProjectOption(root: string, option: ProjectToggle): boolean {
|
|
41
37
|
const path = projectConfigPath(root);
|
|
@@ -74,29 +70,11 @@ export function writeRecoveryCheckpointsOption(root: string, enabled: boolean):
|
|
|
74
70
|
return writeProjectOption(root, "recoveryCheckpoints", enabled);
|
|
75
71
|
}
|
|
76
72
|
|
|
77
|
-
export function
|
|
78
|
-
|
|
79
|
-
if (reason === undefined) sessionBlockedReasons.clear();
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function getLastBlockedReason(): string | undefined {
|
|
83
|
-
return lastBlockedReason;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export function formatBadge(sessionID?: string): { text: string; isBlocked: boolean } {
|
|
87
|
-
const reason = sessionID ? sessionBlockedReasons.get(sessionID) : lastBlockedReason;
|
|
88
|
-
if (!reason) {
|
|
89
|
-
return { text: BADGE_ACTIVE, isBlocked: false };
|
|
90
|
-
}
|
|
91
|
-
const cleanReason = reason.replace(/^\[workflow-guard\]\s*/i, "").replace(/^Blocked(?::|\s+)\s*/i, "");
|
|
92
|
-
const shortReason = cleanReason.length > 30 ? cleanReason.slice(0, 27) + "..." : cleanReason;
|
|
93
|
-
return { text: `[Workflow Guard: Blocked: ${shortReason}]`, isBlocked: true };
|
|
73
|
+
export function formatBadge(): { text: string; isBlocked: boolean } {
|
|
74
|
+
return { text: BADGE_ACTIVE, isBlocked: false };
|
|
94
75
|
}
|
|
95
76
|
|
|
96
77
|
export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
97
|
-
const badgeNodes = new Map<string, BaseRenderable>();
|
|
98
|
-
const blockedBadgeTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
99
|
-
|
|
100
78
|
api.keymap.registerLayer({
|
|
101
79
|
commands: [{
|
|
102
80
|
name: "workflow-guard.project-options",
|
|
@@ -110,6 +88,7 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
|
110
88
|
const memory = readProjectOption(root, "projectMemory");
|
|
111
89
|
const learning = readProjectOption(root, "learning");
|
|
112
90
|
const titleSettle = readProjectOption(root, "titleSettleWorkaround");
|
|
91
|
+
const ralphMode = readProjectOption(root, "ralphMode");
|
|
113
92
|
api.ui.dialog.replace(() => api.ui.DialogSelect({
|
|
114
93
|
title: "Workflow Guard Project Options",
|
|
115
94
|
current: undefined,
|
|
@@ -118,6 +97,7 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
|
118
97
|
{ title: `Project memory: ${memory ? "On" : "Off"}`, value: "projectMemory", description: "Toggle durable local project memory" },
|
|
119
98
|
{ title: `Learner mode: ${learning ? "On" : "Off"}`, value: "learning", description: "Toggle evidence-based learning tools" },
|
|
120
99
|
{ title: `Title settle workaround: ${titleSettle ? "On" : "Off"}`, value: "titleSettleWorkaround", description: "Delay automatic continuation while OpenCode generates a session title" },
|
|
100
|
+
{ title: `Ralph mode: ${ralphMode ? "On" : "Off"}`, value: "ralphMode", description: "Opt into bounded autonomous continuation of already-owned todos" },
|
|
121
101
|
],
|
|
122
102
|
onSelect: (option) => {
|
|
123
103
|
const key = option.value as ProjectToggle;
|
|
@@ -132,67 +112,18 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
|
132
112
|
bindings: [],
|
|
133
113
|
});
|
|
134
114
|
|
|
135
|
-
function scheduleBadgeReset(sessionID?: string): void {
|
|
136
|
-
const key = sessionID ?? "home";
|
|
137
|
-
const existing = blockedBadgeTimers.get(key);
|
|
138
|
-
if (existing) clearTimeout(existing);
|
|
139
|
-
blockedBadgeTimers.set(key, setTimeout(() => {
|
|
140
|
-
blockedBadgeTimers.delete(key);
|
|
141
|
-
if (sessionID) sessionBlockedReasons.delete(sessionID);
|
|
142
|
-
else lastBlockedReason = undefined;
|
|
143
|
-
const node = badgeNodes.get(key);
|
|
144
|
-
if (node) {
|
|
145
|
-
setProp(node, "content", BADGE_ACTIVE);
|
|
146
|
-
setProp(node, "fg", api.theme.current.success);
|
|
147
|
-
}
|
|
148
|
-
}, BLOCKED_BADGE_DURATION_MS));
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
try {
|
|
152
|
-
api.event.on("tui.toast.show", (e) => {
|
|
153
|
-
const title = e.properties.title;
|
|
154
|
-
const msg = e.properties.message;
|
|
155
|
-
if (title === "Workflow Guard Blocked" && typeof msg === "string") {
|
|
156
|
-
const route = api.route.current;
|
|
157
|
-
const sessionID =
|
|
158
|
-
route.name === "session" && typeof route.params?.sessionID === "string"
|
|
159
|
-
? route.params.sessionID
|
|
160
|
-
: undefined;
|
|
161
|
-
if (sessionID) sessionBlockedReasons.set(sessionID, msg);
|
|
162
|
-
else lastBlockedReason = msg;
|
|
163
|
-
const key = sessionID ?? "home";
|
|
164
|
-
const node = badgeNodes.get(key);
|
|
165
|
-
if (node) {
|
|
166
|
-
const badge = formatBadge(sessionID);
|
|
167
|
-
setProp(node, "content", badge.text);
|
|
168
|
-
setProp(node, "fg", badge.isBlocked ? api.theme.current.warning : api.theme.current.success);
|
|
169
|
-
}
|
|
170
|
-
scheduleBadgeReset(sessionID);
|
|
171
|
-
}
|
|
172
|
-
});
|
|
173
|
-
} catch {}
|
|
174
|
-
|
|
175
115
|
api.slots.register({
|
|
176
116
|
order: 1,
|
|
177
117
|
slots: {
|
|
178
118
|
home_prompt_right() {
|
|
179
119
|
const theme = api.theme.current;
|
|
180
120
|
const badge = formatBadge();
|
|
181
|
-
|
|
182
|
-
badgeNodes.set("home", node as unknown as BaseRenderable);
|
|
183
|
-
return node;
|
|
121
|
+
return text({ fg: theme.success }, [badge.text]);
|
|
184
122
|
},
|
|
185
123
|
session_prompt_right() {
|
|
186
124
|
const theme = api.theme.current;
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
route.name === "session" && typeof route.params?.sessionID === "string"
|
|
190
|
-
? route.params.sessionID
|
|
191
|
-
: undefined;
|
|
192
|
-
const badge = formatBadge(sessionID);
|
|
193
|
-
const node = text({ fg: badge.isBlocked ? theme.warning : theme.success }, [badge.text]);
|
|
194
|
-
if (sessionID) badgeNodes.set(sessionID, node as unknown as BaseRenderable);
|
|
195
|
-
return node;
|
|
125
|
+
const badge = formatBadge();
|
|
126
|
+
return text({ fg: theme.success }, [badge.text]);
|
|
196
127
|
},
|
|
197
128
|
},
|
|
198
129
|
});
|
package/src/workflow-guard.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { ToolInvocationLifecycle } from "./lib/tool-lifecycle.ts";
|
|
|
19
19
|
import { ToolOutcomeTracker, type ToolOutcomePart } from "./lib/tool-outcomes.ts";
|
|
20
20
|
import { guardToolCallImpl, isReadOnlyRole } from "./lib/guard-dispatcher.ts";
|
|
21
21
|
import { createCustomTools } from "./lib/custom-tools.ts";
|
|
22
|
-
import type { TodoSdkClient } from "./lib/types.ts";
|
|
22
|
+
import type { PolicyDecision, TodoSdkClient } from "./lib/types.ts";
|
|
23
23
|
export { isReadOnlyRole } from "./lib/guard-dispatcher.ts";
|
|
24
24
|
export { extractReviewFollowups } from "./lib/custom-tools.ts";
|
|
25
25
|
|
|
@@ -30,6 +30,11 @@ export type {
|
|
|
30
30
|
VerifyResult,
|
|
31
31
|
ReviewResult,
|
|
32
32
|
AuditEntry,
|
|
33
|
+
PolicyDecision,
|
|
34
|
+
PolicyDecisionStatus,
|
|
35
|
+
EvidenceRecord,
|
|
36
|
+
EvidenceConfidence,
|
|
37
|
+
EvidenceSubject,
|
|
33
38
|
GitInvocation,
|
|
34
39
|
ShellMutation,
|
|
35
40
|
LearnerProfile,
|
|
@@ -37,6 +42,8 @@ export type {
|
|
|
37
42
|
LearningOpportunity,
|
|
38
43
|
} from "./lib/types.ts";
|
|
39
44
|
export type { TodoSdkClient };
|
|
45
|
+
export { verificationEvidence, reviewEvidence, toolOutcomeEvidence, policyDecisionEvidence, lifecycleStateEvidence, agentAssertionEvidence, evidenceMatchesSubject, isEvidenceFresh } from "./lib/evidence.ts";
|
|
46
|
+
export { getRalphOutcome } from "./policies/continuation.ts";
|
|
40
47
|
|
|
41
48
|
// ── State & runtime context ──────────────────────────────────────────────────
|
|
42
49
|
import {
|
|
@@ -57,6 +64,8 @@ import {
|
|
|
57
64
|
sessionMutationTimestamps,
|
|
58
65
|
sessionVerifyResults,
|
|
59
66
|
getProjectConfig,
|
|
67
|
+
getOperationProfile,
|
|
68
|
+
isRecoveryCheckpointsEnabled,
|
|
60
69
|
isReviewRequired,
|
|
61
70
|
isDocumentationRequired,
|
|
62
71
|
getSubagentMutationBudget,
|
|
@@ -83,6 +92,8 @@ export {
|
|
|
83
92
|
loadProjectConfig,
|
|
84
93
|
reloadProjectConfig,
|
|
85
94
|
stripJsonComments,
|
|
95
|
+
getOperationProfile,
|
|
96
|
+
isRecoveryCheckpointsEnabled,
|
|
86
97
|
isReviewRequired,
|
|
87
98
|
isDocumentationRequired,
|
|
88
99
|
getSubagentMutationBudget,
|
|
@@ -293,20 +304,28 @@ function logObservation(client: unknown, message: string): void {
|
|
|
293
304
|
* Public guard entry point. Audits every decision (block or allow) to a
|
|
294
305
|
* durable JSONL file before returning.
|
|
295
306
|
*/
|
|
296
|
-
export async function
|
|
307
|
+
export async function guardToolDecision(
|
|
297
308
|
toolName: string,
|
|
298
309
|
input: unknown,
|
|
299
310
|
context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string },
|
|
300
|
-
): Promise<
|
|
311
|
+
): Promise<PolicyDecision> {
|
|
301
312
|
const customRoot = context?.worktree || context?.directory;
|
|
302
313
|
const runImpl = () => guardToolCallImpl(toolName, input, context);
|
|
303
|
-
|
|
314
|
+
return customRoot
|
|
304
315
|
? await runWithRuntimeState(customRoot, getSdkClient(), runImpl)
|
|
305
316
|
: await runImpl();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export async function guardToolCall(
|
|
320
|
+
toolName: string,
|
|
321
|
+
input: unknown,
|
|
322
|
+
context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string },
|
|
323
|
+
): Promise<string | undefined> {
|
|
324
|
+
const decision = await guardToolDecision(toolName, input, context);
|
|
306
325
|
const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
|
|
307
326
|
const isMutation = EDIT_TOOL_NAMES.has(toolName);
|
|
308
327
|
const targetPath = extractRecordTargetPath(input);
|
|
309
|
-
logDecision(toolName, input, context,
|
|
328
|
+
logDecision(toolName, input, context, decision, {
|
|
310
329
|
mutation: isMutation,
|
|
311
330
|
targetPath,
|
|
312
331
|
allowLive,
|
|
@@ -319,7 +338,7 @@ export async function guardToolCall(
|
|
|
319
338
|
}
|
|
320
339
|
: undefined,
|
|
321
340
|
});
|
|
322
|
-
return
|
|
341
|
+
return decision.status === "allowed" ? undefined : decision.message;
|
|
323
342
|
}
|
|
324
343
|
|
|
325
344
|
async function emitBlockFeedback(message: string): Promise<void> {
|
|
@@ -371,7 +390,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
371
390
|
const toolOutcomes = new ToolOutcomeTracker();
|
|
372
391
|
|
|
373
392
|
return {
|
|
374
|
-
tool: createCustomTools({ effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions }),
|
|
393
|
+
tool: createCustomTools({ effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client: ctx.client }),
|
|
375
394
|
|
|
376
395
|
"tool.execute.before": async (input, output) => {
|
|
377
396
|
const toolWorktree =
|
|
@@ -597,7 +616,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
597
616
|
},
|
|
598
617
|
|
|
599
618
|
"chat.message": async (input) => {
|
|
600
|
-
if (
|
|
619
|
+
if (!isRecoveryCheckpointsEnabled(effectiveRoot)) return;
|
|
601
620
|
if (isGeneratedContinuationMessage(input.sessionID, input.messageID)) return;
|
|
602
621
|
const parent = await runWithRuntimeState(effectiveRoot, ctx.client, () => fetchParentSession(input.sessionID));
|
|
603
622
|
if (!parent.ok || parent.parentID) return;
|