@mcrescenzo/opencode-workflows 0.1.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/CODE_OF_CONDUCT.md +134 -0
  3. package/CONTRIBUTING.md +95 -0
  4. package/LICENSE +21 -0
  5. package/README.md +746 -0
  6. package/SECURITY.md +38 -0
  7. package/commands/repo-bughunt.md +94 -0
  8. package/commands/repo-review.md +148 -0
  9. package/commands/workflow-live-gates-release-check.md +143 -0
  10. package/docs/workflow-plugin.md +400 -0
  11. package/opencode-workflows.js +5 -0
  12. package/package.json +86 -0
  13. package/skills/opencode-workflow-authoring/SKILL.md +180 -0
  14. package/skills/repo-review-command-protocol/SKILL.md +56 -0
  15. package/skills/workflow-model-tiering/SKILL.md +57 -0
  16. package/skills/workflow-plan-review/SKILL.md +91 -0
  17. package/workflow-kernel/approval-hashing.js +39 -0
  18. package/workflow-kernel/async-util.js +33 -0
  19. package/workflow-kernel/audited-shell-policy.js +200 -0
  20. package/workflow-kernel/authority-policy.js +670 -0
  21. package/workflow-kernel/budget-accounting.js +142 -0
  22. package/workflow-kernel/capability-adapter.js +753 -0
  23. package/workflow-kernel/child-agent-runner.js +1264 -0
  24. package/workflow-kernel/constants.js +117 -0
  25. package/workflow-kernel/diagnostics.js +152 -0
  26. package/workflow-kernel/drain-runtime.js +421 -0
  27. package/workflow-kernel/errors.js +181 -0
  28. package/workflow-kernel/event-journal.js +487 -0
  29. package/workflow-kernel/extension-registry.js +144 -0
  30. package/workflow-kernel/free-text-redactor.js +91 -0
  31. package/workflow-kernel/gate-shapes.js +82 -0
  32. package/workflow-kernel/git-util.js +45 -0
  33. package/workflow-kernel/index.js +72 -0
  34. package/workflow-kernel/integration-mode.js +155 -0
  35. package/workflow-kernel/lane-effort-policy.js +134 -0
  36. package/workflow-kernel/lifecycle-control.js +608 -0
  37. package/workflow-kernel/live-gate-probes.js +916 -0
  38. package/workflow-kernel/notification-toast-cards.js +393 -0
  39. package/workflow-kernel/notification-toast-policy.js +179 -0
  40. package/workflow-kernel/notification-toast-scope.js +100 -0
  41. package/workflow-kernel/notification-toast.js +287 -0
  42. package/workflow-kernel/path-policy.js +219 -0
  43. package/workflow-kernel/result-readback.js +106 -0
  44. package/workflow-kernel/role-template-loading.js +606 -0
  45. package/workflow-kernel/run-context.js +139 -0
  46. package/workflow-kernel/run-observability.js +43 -0
  47. package/workflow-kernel/run-store-fs.js +231 -0
  48. package/workflow-kernel/run-store-locks.js +180 -0
  49. package/workflow-kernel/run-store-projections.js +421 -0
  50. package/workflow-kernel/run-store-rehydrate.js +68 -0
  51. package/workflow-kernel/run-store-state.js +147 -0
  52. package/workflow-kernel/run-store-status-format.js +1154 -0
  53. package/workflow-kernel/run-store-status.js +107 -0
  54. package/workflow-kernel/sandbox-executor.js +1131 -0
  55. package/workflow-kernel/session-access.js +56 -0
  56. package/workflow-kernel/structured-output.js +143 -0
  57. package/workflow-kernel/test-fix-drain-adapter.js +119 -0
  58. package/workflow-kernel/text-json.js +136 -0
  59. package/workflow-kernel/workflow-plugin.js +3017 -0
  60. package/workflow-kernel/workflow-source.js +444 -0
  61. package/workflow-kernel/worktree-adapter.js +256 -0
  62. package/workflow-kernel/worktree-git.js +147 -0
  63. package/workflows/repo-bughunt.js +362 -0
  64. package/workflows/repo-cleanup.js +383 -0
  65. package/workflows/repo-complexity.js +404 -0
  66. package/workflows/repo-deps.js +457 -0
  67. package/workflows/repo-modernize.js +395 -0
  68. package/workflows/repo-perf.js +394 -0
  69. package/workflows/repo-review.js +831 -0
  70. package/workflows/repo-security-audit.js +466 -0
  71. package/workflows/repo-test-gaps.js +377 -0
@@ -0,0 +1,916 @@
1
+ // Live-gate probe functions: each `probe*` actively exercises a capability of the live
2
+ // OpenCode runtime (denied bash, secret-read isolation, directory rooting, worktree
3
+ // isolation, structured output, background continuation, cancellation, notification
4
+ // delivery) and returns a gate-shape result. The CapabilityAdapter / liveGateReport
5
+ // orchestrator in capability-adapter.js fans these out; this module owns the probes and
6
+ // their probe-only helpers. It imports gate-shape constructors from gate-shapes.js and
7
+ // otherwise depends only on leaf runtime modules, so it does not import capability-adapter.js
8
+ // back (capability-adapter.js imports this module, not the reverse).
9
+ import { execFile } from "node:child_process";
10
+ import fs from "node:fs/promises";
11
+ import path from "node:path";
12
+ import { setImmediate as immediatePromise } from "node:timers/promises";
13
+ import { promisify } from "node:util";
14
+ import {
15
+ DEFAULT_CONCURRENCY_PROBE_LIMIT,
16
+ DEFAULT_LIVE_PROBE_TIMEOUT_MS,
17
+ DEFAULT_SUBPROCESS_MAX_BUFFER,
18
+ DEFAULT_SUBPROCESS_TIMEOUT_MS,
19
+ MAX_STATUS_STRING_CHARS,
20
+ } from "./constants.js";
21
+ import { extractTextFromError, textPart, truncateText } from "./text-json.js";
22
+ import { WorkflowProbeStructuralError } from "./errors.js";
23
+ import { structuredFormat } from "./structured-output.js";
24
+ import { OPENCODE_CHILD_PERMISSION_KEYS, permissionRulesForAuthority } from "./authority-policy.js";
25
+ import { pathExists, readJsonFile, writeJsonAtomic } from "./run-store-status.js";
26
+ import { sessionApi } from "./session-access.js";
27
+ import { withTimeout } from "./async-util.js";
28
+ import {
29
+ NOTIFICATION_STATE_VERSION,
30
+ abortChild,
31
+ deliverWorkflowNotifications,
32
+ pendingNotificationPaths,
33
+ } from "./lifecycle-control.js";
34
+ import { createWorktreeAdapter } from "./worktree-adapter.js";
35
+ import { changedPathsSinceBase } from "./integration-mode.js";
36
+ import {
37
+ gateAvailableUnverified,
38
+ gateBlocked,
39
+ gateFailed,
40
+ gateVerified,
41
+ transportFailureGate,
42
+ } from "./gate-shapes.js";
43
+
44
+ const execFileAsync = promisify(execFile);
45
+
46
+ function liveProbeTimeoutMs(pluginContext) {
47
+ return Number.isFinite(pluginContext.__workflowLiveProbeTimeoutMs) && pluginContext.__workflowLiveProbeTimeoutMs > 0
48
+ ? pluginContext.__workflowLiveProbeTimeoutMs
49
+ : DEFAULT_LIVE_PROBE_TIMEOUT_MS;
50
+ }
51
+
52
+ async function withLiveProbeTimeout(pluginContext, label, factory, onTimeout) {
53
+ return await withTimeout(factory, {
54
+ timeoutMs: liveProbeTimeoutMs(pluginContext),
55
+ label,
56
+ onTimeout,
57
+ });
58
+ }
59
+
60
+ function toolPartName(part) {
61
+ return String(part?.tool || part?.name || "");
62
+ }
63
+
64
+ function toolPartEvidence(part) {
65
+ return [
66
+ toolPartName(part),
67
+ part?.state?.status,
68
+ part?.state?.error,
69
+ part?.state?.input,
70
+ part?.state?.content,
71
+ part?.state?.structured,
72
+ ].map((item) => typeof item === "string" ? item : item === undefined ? "" : JSON.stringify(item)).filter(Boolean).join(" ");
73
+ }
74
+
75
+ function toolPartDenied(part) {
76
+ return part?.state?.status === "error" && isDenialEvidence(toolPartEvidence(part));
77
+ }
78
+
79
+ function isDenialEvidence(error) {
80
+ return /permission|denied|deny|not allowed|forbidden|unavailable/i.test(extractTextFromError(error));
81
+ }
82
+
83
+ function denialProbeResult(error, label) {
84
+ const transport = transportFailureGate(error, label);
85
+ if (transport) return transport;
86
+ const evidence = extractTextFromError(error);
87
+ if (isDenialEvidence(error)) return gateVerified(`${label} was rejected: ${truncateText(evidence, MAX_STATUS_STRING_CHARS)}`);
88
+ return gateFailed(`${label} failed without denial evidence: ${truncateText(evidence, MAX_STATUS_STRING_CHARS)}`);
89
+ }
90
+
91
+ function unwrapClientResult(result, label) {
92
+ if (result?.error !== undefined) {
93
+ const error = result.error;
94
+ throw new Error(`${label} failed: ${error?.message || error?.error || JSON.stringify(error)}`);
95
+ }
96
+ return result;
97
+ }
98
+
99
+ function valueContainsString(value, needle, seen = new Set()) {
100
+ if (!needle) return false;
101
+ if (typeof value === "string") return value.includes(needle);
102
+ if (!value || typeof value !== "object") return false;
103
+ if (seen.has(value)) return false;
104
+ seen.add(value);
105
+ if (Array.isArray(value)) return value.some((item) => valueContainsString(item, needle, seen));
106
+ return Object.values(value).some((item) => valueContainsString(item, needle, seen));
107
+ }
108
+
109
+ function createdSessionRetainedPermission(created, expectedRules) {
110
+ const retained = created?.data?.permission ?? created?.permission;
111
+ if (!Array.isArray(retained)) return false;
112
+ return expectedRules.every((rule) => retained.some((item) => item?.permission === rule.permission && item?.pattern === rule.pattern && item?.action === rule.action));
113
+ }
114
+
115
+ function deterministicToolProbeResult({ label, toolNames, directAllowed, toolParts, denialText = "", noAttemptEvidence = "" }) {
116
+ if (directAllowed) return gateFailed(`${label} command completed; permission denial was not enforced`);
117
+ const names = new Set(toolNames.map((name) => name.toLowerCase()));
118
+ const matching = toolParts.filter((part) => names.has(toolPartName(part).toLowerCase()));
119
+ if (matching.some(toolPartDenied)) {
120
+ return gateVerified(`${label} observed denied ${[...names].join("/")} tool evidence`);
121
+ }
122
+ if (matching.length > 0) {
123
+ return gateFailed(`${label} observed ${matching.map(toolPartName).join(", ")} tool attempt without denial evidence`);
124
+ }
125
+ if (isDenialEvidence(denialText)) {
126
+ return gateVerified(`${label} observed denial text without an exposed ${[...names].join("/")} tool part: ${truncateText(denialText, MAX_STATUS_STRING_CHARS)}`);
127
+ }
128
+ if (noAttemptEvidence) return gateVerified(noAttemptEvidence, "no-attempt-fallback");
129
+ return gateBlocked(`${label} completed without an observable ${[...names].join("/")} tool attempt; deterministic permission evidence is unavailable`);
130
+ }
131
+
132
+ function deterministicAllowedToolResult({ label, toolNames, toolParts, expectedText = "" }) {
133
+ const names = new Set(toolNames.map((name) => name.toLowerCase()));
134
+ const matching = toolParts.filter((part) => names.has(toolPartName(part).toLowerCase()));
135
+ if (matching.some((part) => part?.state?.status === "completed" && (!expectedText || toolPartEvidence(part).includes(expectedText)))) {
136
+ return gateVerified(`${label} observed allowed ${[...names].join("/")} tool completion`);
137
+ }
138
+ if (matching.some(toolPartDenied)) return gateFailed(`${label} was denied; allow rule was not enforced`);
139
+ if (matching.length > 0) return gateFailed(`${label} observed ${matching.map(toolPartName).join(", ")} without completed allowed evidence`);
140
+ return gateBlocked(`${label} completed without an observable ${[...names].join("/")} tool attempt; deterministic allow evidence is unavailable`);
141
+ }
142
+
143
+ function secretReadClassCoverageResult({ toolParts, denialText, sentinelContent }) {
144
+ const required = ["read", "grep", "glob", "list"];
145
+ const missing = [];
146
+ for (const toolName of required) {
147
+ const matching = toolParts.filter((part) => toolPartName(part).toLowerCase() === toolName);
148
+ if (matching.some((part) => valueContainsString(part, sentinelContent))) {
149
+ return gateFailed(`secret-read isolation probe observed sentinel secret content in ${toolName} output; permission denial was not enforced`);
150
+ }
151
+ if (matching.some(toolPartDenied)) continue;
152
+ if (new RegExp(`${toolName}[^\n]*(permission|denied|deny|not allowed|forbidden|unavailable)`, "i").test(denialText)) continue;
153
+ missing.push(toolName);
154
+ }
155
+ const lspMatching = toolParts.filter((part) => toolPartName(part).toLowerCase() === "lsp");
156
+ const lspDenied = lspMatching.some(toolPartDenied) || /lsp[^\n]*(permission|denied|deny|not allowed|forbidden|unavailable|unsupported|not exposed)/i.test(denialText);
157
+ if (missing.length === 0) {
158
+ const lspEvidence = lspDenied ? "; lsp denied or explicitly unsupported" : "; lsp coverage unsupported/not observed";
159
+ return gateVerified(`secret-read isolation probe observed denial coverage for read/grep/glob/list${lspEvidence}`);
160
+ }
161
+ return gateBlocked(`secret-read isolation probe lacked deterministic denial evidence for ${missing.join(", ")}; lsp ${lspDenied ? "denied/unsupported" : "unsupported/not observed"}`);
162
+ }
163
+
164
+ async function sessionMessagesPayloadForProbe(pluginContext, childID, directory) {
165
+ const session = sessionApi(pluginContext);
166
+ if (!session.has("messages")) return [];
167
+ const result = await withLiveProbeTimeout(pluginContext, "permission probe message list", () => session.messages({ sessionID: childID, directory, limit: 20 }));
168
+ return unwrapClientResult(result, "Permission probe message list");
169
+ }
170
+
171
+ async function initScratchGitRepo(directory) {
172
+ const execOptions = { cwd: directory, encoding: "utf8", timeout: DEFAULT_SUBPROCESS_TIMEOUT_MS, maxBuffer: DEFAULT_SUBPROCESS_MAX_BUFFER };
173
+ await execFileAsync("git", ["init"], execOptions);
174
+ await execFileAsync("git", ["config", "user.email", "workflow-probe@example.com"], execOptions);
175
+ await execFileAsync("git", ["config", "user.name", "Workflow Probe"], execOptions);
176
+ await fs.writeFile(path.join(directory, "README.md"), "workflow probe\n", "utf8");
177
+ await execFileAsync("git", ["add", "README.md"], execOptions);
178
+ await execFileAsync("git", ["commit", "-m", "initial"], execOptions);
179
+ }
180
+
181
+ async function removeGitWorktreeForce(root, worktreePath) {
182
+ if (!root || !worktreePath) return;
183
+ try {
184
+ await execFileAsync("git", ["worktree", "remove", "--force", worktreePath], { cwd: root, encoding: "utf8", timeout: DEFAULT_SUBPROCESS_TIMEOUT_MS, maxBuffer: DEFAULT_SUBPROCESS_MAX_BUFFER });
185
+ } catch {
186
+ // Probe cleanup is best effort.
187
+ }
188
+ }
189
+
190
+ function collectTextParts(value, parts = []) {
191
+ if (!value) return parts;
192
+ if (Array.isArray(value)) {
193
+ for (const item of value) collectTextParts(item, parts);
194
+ return parts;
195
+ }
196
+ if (typeof value !== "object") return parts;
197
+ if (value.type === "text" && typeof value.text === "string") parts.push(value.text);
198
+ if (Array.isArray(value.parts)) collectTextParts(value.parts, parts);
199
+ if (Array.isArray(value.content)) collectTextParts(value.content, parts);
200
+ if (value.data) collectTextParts(value.data, parts);
201
+ return parts;
202
+ }
203
+
204
+ function collectToolParts(value, parts = []) {
205
+ if (!value) return parts;
206
+ if (Array.isArray(value)) {
207
+ for (const item of value) collectToolParts(item, parts);
208
+ return parts;
209
+ }
210
+ if (typeof value !== "object") return parts;
211
+ if (value.type === "tool") parts.push(value);
212
+ if (Array.isArray(value.parts)) collectToolParts(value.parts, parts);
213
+ if (Array.isArray(value.content)) collectToolParts(value.content, parts);
214
+ if (value.data) collectToolParts(value.data, parts);
215
+ return parts;
216
+ }
217
+
218
+ function opencodeChildPermissionDenyRules() {
219
+ return OPENCODE_CHILD_PERMISSION_KEYS.map((permission) => ({ permission, pattern: "*", action: "deny" }));
220
+ }
221
+
222
+ // Tiny structured-output round-trip: confirms the runtime actually returns structured
223
+ // data for a `format` request. Promotes only on a real structured response; otherwise
224
+ // leaves the capability unverified so schema lanes stay fail-closed (status quo).
225
+ async function probeStructuredOutput(pluginContext, adapter, options = {}) {
226
+ const session = sessionApi(pluginContext);
227
+ if (!session.has("create") || !session.has("prompt")) return "unavailable";
228
+ let childID;
229
+ try {
230
+ const created = unwrapClientResult(await withLiveProbeTimeout(pluginContext, "structured-output probe session create", () => session.create({
231
+ title: "workflow capability probe",
232
+ directory: pluginContext.directory,
233
+ // Test under the SAME deny-by-default permission rules that actual workflow schema
234
+ // lanes use. Without this, the probe gives a false positive: StructuredOutput is
235
+ // visible in unrestricted sessions but hidden when `*` deny rules are enforced.
236
+ permission: permissionRulesForAuthority({ readOnly: true }),
237
+ })), "probe session create");
238
+ childID = created?.data?.id;
239
+ if (!childID) return "available-unverified";
240
+ const schema = { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"], additionalProperties: false };
241
+ const result = unwrapClientResult(await withLiveProbeTimeout(pluginContext, "structured-output probe prompt", () => session.prompt({
242
+ sessionID: childID,
243
+ directory: pluginContext.directory,
244
+ body: { system: "Reply only with the requested JSON object.", format: structuredFormat(schema), parts: [textPart('Return {"ok": true}')] },
245
+ }), () => abortChild(pluginContext, childID, pluginContext.directory)), "probe prompt");
246
+ const structured = adapter.getStructured(result);
247
+ return structured && typeof structured === "object" ? "available" : "available-unverified";
248
+ } catch (error) {
249
+ if (options.returnError) return { error };
250
+ return "available-unverified";
251
+ } finally {
252
+ await abortChild(pluginContext, childID, pluginContext.directory);
253
+ }
254
+ }
255
+
256
+ // Live create+remove of a throwaway worktree via the (v2) worktree client.
257
+ async function probeWorktree(pluginContext, adapter) {
258
+ try {
259
+ if (!(await adapter.hasWorktreeClient())) return { worktree: "unavailable" };
260
+ const created = await adapter.createWorktree({ name: "workflow-capability-probe", directory: pluginContext.directory });
261
+ const dir = created?.path || created?.directory || created?.dir;
262
+ try { await adapter.removeWorktree({ directory: dir, id: created?.id }); } catch (error) {
263
+ return { worktree: "available-unverified", directoryRooting: "available-unverified", cleanupError: error, directory: dir };
264
+ }
265
+ return { worktree: "available", directoryRooting: "available" };
266
+ } catch {
267
+ return { worktree: "available-unverified" };
268
+ }
269
+ }
270
+
271
+ async function probeDeniedBash(pluginContext, context) {
272
+ const session = sessionApi(pluginContext);
273
+ if (!session.has("create") || !session.has("prompt")) {
274
+ return gateBlocked("session.create/session.prompt are unavailable for a live blocked-bash prompt-path probe");
275
+ }
276
+
277
+ const directory = context.directory || context.worktree;
278
+ let childID;
279
+ try {
280
+ const permission = [
281
+ { permission: "*", pattern: "*", action: "deny" },
282
+ { permission: "read", pattern: "*", action: "allow" },
283
+ { permission: "bash", pattern: "*", action: "deny" },
284
+ ...opencodeChildPermissionDenyRules(),
285
+ ];
286
+ const created = unwrapClientResult(await withLiveProbeTimeout(pluginContext, "blocked-bash probe session create", () => session.create({
287
+ parentID: context.sessionID,
288
+ title: "workflow live blocked-bash probe",
289
+ permission,
290
+ directory,
291
+ })), "Blocked-bash live probe session creation");
292
+ childID = created?.data?.id;
293
+ if (!childID) throw new WorkflowProbeStructuralError("OpenCode returned no child session id for the blocked-bash probe");
294
+ if (!createdSessionRetainedPermission(created, permission)) {
295
+ return gateBlocked("blocked-bash live probe could not verify that child session retained permission rules; active session API may not support per-session permissions");
296
+ }
297
+
298
+ const result = unwrapClientResult(await withLiveProbeTimeout(pluginContext, "blocked-bash probe prompt", () => session.prompt({
299
+ sessionID: childID,
300
+ directory,
301
+ body: {
302
+ parts: [textPart("Use the bash tool to run exactly `pwd`. Do not answer from memory; report the tool result or rejection briefly.")],
303
+ },
304
+ }), () => abortChild(pluginContext, childID, directory)), "Blocked-bash live probe prompt");
305
+ const messagePayload = await sessionMessagesPayloadForProbe(pluginContext, childID, directory);
306
+ const toolParts = [...collectToolParts(result), ...collectToolParts(messagePayload)];
307
+ const denialText = [...collectTextParts(result), ...collectTextParts(messagePayload)].join("\n");
308
+ return deterministicToolProbeResult({
309
+ label: "blocked-bash live probe",
310
+ toolNames: ["bash", "oc_shell"],
311
+ directAllowed: false,
312
+ toolParts,
313
+ denialText,
314
+ noAttemptEvidence: "blocked-bash live probe verified retained bash block rules and observed no successful bash tool part; blocked tool appears hidden/unavailable",
315
+ });
316
+ } catch (error) {
317
+ return denialProbeResult(error, "blocked-bash live probe");
318
+ } finally {
319
+ if (childID && session.has("abort")) {
320
+ try {
321
+ await withLiveProbeTimeout(pluginContext, "blocked-bash probe abort", () => session.abort({ sessionID: childID, directory }));
322
+ } catch {
323
+ // Probe cleanup is best effort.
324
+ }
325
+ }
326
+ }
327
+ }
328
+
329
+ async function probeCommandScopedBash(pluginContext, context) {
330
+ const session = sessionApi(pluginContext);
331
+ if (!session.has("create") || !session.has("prompt")) {
332
+ return gateBlocked("session.create/session.prompt are unavailable for a command-scoped bash prompt-path probe");
333
+ }
334
+
335
+ const directory = context.directory || context.worktree;
336
+ let childID;
337
+ try {
338
+ const permission = [
339
+ { permission: "*", pattern: "*", action: "deny" },
340
+ { permission: "read", pattern: "*", action: "allow" },
341
+ { permission: "bash", pattern: "*", action: "allow" },
342
+ { permission: "bash", pattern: "pwd", action: "deny" },
343
+ ...opencodeChildPermissionDenyRules(),
344
+ ];
345
+ const created = unwrapClientResult(await withLiveProbeTimeout(pluginContext, "command-scoped bash probe session create", () => session.create({
346
+ parentID: context.sessionID,
347
+ title: "workflow command-scoped bash probe",
348
+ permission,
349
+ directory,
350
+ })), "Command-scoped bash probe session creation");
351
+ childID = created?.data?.id;
352
+ if (!childID) throw new WorkflowProbeStructuralError("OpenCode returned no child session id for the command-scoped bash probe");
353
+ if (!createdSessionRetainedPermission(created, permission)) {
354
+ return gateBlocked("command-scoped bash probe could not verify that child session retained permission rules; active session API may not support per-session permissions");
355
+ }
356
+
357
+ const allowSentinel = "workflow-bash-allow-probe";
358
+ const allowedResult = unwrapClientResult(await withLiveProbeTimeout(pluginContext, "command-scoped bash allow probe prompt", () => session.prompt({
359
+ sessionID: childID,
360
+ directory,
361
+ body: {
362
+ parts: [textPart(`Use the bash tool to run exactly \`printf ${allowSentinel}\`. Do not answer from memory; report the tool result briefly.`)],
363
+ },
364
+ }), () => abortChild(pluginContext, childID, directory)), "Command-scoped bash allow probe prompt");
365
+ const allowedMessages = await sessionMessagesPayloadForProbe(pluginContext, childID, directory);
366
+ const allowedToolParts = [...collectToolParts(allowedResult), ...collectToolParts(allowedMessages)];
367
+ const allowedGate = deterministicAllowedToolResult({ label: "command-scoped bash allow probe", toolNames: ["bash", "oc_shell"], toolParts: allowedToolParts, expectedText: allowSentinel });
368
+ if (allowedGate.verified !== true) return allowedGate;
369
+
370
+ const result = unwrapClientResult(await withLiveProbeTimeout(pluginContext, "command-scoped bash deny probe prompt", () => session.prompt({
371
+ sessionID: childID,
372
+ directory,
373
+ body: {
374
+ parts: [textPart("Use the bash tool to run exactly `pwd`. Do not answer from memory; report the tool result or denial briefly.")],
375
+ },
376
+ }), () => abortChild(pluginContext, childID, directory)), "Command-scoped bash deny probe prompt");
377
+ const messagePayload = await sessionMessagesPayloadForProbe(pluginContext, childID, directory);
378
+ const toolParts = [...collectToolParts(result), ...collectToolParts(messagePayload)];
379
+ const denialText = [...collectTextParts(result), ...collectTextParts(messagePayload)].join("\n");
380
+ const deniedGate = deterministicToolProbeResult({ label: "command-scoped bash deny probe", toolNames: ["bash", "oc_shell"], directAllowed: false, toolParts, denialText });
381
+ if (deniedGate.verified !== true) return deniedGate;
382
+ return gateVerified("command-scoped bash probe observed one allowed command and one denied command");
383
+ } catch (error) {
384
+ return denialProbeResult(error, "command-scoped bash probe");
385
+ } finally {
386
+ if (childID && session.has("abort")) {
387
+ try { await withLiveProbeTimeout(pluginContext, "command-scoped bash probe abort", () => session.abort({ sessionID: childID, directory })); } catch {
388
+ // Probe cleanup is best effort.
389
+ }
390
+ }
391
+ }
392
+ }
393
+
394
+ async function probeMcpAccessGate(pluginContext, context) {
395
+ const session = sessionApi(pluginContext);
396
+ if (!session.has("create") || !session.has("prompt")) {
397
+ return gateBlocked("session.create/session.prompt are unavailable for an MCP access prompt-path probe");
398
+ }
399
+
400
+ const directory = context.directory || context.worktree;
401
+ const allowPattern = "workflow-mcp-allow-probe";
402
+ const denyPattern = "workflow-mcp-deny-probe";
403
+ const allowToolNames = ["mcp", "mcp__workflow_mcp_allow_probe", "mcp__workflow-mcp-allow-probe", allowPattern];
404
+ const denyToolNames = ["mcp", "mcp__workflow_mcp_deny_probe", "mcp__workflow-mcp-deny-probe", denyPattern];
405
+ let childID;
406
+ let phase = "setup";
407
+ try {
408
+ const permission = [
409
+ { permission: "*", pattern: "*", action: "deny" },
410
+ { permission: "read", pattern: "*", action: "allow" },
411
+ { permission: "mcp", pattern: allowPattern, action: "allow" },
412
+ { permission: "mcp", pattern: `mcp__${allowPattern.replaceAll("-", "_")}`, action: "allow" },
413
+ { permission: "mcp", pattern: denyPattern, action: "deny" },
414
+ { permission: "mcp", pattern: `mcp__${denyPattern.replaceAll("-", "_")}`, action: "deny" },
415
+ ...opencodeChildPermissionDenyRules(),
416
+ ];
417
+ const created = unwrapClientResult(await withLiveProbeTimeout(pluginContext, "MCP access probe session create", () => session.create({
418
+ parentID: context.sessionID,
419
+ title: "workflow MCP access probe",
420
+ permission,
421
+ directory,
422
+ })), "MCP access probe session creation");
423
+ childID = created?.data?.id;
424
+ if (!childID) throw new WorkflowProbeStructuralError("OpenCode returned no child session id for the MCP access probe");
425
+ if (!createdSessionRetainedPermission(created, permission)) {
426
+ return gateBlocked("MCP access probe could not verify that child session retained permission rules; active session API may not support per-session permissions");
427
+ }
428
+
429
+ phase = "allow";
430
+ const allowedResult = unwrapClientResult(await withLiveProbeTimeout(pluginContext, "MCP access allow probe prompt", () => session.prompt({
431
+ sessionID: childID,
432
+ directory,
433
+ body: {
434
+ parts: [textPart(`Use an MCP tool from the ${allowPattern} probe server/tool, then report the exact tool result text ${allowPattern}. Do not answer from memory; if the tool is unavailable, report the tool rejection briefly.`)],
435
+ },
436
+ }), () => abortChild(pluginContext, childID, directory)), "MCP access allow probe prompt");
437
+ const allowedMessages = await sessionMessagesPayloadForProbe(pluginContext, childID, directory);
438
+ const allowedToolParts = [...collectToolParts(allowedResult), ...collectToolParts(allowedMessages)];
439
+ const allowedGate = deterministicAllowedToolResult({
440
+ label: "MCP access allow probe",
441
+ toolNames: allowToolNames,
442
+ toolParts: allowedToolParts,
443
+ expectedText: allowPattern,
444
+ });
445
+ if (allowedGate.verified !== true) return allowedGate;
446
+
447
+ phase = "deny";
448
+ const deniedResult = unwrapClientResult(await withLiveProbeTimeout(pluginContext, "MCP access deny probe prompt", () => session.prompt({
449
+ sessionID: childID,
450
+ directory,
451
+ body: {
452
+ parts: [textPart(`Use an MCP tool from the ${denyPattern} probe server/tool. Do not answer from memory; report the tool result or denial briefly.`)],
453
+ },
454
+ }), () => abortChild(pluginContext, childID, directory)), "MCP access deny probe prompt");
455
+ const deniedMessages = await sessionMessagesPayloadForProbe(pluginContext, childID, directory);
456
+ const deniedToolParts = [...collectToolParts(deniedResult), ...collectToolParts(deniedMessages)];
457
+ const denialText = [...collectTextParts(deniedResult), ...collectTextParts(deniedMessages)].join("\n");
458
+ const deniedGate = deterministicToolProbeResult({
459
+ label: "MCP access deny probe",
460
+ toolNames: denyToolNames,
461
+ directAllowed: false,
462
+ toolParts: deniedToolParts,
463
+ denialText,
464
+ });
465
+ if (deniedGate.verified !== true) return deniedGate;
466
+ return gateVerified("MCP access probe observed one allowed MCP tool completion and one denied MCP tool attempt");
467
+ } catch (error) {
468
+ if (phase === "deny") return denialProbeResult(error, "MCP access probe");
469
+ const transport = transportFailureGate(error, "MCP access probe");
470
+ if (transport) return transport;
471
+ return gateFailed(`MCP access probe failed before denied tool verification: ${truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS)}`);
472
+ } finally {
473
+ if (childID && session.has("abort")) {
474
+ try { await withLiveProbeTimeout(pluginContext, "MCP access probe abort", () => session.abort({ sessionID: childID, directory })); } catch {
475
+ // Probe cleanup is best effort.
476
+ }
477
+ }
478
+ }
479
+ }
480
+
481
+ async function probeSecretReadDeny(pluginContext, context) {
482
+ const session = sessionApi(pluginContext);
483
+ if (!session.has("create") || !session.has("prompt")) {
484
+ return gateBlocked("session.create/session.prompt are unavailable for a secret-read isolation probe");
485
+ }
486
+
487
+ const directory = context.directory || context.worktree;
488
+ const sentinelPath = path.join(directory, "__workflow_secret_read_probe__");
489
+ const sentinelName = path.basename(sentinelPath);
490
+ const sentinelContent = `workflow-secret-probe-${crypto.randomUUID()}`;
491
+ let childID;
492
+ let wroteSentinel = false;
493
+ try {
494
+ await fs.writeFile(sentinelPath, `${sentinelContent}\n`, { encoding: "utf8", flag: "wx" });
495
+ wroteSentinel = true;
496
+ const permission = [
497
+ { permission: "*", pattern: "*", action: "deny" },
498
+ ...["read", "grep", "glob", "list", "lsp"].flatMap((permissionName) => [
499
+ { permission: permissionName, pattern: "*", action: "allow" },
500
+ { permission: permissionName, pattern: sentinelPath, action: "deny" },
501
+ { permission: permissionName, pattern: sentinelName, action: "deny" },
502
+ { permission: permissionName, pattern: `**/${sentinelName}`, action: "deny" },
503
+ ]),
504
+ ];
505
+ const created = unwrapClientResult(await withLiveProbeTimeout(pluginContext, "secret-read isolation probe session create", () => session.create({
506
+ parentID: context.sessionID,
507
+ title: "workflow secret-read isolation probe",
508
+ permission,
509
+ directory,
510
+ })), "Secret-read isolation probe session creation");
511
+ childID = created?.data?.id;
512
+ if (!childID) throw new WorkflowProbeStructuralError("OpenCode returned no child session id for the secret-read isolation probe");
513
+ if (!createdSessionRetainedPermission(created, permission)) {
514
+ return gateBlocked("secret-read isolation probe could not verify that child session retained permission rules; active session API may not support per-session permissions");
515
+ }
516
+
517
+ const result = unwrapClientResult(await withLiveProbeTimeout(pluginContext, "secret-read isolation probe prompt", () => session.prompt({
518
+ sessionID: childID,
519
+ directory,
520
+ body: { parts: [textPart(`Attempt secret-denial checks against ${sentinelPath}: use read, grep, glob, and list on the sentinel path/name, and use lsp if that tool is exposed. Report each tool as denied or unavailable; do not include file contents if any access succeeds.`)] },
521
+ }), () => abortChild(pluginContext, childID, directory)), "Secret-read isolation probe prompt");
522
+ const messagePayload = await sessionMessagesPayloadForProbe(pluginContext, childID, directory);
523
+ if (valueContainsString(result, sentinelContent) || valueContainsString(messagePayload, sentinelContent)) {
524
+ return gateFailed("secret-read isolation probe observed sentinel secret content in prompt-path output; permission denial was not enforced");
525
+ }
526
+ const toolParts = [...collectToolParts(result), ...collectToolParts(messagePayload)];
527
+ const denialText = [...collectTextParts(result), ...collectTextParts(messagePayload)].join("\n");
528
+ return secretReadClassCoverageResult({ toolParts, denialText, sentinelContent });
529
+ } catch (error) {
530
+ if (valueContainsString(error, sentinelContent) || extractTextFromError(error).includes(sentinelContent)) {
531
+ return gateFailed("secret-read isolation probe error exposed sentinel secret content; permission denial was not enforced");
532
+ }
533
+ return denialProbeResult(error, "secret-read isolation probe");
534
+ } finally {
535
+ if (childID && session.has("abort")) {
536
+ try { await withLiveProbeTimeout(pluginContext, "secret-read isolation probe abort", () => session.abort({ sessionID: childID, directory })); } catch {
537
+ // Probe cleanup is best effort.
538
+ }
539
+ }
540
+ try { if (wroteSentinel) await fs.rm(sentinelPath, { force: true }); } catch {
541
+ // Probe sentinel cleanup is best effort.
542
+ }
543
+ }
544
+ }
545
+
546
+ async function probeStructuredOutputGate(pluginContext, adapter) {
547
+ const result = await probeStructuredOutput(pluginContext, adapter, { returnError: true });
548
+ if (result?.error) return gateFailed(`structured-output probe failed: ${truncateText(extractTextFromError(result.error), MAX_STATUS_STRING_CHARS)}`);
549
+ if (result === "available") return gateVerified("structured-output live probe returned schema-shaped data");
550
+ if (result === "unavailable") return gateBlocked("session.prompt is unavailable for structured-output probe");
551
+ return gateFailed("structured-output probe ran but did not return structured data");
552
+ }
553
+
554
+ async function probeWorktreeGate(pluginContext, adapter) {
555
+ const result = await probeWorktree(pluginContext, adapter);
556
+ if (result?.worktree === "available") return gateVerified("worktree create/remove live probe completed");
557
+ if (result?.worktree === "unavailable") return gateBlocked("worktree create/remove API is unavailable");
558
+ if (result?.cleanupError) {
559
+ const where = result.directory ? ` at ${truncateText(result.directory, MAX_STATUS_STRING_CHARS)}` : "";
560
+ return gateFailed(`worktree create/remove probe created a worktree${where}, but cleanup failed: ${truncateText(extractTextFromError(result.cleanupError), MAX_STATUS_STRING_CHARS)}`);
561
+ }
562
+ return gateFailed("worktree create/remove probe did not produce verified evidence");
563
+ }
564
+
565
+ async function probeDirectoryRootingGate(pluginContext, context) {
566
+ const session = sessionApi(pluginContext);
567
+ if (!session.has("create") || !session.has("prompt")) {
568
+ return gateBlocked("session.create/session.prompt are unavailable for a directory-rooting probe");
569
+ }
570
+ const directory = context.directory || context.worktree;
571
+ if (!directory) return gateBlocked("directory-rooting probe requires a target directory in context");
572
+ const sentinelName = `__workflow_dir_root_probe__${crypto.randomUUID()}`;
573
+ const sentinelPath = path.join(directory, sentinelName);
574
+ const sentinelContent = `workflow-dir-root-probe:${crypto.randomUUID()}`;
575
+ let childID;
576
+ let wroteSentinel = false;
577
+ try {
578
+ await fs.writeFile(sentinelPath, sentinelContent, { encoding: "utf8", flag: "wx" });
579
+ wroteSentinel = true;
580
+ const created = unwrapClientResult(await withLiveProbeTimeout(pluginContext, "directory-rooting probe session create", () => session.create({ parentID: context.sessionID, title: "workflow directory-rooting probe", directory })), "Directory-rooting probe session creation");
581
+ childID = created?.data?.id;
582
+ if (!childID) throw new Error("OpenCode did not return a child session id for directory-rooting probe");
583
+ const result = await withLiveProbeTimeout(pluginContext, "directory-rooting probe prompt", () => session.prompt({ sessionID: childID, directory, body: { parts: [textPart(`Use the read tool to read the relative path \`${sentinelName}\` and reply with its exact content.`)] } }), () => abortChild(pluginContext, childID, directory));
584
+ const messagePayload = await sessionMessagesPayloadForProbe(pluginContext, childID, directory);
585
+ const toolParts = [...collectToolParts(result), ...collectToolParts(messagePayload)].filter((part) => toolPartName(part).toLowerCase() === "read");
586
+ const observed = toolParts.some((part) => {
587
+ if (part?.state?.status !== "completed") return false;
588
+ const inputPath = String(part?.state?.input?.filePath ?? part?.state?.input?.path ?? "");
589
+ const content = String(part?.state?.content ?? "");
590
+ return (inputPath === sentinelName || inputPath === sentinelPath || inputPath === `./${sentinelName}`)
591
+ && content.includes(sentinelContent);
592
+ });
593
+ if (observed) {
594
+ return gateVerified(`directory-rooting probe observed completed read of sentinel ${sentinelName} returning unique content under ${directory}`);
595
+ }
596
+ // R31 (opencode-workflows-8w8): model-reported cwd text is NOT verification.
597
+ // A child can echo the target directory in plain text without ever rooting there
598
+ // (e.g. it parrots the path from the prompt). Only the deterministic sentinel read
599
+ // above — a completed `read` tool part returning unique on-disk content under the
600
+ // expected directory — proves the child session is actually rooted. Text-only echo
601
+ // is therefore reported as available-unverified (verified=false), which maps to the
602
+ // "available-unverified" capability and does NOT satisfy the required directoryRooting
603
+ // authority gate. Previously this returned gateVerified(..., "model-text-only"), which
604
+ // fail-open verified the required gate on a model echo alone.
605
+ const text = [...collectTextParts(result), ...collectTextParts(messagePayload)].join("\n").trim();
606
+ if (text.includes(directory)) {
607
+ return gateAvailableUnverified(
608
+ `directory-rooting probe observed only model-reported cwd text matching ${directory}; no deterministic read tool evidence under the expected directory, so rooting is unverified`,
609
+ );
610
+ }
611
+ return gateFailed(`directory-rooting probe observed neither a completed sentinel read nor matching cwd text; response=${truncateText(text || JSON.stringify(result?.data ?? {}), MAX_STATUS_STRING_CHARS)}`);
612
+ } catch (error) {
613
+ return gateFailed(`directory-rooting probe failed: ${truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS)}`);
614
+ } finally {
615
+ if (childID && session.has("abort")) {
616
+ try { await withLiveProbeTimeout(pluginContext, "directory-rooting probe abort", () => session.abort({ sessionID: childID, directory })); } catch {
617
+ // Probe cleanup is best effort.
618
+ }
619
+ }
620
+ if (wroteSentinel) {
621
+ try { await fs.rm(sentinelPath, { force: true }); } catch {
622
+ // Probe cleanup is best effort.
623
+ }
624
+ }
625
+ }
626
+ }
627
+
628
+ async function probeWorktreeEditIsolationGate(pluginContext, context, adapter) {
629
+ try {
630
+ if (!(await adapter.hasWorktreeClient())) return gateBlocked("worktree API is unavailable for edit-isolation probe");
631
+ const primary = path.resolve(context.worktree || context.directory);
632
+ const created = await adapter.createWorktree({ name: "workflow-edit-isolation-probe", directory: primary });
633
+ const rawPath = created?.path || created?.directory || created?.dir;
634
+ // Validate the RAW extracted path before resolving. resolve('') falls back to the
635
+ // process cwd (truthy and typically != primary), which would falsely verify isolation
636
+ // when the worktree API omits all path fields. Mirror normalizeCreatedWorktree, which
637
+ // only resolves once it has a concrete path to resolve.
638
+ if (!rawPath) {
639
+ try { await adapter.removeWorktree({ id: created?.id }); } catch {
640
+ // Probe cleanup is best effort.
641
+ }
642
+ return gateFailed("worktree edit-isolation probe did not produce a worktree path");
643
+ }
644
+ const dir = path.resolve(rawPath);
645
+ try { await adapter.removeWorktree({ directory: dir, id: created?.id }); } catch {
646
+ // Probe cleanup is best effort.
647
+ }
648
+ if (dir !== primary) return gateVerified(`worktree edit-isolation probe created distinct worktree ${dir}`);
649
+ return gateFailed("worktree edit-isolation probe did not produce a distinct worktree path");
650
+ } catch (error) {
651
+ return gateFailed(`worktree edit-isolation probe failed: ${truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS)}`);
652
+ }
653
+ }
654
+
655
+ async function probeIntegrationWorktreeIsolationGate(pluginContext, context, options = {}) {
656
+ const session = sessionApi(pluginContext);
657
+ if (!session.has("create") || !session.has("prompt")) {
658
+ return gateBlocked("session.create/session.prompt are unavailable for an integration-worktree rooting probe");
659
+ }
660
+
661
+ const tempRoot = options.primaryDirectory ? undefined : await fs.mkdtemp(path.join("/tmp", "workflow-integration-worktree-probe-"));
662
+ const primary = path.resolve(options.primaryDirectory || path.join(tempRoot, "primary"));
663
+ const worktreeRoot = path.resolve(options.worktreeRoot || path.join(tempRoot || path.dirname(primary), "worktrees"));
664
+ let childID;
665
+ let cleanWorktree;
666
+ let dirtyWorktree;
667
+ let adapter;
668
+ let rootingSentinelPath;
669
+ try {
670
+ if (!options.primaryDirectory) {
671
+ await fs.mkdir(primary, { recursive: true });
672
+ await initScratchGitRepo(primary);
673
+ }
674
+ const baseCommit = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: primary, encoding: "utf8", timeout: DEFAULT_SUBPROCESS_TIMEOUT_MS, maxBuffer: DEFAULT_SUBPROCESS_MAX_BUFFER })).stdout.trim();
675
+ adapter = await createWorktreeAdapter({ directory: primary, worktreeRoot });
676
+
677
+ cleanWorktree = await adapter.createIntegrationWorktree({ runId: "live-gate-probe-clean", branch: "workflow/live-gate-probe/clean" });
678
+ const cleanPath = path.resolve(cleanWorktree.path);
679
+ if (cleanPath === primary) return gateFailed("integration-worktree probe created the primary worktree path");
680
+
681
+ const created = unwrapClientResult(await withLiveProbeTimeout(pluginContext, "integration-worktree probe session create", () => session.create({ parentID: context.sessionID, title: "workflow integration-worktree rooting probe", directory: cleanPath })), "Integration-worktree rooting probe session creation");
682
+ childID = created?.data?.id;
683
+ if (!childID) throw new Error("OpenCode did not return a child session id for integration-worktree rooting probe");
684
+ // Tool-observed child-rooting proof (mirrors probeDirectoryRootingGate / R31). The
685
+ // integration gate's prior check asked the child to "reply with the current working
686
+ // directory" and accepted a model-text echo of cleanPath. A child can parrot cleanPath
687
+ // (it is passed to session.create/prompt) without ever rooting there, so model text is
688
+ // not verification. Instead write a unique-content sentinel under cleanPath and require a
689
+ // completed `read` tool part returning that content; a text-only echo downgrades to
690
+ // available-unverified (verified=false), matching the directoryRooting gate's discipline.
691
+ const rootingSentinelName = `__workflow_int_root_probe__${crypto.randomUUID()}`;
692
+ rootingSentinelPath = path.join(cleanPath, rootingSentinelName);
693
+ const rootingSentinelContent = `workflow-int-root-probe:${crypto.randomUUID()}`;
694
+ await fs.writeFile(rootingSentinelPath, rootingSentinelContent, { encoding: "utf8", flag: "wx" });
695
+ const rooted = await withLiveProbeTimeout(pluginContext, "integration-worktree rooting probe prompt", () => session.prompt({
696
+ sessionID: childID,
697
+ directory: cleanPath,
698
+ body: { parts: [textPart(`Use the read tool to read the relative path \`${rootingSentinelName}\` and reply with its exact content.`)] },
699
+ }), () => abortChild(pluginContext, childID, cleanPath));
700
+ const rootedMessages = await sessionMessagesPayloadForProbe(pluginContext, childID, cleanPath);
701
+ const rootedToolParts = [...collectToolParts(rooted), ...collectToolParts(rootedMessages)].filter((part) => toolPartName(part).toLowerCase() === "read");
702
+ const rootedObserved = rootedToolParts.some((part) => {
703
+ if (part?.state?.status !== "completed") return false;
704
+ const inputPath = String(part?.state?.input?.filePath ?? part?.state?.input?.path ?? "");
705
+ const content = String(part?.state?.content ?? "");
706
+ return (inputPath === rootingSentinelName || inputPath === rootingSentinelPath || inputPath === `./${rootingSentinelName}`)
707
+ && content.includes(rootingSentinelContent);
708
+ });
709
+ // Remove the rooting sentinel before the Git isolation checks below: an untracked file
710
+ // in cleanPath would make the worktree "dirty" and defeat the clean-removal proof.
711
+ try { await fs.rm(rootingSentinelPath, { force: true }); } catch {
712
+ // Probe sentinel cleanup is best effort.
713
+ }
714
+ rootingSentinelPath = undefined;
715
+ if (!rootedObserved) {
716
+ const rootedText = [...collectTextParts(rooted), ...collectTextParts(rootedMessages)].join("\n").trim();
717
+ if (rootedText.includes(cleanPath)) {
718
+ return gateAvailableUnverified(
719
+ `integration-worktree probe observed only model-reported cwd text matching ${cleanPath}; no deterministic read tool evidence under the integration worktree, so child rooting is unverified`,
720
+ );
721
+ }
722
+ return gateFailed(`integration-worktree probe observed neither a completed sentinel read nor matching cwd text; response=${truncateText(rootedText || JSON.stringify(rooted?.data ?? {}), MAX_STATUS_STRING_CHARS)}`);
723
+ }
724
+
725
+ const sentinel = "integration-only.txt";
726
+ await fs.writeFile(path.join(cleanPath, sentinel), "integration worktree only\n", "utf8");
727
+ const committed = await adapter.commit({ directory: cleanPath, message: "integration probe change" });
728
+ if (committed.committed !== true) return gateFailed("integration-worktree probe could not commit sentinel change");
729
+ const changes = await changedPathsSinceBase(cleanPath, baseCommit);
730
+ if (!changes.some((change) => change.path === sentinel)) return gateFailed("integration-worktree probe did not detect sentinel as an integration changed path");
731
+ if (await pathExists(path.join(primary, sentinel))) return gateFailed("integration-worktree probe sentinel appeared in the primary worktree");
732
+
733
+ const cleanRemoval = await adapter.remove(cleanWorktree);
734
+ if (cleanRemoval.removed !== true) return gateFailed(`integration-worktree clean cleanup did not remove worktree: ${cleanRemoval.reason || "unknown"}`);
735
+ cleanWorktree = undefined;
736
+
737
+ dirtyWorktree = await adapter.createIntegrationWorktree({ runId: "live-gate-probe-dirty", branch: "workflow/live-gate-probe/dirty" });
738
+ await fs.writeFile(path.join(dirtyWorktree.path, "dirty.txt"), "preserve dirty worktree\n", "utf8");
739
+ const dirtyRemoval = await adapter.remove(dirtyWorktree);
740
+ if (dirtyRemoval.preserved !== true || dirtyRemoval.reason !== "dirty") {
741
+ return gateFailed(`integration-worktree dirty cleanup was not preserved: ${dirtyRemoval.reason || "unknown"}`);
742
+ }
743
+
744
+ return gateVerified("local Git integration-worktree probe created a scratch worktree, rooted a child session there, isolated changed paths, removed clean worktrees, and preserved dirty worktrees");
745
+ } catch (error) {
746
+ const evidence = truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS);
747
+ if (/rev-parse --verify HEAD|rev-parse HEAD|requires a Git repository|not a git repository/i.test(evidence)) {
748
+ return gateBlocked(`local Git integration-worktree probe requires a Git repository with HEAD: ${evidence}`);
749
+ }
750
+ return gateFailed(`integration-worktree probe failed: ${evidence}`);
751
+ } finally {
752
+ if (childID && session.has("abort")) {
753
+ try { await withLiveProbeTimeout(pluginContext, "integration-worktree probe abort", () => session.abort({ sessionID: childID, directory: cleanWorktree?.path || dirtyWorktree?.path || primary })); } catch {
754
+ // Probe cleanup is best effort.
755
+ }
756
+ }
757
+ if (rootingSentinelPath) {
758
+ try { await fs.rm(rootingSentinelPath, { force: true }); } catch {
759
+ // Probe sentinel cleanup is best effort.
760
+ }
761
+ }
762
+ await removeGitWorktreeForce(adapter?.root || primary, cleanWorktree?.path);
763
+ await removeGitWorktreeForce(adapter?.root || primary, dirtyWorktree?.path);
764
+ if (tempRoot) {
765
+ try { await fs.rm(tempRoot, { recursive: true, force: true }); } catch {
766
+ // Probe cleanup is best effort.
767
+ }
768
+ }
769
+ }
770
+ }
771
+
772
+ async function probeBackgroundContinuationGate() {
773
+ await immediatePromise();
774
+ return gateVerified(
775
+ "background continuation smoke probe observed in-process smoke only; restart survival not implied (probe yields the event loop and does not exercise the OpenCode background subsystem)",
776
+ "in-process-smoke",
777
+ );
778
+ }
779
+
780
+ async function probeConcurrencyCapacityGate(pluginContext, context, options = {}) {
781
+ const session = sessionApi(pluginContext);
782
+ if (!session.has("create") || !session.has("prompt")) {
783
+ return gateBlocked("session.create/session.prompt are unavailable for a concurrency-capacity probe");
784
+ }
785
+ const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : DEFAULT_CONCURRENCY_PROBE_LIMIT;
786
+ const directory = context.directory || context.worktree || pluginContext.directory;
787
+ if (!directory) return gateBlocked("concurrency-capacity probe requires a target directory in context");
788
+
789
+ const childIDs = [];
790
+ const startedAt = Date.now();
791
+ try {
792
+ const created = await Promise.all(Array.from({ length: limit }, async (_item, index) => {
793
+ const result = unwrapClientResult(await withLiveProbeTimeout(pluginContext, `concurrency-capacity probe session create ${index + 1}/${limit}`, () => session.create({
794
+ parentID: context.sessionID,
795
+ title: `workflow concurrency-capacity probe ${index + 1}/${limit}`,
796
+ directory,
797
+ })), `Concurrency-capacity probe session creation ${index + 1}/${limit}`);
798
+ const childID = result?.data?.id;
799
+ if (!childID) throw new WorkflowProbeStructuralError(`OpenCode returned no child session id for concurrency-capacity probe ${index + 1}/${limit}`);
800
+ return childID;
801
+ }));
802
+ childIDs.push(...created);
803
+
804
+ await withLiveProbeTimeout(
805
+ pluginContext,
806
+ `concurrency-capacity probe ${limit} concurrent prompts`,
807
+ () => Promise.all(childIDs.map((childID, index) => {
808
+ const sentinel = `workflow-concurrency-probe-${index + 1}-of-${limit}`;
809
+ return session.prompt({
810
+ sessionID: childID,
811
+ directory,
812
+ body: { parts: [textPart(`Reply with exactly ${sentinel}. Do not call tools.`)] },
813
+ }).then((result) => unwrapClientResult(result, `Concurrency-capacity probe prompt ${index + 1}/${limit}`));
814
+ })),
815
+ () => Promise.allSettled(childIDs.map((childID) => abortChild(pluginContext, childID, directory))),
816
+ );
817
+
818
+ return gateVerified(`concurrency-capacity live probe completed ${limit}/${limit} concurrent session.prompt calls in ${Date.now() - startedAt}ms`);
819
+ } catch (error) {
820
+ const transport = transportFailureGate(error, "concurrency-capacity probe");
821
+ if (transport) return transport;
822
+ return gateFailed(`concurrency-capacity probe failed at ${limit} concurrent prompts: ${truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS)}`);
823
+ } finally {
824
+ await Promise.allSettled(childIDs.map((childID) => abortChild(pluginContext, childID, directory)));
825
+ }
826
+ }
827
+
828
+ async function probeCancellationGate(pluginContext, context) {
829
+ const session = sessionApi(pluginContext);
830
+ if (!session.has("create") || !session.has("abort")) {
831
+ return gateBlocked("session.create/session.abort are unavailable for a cancellation probe");
832
+ }
833
+ const directory = context.directory || context.worktree;
834
+ let childID;
835
+ try {
836
+ const created = unwrapClientResult(await withLiveProbeTimeout(pluginContext, "cancellation probe session create", () => session.create({ parentID: context.sessionID, title: "workflow cancellation probe", directory })), "Cancellation probe session creation");
837
+ childID = created?.data?.id;
838
+ if (!childID) throw new Error("OpenCode did not return a child session id for cancellation probe");
839
+ await withLiveProbeTimeout(pluginContext, "cancellation probe abort", () => session.abort({ sessionID: childID, directory }));
840
+ childID = undefined;
841
+ return gateVerified("session.abort live probe completed for a child session");
842
+ } catch (error) {
843
+ return gateFailed(`cancellation probe failed: ${truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS)}`);
844
+ } finally {
845
+ await abortChild(pluginContext, childID, directory);
846
+ }
847
+ }
848
+
849
+ async function probeWorkflowNotificationGate(pluginContext, context) {
850
+ if (!sessionApi(pluginContext).has("promptAsync")) return gateBlocked("session.promptAsync is unavailable for workflow notification delivery");
851
+ const dir = await fs.mkdtemp(path.join("/tmp", "workflow-notification-probe-"));
852
+ const notificationPath = path.join(dir, "notification.json");
853
+ try {
854
+ await writeJsonAtomic(notificationPath, {
855
+ stateVersion: NOTIFICATION_STATE_VERSION,
856
+ runId: "workflow-notification-probe",
857
+ status: "completed",
858
+ sessionID: context.sessionID,
859
+ directory: context.directory || context.worktree,
860
+ agent: context.agent || "build",
861
+ resultPath: path.join(dir, "result.json"),
862
+ sentAt: null,
863
+ delivery: { attempts: 0, lastAttemptAt: null, lastError: null },
864
+ notificationPath,
865
+ });
866
+ pendingNotificationPaths.add(notificationPath);
867
+ const result = await deliverWorkflowNotifications(pluginContext, { type: "session.idle", properties: { sessionID: context.sessionID } });
868
+ const record = await readJsonFile(notificationPath, {});
869
+ if (result.delivered === 1 && record.sentAt) return gateVerified("workflow completion notification probe delivered one promptAsync continuation after session.idle");
870
+ return gateFailed(`workflow notification probe did not deliver: ${JSON.stringify(result)}`);
871
+ } catch (error) {
872
+ return gateFailed(`workflow notification probe failed: ${truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS)}`);
873
+ } finally {
874
+ pendingNotificationPaths.delete(notificationPath);
875
+ try { await fs.rm(dir, { recursive: true, force: true }); } catch {
876
+ // Probe cleanup is best effort.
877
+ }
878
+ }
879
+ }
880
+
881
+ export {
882
+ liveProbeTimeoutMs,
883
+ withLiveProbeTimeout,
884
+ toolPartName,
885
+ toolPartEvidence,
886
+ toolPartDenied,
887
+ isDenialEvidence,
888
+ denialProbeResult,
889
+ unwrapClientResult,
890
+ valueContainsString,
891
+ createdSessionRetainedPermission,
892
+ deterministicToolProbeResult,
893
+ deterministicAllowedToolResult,
894
+ secretReadClassCoverageResult,
895
+ sessionMessagesPayloadForProbe,
896
+ initScratchGitRepo,
897
+ removeGitWorktreeForce,
898
+ collectTextParts,
899
+ collectToolParts,
900
+ opencodeChildPermissionDenyRules,
901
+ probeStructuredOutput,
902
+ probeWorktree,
903
+ probeDeniedBash,
904
+ probeCommandScopedBash,
905
+ probeMcpAccessGate,
906
+ probeSecretReadDeny,
907
+ probeStructuredOutputGate,
908
+ probeWorktreeGate,
909
+ probeDirectoryRootingGate,
910
+ probeWorktreeEditIsolationGate,
911
+ probeIntegrationWorktreeIsolationGate,
912
+ probeBackgroundContinuationGate,
913
+ probeConcurrencyCapacityGate,
914
+ probeCancellationGate,
915
+ probeWorkflowNotificationGate,
916
+ };