opencode-workflow-guard 1.8.7 → 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/package.json +1 -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/package.json
CHANGED
package/src/lib/audit.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { Database, type SqliteDatabase } from "./sqlite.ts";
|
|
6
|
-
import type { AuditEntry, VerifyResult } from "./types.ts";
|
|
6
|
+
import type { AuditEntry, PolicyDecision, VerifyResult } from "./types.ts";
|
|
7
7
|
import { asRecord } from "./utils.ts";
|
|
8
8
|
|
|
9
9
|
const AUDIT_DIR = join(
|
|
@@ -207,17 +207,19 @@ export function logDecision(
|
|
|
207
207
|
tool: string,
|
|
208
208
|
input: unknown,
|
|
209
209
|
context: { sessionID?: string; callID?: string } | undefined,
|
|
210
|
-
|
|
210
|
+
policyDecision: PolicyDecision,
|
|
211
211
|
evidence?: AuditEntry["evidence"],
|
|
212
212
|
): void {
|
|
213
|
+
const blocked = policyDecision.status !== "allowed";
|
|
213
214
|
audit({
|
|
214
215
|
ts: new Date().toISOString(),
|
|
215
216
|
sessionID: context?.sessionID,
|
|
216
217
|
callID: context?.callID,
|
|
217
218
|
tool,
|
|
218
|
-
decision:
|
|
219
|
+
decision: blocked ? "block" : "allow",
|
|
220
|
+
policyDecision,
|
|
219
221
|
phase: "decision",
|
|
220
|
-
reason,
|
|
222
|
+
reason: blocked ? policyDecision.message : undefined,
|
|
221
223
|
input: summarizeInput(input),
|
|
222
224
|
evidence,
|
|
223
225
|
});
|
package/src/lib/custom-tools.ts
CHANGED
|
@@ -12,24 +12,31 @@ import {
|
|
|
12
12
|
searchProjectMemory,
|
|
13
13
|
} from "./project-memory.ts";
|
|
14
14
|
import { loadLearnerProfile, recordLearningEvidence, selectLearningOpportunity, updateLearnerProfile } from "./learning.ts";
|
|
15
|
+
import { isEvidenceFresh, reviewEvidence, verificationEvidence } from "./evidence.ts";
|
|
15
16
|
import {
|
|
16
17
|
getLearningInterventionBudget,
|
|
18
|
+
getOperationProfile,
|
|
17
19
|
getProjectConfig,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
getRalphMaxIterations,
|
|
21
|
+
getLastReviewResultForWorkspace,
|
|
22
|
+
getLastVerifyResultForWorkspace,
|
|
23
|
+
getWorkspaceMutationCount,
|
|
24
|
+
getWorkspaceMutationTimestamp,
|
|
22
25
|
getWorkspaceRoot,
|
|
23
26
|
isDocumentationRequired,
|
|
27
|
+
isRecoveryCheckpointsEnabled,
|
|
28
|
+
isRalphModeEnabled,
|
|
24
29
|
isReviewRequired,
|
|
25
30
|
recordMutation,
|
|
26
31
|
recordReviewResult,
|
|
27
32
|
runWithRuntimeState,
|
|
28
33
|
} from "./state.ts";
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
34
|
+
import { getRalphOutcome } from "../policies/continuation.ts";
|
|
35
|
+
import { loadProjectConfig, projectRootKey } from "./project-config.ts";
|
|
36
|
+
import { detectVerifyCommand, getCurrentGitCommitHash, getGitWorktreeFingerprint } from "./verify.ts";
|
|
31
37
|
import { buildReviewRubric } from "./review.ts";
|
|
32
38
|
import { createGitWorktree, cleanupGitWorktree } from "./worktree.ts";
|
|
39
|
+
import { branchHasDocumentationChange } from "../policies/docs.ts";
|
|
33
40
|
import { restoreRecoveryCheckpoint } from "./checkpoint.ts";
|
|
34
41
|
import { audit, getRecentAuditEntries } from "./audit.ts";
|
|
35
42
|
import { guardToolCallImpl } from "./guard-dispatcher.ts";
|
|
@@ -58,7 +65,7 @@ export function createCustomTools(options: {
|
|
|
58
65
|
}) {
|
|
59
66
|
const { effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client } = options;
|
|
60
67
|
return {
|
|
61
|
-
...(
|
|
68
|
+
...(isRecoveryCheckpointsEnabled(effectiveRoot) ? {
|
|
62
69
|
guard_recovery_restore: tool({
|
|
63
70
|
description: "Restore this root session's workspace to its pre-run recovery checkpoint. Refuses if the run has not gone idle or if the workspace changed after that boundary.",
|
|
64
71
|
args: { run: tool.schema.number() },
|
|
@@ -145,13 +152,27 @@ export function createCustomTools(options: {
|
|
|
145
152
|
} : {}),
|
|
146
153
|
guard_status: tool({
|
|
147
154
|
description: "Inspect active guardrails, current branch protection, and verification/review status.", args: {},
|
|
148
|
-
execute: async () => {
|
|
149
|
-
const root =
|
|
150
|
-
|
|
155
|
+
execute: async (_args, toolContext) => {
|
|
156
|
+
const root = effectiveRoot; const branch = currentGitBranch(root) ?? "unknown"; const isProtected = onProtectedBranch(root); const lastV = getLastVerifyResultForWorkspace(root); const lastR = getLastReviewResultForWorkspace(root); const lastMut = getWorkspaceMutationTimestamp(root); const cfg = loadProjectConfig(root);
|
|
157
|
+
const subject = { workspace: projectRootKey(root), commitHash: getCurrentGitCommitHash(root), worktreeFingerprint: getGitWorktreeFingerprint(root) };
|
|
158
|
+
const verifyEvidence = lastV ? verificationEvidence(lastV) : undefined;
|
|
159
|
+
const reviewEvidenceRecord = lastR ? reviewEvidence(lastR) : undefined;
|
|
160
|
+
const verifyCommand = detectVerifyCommand(root);
|
|
161
|
+
const verifyFresh = Boolean(lastV && verifyEvidence && lastV.passed && lastV.command === verifyCommand && isEvidenceFresh(verifyEvidence, subject, lastMut));
|
|
162
|
+
const reviewRequired = isReviewRequired(root);
|
|
163
|
+
const reviewFresh = Boolean(lastR && reviewEvidenceRecord && lastR.passed && isEvidenceFresh(reviewEvidenceRecord, { ...subject, sessionID: lastR.targetSessionID }, lastMut));
|
|
164
|
+
const documentationRequired = isDocumentationRequired(root);
|
|
165
|
+
const outstandingRequirements = [
|
|
166
|
+
...(verifyCommand && !verifyFresh ? ["verification"] : []),
|
|
167
|
+
...(reviewRequired && !reviewFresh ? ["review"] : []),
|
|
168
|
+
...(documentationRequired && !branchHasDocumentationChange(root) ? ["documentation"] : []),
|
|
169
|
+
];
|
|
170
|
+
const ralphOutcome = runWithRuntimeState(root, client, () => getRalphOutcome(toolContext.sessionID));
|
|
171
|
+
return JSON.stringify({ workspaceRoot: root, branch, onProtectedBranch: isProtected, outstandingRequirements, lastMutationTimestamp: lastMut, mutationCount: getWorkspaceMutationCount(root), lastVerify: lastV && verifyEvidence ? { command: lastV.command, passed: lastV.passed, fresh: verifyFresh, evidenceId: verifyEvidence.id, commitHash: lastV.commitHash } : null, lastReview: lastR && reviewEvidenceRecord ? { reviewer: lastR.reviewer, passed: lastR.passed, summary: lastR.summary, fresh: reviewFresh, evidenceId: reviewEvidenceRecord.id } : null, ralph: { enabled: isRalphModeEnabled(root), maxIterations: getRalphMaxIterations(root), outcome: ralphOutcome ?? null }, projectConfig: { profile: getOperationProfile(root), protectedBranches: cfg.protectedBranches ?? ["main", "master"], verifyCommand: verifyCommand ?? null, requireReview: reviewRequired, requireDocumentation: documentationRequired, recoveryCheckpoints: isRecoveryCheckpointsEnabled(root) } }, null, 2);
|
|
151
172
|
},
|
|
152
173
|
}),
|
|
153
174
|
guard_audit: tool({ description: "View recent audit entries recorded by opencode-workflow-guard.", args: { limit: tool.schema.number().optional().describe("Maximum entries to return (default 10)") }, execute: async (args) => JSON.stringify(getRecentAuditEntries(typeof args?.limit === "number" ? Math.min(args.limit, 50) : 10), null, 2) }),
|
|
154
|
-
guard_why: tool({ description: "Simulate and
|
|
175
|
+
guard_why: tool({ description: "Simulate and return the structured policy decision for a specific tool call or command.", args: { tool: tool.schema.string().describe("Tool name (e.g. bash, edit, write, read, apply_patch)"), input: tool.schema.record(tool.schema.string(), tool.schema.any()).optional().describe("Tool input arguments") }, execute: async (args, toolContext) => JSON.stringify(await runWithRuntimeState(effectiveRoot, client, () => guardToolCallImpl(args.tool, args.input ?? {}, { sessionID: toolContext.sessionID, worktree: toolContext.worktree, directory: toolContext.directory, simulate: true })), null, 2) }),
|
|
155
176
|
record_review: tool({
|
|
156
177
|
description: "Record a secondary reviewer agent's approval or critique of the current changes. The summary must reference the 5 core review axes from guard_review_rubric.",
|
|
157
178
|
args: { reviewer: tool.schema.string().describe("Identifier/name of the reviewer subagent"), summary: tool.schema.string().describe("Review findings summary across the 5 core review axes"), passed: tool.schema.boolean().describe("True if change is approved, false if changes requested") },
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { ToolOutcome } from "./tool-outcomes.ts";
|
|
3
|
+
import type { EvidenceRecord, EvidenceSubject, PolicyDecision, ReviewResult, VerifyResult } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
function evidenceId(kind: EvidenceRecord["kind"], observedAt: number, subject: EvidenceRecord["subject"], source: Record<string, unknown>): string {
|
|
6
|
+
return createHash("sha256").update(JSON.stringify({ kind, observedAt, subject, source })).digest("hex");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function verificationEvidence(result: VerifyResult, sessionID?: string): EvidenceRecord {
|
|
10
|
+
const subject = { workspace: result.workspaceRoot, commitHash: result.commitHash, worktreeFingerprint: result.worktreeFingerprint, sessionID };
|
|
11
|
+
const source = { command: result.command, passed: result.passed, durationMs: result.durationMs, gitStatus: result.gitStatus };
|
|
12
|
+
return { id: evidenceId("verification", result.timestamp, subject, source), kind: "verification", confidence: "deterministic_observation", observedAt: result.timestamp, subject, source };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function reviewEvidence(result: ReviewResult): EvidenceRecord {
|
|
16
|
+
const subject = { workspace: result.workspace, commitHash: result.commitHash, worktreeFingerprint: result.worktreeFingerprint, sessionID: result.targetSessionID, actor: result.reviewer };
|
|
17
|
+
const source = { reviewer: result.reviewer, passed: result.passed, summary: result.summary, gitStatus: result.gitStatus };
|
|
18
|
+
return { id: evidenceId("review", result.timestamp, subject, source), kind: "review", confidence: "attestation", observedAt: result.timestamp, subject, source };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function toolOutcomeEvidence(outcome: ToolOutcome, observedAt: number, workspace?: string): EvidenceRecord {
|
|
22
|
+
const subject = { workspace, sessionID: outcome.sessionID, callID: outcome.callID };
|
|
23
|
+
const source = { tool: outcome.tool, status: outcome.status, durationMs: outcome.durationMs, repeatedFailureCount: outcome.repeatedFailureCount };
|
|
24
|
+
return { id: evidenceId("tool_outcome", observedAt, subject, source), kind: "tool_outcome", confidence: "deterministic_observation", observedAt, subject, source };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function policyDecisionEvidence(decision: PolicyDecision, observedAt: number, subject: EvidenceSubject): EvidenceRecord {
|
|
28
|
+
const source = { status: decision.status, code: decision.code, policy: decision.policy };
|
|
29
|
+
return { id: evidenceId("policy_evaluation", observedAt, subject, source), kind: "policy_evaluation", confidence: "deterministic_observation", observedAt, subject, source };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function lifecycleStateEvidence(state: string, observedAt: number, subject: EvidenceSubject, source: Record<string, unknown> = {}): EvidenceRecord {
|
|
33
|
+
const lifecycleSource = { state, ...source };
|
|
34
|
+
return { id: evidenceId("lifecycle_state", observedAt, subject, lifecycleSource), kind: "lifecycle_state", confidence: "derived_state", observedAt, subject, source: lifecycleSource };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function agentAssertionEvidence(assertion: string, observedAt: number, subject: EvidenceSubject): EvidenceRecord {
|
|
38
|
+
const source = { assertion };
|
|
39
|
+
return { id: evidenceId("agent_assertion", observedAt, subject, source), kind: "agent_assertion", confidence: "agent_assertion", observedAt, subject, source };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function evidenceMatchesSubject(evidence: EvidenceRecord, subject: EvidenceSubject): boolean {
|
|
43
|
+
return evidence.subject.workspace === subject.workspace &&
|
|
44
|
+
evidence.subject.commitHash === subject.commitHash &&
|
|
45
|
+
evidence.subject.worktreeFingerprint === subject.worktreeFingerprint &&
|
|
46
|
+
evidence.subject.sessionID === subject.sessionID;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isEvidenceFresh(evidence: EvidenceRecord, subject: EvidenceSubject, mutationTimestamp: number): boolean {
|
|
50
|
+
// HEAD is legitimately absent in a freshly initialized repository, but the
|
|
51
|
+
// worktree fingerprint remains available there and is required for freshness.
|
|
52
|
+
if (!evidence.subject.worktreeFingerprint || !subject.worktreeFingerprint) return false;
|
|
53
|
+
return evidence.observedAt >= mutationTimestamp && evidenceMatchesSubject(evidence, subject);
|
|
54
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { loadVerifyCache } from "./audit.ts";
|
|
4
|
+
import { isEvidenceFresh, reviewEvidence, verificationEvidence } from "./evidence.ts";
|
|
5
|
+
import { projectRootKey } from "./project-config.ts";
|
|
4
6
|
import {
|
|
5
7
|
getLastReviewResult,
|
|
6
8
|
getSdkClient,
|
|
@@ -24,6 +26,7 @@ import {
|
|
|
24
26
|
unwrapShellCommand,
|
|
25
27
|
} from "./shell.ts";
|
|
26
28
|
import { asRecord, extractTargetPath } from "./utils.ts";
|
|
29
|
+
import type { PolicyDecision } from "./types.ts";
|
|
27
30
|
import { getProjectMemoryIdentity } from "./project-memory.ts";
|
|
28
31
|
import {
|
|
29
32
|
detectVerifyCommand,
|
|
@@ -37,7 +40,7 @@ import { detectShellMutation, extractPatchPaths, guardShellMutation, isPathOutsi
|
|
|
37
40
|
import { branchHasChangelogChange, checkLockfileSync, hasPrCreateInvocation, prBodyHasLiteralLineBreakEscapes, prBodyIncludesChangelog } from "../policies/changelog.ts";
|
|
38
41
|
import { extractEditContent, liveMutationIn } from "../policies/destructive.ts";
|
|
39
42
|
import { branchHasDocumentationChange } from "../policies/docs.ts";
|
|
40
|
-
import { claimFiles } from "../policies/file-claims.ts";
|
|
43
|
+
import { claimFiles, fileClaimConflictReason } from "../policies/file-claims.ts";
|
|
41
44
|
import {
|
|
42
45
|
GIT_BRANCH_CREATE_RE,
|
|
43
46
|
GIT_WRITE_RE,
|
|
@@ -88,7 +91,8 @@ export function isReadOnlyRole(agent?: string): boolean {
|
|
|
88
91
|
return READ_ONLY_ROLES.has(lower) || Array.from(READ_ONLY_ROLES).some((r) => lower.includes(r));
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
function logBlock(message: string): void {
|
|
94
|
+
function logBlock(message: string, simulate = false): void {
|
|
95
|
+
if (simulate) return;
|
|
92
96
|
try {
|
|
93
97
|
void getSdkClient()?.app?.log?.({
|
|
94
98
|
body: {
|
|
@@ -103,22 +107,27 @@ function logBlock(message: string): void {
|
|
|
103
107
|
export async function guardToolCallImpl(
|
|
104
108
|
toolName: string,
|
|
105
109
|
input: unknown,
|
|
106
|
-
context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string },
|
|
107
|
-
): Promise<
|
|
110
|
+
context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string; simulate?: boolean },
|
|
111
|
+
): Promise<PolicyDecision> {
|
|
112
|
+
const logPolicyBlock = (message: string) => logBlock(message, context?.simulate);
|
|
108
113
|
const currentRoot = getWorkspaceRoot();
|
|
114
|
+
const allow = (): PolicyDecision => ({ status: "allowed", code: "allowed", message: "Allowed by current guardrails." });
|
|
115
|
+
const block = (policy: string, code: string, message: string): PolicyDecision => ({ status: "blocked", policy, code, message });
|
|
116
|
+
const needsApproval = (policy: string, code: string, message: string): PolicyDecision => ({ status: "needs_approval", policy, code, message });
|
|
117
|
+
let remotePrStateUnchecked = false;
|
|
109
118
|
|
|
110
119
|
if (context?.agent && isReadOnlyRole(context.agent)) {
|
|
111
120
|
if (EDIT_TOOL_NAMES.has(toolName) || toolName.startsWith("guard_worktree_")) {
|
|
112
121
|
const reason = `Blocked: subagent with read-only role '${context.agent}' cannot perform file mutations or lifecycle changes.`;
|
|
113
|
-
|
|
114
|
-
return reason;
|
|
122
|
+
logPolicyBlock(`[workflow-guard] ${reason}`);
|
|
123
|
+
return block("subagent-role", "read_only_role", reason);
|
|
115
124
|
}
|
|
116
125
|
}
|
|
117
126
|
|
|
118
127
|
if (toolName === "read" || toolName === "read_file") {
|
|
119
128
|
const target = extractTargetPath(input) ?? "";
|
|
120
129
|
if (target && isSecretPath(target)) {
|
|
121
|
-
|
|
130
|
+
logPolicyBlock(`[workflow-guard] blocked read: secret file ${target}`);
|
|
122
131
|
if (isEnvFilePath(target)) {
|
|
123
132
|
let schemaHint = "";
|
|
124
133
|
try {
|
|
@@ -129,11 +138,11 @@ export async function guardToolCallImpl(
|
|
|
129
138
|
schemaHint = `\n\nSafe variable schema mask:\n\`\`\`\n${masked.slice(0, 800)}\n\`\`\``;
|
|
130
139
|
}
|
|
131
140
|
} catch {}
|
|
132
|
-
return `Blocked: reading sensitive credential file '${target}' directly is not permitted. Use environment variables or reference safe templates.${schemaHint}
|
|
141
|
+
return block("secrets", "secret_read", `Blocked: reading sensitive credential file '${target}' directly is not permitted. Use environment variables or reference safe templates.${schemaHint}`);
|
|
133
142
|
}
|
|
134
|
-
return `Blocked: reading sensitive credential/secret file '${target}' is not permitted. Reference environment variables by name or inspect safe templates (e.g. .env.example) instead
|
|
143
|
+
return block("secrets", "secret_read", `Blocked: reading sensitive credential/secret file '${target}' is not permitted. Reference environment variables by name or inspect safe templates (e.g. .env.example) instead.`);
|
|
135
144
|
}
|
|
136
|
-
return
|
|
145
|
+
return allow();
|
|
137
146
|
}
|
|
138
147
|
|
|
139
148
|
if (toolName === "todowrite") {
|
|
@@ -144,8 +153,8 @@ export async function guardToolCallImpl(
|
|
|
144
153
|
const existingTodos = context?.sessionID ? await fetchSessionTodos(context.sessionID) : undefined;
|
|
145
154
|
const err = validateTodoLifecycle(newTodos, existingTodos);
|
|
146
155
|
if (err) {
|
|
147
|
-
|
|
148
|
-
return err;
|
|
156
|
+
logPolicyBlock(`[workflow-guard] ${err}`);
|
|
157
|
+
return block("todo", "todo_lifecycle", err);
|
|
149
158
|
}
|
|
150
159
|
const allDone = newTodos.length > 0 && newTodos.every((t) => {
|
|
151
160
|
const s = String(t.status ?? "");
|
|
@@ -155,8 +164,8 @@ export async function guardToolCallImpl(
|
|
|
155
164
|
const conflictCheck = checkMergeConflicts(currentRoot);
|
|
156
165
|
if (conflictCheck.hasConflicts) {
|
|
157
166
|
const reason = `Blocked todowrite: ${conflictCheck.reason}`;
|
|
158
|
-
|
|
159
|
-
return reason;
|
|
167
|
+
logPolicyBlock(`[workflow-guard] ${reason}`);
|
|
168
|
+
return block("git", "merge_conflict", reason);
|
|
160
169
|
}
|
|
161
170
|
const command = detectVerifyCommand(currentRoot);
|
|
162
171
|
if (command) {
|
|
@@ -164,27 +173,26 @@ export async function guardToolCallImpl(
|
|
|
164
173
|
let verifyResult = sessionID ? sessionVerifyResults.get(sessionID) : lastVerify;
|
|
165
174
|
if (!verifyResult) {
|
|
166
175
|
const diskCached = loadVerifyCache();
|
|
167
|
-
if (diskCached && diskCached.passed && diskCached.command === command && diskCached.workspaceRoot ===
|
|
176
|
+
if (diskCached && diskCached.passed && diskCached.command === command && diskCached.workspaceRoot && projectRootKey(diskCached.workspaceRoot) === projectRootKey(currentRoot)) verifyResult = diskCached;
|
|
168
177
|
}
|
|
169
178
|
const mutationTimestamp = sessionID ? (sessionMutationTimestamps.get(sessionID) ?? 0) : lastMutationTimestamp;
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
const gitStateMatches = !verifyResult?.commitHash || (verifyResult.commitHash === currentCommit && verifyResult.gitStatus === currentGitStatus);
|
|
173
|
-
const isFresh = verifyResult !== undefined && verifyResult.passed && verifyResult.command === command && verifyResult.timestamp >= mutationTimestamp && gitStateMatches;
|
|
179
|
+
const currentSubject = { workspace: projectRootKey(currentRoot), commitHash: getCurrentGitCommitHash(currentRoot), worktreeFingerprint: getGitWorktreeFingerprint(currentRoot), sessionID };
|
|
180
|
+
const isFresh = verifyResult !== undefined && verifyResult.passed && verifyResult.command === command && isEvidenceFresh(verificationEvidence(verifyResult, sessionID), currentSubject, mutationTimestamp);
|
|
174
181
|
if (!isFresh) {
|
|
182
|
+
if (context?.simulate) return block("verification", "verification_required", `Blocked: finalization requires fresh passing verification (${command}).`);
|
|
175
183
|
const result = await runVerify(command, currentRoot);
|
|
176
184
|
recordVerifyResult(command, result, sessionID, currentRoot);
|
|
177
185
|
if (!result.passed) {
|
|
178
186
|
const tail = snipVerifyOutput(result.output, result.passed).slice(-500);
|
|
179
187
|
const reason = `Blocked todowrite: all tasks marked done but verification is failing (${command}). Fix the failure before finishing; output tail: ${tail}`;
|
|
180
|
-
|
|
181
|
-
return reason;
|
|
188
|
+
logPolicyBlock(`[workflow-guard] ${reason}`);
|
|
189
|
+
return block("verification", "verification_failed", reason);
|
|
182
190
|
}
|
|
183
191
|
}
|
|
184
192
|
}
|
|
185
193
|
}
|
|
186
194
|
}
|
|
187
|
-
return
|
|
195
|
+
return allow();
|
|
188
196
|
}
|
|
189
197
|
|
|
190
198
|
if (EDIT_TOOL_NAMES.has(toolName)) {
|
|
@@ -192,39 +200,39 @@ export async function guardToolCallImpl(
|
|
|
192
200
|
const record = asRecord(input);
|
|
193
201
|
const target = extractTargetPath(input) ?? "";
|
|
194
202
|
if (target && isProtectedPath(target)) {
|
|
195
|
-
|
|
196
|
-
return PROTECTED_PATH_REASON;
|
|
203
|
+
logPolicyBlock(`[workflow-guard] blocked ${toolName}: protected path ${target}`);
|
|
204
|
+
return block("tamper", "protected_path", PROTECTED_PATH_REASON);
|
|
197
205
|
}
|
|
198
206
|
if (target && isSecretPath(target)) {
|
|
199
|
-
|
|
200
|
-
return `Blocked: modifying secret file '${target}' directly is not permitted. Store credentials in environment variables or safe secret stores instead
|
|
207
|
+
logPolicyBlock(`[workflow-guard] blocked ${toolName}: secret file path ${target}`);
|
|
208
|
+
return block("secrets", "secret_write", `Blocked: modifying secret file '${target}' directly is not permitted. Store credentials in environment variables or safe secret stores instead.`);
|
|
201
209
|
}
|
|
202
210
|
if (toolName === "apply_patch") {
|
|
203
211
|
const patchText = typeof record?.patchText === "string" ? record.patchText : "";
|
|
204
212
|
for (const patchPath of extractPatchPaths(patchText)) {
|
|
205
213
|
if (isProtectedPath(patchPath)) {
|
|
206
|
-
|
|
207
|
-
return PROTECTED_PATH_REASON;
|
|
214
|
+
logPolicyBlock(`[workflow-guard] blocked apply_patch: protected path ${patchPath}`);
|
|
215
|
+
return block("tamper", "protected_path", PROTECTED_PATH_REASON);
|
|
208
216
|
}
|
|
209
217
|
if (isSecretPath(patchPath)) {
|
|
210
|
-
|
|
211
|
-
return `Blocked: modifying secret file '${patchPath}' directly is not permitted
|
|
218
|
+
logPolicyBlock(`[workflow-guard] blocked apply_patch: secret file path ${patchPath}`);
|
|
219
|
+
return block("secrets", "secret_write", `Blocked: modifying secret file '${patchPath}' directly is not permitted.`);
|
|
212
220
|
}
|
|
213
221
|
if (isPathOutsideWorkspace(patchPath, currentRoot)) {
|
|
214
|
-
|
|
215
|
-
return `Blocked: patch targets file '${patchPath}' outside workspace root (${currentRoot})
|
|
222
|
+
logPolicyBlock(`[workflow-guard] blocked apply_patch: patch target escapes workspace: ${patchPath}`);
|
|
223
|
+
return block("boundary", "workspace_escape", `Blocked: patch targets file '${patchPath}' outside workspace root (${currentRoot}).`);
|
|
216
224
|
}
|
|
217
225
|
}
|
|
218
226
|
}
|
|
219
227
|
if (target && isPathOutsideWorkspace(target, currentRoot)) {
|
|
220
|
-
|
|
221
|
-
return `Blocked: file path '${target}' escapes workspace root (${currentRoot}). All changes must stay within the workspace
|
|
228
|
+
logPolicyBlock(`[workflow-guard] blocked ${toolName}: path escapes workspace: ${target}`);
|
|
229
|
+
return block("boundary", "workspace_escape", `Blocked: file path '${target}' escapes workspace root (${currentRoot}). All changes must stay within the workspace.`);
|
|
222
230
|
}
|
|
223
231
|
for (const content of extractEditContent(input)) {
|
|
224
232
|
const secret = secretIn(content);
|
|
225
233
|
if (secret) {
|
|
226
|
-
|
|
227
|
-
return `Blocked: payload appears to contain a ${secret}. Secrets must not be committed to the repository. Store them in a secret manager or environment file excluded from git, and reference them by name instead
|
|
234
|
+
logPolicyBlock(`[workflow-guard] blocked ${toolName}: payload contains ${secret}`);
|
|
235
|
+
return block("secrets", "secret_payload", `Blocked: payload appears to contain a ${secret}. Secrets must not be committed to the repository. Store them in a secret manager or environment file excluded from git, and reference them by name instead.`);
|
|
228
236
|
}
|
|
229
237
|
}
|
|
230
238
|
if (!allowLive) {
|
|
@@ -234,58 +242,61 @@ export async function guardToolCallImpl(
|
|
|
234
242
|
if (trimmed.startsWith("#") || trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) continue;
|
|
235
243
|
const what = liveMutationIn(normalizeGitCommands(line));
|
|
236
244
|
if (what) {
|
|
237
|
-
|
|
238
|
-
return `Blocked: the file you are writing contains a ${what}. Script files are not a way to smuggle destructive commands past the shell guard. Only the user can allow live mutations (WORKFLOW_GUARD_ALLOW_LIVE=1)
|
|
245
|
+
logPolicyBlock(`[workflow-guard] blocked ${toolName}: payload contains ${what}`);
|
|
246
|
+
return block("destructive", "live_mutation_payload", `Blocked: the file you are writing contains a ${what}. Script files are not a way to smuggle destructive commands past the shell guard. Only the user can allow live mutations (WORKFLOW_GUARD_ALLOW_LIVE=1).`);
|
|
239
247
|
}
|
|
240
248
|
}
|
|
241
249
|
}
|
|
242
250
|
}
|
|
243
|
-
for (const content of extractEditContent(input)) if (isSettingsTamper(content)) return PROTECTED_PATH_REASON;
|
|
251
|
+
for (const content of extractEditContent(input)) if (isSettingsTamper(content)) return block("tamper", "settings_tamper", PROTECTED_PATH_REASON);
|
|
244
252
|
if (onProtectedBranch(currentRoot)) {
|
|
245
|
-
|
|
246
|
-
return branchGuardReason();
|
|
253
|
+
logPolicyBlock(`[workflow-guard] blocked ${toolName}: on protected branch ${currentGitBranch(currentRoot)}`);
|
|
254
|
+
return block("git", "protected_branch", branchGuardReason());
|
|
247
255
|
}
|
|
248
256
|
const todos = await effectiveTodos(context?.sessionID);
|
|
249
257
|
if (todos !== undefined && !hasActiveTodo(todos)) {
|
|
250
|
-
|
|
251
|
-
return "Blocked: no active todo item. First break the request down with the todowrite tool (create items with status 'pending' or 'in_progress'), then work them through, marking each completed via todowrite as you finish it. When every item is completed, create a fresh todo list before starting new work.";
|
|
258
|
+
logPolicyBlock(`[workflow-guard] blocked ${toolName}: no active todo item (session ${context?.sessionID ?? "?"})`);
|
|
259
|
+
return block("todo", "no_active_todo", "Blocked: no active todo item. First break the request down with the todowrite tool (create items with status 'pending' or 'in_progress'), then work them through, marking each completed via todowrite as you finish it. When every item is completed, create a fresh todo list before starting new work.");
|
|
252
260
|
}
|
|
253
261
|
if (context?.sessionID) {
|
|
254
262
|
const reason = await subagentMutationBudgetReason(context.sessionID, currentRoot);
|
|
255
263
|
if (reason) {
|
|
256
|
-
|
|
257
|
-
return reason;
|
|
264
|
+
logPolicyBlock(`[workflow-guard] ${reason}`);
|
|
265
|
+
return block("todo", "mutation_budget", reason);
|
|
258
266
|
}
|
|
259
267
|
}
|
|
260
|
-
if (context?.sessionID
|
|
268
|
+
if (context?.sessionID) {
|
|
261
269
|
if (toolName === "edit" || toolName === "write") {
|
|
262
270
|
for (const path of editTargets(input, currentRoot)) {
|
|
263
271
|
const staleReason = staleWriteReason(path, context.sessionID);
|
|
264
272
|
if (staleReason) {
|
|
265
|
-
|
|
266
|
-
return staleReason;
|
|
273
|
+
logPolicyBlock(`[workflow-guard] ${staleReason}`);
|
|
274
|
+
return block("stale-write", "stale_write", staleReason);
|
|
267
275
|
}
|
|
268
276
|
}
|
|
269
277
|
}
|
|
270
|
-
const
|
|
278
|
+
const targets = editTargets(input, currentRoot);
|
|
279
|
+
const claimReason = context.simulate
|
|
280
|
+
? fileClaimConflictReason(targets, context.sessionID)
|
|
281
|
+
: context.callID ? claimFiles(targets, context.sessionID, context.callID) : undefined;
|
|
271
282
|
if (claimReason) {
|
|
272
|
-
|
|
273
|
-
return claimReason;
|
|
283
|
+
logPolicyBlock(`[workflow-guard] ${claimReason}`);
|
|
284
|
+
return block("file-claims", "file_claim_conflict", claimReason);
|
|
274
285
|
}
|
|
275
286
|
}
|
|
276
|
-
recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
|
|
277
|
-
return
|
|
287
|
+
if (!context?.simulate) recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
|
|
288
|
+
return allow();
|
|
278
289
|
}
|
|
279
290
|
|
|
280
291
|
const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
|
|
281
292
|
if (!allowLive) {
|
|
282
293
|
const mcpWhat = mcpMutationTool(toolName);
|
|
283
294
|
if (mcpWhat) {
|
|
284
|
-
|
|
285
|
-
return `Blocked: ${toolName} mutates ${mcpWhat} - a live system. Changes must be made in code unless the user explicitly allows live changes. Only the user can override this, via the WORKFLOW_GUARD_ALLOW_LIVE=1 environment variable set before launching the agent
|
|
295
|
+
logPolicyBlock(`[workflow-guard] blocked MCP tool ${toolName} (${mcpWhat} mutation)`);
|
|
296
|
+
return block("mcp", "live_mcp_mutation", `Blocked: ${toolName} mutates ${mcpWhat} - a live system. Changes must be made in code unless the user explicitly allows live changes. Only the user can override this, via the WORKFLOW_GUARD_ALLOW_LIVE=1 environment variable set before launching the agent.`);
|
|
286
297
|
}
|
|
287
298
|
}
|
|
288
|
-
if (!SHELL_TOOL_NAMES.has(toolName)) return
|
|
299
|
+
if (!SHELL_TOOL_NAMES.has(toolName)) return allow();
|
|
289
300
|
const shellWorkdir = asRecord(input)?.workdir;
|
|
290
301
|
const requestedShellRoot = typeof shellWorkdir === "string" ? resolve(currentRoot, shellWorkdir) : currentRoot;
|
|
291
302
|
const shellRoot = !isPathOutsideWorkspace(requestedShellRoot, currentRoot) || getProjectMemoryIdentity(requestedShellRoot) === getProjectMemoryIdentity(currentRoot)
|
|
@@ -294,116 +305,117 @@ export async function guardToolCallImpl(
|
|
|
294
305
|
|
|
295
306
|
for (const raw of extractCommands(input)) {
|
|
296
307
|
const dynamicSyntax = dynamicShellSyntaxIn(raw);
|
|
297
|
-
if (dynamicSyntax) return `Blocked: command contains ${dynamicSyntax} that cannot be safely classified without executing shell expansion
|
|
308
|
+
if (dynamicSyntax) return block("shell-safety", "dynamic_shell_syntax", `Blocked: command contains ${dynamicSyntax} that cannot be safely classified without executing shell expansion.`);
|
|
298
309
|
const command = normalize(raw);
|
|
299
310
|
const ttyCheck = checkInteractiveTtyCommand(command);
|
|
300
311
|
if (ttyCheck.isInteractive) {
|
|
301
|
-
|
|
302
|
-
return `Blocked: '${command.slice(0, 60)}' is an ${ttyCheck.name} and will hang non-interactive agent execution. ${ttyCheck.advice}
|
|
312
|
+
logPolicyBlock(`[workflow-guard] blocked interactive TTY command: ${command.slice(0, 100)}`);
|
|
313
|
+
return block("shell-safety", "interactive_tty", `Blocked: '${command.slice(0, 60)}' is an ${ttyCheck.name} and will hang non-interactive agent execution. ${ttyCheck.advice}`);
|
|
303
314
|
}
|
|
304
315
|
const packageCheck = checkPackageHygiene(command);
|
|
305
316
|
if (packageCheck.isViolating && !allowLive) {
|
|
306
|
-
|
|
307
|
-
return `Blocked: '${command.slice(0, 60)}' is a ${packageCheck.name}. ${packageCheck.advice}
|
|
317
|
+
logPolicyBlock(`[workflow-guard] blocked package supply-chain violation: ${command.slice(0, 100)}`);
|
|
318
|
+
return block("shell-safety", "package_hygiene", `Blocked: '${command.slice(0, 60)}' is a ${packageCheck.name}. ${packageCheck.advice}`);
|
|
308
319
|
}
|
|
309
320
|
const normalizedCommand = normalizeGitCommands(command);
|
|
310
|
-
if (hasUnsafeGitAlias(command)) return "Blocked: per-invocation Git aliases cannot be used because they can hide guarded Git operations.";
|
|
321
|
+
if (hasUnsafeGitAlias(command)) return block("git", "unsafe_git_alias", "Blocked: per-invocation Git aliases cannot be used because they can hide guarded Git operations.");
|
|
311
322
|
const gitInvocations = splitShellSegments(command).map((segment) => parseGitInvocation(segment.trim())).filter((invocation): invocation is NonNullable<ReturnType<typeof parseGitInvocation>> => invocation !== undefined);
|
|
312
323
|
const effectiveRoot = gitInvocations[0]?.repoDir ?? currentRoot;
|
|
313
324
|
for (const invocation of gitInvocations) {
|
|
314
325
|
const normalizedInvocation = `git ${invocation.rest}`;
|
|
315
326
|
if (isPathOutsideWorkspace(invocation.repoDir, currentRoot) && (GIT_WRITE_RE.test(normalizedInvocation) || /\bgit\s+push\b/.test(normalizedInvocation))) {
|
|
316
|
-
|
|
317
|
-
return `Blocked: git command targets repository '${invocation.repoDir}' outside workspace root (${currentRoot}). All changes must stay within the workspace
|
|
327
|
+
logPolicyBlock(`[workflow-guard] blocked git mutation on repository outside workspace: ${invocation.repoDir}`);
|
|
328
|
+
return block("boundary", "workspace_escape", `Blocked: git command targets repository '${invocation.repoDir}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`);
|
|
318
329
|
}
|
|
319
330
|
}
|
|
320
331
|
for (const invocation of gitInvocations) {
|
|
321
332
|
if (GIT_WRITE_RE.test(`git ${invocation.rest}`) && onProtectedBranch(invocation.repoDir)) {
|
|
322
|
-
|
|
323
|
-
return branchGuardReason();
|
|
333
|
+
logPolicyBlock(`[workflow-guard] blocked git write on protected branch: ${command.slice(0, 120)}`);
|
|
334
|
+
return block("git", "protected_branch", branchGuardReason());
|
|
324
335
|
}
|
|
325
336
|
}
|
|
326
337
|
if (GIT_BRANCH_CREATE_RE.test(normalizedCommand)) {
|
|
327
338
|
const behindCheck = checkBranchBaseIsUpToDate(effectiveRoot);
|
|
328
339
|
if (behindCheck.isBehind) {
|
|
329
|
-
|
|
330
|
-
return `Blocked: ${behindCheck.reason}
|
|
340
|
+
logPolicyBlock(`[workflow-guard] blocked branch creation: base is behind remote ${behindCheck.baseRef}`);
|
|
341
|
+
return block("git", "branch_base_behind", `Blocked: ${behindCheck.reason}`);
|
|
331
342
|
}
|
|
332
343
|
}
|
|
333
344
|
if (isSettingsTamper(command)) {
|
|
334
|
-
|
|
335
|
-
return PROTECTED_PATH_REASON;
|
|
345
|
+
logPolicyBlock(`[workflow-guard] blocked settings tamper: ${command.slice(0, 120)}`);
|
|
346
|
+
return block("tamper", "settings_tamper", PROTECTED_PATH_REASON);
|
|
336
347
|
}
|
|
337
348
|
for (const segment of command.split(/[\n|;&]+/)) {
|
|
338
349
|
const secretFile = secretFileReadIn(segment.trim());
|
|
339
350
|
if (secretFile) {
|
|
340
|
-
|
|
341
|
-
return `Blocked: reading sensitive credential/secret file '${secretFile}' via shell is not permitted. Reference environment variables by name or inspect safe templates (e.g. .env.example) instead
|
|
351
|
+
logPolicyBlock(`[workflow-guard] blocked shell read of secret file: ${secretFile}`);
|
|
352
|
+
return block("secrets", "secret_read", `Blocked: reading sensitive credential/secret file '${secretFile}' via shell is not permitted. Reference environment variables by name or inspect safe templates (e.g. .env.example) instead.`);
|
|
342
353
|
}
|
|
343
354
|
}
|
|
344
355
|
for (const payload of extractInterpreterPayload(raw)) {
|
|
345
356
|
const outsidePath = outsideWritePathInPayload(payload, currentRoot);
|
|
346
357
|
if (outsidePath) {
|
|
347
|
-
|
|
348
|
-
return `Blocked: inline interpreter script targets file '${outsidePath}' outside workspace root (${currentRoot}). All changes must stay within the workspace
|
|
358
|
+
logPolicyBlock(`[workflow-guard] blocked interpreter payload writing outside workspace: ${outsidePath}`);
|
|
359
|
+
return block("boundary", "workspace_escape", `Blocked: inline interpreter script targets file '${outsidePath}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`);
|
|
349
360
|
}
|
|
350
361
|
const writePaths = writePathsInPayload(payload);
|
|
351
362
|
if (writePaths.length > 0) {
|
|
352
|
-
if (context?.agent && isReadOnlyRole(context.agent)) return `Blocked: subagent with read-only role '${context.agent}' cannot perform shell file mutations
|
|
353
|
-
for (const path of writePaths) if (isProtectedPath(path)) return PROTECTED_PATH_REASON;
|
|
354
|
-
if (onProtectedBranch(currentRoot)) return branchGuardReason();
|
|
363
|
+
if (context?.agent && isReadOnlyRole(context.agent)) return block("subagent-role", "read_only_role", `Blocked: subagent with read-only role '${context.agent}' cannot perform shell file mutations.`);
|
|
364
|
+
for (const path of writePaths) if (isProtectedPath(path)) return block("tamper", "protected_path", PROTECTED_PATH_REASON);
|
|
365
|
+
if (onProtectedBranch(currentRoot)) return block("git", "protected_branch", branchGuardReason());
|
|
355
366
|
const todos = await effectiveTodos(context?.sessionID);
|
|
356
|
-
if (todos !== undefined && !hasActiveTodo(todos)) return "Blocked: inline interpreter file mutation with no active todo item.";
|
|
357
|
-
recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
|
|
367
|
+
if (todos !== undefined && !hasActiveTodo(todos)) return block("todo", "no_active_todo", "Blocked: inline interpreter file mutation with no active todo item.");
|
|
368
|
+
if (!context?.simulate) recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
|
|
358
369
|
}
|
|
359
370
|
if (!allowLive) {
|
|
360
371
|
const liveCheck = liveMutationIn(normalizeGitCommands(normalize(payload)));
|
|
361
372
|
if (liveCheck) {
|
|
362
|
-
|
|
363
|
-
return `Blocked: inline interpreter script contains a ${liveCheck}. Interpreter payloads cannot smuggle live destructive commands past the guard
|
|
373
|
+
logPolicyBlock(`[workflow-guard] blocked interpreter payload containing ${liveCheck}`);
|
|
374
|
+
return block("destructive", "live_mutation_payload", `Blocked: inline interpreter script contains a ${liveCheck}. Interpreter payloads cannot smuggle live destructive commands past the guard.`);
|
|
364
375
|
}
|
|
365
376
|
}
|
|
366
|
-
if (isSettingsTamper(payload)) return PROTECTED_PATH_REASON;
|
|
377
|
+
if (isSettingsTamper(payload)) return block("tamper", "settings_tamper", PROTECTED_PATH_REASON);
|
|
367
378
|
const secretPath = secretPathInPayload(payload);
|
|
368
|
-
if (secretPath) return `Blocked: reading sensitive credential/secret file '${secretPath}' via inline interpreter script is not permitted
|
|
379
|
+
if (secretPath) return block("secrets", "secret_read", `Blocked: reading sensitive credential/secret file '${secretPath}' via inline interpreter script is not permitted.`);
|
|
369
380
|
}
|
|
370
|
-
const shellMutationReason = await guardShellMutation(command, context?.sessionID);
|
|
381
|
+
const shellMutationReason = await guardShellMutation(command, context?.sessionID, !context?.simulate);
|
|
371
382
|
if (shellMutationReason) {
|
|
372
|
-
|
|
373
|
-
return shellMutationReason;
|
|
383
|
+
logPolicyBlock(`[workflow-guard] blocked shell mutation: ${command.slice(0, 120)}`);
|
|
384
|
+
return block("boundary", "shell_mutation", shellMutationReason);
|
|
374
385
|
}
|
|
375
386
|
if (!allowLive) {
|
|
376
387
|
const what = liveMutationIn(normalizedCommand) ?? liveMutationIn(command);
|
|
377
388
|
if (what) {
|
|
378
|
-
|
|
379
|
-
return `Blocked: ${what} targets a live system. Changes must be made in code (IaC, migrations, source) unless the user explicitly allows live changes. Only the user can override this, via the WORKFLOW_GUARD_ALLOW_LIVE=1 environment variable set before launching the agent
|
|
389
|
+
logPolicyBlock(`[workflow-guard] blocked ${what}: ${command.slice(0, 120)}`);
|
|
390
|
+
return block("destructive", "live_mutation", `Blocked: ${what} targets a live system. Changes must be made in code (IaC, migrations, source) unless the user explicitly allows live changes. Only the user can override this, via the WORKFLOW_GUARD_ALLOW_LIVE=1 environment variable set before launching the agent.`);
|
|
380
391
|
}
|
|
381
392
|
}
|
|
382
393
|
if (PUSH_TO_MAIN_RE.test(normalizedCommand)) {
|
|
383
|
-
|
|
384
|
-
return "Blocked: direct pushes to main/master are not allowed. Create a feature branch and open a PR instead.";
|
|
394
|
+
logPolicyBlock(`[workflow-guard] blocked push to main/master: ${command}`);
|
|
395
|
+
return block("git", "protected_branch_push", "Blocked: direct pushes to main/master are not allowed. Create a feature branch and open a PR instead.");
|
|
385
396
|
}
|
|
386
397
|
for (const invocation of gitInvocations) {
|
|
387
398
|
const pushText = `git ${invocation.rest}`;
|
|
388
399
|
if (!/\bgit\s+push\b/.test(pushText)) continue;
|
|
389
400
|
const pushedBranch = pushedProtectedBranchIn(pushText, invocation.repoDir);
|
|
390
401
|
if (pushedBranch) {
|
|
391
|
-
|
|
392
|
-
return `Blocked: direct pushes to protected branch '${pushedBranch}' are not allowed. Create a feature branch and open a PR instead
|
|
402
|
+
logPolicyBlock(`[workflow-guard] blocked push to protected branch '${pushedBranch}': ${command}`);
|
|
403
|
+
return block("git", "protected_branch_push", `Blocked: direct pushes to protected branch '${pushedBranch}' are not allowed. Create a feature branch and open a PR instead.`);
|
|
393
404
|
}
|
|
394
405
|
}
|
|
395
406
|
for (const invocation of gitInvocations) {
|
|
396
407
|
if (!/\bgit\s+push\b/.test(`git ${invocation.rest}`)) continue;
|
|
397
408
|
if (onProtectedBranch(invocation.repoDir)) {
|
|
398
|
-
|
|
399
|
-
return branchGuardReason();
|
|
409
|
+
logPolicyBlock(`[workflow-guard] blocked push from protected branch: ${command}`);
|
|
410
|
+
return block("git", "protected_branch", branchGuardReason());
|
|
400
411
|
}
|
|
401
412
|
const branch = currentGitBranch(invocation.repoDir);
|
|
402
413
|
if (branch) {
|
|
403
|
-
|
|
414
|
+
if (context?.simulate) remotePrStateUnchecked = true;
|
|
415
|
+
const mergedStatus = isBranchAlreadyMergedOrClosed(invocation.repoDir, branch, !context?.simulate);
|
|
404
416
|
if (mergedStatus.merged) {
|
|
405
|
-
|
|
406
|
-
return `Blocked: ${mergedStatus.reason}
|
|
417
|
+
logPolicyBlock(`[workflow-guard] blocked push to merged/closed branch: ${branch}`);
|
|
418
|
+
return block("git", "merged_branch", `Blocked: ${mergedStatus.reason}`);
|
|
407
419
|
}
|
|
408
420
|
}
|
|
409
421
|
}
|
|
@@ -418,12 +430,13 @@ export async function guardToolCallImpl(
|
|
|
418
430
|
if (conflictCheck.hasConflicts) preflightFailures.push(conflictCheck.reason ?? "Branch has merge conflicts with its base branch.");
|
|
419
431
|
const branch = currentGitBranch(prRoot);
|
|
420
432
|
if (branch) {
|
|
421
|
-
|
|
433
|
+
if (context?.simulate) remotePrStateUnchecked = true;
|
|
434
|
+
const mergedStatus = isBranchAlreadyMergedOrClosed(prRoot, branch, !context?.simulate);
|
|
422
435
|
if (mergedStatus.merged) preflightFailures.push(mergedStatus.reason ?? "Branch is already merged or closed.");
|
|
423
436
|
}
|
|
424
437
|
if (isReviewRequired(prRoot)) {
|
|
425
438
|
const review = context?.sessionID ? (sessionReviews.get(context.sessionID) ?? getLastReviewResult()) : getLastReviewResult();
|
|
426
|
-
const reviewMatchesContext = review?.passed === true && review
|
|
439
|
+
const reviewMatchesContext = review?.passed === true && isEvidenceFresh(reviewEvidence(review), { workspace: projectRootKey(prRoot), commitHash: getCurrentGitCommitHash(prRoot), worktreeFingerprint: getGitWorktreeFingerprint(prRoot), sessionID: review.targetSessionID }, 0) && (!review.targetSessionID || review.targetSessionID === context?.sessionID);
|
|
427
440
|
if (!reviewMatchesContext) preflightFailures.push("Passing secondary review approval is required; invoke a secondary review subagent and record approval with record_review.");
|
|
428
441
|
}
|
|
429
442
|
if (isDocumentationRequired(prRoot) && !branchHasDocumentationChange(prRoot)) preflightFailures.push("Documentation update is required (Policy 21); update README.md or relevant documentation in docs/.");
|
|
@@ -444,8 +457,8 @@ export async function guardToolCallImpl(
|
|
|
444
457
|
if (lockCheck.isOutOfSync) preflightFailures.push(lockCheck.reason ?? "Dependency lockfile is out of sync with its manifest.");
|
|
445
458
|
if (preflightFailures.length > 0) {
|
|
446
459
|
const reason = `Blocked: PR preflight failed:\n${preflightFailures.map((failure) => `- ${failure}`).join("\n")}`;
|
|
447
|
-
|
|
448
|
-
return reason;
|
|
460
|
+
logPolicyBlock(`[workflow-guard] blocked ${prTool}: ${preflightFailures.length} preflight requirement(s) failed`);
|
|
461
|
+
return block("pr-preflight", "pr_preflight_failed", reason,);
|
|
449
462
|
}
|
|
450
463
|
}
|
|
451
464
|
const hasGitMutation = gitInvocations.some((invocation) => {
|
|
@@ -455,12 +468,12 @@ export async function guardToolCallImpl(
|
|
|
455
468
|
if (context?.agent && isReadOnlyRole(context.agent)) {
|
|
456
469
|
const mutation = detectShellMutation(command);
|
|
457
470
|
if (mutation) {
|
|
458
|
-
|
|
459
|
-
return `Blocked: subagent with read-only role '${context.agent}' cannot perform shell file mutations (${mutation.what})
|
|
471
|
+
logPolicyBlock(`[workflow-guard] blocked shell mutation for read-only role ${context.agent}: ${mutation.what}`);
|
|
472
|
+
return block("subagent-role", "read_only_role", `Blocked: subagent with read-only role '${context.agent}' cannot perform shell file mutations (${mutation.what}).`);
|
|
460
473
|
}
|
|
461
474
|
if (hasGitMutation) {
|
|
462
|
-
|
|
463
|
-
return `Blocked: subagent with read-only role '${context.agent}' cannot mutate git history or state
|
|
475
|
+
logPolicyBlock(`[workflow-guard] blocked git mutation for read-only role ${context.agent}`);
|
|
476
|
+
return block("subagent-role", "read_only_role", `Blocked: subagent with read-only role '${context.agent}' cannot mutate git history or state.`);
|
|
464
477
|
}
|
|
465
478
|
}
|
|
466
479
|
const shellMut = detectShellMutation(command);
|
|
@@ -468,12 +481,13 @@ export async function guardToolCallImpl(
|
|
|
468
481
|
if (context?.sessionID) {
|
|
469
482
|
const reason = await subagentMutationBudgetReason(context.sessionID, currentRoot);
|
|
470
483
|
if (reason) {
|
|
471
|
-
|
|
472
|
-
return reason;
|
|
484
|
+
logPolicyBlock(`[workflow-guard] ${reason}`);
|
|
485
|
+
return block("todo", "mutation_budget", reason);
|
|
473
486
|
}
|
|
474
487
|
}
|
|
475
488
|
}
|
|
476
|
-
if (hasGitMutation) recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
|
|
489
|
+
if (hasGitMutation && !context?.simulate) recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
|
|
477
490
|
}
|
|
478
|
-
return
|
|
491
|
+
if (remotePrStateUnchecked) return needsApproval("git", "remote_state_unchecked", "Remote GitHub/Azure PR state was not queried during simulation; no deterministic local guardrail blocked this call, but final allow/block status requires real enforcement.");
|
|
492
|
+
return allow();
|
|
479
493
|
}
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
3
|
import type { ProjectConfig } from "./types.ts";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
const projectConfigCache = new Map<string, ProjectConfig>();
|
|
6
|
+
|
|
7
|
+
export function projectRootKey(root: string): string {
|
|
8
|
+
try {
|
|
9
|
+
return realpathSync(root);
|
|
10
|
+
} catch {
|
|
11
|
+
return resolve(root);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
6
14
|
|
|
7
15
|
export function projectConfigCandidates(root: string): string[] {
|
|
8
16
|
return [
|
|
@@ -60,9 +68,9 @@ export function loadProjectConfig(root: string): ProjectConfig {
|
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
export function reloadProjectConfig(root: string): void {
|
|
63
|
-
|
|
71
|
+
projectConfigCache.set(projectRootKey(root), loadProjectConfig(root));
|
|
64
72
|
}
|
|
65
73
|
|
|
66
|
-
export function getCachedProjectConfig(): ProjectConfig | undefined {
|
|
67
|
-
return
|
|
74
|
+
export function getCachedProjectConfig(root: string): ProjectConfig | undefined {
|
|
75
|
+
return projectConfigCache.get(projectRootKey(root));
|
|
68
76
|
}
|
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;
|