opencode-workflow-guard 1.12.0 → 1.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/docs/installation.md +4 -3
- package/docs/policies.md +7 -3
- package/package.json +4 -2
- package/src/lib/audit.ts +33 -1
- package/src/lib/custom-tools.ts +39 -15
- package/src/lib/evidence.ts +6 -1
- package/src/lib/guard-dispatcher.ts +15 -5
- package/src/lib/project-config.ts +52 -2
- package/src/lib/state.ts +94 -6
- package/src/lib/types.ts +1 -0
- package/src/lib/v2-plugin.ts +625 -0
- package/src/lib/verify.ts +11 -0
- package/src/policies/boundary.ts +16 -3
- package/src/policies/continuation.ts +21 -2
- package/src/policies/git.ts +42 -1
- package/src/policies/tamper.ts +17 -0
- package/src/workflow-guard-ui.ts +95 -14
- package/src/workflow-guard.ts +60 -8
- package/test/test-e2e.mts +129 -27
- package/test/test.mts +166 -1
|
@@ -14,6 +14,7 @@ type ContinuationState = {
|
|
|
14
14
|
generatedMessageIDs: Map<string, Set<string>>;
|
|
15
15
|
lastUserMessageIDs: Map<string, string>;
|
|
16
16
|
inFlight: Set<string>;
|
|
17
|
+
interrupted: Set<string>;
|
|
17
18
|
};
|
|
18
19
|
export type RalphOutcome = "running" | "completed" | "blocked" | "budget_exhausted" | "user_stopped";
|
|
19
20
|
const states = new WeakMap<object, Map<string, ContinuationState>>();
|
|
@@ -43,7 +44,7 @@ function getState(): ContinuationState | undefined {
|
|
|
43
44
|
const root = projectRootKey(getWorkspaceRoot());
|
|
44
45
|
let state = clientStates.get(root);
|
|
45
46
|
if (!state) {
|
|
46
|
-
state = { counts: new Map(), ralphOutcomes: new Map(), generatedMessageIDs: new Map(), lastUserMessageIDs: new Map(), inFlight: new Set() };
|
|
47
|
+
state = { counts: new Map(), ralphOutcomes: new Map(), generatedMessageIDs: new Map(), lastUserMessageIDs: new Map(), inFlight: new Set(), interrupted: new Set() };
|
|
47
48
|
clientStates.set(root, state);
|
|
48
49
|
}
|
|
49
50
|
return state;
|
|
@@ -56,6 +57,9 @@ export async function continueUnfinishedSession(sessionID: string, settleTitle =
|
|
|
56
57
|
try {
|
|
57
58
|
const ralph = isRalphModeEnabled(getWorkspaceRoot());
|
|
58
59
|
if (ralph && state.ralphOutcomes.get(sessionID) === "user_stopped") return false;
|
|
60
|
+
// A user-initiated interrupt (Esc) suspends automatic continuation:
|
|
61
|
+
// only genuine user input re-enables it (see recordUserMessage).
|
|
62
|
+
if (state.interrupted.has(sessionID)) return false;
|
|
59
63
|
const effective = await effectiveTodosWithOwner(sessionID);
|
|
60
64
|
if (!effective || effective.ownerSessionID !== sessionID || !hasActiveTodo(effective.todos)) {
|
|
61
65
|
if (ralph && state.ralphOutcomes.has(sessionID)) state.ralphOutcomes.set(sessionID, "completed");
|
|
@@ -73,7 +77,7 @@ export async function continueUnfinishedSession(sessionID: string, settleTitle =
|
|
|
73
77
|
const session = getSdkClient()?.session;
|
|
74
78
|
if (typeof session?.promptAsync !== "function") return false;
|
|
75
79
|
|
|
76
|
-
const messageID = `
|
|
80
|
+
const messageID = `msg_wg_${randomUUID().replace(/-/g, "")}`;
|
|
77
81
|
state.counts.set(sessionID, count + 1);
|
|
78
82
|
if (ralph) state.ralphOutcomes.set(sessionID, "running");
|
|
79
83
|
const generatedIDs = state.generatedMessageIDs.get(sessionID) ?? new Set<string>();
|
|
@@ -122,12 +126,26 @@ export function recordUserMessage(sessionID: string, messageID?: string): void {
|
|
|
122
126
|
if (messageID && state.lastUserMessageIDs.get(sessionID) === messageID) return;
|
|
123
127
|
if (messageID) state.lastUserMessageIDs.set(sessionID, messageID);
|
|
124
128
|
state.counts.delete(sessionID);
|
|
129
|
+
state.interrupted.delete(sessionID);
|
|
125
130
|
if (isRalphModeEnabled(getWorkspaceRoot()) && state.ralphOutcomes.has(sessionID)) {
|
|
126
131
|
if (state.ralphOutcomes.get(sessionID) === "user_stopped") state.ralphOutcomes.delete(sessionID);
|
|
127
132
|
else state.ralphOutcomes.set(sessionID, "user_stopped");
|
|
128
133
|
}
|
|
129
134
|
}
|
|
130
135
|
|
|
136
|
+
// Record a user-initiated interrupt (Esc): the session's run was aborted
|
|
137
|
+
// from the user side, so automatic continuation must stop. Ralph runs are
|
|
138
|
+
// marked user_stopped, mirroring genuine user input. A later genuine user
|
|
139
|
+
// message re-enables continuation (recordUserMessage clears the flag).
|
|
140
|
+
export function markInterrupted(sessionID: string): void {
|
|
141
|
+
const state = getState();
|
|
142
|
+
if (!state) return;
|
|
143
|
+
state.interrupted.add(sessionID);
|
|
144
|
+
if (isRalphModeEnabled(getWorkspaceRoot()) && state.ralphOutcomes.has(sessionID)) {
|
|
145
|
+
state.ralphOutcomes.set(sessionID, "user_stopped");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
131
149
|
export function getRalphOutcome(sessionID: string): RalphOutcome | undefined {
|
|
132
150
|
return getState()?.ralphOutcomes.get(sessionID);
|
|
133
151
|
}
|
|
@@ -145,4 +163,5 @@ export function clearContinuationState(sessionID: string): void {
|
|
|
145
163
|
state.generatedMessageIDs.delete(sessionID);
|
|
146
164
|
state.lastUserMessageIDs.delete(sessionID);
|
|
147
165
|
state.inFlight.delete(sessionID);
|
|
166
|
+
state.interrupted.delete(sessionID);
|
|
148
167
|
}
|
package/src/policies/git.ts
CHANGED
|
@@ -123,7 +123,7 @@ export function normalizeGitCommands(command: string): string {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
export const GIT_WRITE_RE =
|
|
126
|
-
/\bgit\s+(add|rm|mv|commit|merge|rebase|cherry-pick|revert|stash\s+pop|apply|am|restore|reset|update-ref|filter-branch)\b|\bgit\s+tag\s+(?!--?list\b|-l\b)|\bgit\s+checkout\s+(?!-b\b)|\bgit\s+branch\s+(?:[^|;&]*\s)?-[dDM]\b/;
|
|
126
|
+
/\bgit\s+(add|rm|mv|commit|merge|rebase|cherry-pick|revert|stash\s+pop|apply|am|restore|reset|update-ref|filter-branch)\b|\bgit\s+tag\s+(?!--?list\b|-l\b)(?:[^|;&]*\s)?(?:-d\b|--delete\b)|\bgit\s+checkout\s+(?!-b\b)|\bgit\s+branch\s+(?:[^|;&]*\s)?-[dDM]\b/;
|
|
127
127
|
|
|
128
128
|
export function currentGitBranch(root: string): string | undefined {
|
|
129
129
|
const result = spawnSync("git", ["branch", "--show-current"], {
|
|
@@ -179,6 +179,47 @@ export function pushedProtectedBranchIn(pushCommand: string, root: string): stri
|
|
|
179
179
|
return undefined;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* True when a `git push` command pushes a tag refspec: an explicit
|
|
184
|
+
* `refs/tags/<name>` destination, or a short name that resolves to an
|
|
185
|
+
* existing tag in the repository at `root`. Publishing a tag is a release
|
|
186
|
+
* operation, not a branch mutation, so callers exempt it from the
|
|
187
|
+
* protected-branch and merged-branch push rules (#134). Deletion refspecs
|
|
188
|
+
* (`:refs/tags/<name>`, `git push --delete`, `-d`) return false so tag
|
|
189
|
+
* deletions stay blocked.
|
|
190
|
+
*/
|
|
191
|
+
export function tagRefspecIn(pushCommand: string, root: string): boolean {
|
|
192
|
+
if (/\s--delete\b|\s-d(?:\b|$)/.test(pushCommand)) return false;
|
|
193
|
+
for (const segment of splitShellSegments(pushCommand)) {
|
|
194
|
+
const parsed = parseGitInvocation(segment);
|
|
195
|
+
if (!parsed || !/\bpush\b/.test(parsed.rest)) continue;
|
|
196
|
+
const tokens = parsed.rest.split(/\s+/).slice(1);
|
|
197
|
+
let remoteSeen = false;
|
|
198
|
+
for (const rawToken of tokens) {
|
|
199
|
+
if (rawToken.startsWith("-")) continue;
|
|
200
|
+
if (!remoteSeen) {
|
|
201
|
+
remoteSeen = true;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const token = rawToken.replace(/^\+/, "");
|
|
205
|
+
if (!token) continue;
|
|
206
|
+
if (token.startsWith(":")) continue; // deletion refspec: empty source
|
|
207
|
+
if (token.includes(":")) {
|
|
208
|
+
if (token.includes(":refs/tags/")) return true; // <src>:refs/tags/<name>
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (token.startsWith("refs/tags/")) return true; // explicit tag refspec
|
|
212
|
+
const probe = spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/tags/${token}`], {
|
|
213
|
+
cwd: root,
|
|
214
|
+
encoding: "utf8",
|
|
215
|
+
timeout: 10_000,
|
|
216
|
+
});
|
|
217
|
+
if (probe.status === 0) return true;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
|
|
182
223
|
export function onProtectedBranch(root: string): boolean {
|
|
183
224
|
return isProtectedBranchName(currentGitBranch(root) ?? "", root);
|
|
184
225
|
}
|
package/src/policies/tamper.ts
CHANGED
|
@@ -36,11 +36,23 @@ export function normalizeGlobPathEvasion(text: string): string {
|
|
|
36
36
|
.replace(/opencode\.[?*]/gi, ocJson);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Collaboration invocations (hosted-git/PR/issue CLIs) never write local
|
|
40
|
+
// configuration: their arguments may legitimately mention guarded paths.
|
|
41
|
+
const COLLABORATION_INVOCATION_PATTERNS: RegExp[] = [
|
|
42
|
+
/^\s*(?:gh|glab)\s+(?:issue|pr)\b/,
|
|
43
|
+
/^\s*az\s+repos\s+pr\b/,
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export function isCollaborationInvocation(segment: string): boolean {
|
|
47
|
+
return COLLABORATION_INVOCATION_PATTERNS.some((re) => re.test(segment));
|
|
48
|
+
}
|
|
49
|
+
|
|
39
50
|
export function isSettingsTamper(command: string): boolean {
|
|
40
51
|
const segments = command.split(/[\n|;&]+/).map((s) =>
|
|
41
52
|
normalizeGlobPathEvasion(normalizeShellEvasion(s)),
|
|
42
53
|
);
|
|
43
54
|
return segments.some((segment) =>
|
|
55
|
+
!isCollaborationInvocation(segment) &&
|
|
44
56
|
SETTINGS_TAMPER_PATTERNS.some((re) => re.test(segment)),
|
|
45
57
|
);
|
|
46
58
|
}
|
|
@@ -53,6 +65,11 @@ export function isProtectedPath(targetPath: string): boolean {
|
|
|
53
65
|
const base = basename(path);
|
|
54
66
|
const lower = path.toLowerCase();
|
|
55
67
|
const dotOc = "." + "opencode/";
|
|
68
|
+
// opencode plan mode writes agent plan markdown under the project's
|
|
69
|
+
// .opencode/plans/ directory - plan files are documents, not
|
|
70
|
+
// configuration. The trailing slash keeps the plans directory
|
|
71
|
+
// itself protected.
|
|
72
|
+
if (lower.includes("/" + dotOc + "plans/")) return false;
|
|
56
73
|
const cfgOc = "/.config/" + "opencode/";
|
|
57
74
|
const cfgOcJson = "/.config/" + "opencode.json";
|
|
58
75
|
return (
|
package/src/workflow-guard-ui.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Guard TUI companion plugin for OpenCode V2 (CLI plugin API).
|
|
3
|
+
*
|
|
4
|
+
* - Registers the `/guard-options` palette + slash command to toggle project options.
|
|
5
|
+
* - Renders the Workflow Guard badge in the home and prompt footer status slots.
|
|
6
|
+
*
|
|
7
|
+
* The V1 entrypoint (`WorkflowGuardTui`) remains for OpenCode 1.x TUI clients.
|
|
8
|
+
*/
|
|
9
|
+
|
|
1
10
|
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui";
|
|
11
|
+
import { Plugin } from "@opencode/plugin/tui";
|
|
2
12
|
import type { JSX } from "@opentui/solid";
|
|
3
13
|
import { createElement, insert, setProp } from "@opentui/solid";
|
|
4
14
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -75,6 +85,79 @@ export function formatBadge(): { text: string; isBlocked: boolean } {
|
|
|
75
85
|
return { text: BADGE_ACTIVE, isBlocked: false };
|
|
76
86
|
}
|
|
77
87
|
|
|
88
|
+
// ── V2 CLI plugin ─────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
type TuiContext = Plugin.Context;
|
|
91
|
+
|
|
92
|
+
const TOGGLE_OPTIONS: Array<{ key: ProjectToggle; label: string; description: string }> = [
|
|
93
|
+
{ key: "recoveryCheckpoints", label: "Recovery checkpoints", description: "Toggle durable pre-run Git checkpoints" },
|
|
94
|
+
{ key: "projectMemory", label: "Project memory", description: "Toggle durable local project memory" },
|
|
95
|
+
{ key: "learning", label: "Learner mode", description: "Toggle evidence-based learning tools" },
|
|
96
|
+
{ key: "titleSettleWorkaround", label: "Title settle workaround", description: "Delay automatic continuation while OpenCode generates a session title" },
|
|
97
|
+
{ key: "ralphMode", label: "Ralph mode", description: "Opt into bounded autonomous continuation of already-owned todos" },
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
export const WorkflowGuardTuiV2 = (ctx: TuiContext) => {
|
|
101
|
+
const badge = () => {
|
|
102
|
+
const formatted = formatBadge();
|
|
103
|
+
return text({ fg: ctx.theme.text.feedback.success.base }, [formatted.text]);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const optionsRoot = () => ctx.location?.directory || process.cwd();
|
|
107
|
+
|
|
108
|
+
ctx.keymap.layer(() => ({
|
|
109
|
+
commands: [{
|
|
110
|
+
id: "workflow-guard.project-options",
|
|
111
|
+
title: "Workflow Guard: Project Options",
|
|
112
|
+
description: "Toggle Workflow Guard project options (recovery checkpoints, project memory, learning, title settle, ralph mode)",
|
|
113
|
+
group: "Workflow Guard",
|
|
114
|
+
palette: true,
|
|
115
|
+
slash: { name: "guard-options" },
|
|
116
|
+
async run() {
|
|
117
|
+
const root = optionsRoot();
|
|
118
|
+
for (;;) {
|
|
119
|
+
const current = new Map(TOGGLE_OPTIONS.map((option) => [option.key, readProjectOption(root, option.key)]));
|
|
120
|
+
const choice = await ctx.ui.dialog.select<ProjectToggle>({
|
|
121
|
+
title: "Workflow Guard Project Options",
|
|
122
|
+
options: TOGGLE_OPTIONS.map((option) => ({
|
|
123
|
+
title: `${option.label}: ${current.get(option.key) ? "On" : "Off"}`,
|
|
124
|
+
value: option.key,
|
|
125
|
+
description: option.description,
|
|
126
|
+
})),
|
|
127
|
+
});
|
|
128
|
+
if (!choice) break;
|
|
129
|
+
try {
|
|
130
|
+
const enabled = !readProjectOption(root, choice);
|
|
131
|
+
const path = writeProjectOption(root, choice, enabled);
|
|
132
|
+
ctx.ui.toast.show({
|
|
133
|
+
variant: "success",
|
|
134
|
+
title: "Workflow Guard",
|
|
135
|
+
message: `Saved ${choice} ${enabled ? "on" : "off"} in ${path}. Restart OpenCode to apply.`,
|
|
136
|
+
});
|
|
137
|
+
} catch (error) {
|
|
138
|
+
ctx.ui.toast.show({
|
|
139
|
+
variant: "error",
|
|
140
|
+
title: "Workflow Guard",
|
|
141
|
+
message: error instanceof Error ? error.message : String(error),
|
|
142
|
+
});
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
}],
|
|
148
|
+
bindings: [],
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
ctx.ui.slot({ append: "home.footer.status", render: () => badge() });
|
|
152
|
+
ctx.ui.slot({ append: "prompt.footer.status", render: () => badge() });
|
|
153
|
+
|
|
154
|
+
return () => {
|
|
155
|
+
// Slot claims and keymap layers are disposed automatically on unload.
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// ── V1 TUI plugin ─────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
78
161
|
export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
79
162
|
api.keymap.registerLayer({
|
|
80
163
|
commands: [{
|
|
@@ -85,11 +168,6 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
|
85
168
|
slashName: "guard-options",
|
|
86
169
|
run() {
|
|
87
170
|
const root = api.state.path.worktree || api.state.path.directory;
|
|
88
|
-
const recovery = readProjectOption(root, "recoveryCheckpoints");
|
|
89
|
-
const memory = readProjectOption(root, "projectMemory");
|
|
90
|
-
const learning = readProjectOption(root, "learning");
|
|
91
|
-
const titleSettle = readProjectOption(root, "titleSettleWorkaround");
|
|
92
|
-
const ralphMode = readProjectOption(root, "ralphMode");
|
|
93
171
|
const toggle = (key: ProjectToggle) => {
|
|
94
172
|
const enabled = !readProjectOption(root, key);
|
|
95
173
|
const path = writeProjectOption(root, key, enabled);
|
|
@@ -99,13 +177,12 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
|
99
177
|
api.ui.dialog.replace(() => api.ui.DialogSelect({
|
|
100
178
|
title: "Workflow Guard Project Options",
|
|
101
179
|
current: undefined,
|
|
102
|
-
options:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
],
|
|
180
|
+
options: TOGGLE_OPTIONS.map((option) => ({
|
|
181
|
+
title: `${option.label}: ${readProjectOption(root, option.key) ? "On" : "Off"}`,
|
|
182
|
+
value: option.key,
|
|
183
|
+
description: option.description,
|
|
184
|
+
onSelect: () => toggle(option.key),
|
|
185
|
+
})),
|
|
109
186
|
}));
|
|
110
187
|
},
|
|
111
188
|
}],
|
|
@@ -129,7 +206,11 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
|
129
206
|
});
|
|
130
207
|
};
|
|
131
208
|
|
|
209
|
+
// Default export supports BOTH generations: V1 TUI clients call tui(), V2 reads setup().
|
|
132
210
|
export default {
|
|
133
|
-
|
|
211
|
+
...Plugin.define({
|
|
212
|
+
id: "workflow-guard-ui",
|
|
213
|
+
setup: WorkflowGuardTuiV2,
|
|
214
|
+
}),
|
|
134
215
|
tui: WorkflowGuardTui,
|
|
135
|
-
} satisfies TuiPluginModule;
|
|
216
|
+
} as const satisfies TuiPluginModule;
|
package/src/workflow-guard.ts
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* from ./lib/, and re-exports the public helper surface for tests.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { type Plugin, type PluginModule } from "@opencode-ai/plugin";
|
|
11
|
+
import { type Plugin as V1Plugin, type PluginModule } from "@opencode-ai/plugin";
|
|
12
|
+
import { Plugin } from "@opencode/plugin";
|
|
13
|
+
import { WorkflowGuardV2 } from "./lib/v2-plugin.ts";
|
|
12
14
|
import { existsSync, readFileSync } from "node:fs";
|
|
13
15
|
import { resolve } from "node:path";
|
|
14
16
|
import { join } from "node:path";
|
|
@@ -74,10 +76,14 @@ import {
|
|
|
74
76
|
getLearningInterventionBudget,
|
|
75
77
|
recordReviewResult,
|
|
76
78
|
getLastReviewResult,
|
|
79
|
+
getLastReviewResultForWorkspace,
|
|
77
80
|
resetReviewState,
|
|
78
81
|
sessionReviews,
|
|
82
|
+
setSessionWorkspace,
|
|
83
|
+
getSessionWorkspace,
|
|
84
|
+
resolveEffectiveWorkspace,
|
|
79
85
|
} from "./lib/state.ts";
|
|
80
|
-
import { loadProjectConfig, reloadProjectConfig, stripJsonComments } from "./lib/project-config.ts";
|
|
86
|
+
import { findGitRoot, isSameGitRepo, loadProjectConfig, reloadProjectConfig, stripJsonComments } from "./lib/project-config.ts";
|
|
81
87
|
|
|
82
88
|
export {
|
|
83
89
|
setWorkspaceRoot,
|
|
@@ -101,11 +107,18 @@ export {
|
|
|
101
107
|
getLearningInterventionBudget,
|
|
102
108
|
recordReviewResult,
|
|
103
109
|
getLastReviewResult,
|
|
110
|
+
getLastReviewResultForWorkspace,
|
|
104
111
|
resetReviewState,
|
|
112
|
+
setSessionWorkspace,
|
|
113
|
+
getSessionWorkspace,
|
|
114
|
+
resolveEffectiveWorkspace,
|
|
115
|
+
findGitRoot,
|
|
116
|
+
isSameGitRepo,
|
|
105
117
|
};
|
|
106
118
|
|
|
107
119
|
// ── Shared shell/env utilities ───────────────────────────────────────────────
|
|
108
120
|
import {
|
|
121
|
+
asRecord,
|
|
109
122
|
extractRecordTargetPath,
|
|
110
123
|
getCleanEnv,
|
|
111
124
|
showBlockToast,
|
|
@@ -149,14 +162,17 @@ import { ensureProjectMemoryExcluded, getProjectMemoryIdentity, getRecentProject
|
|
|
149
162
|
import {
|
|
150
163
|
getAuditFilePath,
|
|
151
164
|
getVerifyCacheFilePath,
|
|
165
|
+
getReviewCacheFilePath,
|
|
152
166
|
persistVerifyCache,
|
|
153
167
|
loadVerifyCache,
|
|
168
|
+
persistReviewCache,
|
|
169
|
+
loadReviewCache,
|
|
154
170
|
audit,
|
|
155
171
|
logDecision,
|
|
156
172
|
summarizeInput,
|
|
157
173
|
} from "./lib/audit.ts";
|
|
158
174
|
|
|
159
|
-
export { getAuditFilePath, getVerifyCacheFilePath, getVerifyHistoryFilePath, persistVerifyCache, loadVerifyCache, getRecentAuditEntries, getRecentVerifyHistory } from "./lib/audit.ts";
|
|
175
|
+
export { getAuditFilePath, getVerifyCacheFilePath, getReviewCacheFilePath, getVerifyHistoryFilePath, persistVerifyCache, loadVerifyCache, persistReviewCache, loadReviewCache, getRecentAuditEntries, getRecentVerifyHistory } from "./lib/audit.ts";
|
|
160
176
|
export { summarizeInput };
|
|
161
177
|
|
|
162
178
|
export function managedConfigDiagnostic(platform = process.platform, env: NodeJS.ProcessEnv = process.env): string {
|
|
@@ -174,6 +190,7 @@ export function managedConfigDiagnostic(platform = process.platform, env: NodeJS
|
|
|
174
190
|
// ── Verification engine ──────────────────────────────────────────────────────
|
|
175
191
|
import {
|
|
176
192
|
detectVerifyCommand,
|
|
193
|
+
resolveVerifyTimeoutMs,
|
|
177
194
|
runVerify,
|
|
178
195
|
snipVerifyOutput,
|
|
179
196
|
getCurrentGitCommitHash,
|
|
@@ -183,6 +200,7 @@ import {
|
|
|
183
200
|
|
|
184
201
|
export {
|
|
185
202
|
detectVerifyCommand,
|
|
203
|
+
resolveVerifyTimeoutMs,
|
|
186
204
|
runVerify,
|
|
187
205
|
snipVerifyOutput,
|
|
188
206
|
getCurrentGitCommitHash,
|
|
@@ -225,6 +243,7 @@ import {
|
|
|
225
243
|
clearContinuationState,
|
|
226
244
|
continueUnfinishedSession,
|
|
227
245
|
isGeneratedContinuationMessage,
|
|
246
|
+
markInterrupted,
|
|
228
247
|
recordUserMessage,
|
|
229
248
|
} from "./policies/continuation.ts";
|
|
230
249
|
import {
|
|
@@ -363,15 +382,22 @@ export function buildCompactionContext(operationalState: string, priorityBlocks:
|
|
|
363
382
|
return context;
|
|
364
383
|
}
|
|
365
384
|
|
|
366
|
-
export const WorkflowGuard:
|
|
385
|
+
export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
|
|
367
386
|
// Honor worktree if present (e.g. opencode worktrees or devcontainers)
|
|
368
387
|
// so worktree plugins cannot punch through boundary gates. When the host
|
|
369
388
|
// reports the filesystem root, use the SDK's actual project worktree instead.
|
|
370
389
|
const hostRoot = ctx.worktree || ctx.directory || process.cwd();
|
|
371
390
|
const hostIsFilesystemRoot = resolve(hostRoot) === resolve(hostRoot, "..");
|
|
372
|
-
|
|
391
|
+
let effectiveRoot = hostIsFilesystemRoot && ctx.project?.worktree && resolve(ctx.project.worktree) !== resolve(ctx.project.worktree, "..")
|
|
373
392
|
? ctx.project.worktree
|
|
374
393
|
: hostRoot;
|
|
394
|
+
if (resolve(effectiveRoot) === resolve(effectiveRoot, "..")) {
|
|
395
|
+
const nonRootCandidate = [ctx.directory, process.cwd()].find((p) => typeof p === "string" && resolve(p) !== resolve(p, ".."));
|
|
396
|
+
if (nonRootCandidate) {
|
|
397
|
+
const gitRoot = findGitRoot(nonRootCandidate);
|
|
398
|
+
effectiveRoot = gitRoot ?? nonRootCandidate;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
375
401
|
setWorkspaceRoot(effectiveRoot);
|
|
376
402
|
setSdkClient(ctx.client);
|
|
377
403
|
reloadProjectConfig(effectiveRoot);
|
|
@@ -439,6 +465,16 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
439
465
|
throw new Error(`[workflow-guard] ${reason}${circuitBreakerSuffix}`);
|
|
440
466
|
}
|
|
441
467
|
toolLifecycle.start(input.sessionID, input.callID);
|
|
468
|
+
if (input.sessionID) {
|
|
469
|
+
const record = asRecord(args);
|
|
470
|
+
const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
|
|
471
|
+
const filePath = typeof record?.filePath === "string" ? record.filePath : (typeof record?.path === "string" ? record.path : undefined);
|
|
472
|
+
const candidatePath = workdir ?? filePath ?? toolWorktree;
|
|
473
|
+
if (candidatePath) {
|
|
474
|
+
const gitRoot = findGitRoot(candidatePath);
|
|
475
|
+
if (gitRoot) setSessionWorkspace(input.sessionID, gitRoot);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
442
478
|
if (input.tool === "read") {
|
|
443
479
|
const target = editTargets(args, toolWorktree)[0];
|
|
444
480
|
if (target) {
|
|
@@ -732,12 +768,24 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
732
768
|
}
|
|
733
769
|
}
|
|
734
770
|
if (event?.type === "message.updated") {
|
|
735
|
-
const info = (event.properties as { info?: { id?: unknown; role?: unknown; sessionID?: unknown } })?.info;
|
|
771
|
+
const info = (event.properties as { info?: { id?: unknown; role?: unknown; sessionID?: unknown; error?: { name?: unknown } } })?.info;
|
|
736
772
|
if (info?.role === "user" && typeof info.sessionID === "string") {
|
|
737
773
|
await runWithRuntimeState(effectiveRoot, ctx.client, () => {
|
|
738
774
|
recordUserMessage(info.sessionID as string, typeof info.id === "string" ? info.id : undefined);
|
|
739
775
|
});
|
|
740
776
|
}
|
|
777
|
+
// A user-initiated interrupt (Esc) surfaces as an aborted
|
|
778
|
+
// assistant message: stop automatic continuation for the
|
|
779
|
+
// session until genuine user input arrives.
|
|
780
|
+
if (info?.role === "assistant" && typeof info.sessionID === "string" && info.error?.name === "MessageAbortedError") {
|
|
781
|
+
await runWithRuntimeState(effectiveRoot, ctx.client, () => markInterrupted(info.sessionID as string));
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (event?.type === "session.error") {
|
|
785
|
+
const props = event.properties as { sessionID?: unknown; error?: { name?: unknown } };
|
|
786
|
+
if (typeof props?.sessionID === "string" && props?.error?.name === "MessageAbortedError") {
|
|
787
|
+
await runWithRuntimeState(effectiveRoot, ctx.client, () => markInterrupted(props.sessionID as string));
|
|
788
|
+
}
|
|
741
789
|
}
|
|
742
790
|
if (event?.type === "session.deleted") {
|
|
743
791
|
const sessionID = (event.properties as { info?: { id?: unknown } })?.info?.id;
|
|
@@ -773,8 +821,12 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
773
821
|
};
|
|
774
822
|
};
|
|
775
823
|
|
|
776
|
-
// Default export
|
|
824
|
+
// Default export supports BOTH generations: V1 calls server(), V2 reads id+setup().
|
|
825
|
+
// Spread Plugin.define() so the V2 definition type-checks separately from server().
|
|
777
826
|
export default {
|
|
778
|
-
|
|
827
|
+
...Plugin.define({
|
|
828
|
+
id: "workflow-guard",
|
|
829
|
+
setup: WorkflowGuardV2,
|
|
830
|
+
}),
|
|
779
831
|
server: WorkflowGuard,
|
|
780
832
|
} satisfies PluginModule;
|