opencode-workflow-guard 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/package.json +3 -2
- package/src/lib/audit.ts +82 -7
- package/src/lib/checkpoint.ts +308 -0
- package/src/lib/custom-tools.ts +198 -0
- package/src/lib/guard-dispatcher.ts +471 -0
- package/src/lib/project-config.ts +68 -0
- package/src/lib/project-memory.ts +44 -1
- package/src/lib/shell.ts +399 -0
- package/src/lib/state.ts +12 -95
- package/src/lib/tool-lifecycle.ts +63 -0
- package/src/lib/tool-outcomes.ts +64 -0
- package/src/lib/types.ts +40 -0
- package/src/lib/utils.ts +13 -256
- package/src/lib/verify.ts +69 -8
- package/src/policies/boundary.ts +2 -2
- package/src/policies/changelog.ts +115 -16
- package/src/policies/continuation.ts +88 -0
- package/src/policies/destructive.ts +2 -1
- package/src/policies/file-claims.ts +56 -0
- package/src/policies/git.ts +1 -1
- package/src/policies/planning.ts +50 -0
- package/src/policies/post-edit-validation.ts +76 -0
- package/src/policies/secrets.ts +1 -1
- package/src/policies/stale-write.ts +70 -0
- package/src/policies/tamper.ts +1 -1
- package/src/policies/todo.ts +33 -17
- package/src/workflow-guard-ui.ts +75 -0
- package/src/workflow-guard.ts +192 -1086
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import type { LearningEvidenceKind } from "./types.ts";
|
|
4
|
+
import type { ProjectMemoryStore } from "./project-memory.ts";
|
|
5
|
+
import {
|
|
6
|
+
exportProjectKnowledge,
|
|
7
|
+
importProjectKnowledge,
|
|
8
|
+
listReviewFollowups,
|
|
9
|
+
recordProjectMemory,
|
|
10
|
+
recordReviewFollowup,
|
|
11
|
+
resolveReviewFollowup,
|
|
12
|
+
searchProjectMemory,
|
|
13
|
+
} from "./project-memory.ts";
|
|
14
|
+
import { loadLearnerProfile, recordLearningEvidence, selectLearningOpportunity, updateLearnerProfile } from "./learning.ts";
|
|
15
|
+
import {
|
|
16
|
+
getLearningInterventionBudget,
|
|
17
|
+
getProjectConfig,
|
|
18
|
+
getLastMutationTimestamp,
|
|
19
|
+
getLastReviewResult,
|
|
20
|
+
getLastVerifyResult,
|
|
21
|
+
getMutationCount,
|
|
22
|
+
getWorkspaceRoot,
|
|
23
|
+
isDocumentationRequired,
|
|
24
|
+
isReviewRequired,
|
|
25
|
+
recordMutation,
|
|
26
|
+
recordReviewResult,
|
|
27
|
+
} from "./state.ts";
|
|
28
|
+
import { loadProjectConfig } from "./project-config.ts";
|
|
29
|
+
import { detectVerifyCommand, getCurrentGitCommitHash } from "./verify.ts";
|
|
30
|
+
import { buildReviewRubric } from "./review.ts";
|
|
31
|
+
import { createGitWorktree, cleanupGitWorktree } from "./worktree.ts";
|
|
32
|
+
import { restoreRecoveryCheckpoint } from "./checkpoint.ts";
|
|
33
|
+
import { getRecentAuditEntries } from "./audit.ts";
|
|
34
|
+
import { guardToolCallImpl } from "./guard-dispatcher.ts";
|
|
35
|
+
import { discoverPlanningSources } from "../policies/planning.ts";
|
|
36
|
+
import { secretIn } from "../policies/secrets.ts";
|
|
37
|
+
import { currentGitBranch, onProtectedBranch } from "../policies/git.ts";
|
|
38
|
+
import { effectiveTodoOwnerSessionID, effectiveTodos, fetchParentSession, fetchParentSessionID, hasActiveTodo } from "../policies/todo.ts";
|
|
39
|
+
|
|
40
|
+
export function extractReviewFollowups(summary: string): Array<{ severity: "P2" | "P3"; summary: string }> {
|
|
41
|
+
return summary.split(/\r?\n/).map((line) => ({
|
|
42
|
+
line: line.trim(),
|
|
43
|
+
severity: line.match(/(?:^|\s)(P[23])(?:\b|[:])/i)?.[1]?.toUpperCase() as "P2" | "P3" | undefined,
|
|
44
|
+
})).filter((finding): finding is { line: string; severity: "P2" | "P3" } => Boolean(finding.line && finding.severity))
|
|
45
|
+
.map((finding) => ({ severity: finding.severity, summary: finding.line }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createCustomTools(options: {
|
|
49
|
+
effectiveRoot: string;
|
|
50
|
+
projectMemoryEnabled: boolean;
|
|
51
|
+
learningEnabled: boolean;
|
|
52
|
+
projectMemory: ProjectMemoryStore | undefined;
|
|
53
|
+
followupStore: ProjectMemoryStore | undefined;
|
|
54
|
+
portableMemoryPath: string;
|
|
55
|
+
learningInterventions: Map<string, number>;
|
|
56
|
+
}) {
|
|
57
|
+
const { effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions } = options;
|
|
58
|
+
return {
|
|
59
|
+
...(getProjectConfig(effectiveRoot).recoveryCheckpoints === true ? {
|
|
60
|
+
guard_recovery_restore: tool({
|
|
61
|
+
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.",
|
|
62
|
+
args: { run: tool.schema.number() },
|
|
63
|
+
execute: async (args, toolContext) => {
|
|
64
|
+
if (!Number.isInteger(args.run) || args.run < 1) return "[workflow-guard] Recovery rejected: run must be a positive integer.";
|
|
65
|
+
const parent = await fetchParentSession(toolContext.sessionID);
|
|
66
|
+
if (!parent.ok) return "[workflow-guard] Recovery rejected: could not confirm this is a root session.";
|
|
67
|
+
if (parent.parentID) return "[workflow-guard] Recovery rejected: only root sessions can restore their checkpoints.";
|
|
68
|
+
const result = restoreRecoveryCheckpoint(effectiveRoot, toolContext.sessionID, args.run);
|
|
69
|
+
return result.ok ? `[workflow-guard] Restored recovery checkpoint for run ${args.run}.` : `[workflow-guard] Recovery rejected: ${result.error}`;
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
} : {}),
|
|
73
|
+
guard_next_tasks: tool({
|
|
74
|
+
description: "Load durable repository task context when deciding what to work on next. Prefers TODO.md; if absent, discovers conventional roadmap, plan, tasks, backlog, and docs/plans Markdown files.",
|
|
75
|
+
args: {},
|
|
76
|
+
execute: async () => {
|
|
77
|
+
const sources = discoverPlanningSources(effectiveRoot);
|
|
78
|
+
return sources.length > 0 ? JSON.stringify({ sources }, null, 2) : JSON.stringify({ sources: [], message: "No TODO, roadmap, plan, tasks, or backlog Markdown files found." });
|
|
79
|
+
},
|
|
80
|
+
}),
|
|
81
|
+
...(projectMemoryEnabled ? {
|
|
82
|
+
project_memory_search: tool({
|
|
83
|
+
description: "Search current durable knowledge for this project. Returns concise typed records with provenance; superseded records are excluded.",
|
|
84
|
+
args: { query: tool.schema.string() },
|
|
85
|
+
execute: async (args) => projectMemory ? JSON.stringify(args.query.length <= 500 ? searchProjectMemory(projectMemory, args.query, 8) : [], null, 2) : "[workflow-guard] Project memory unavailable; core guard enforcement remains active.",
|
|
86
|
+
}),
|
|
87
|
+
project_memory_record: tool({
|
|
88
|
+
description: "Record a durable project fact, decision, constraint, or lesson when it will matter in future sessions. Do not record transient tool output, secrets, or speculative hypotheses.",
|
|
89
|
+
args: { kind: tool.schema.string().describe("fact, decision, constraint, or lesson"), content: tool.schema.string(), paths: tool.schema.array(tool.schema.string()), supersedes: tool.schema.string().optional() },
|
|
90
|
+
execute: async (args, toolContext) => {
|
|
91
|
+
if (!projectMemory) return "[workflow-guard] Project memory unavailable; core guard enforcement remains active.";
|
|
92
|
+
if (!(["fact", "decision", "constraint", "lesson"] as string[]).includes(args.kind)) return "[workflow-guard] Project memory rejected: invalid kind.";
|
|
93
|
+
const detectedSecret = secretIn(args.content);
|
|
94
|
+
if (detectedSecret) return `[workflow-guard] Project memory rejected: possible secret detected (${detectedSecret}).`;
|
|
95
|
+
return JSON.stringify(recordProjectMemory(projectMemory, { kind: args.kind as "fact" | "decision" | "constraint" | "lesson", content: args.content, source: "agent", sessionID: toolContext.sessionID, commit: getCurrentGitCommitHash(effectiveRoot), paths: args.paths, supersedes: args.supersedes || undefined }));
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
project_memory_export: tool({
|
|
99
|
+
description: "Explicitly promote selected durable project-memory records to the human-readable repo-local .opencode/memory/project-memory.jsonl file. Nothing is exported automatically.",
|
|
100
|
+
args: { ids: tool.schema.array(tool.schema.string()) },
|
|
101
|
+
execute: async (args) => {
|
|
102
|
+
if (!projectMemory) return "[workflow-guard] Project memory unavailable; core guard enforcement remains active.";
|
|
103
|
+
if (args.ids.length > 100 || args.ids.some((id) => id.length > 200)) return "[workflow-guard] Project memory export rejected: invalid record IDs.";
|
|
104
|
+
const count = exportProjectKnowledge(projectMemory, args.ids, portableMemoryPath, (content) => secretIn(content) !== undefined);
|
|
105
|
+
return `[workflow-guard] Exported ${count} project-memory record(s) to .opencode/memory/project-memory.jsonl.`;
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
108
|
+
project_memory_import: tool({ description: "Import the fixed repo-local .opencode/memory/project-memory.jsonl file into this project's local working-memory index.", args: {}, execute: async () => projectMemory ? `[workflow-guard] Imported ${importProjectKnowledge(projectMemory, portableMemoryPath, (content) => secretIn(content) !== undefined)} new project-memory record(s).` : "[workflow-guard] Project memory unavailable; core guard enforcement remains active." }),
|
|
109
|
+
} : {}),
|
|
110
|
+
...(learningEnabled ? {
|
|
111
|
+
learning_profile: tool({ description: "Inspect the local evidence-based learner profile so teaching can build on demonstrated knowledge without assuming unobserved concepts are gaps.", args: {}, execute: async () => JSON.stringify(loadLearnerProfile(), null, 2) }),
|
|
112
|
+
learning_checkpoint: tool({
|
|
113
|
+
description: "At a high-value design decision, debugging moment, or important new concept, rank candidate Socratic learning opportunities. If one is selected, ask one concise question before continuing the work; do not turn routine syntax into a lesson.",
|
|
114
|
+
args: { opportunities: tool.schema.array(tool.schema.object({ type: tool.schema.string().describe("design, debugging, or new-concept"), concept: tool.schema.string().describe("Transferable engineering concept"), relevance: tool.schema.number().describe("Current-task relevance from 0 to 1"), consequence: tool.schema.number().describe("Decision consequence from 0 to 1") })) },
|
|
115
|
+
execute: async (args, toolContext) => {
|
|
116
|
+
const valid = args.opportunities.slice(0, 20).filter((candidate): candidate is typeof candidate & { type: "design" | "debugging" | "new-concept" } => (candidate.type === "design" || candidate.type === "debugging" || candidate.type === "new-concept") && candidate.concept.length > 0 && candidate.concept.length <= 100 && candidate.relevance >= 0 && candidate.relevance <= 1 && candidate.consequence >= 0 && candidate.consequence <= 1);
|
|
117
|
+
const used = learningInterventions.get(toolContext.sessionID) ?? 0;
|
|
118
|
+
const selected = selectLearningOpportunity(loadLearnerProfile(), valid, { interventionsThisSession: used, maxInterventionsPerSession: getLearningInterventionBudget(effectiveRoot) });
|
|
119
|
+
if (!selected) return JSON.stringify({ intervene: false, reason: "No opportunity selected or session learning budget reached." });
|
|
120
|
+
learningInterventions.set(toolContext.sessionID, used + 1);
|
|
121
|
+
return JSON.stringify({ intervene: true, opportunity: selected, guidance: "Ask one question, use the answer as task context, briefly reconcile if useful, then continue building." });
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
learning_record: tool({
|
|
125
|
+
description: "Record concise evidence from a real Socratic interaction after observing the learner's reasoning. Record what was demonstrated, not a grade or an inferred deficit.",
|
|
126
|
+
args: { concept: tool.schema.string(), kind: tool.schema.string().describe("exposed, developing, demonstrated, independent, critique, or needs-reinforcement"), summary: tool.schema.string().describe("Short factual description of observed reasoning") },
|
|
127
|
+
execute: async (args, toolContext) => {
|
|
128
|
+
const validKinds = new Set(["exposed", "developing", "demonstrated", "independent", "critique", "needs-reinforcement"]);
|
|
129
|
+
if (!validKinds.has(args.kind)) return "[workflow-guard] Learning evidence rejected: invalid evidence kind.";
|
|
130
|
+
if (args.concept.length < 1 || args.concept.length > 100 || args.summary.length < 1 || args.summary.length > 1000) return "[workflow-guard] Learning evidence rejected: concept must be 1-100 characters and summary 1-1000 characters.";
|
|
131
|
+
try {
|
|
132
|
+
updateLearnerProfile((profile) => {
|
|
133
|
+
if (!profile.concepts[args.concept] && Object.keys(profile.concepts).length >= 500) throw new Error("concept-limit");
|
|
134
|
+
recordLearningEvidence(profile, { concept: args.concept, kind: args.kind as LearningEvidenceKind, summary: args.summary, timestamp: Date.now(), sessionID: toolContext.sessionID, project: effectiveRoot });
|
|
135
|
+
});
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if ((error as Error).message === "concept-limit") return "[workflow-guard] Learning evidence rejected: learner profile concept limit reached.";
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
return `[workflow-guard] Learning evidence recorded for ${args.concept}.`;
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
} : {}),
|
|
144
|
+
guard_status: tool({
|
|
145
|
+
description: "Inspect active guardrails, current branch protection, and verification/review status.", args: {},
|
|
146
|
+
execute: async () => {
|
|
147
|
+
const root = getWorkspaceRoot(); const branch = currentGitBranch(root) ?? "unknown"; const isProtected = onProtectedBranch(root); const lastV = getLastVerifyResult(); const lastR = getLastReviewResult(); const lastMut = getLastMutationTimestamp(); const cfg = loadProjectConfig(root);
|
|
148
|
+
return JSON.stringify({ workspaceRoot: root, branch, onProtectedBranch: isProtected, lastMutationTimestamp: lastMut, mutationCount: getMutationCount(), lastVerify: lastV ? { command: lastV.command, passed: lastV.passed, fresh: lastV.timestamp >= lastMut, commitHash: lastV.commitHash } : null, lastReview: lastR ? { reviewer: lastR.reviewer, passed: lastR.passed, summary: lastR.summary } : null, projectConfig: { protectedBranches: cfg.protectedBranches ?? ["main", "master"], verifyCommand: detectVerifyCommand(root) ?? null, requireReview: isReviewRequired(root), requireDocumentation: isDocumentationRequired(root) } }, null, 2);
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
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) }),
|
|
152
|
+
guard_why: tool({ description: "Simulate and explain whether a specific tool call or command would be blocked by guardrails.", 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) => { const reason = await guardToolCallImpl(args.tool, args.input ?? {}); return reason ? `BLOCKED: ${reason}` : "ALLOWED: Satisfies all current guardrails."; } }),
|
|
153
|
+
record_review: tool({
|
|
154
|
+
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.",
|
|
155
|
+
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") },
|
|
156
|
+
execute: async (args, toolContext) => {
|
|
157
|
+
const parentSessionID = await fetchParentSessionID(toolContext.sessionID);
|
|
158
|
+
if (!parentSessionID) return "[workflow-guard] Review rejected: record_review must be called from a secondary/subagent session.";
|
|
159
|
+
const axesRefs = ["test integrity", "task completeness", "cleanliness", "security", "platform"];
|
|
160
|
+
const referenced = axesRefs.filter((axis) => args.summary.toLowerCase().includes(axis));
|
|
161
|
+
if (referenced.length < 3) return `[workflow-guard] Review rejected: summary must reference the review axes (found ${referenced.length}/5). Call guard_review_rubric to get the rubric, evaluate each axis, and include findings per axis in the summary.`;
|
|
162
|
+
if (args.passed && /(?:^|\s)(?:\[p[01]\]|p[01]\s*:\s*(?:blocker|defect|vulnerability|error|bug|issue)|p[01]\s+blocker)/i.test(args.summary)) return "[workflow-guard] Review rejected: cannot record approval when P0 or P1 blockers are flagged in findings. Resolve all P0/P1 issues before approving or record review with passed=false.";
|
|
163
|
+
recordReviewResult(args.reviewer, args.summary, args.passed, parentSessionID, toolContext.worktree || toolContext.directory);
|
|
164
|
+
if (followupStore && !secretIn(args.summary)) for (const finding of extractReviewFollowups(args.summary)) recordReviewFollowup(followupStore, { severity: finding.severity, summary: finding.summary, reviewer: args.reviewer, sessionID: toolContext.sessionID, commit: getCurrentGitCommitHash(effectiveRoot) });
|
|
165
|
+
return args.passed ? `[workflow-guard] Review recorded as APPROVED by ${args.reviewer}.` : `[workflow-guard] Review recorded as CHANGES REQUESTED by ${args.reviewer}.`;
|
|
166
|
+
},
|
|
167
|
+
}),
|
|
168
|
+
guard_review_followups: tool({ description: "List durable local P2/P3 review follow-ups for this project. Open findings are technical debt that should be addressed rather than indefinitely deferred.", args: {}, execute: async () => JSON.stringify(followupStore ? listReviewFollowups(followupStore) : [], null, 2) }),
|
|
169
|
+
guard_review_followup_resolve: tool({ description: "Resolve a durable local review follow-up after the underlying issue has been fixed and verified.", args: { id: tool.schema.string() }, execute: async (args) => followupStore && resolveReviewFollowup(followupStore, args.id) ? `[workflow-guard] Review follow-up ${args.id} resolved.` : `[workflow-guard] Review follow-up ${args.id} was not open or was not found.` }),
|
|
170
|
+
guard_review_rubric: tool({
|
|
171
|
+
description: "Get the secondary-review rubric for the current branch diff. The orchestrator calls this, spawns a reviewer subagent with the rubric as the prompt, then the reviewer records its verdict via record_review.", args: { base: tool.schema.string().optional().describe("Base ref to diff against (default: origin/main, origin/master, main)") },
|
|
172
|
+
execute: async (args) => {
|
|
173
|
+
const sanitizedBase = typeof args.base === "string" && !args.base.startsWith("-") && !/[\s;'"\0]/.test(args.base) ? args.base : undefined;
|
|
174
|
+
const bases = sanitizedBase ? [sanitizedBase] : ["origin/main", "origin/master", "main", "master"];
|
|
175
|
+
let diffText = "";
|
|
176
|
+
for (const base of bases) { const res = spawnSync("git", ["diff", "--", `${base}...HEAD`], { cwd: getWorkspaceRoot(), encoding: "utf8", timeout: 10_000 }); if (res.status === 0 && res.stdout.trim()) { diffText = res.stdout; break; } }
|
|
177
|
+
if (!diffText) { const last = spawnSync("git", ["diff", "--", "HEAD~1"], { cwd: getWorkspaceRoot(), encoding: "utf8", timeout: 10_000 }); diffText = last.status === 0 ? last.stdout : "(no diff available)"; }
|
|
178
|
+
return buildReviewRubric(diffText);
|
|
179
|
+
},
|
|
180
|
+
}),
|
|
181
|
+
guard_worktree_create: tool({
|
|
182
|
+
description: "Create an isolated git worktree directory for concurrent subagent execution.", args: { branch: tool.schema.string().describe("Branch name for the isolated worktree (e.g. 'feat/subagent-task')"), baseBranch: tool.schema.string().optional().describe("Base branch to branch off of (defaults to HEAD)") },
|
|
183
|
+
execute: async (args, toolContext) => {
|
|
184
|
+
const todos = await effectiveTodos(toolContext.sessionID); if (todos !== undefined && !hasActiveTodo(todos)) return "[workflow-guard] Blocked: worktree creation with no active todo item. Break the request down with todowrite first, then create worktrees.";
|
|
185
|
+
const toolRoot = toolContext.worktree || toolContext.directory || effectiveRoot; const res = createGitWorktree(args.branch, args.baseBranch ?? "HEAD", toolRoot); if (!res.success) return `[workflow-guard] Failed to create worktree: ${res.error}`;
|
|
186
|
+
recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID); return `[workflow-guard] Worktree created successfully at: ${res.worktreePath}\nRun subagent tasks or pass worktree directory context to isolate file mutations.`;
|
|
187
|
+
},
|
|
188
|
+
}),
|
|
189
|
+
guard_worktree_cleanup: tool({
|
|
190
|
+
description: "Commit a final snapshot and remove an isolated git worktree directory.", args: { worktreePath: tool.schema.string().describe("Path of the worktree directory to clean up") },
|
|
191
|
+
execute: async (args, toolContext) => {
|
|
192
|
+
const todos = await effectiveTodos(toolContext.sessionID); if (todos !== undefined && !hasActiveTodo(todos)) return "[workflow-guard] Blocked: worktree cleanup with no active todo item. Break the request down with todowrite first, then clean up worktrees.";
|
|
193
|
+
const toolRoot = toolContext.worktree || toolContext.directory || effectiveRoot; const res = cleanupGitWorktree(args.worktreePath, toolRoot); if (!res.success) return `[workflow-guard] Failed to clean up worktree: ${res.error}`;
|
|
194
|
+
recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID); return `[workflow-guard] Worktree at '${args.worktreePath}' cleaned up successfully.`;
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
};
|
|
198
|
+
}
|