opencode-workflow-guard 1.8.7 → 1.9.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/docs/accountability-evolution-plan.md +106 -0
- package/docs/assets/workflow-guard-tui-badge.png +0 -0
- package/docs/installation.md +163 -0
- package/docs/managed-deployment.md +15 -0
- package/docs/policies.md +211 -0
- package/docs/testing.md +56 -0
- package/docs/troubleshooting.md +84 -0
- package/package.json +4 -1
- package/src/lib/audit.ts +6 -4
- package/src/lib/custom-tools.ts +32 -11
- 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 +3 -1
- package/src/workflow-guard.ts +26 -7
- package/test/run-test.mjs +13 -0
- package/test/test-e2e.mts +288 -0
- package/test/test.mts +3479 -0
- package/tsconfig.json +16 -0
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
|
@@ -31,7 +31,7 @@ function text(props: Record<string, unknown>, children: Child[]) {
|
|
|
31
31
|
|
|
32
32
|
const BADGE_ACTIVE = "Workflow Guard 🛡️";
|
|
33
33
|
|
|
34
|
-
type ProjectToggle = "recoveryCheckpoints" | "projectMemory" | "learning" | "titleSettleWorkaround";
|
|
34
|
+
type ProjectToggle = "recoveryCheckpoints" | "projectMemory" | "learning" | "titleSettleWorkaround" | "ralphMode";
|
|
35
35
|
|
|
36
36
|
export function readProjectOption(root: string, option: ProjectToggle): boolean {
|
|
37
37
|
const path = projectConfigPath(root);
|
|
@@ -88,6 +88,7 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
|
88
88
|
const memory = readProjectOption(root, "projectMemory");
|
|
89
89
|
const learning = readProjectOption(root, "learning");
|
|
90
90
|
const titleSettle = readProjectOption(root, "titleSettleWorkaround");
|
|
91
|
+
const ralphMode = readProjectOption(root, "ralphMode");
|
|
91
92
|
api.ui.dialog.replace(() => api.ui.DialogSelect({
|
|
92
93
|
title: "Workflow Guard Project Options",
|
|
93
94
|
current: undefined,
|
|
@@ -96,6 +97,7 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
|
96
97
|
{ title: `Project memory: ${memory ? "On" : "Off"}`, value: "projectMemory", description: "Toggle durable local project memory" },
|
|
97
98
|
{ title: `Learner mode: ${learning ? "On" : "Off"}`, value: "learning", description: "Toggle evidence-based learning tools" },
|
|
98
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" },
|
|
99
101
|
],
|
|
100
102
|
onSelect: (option) => {
|
|
101
103
|
const key = option.value as ProjectToggle;
|
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> {
|
|
@@ -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;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const testHome = mkdtempSync(join(tmpdir(), "workflow-guard-test-home-"));
|
|
6
|
+
process.env.XDG_STATE_HOME = join(testHome, "state");
|
|
7
|
+
process.env.XDG_DATA_HOME = join(testHome, "data");
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
await import("./test.mts");
|
|
11
|
+
} finally {
|
|
12
|
+
rmSync(testHome, { recursive: true, force: true });
|
|
13
|
+
}
|