@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,670 @@
1
+ import { SECRET_GLOBS } from "./constants.js";
2
+ import { WorkflowAuthorityError } from "./errors.js";
3
+ import { redactValue } from "./text-json.js";
4
+ import { auditedShellPermissionPatterns } from "./audited-shell-policy.js";
5
+
6
+ export const NON_DRY_DRAIN_REQUIRED_GATES = [
7
+ "permissionEnforcement",
8
+ "commandScopedBash",
9
+ "secretReadDeny",
10
+ "structuredOutput",
11
+ "directoryRooting",
12
+ "integrationWorktreeIsolation",
13
+ "cancellation",
14
+ ];
15
+
16
+ export const AD_HOC_AUTHORITY_PROFILE = "ad-hoc";
17
+ export const AUTO_APPROVE_TIERS = Object.freeze(["readOnly", "worktree", "all"]);
18
+ const AUTO_APPROVE_TIER_RANK = Object.freeze({ readOnly: 1, worktree: 2, all: 3 });
19
+ const AUTO_APPROVE_TIER_BY_RANK = Object.freeze({ 1: "readOnly", 2: "worktree", 3: "all" });
20
+ export const WORKFLOW_AUTHORITY_PROFILES = Object.freeze({
21
+ "read-only-review": Object.freeze({
22
+ authority: Object.freeze({ readOnly: true }),
23
+ requiredGates: Object.freeze([]),
24
+ }),
25
+ "inspect-with-shell": Object.freeze({
26
+ authority: Object.freeze({ readOnly: true, shell: true }),
27
+ requiredGates: Object.freeze(["permissionEnforcement", "commandScopedBash"]),
28
+ }),
29
+ "drain-dry-run": Object.freeze({
30
+ authority: Object.freeze({ readOnly: true }),
31
+ requiredGates: Object.freeze([]),
32
+ }),
33
+ "drain-autonomous-local": Object.freeze({
34
+ authority: Object.freeze({ integration: true, network: false, mcp: false }),
35
+ requiredGates: Object.freeze(NON_DRY_DRAIN_REQUIRED_GATES),
36
+ }),
37
+ "edit-plan-only": Object.freeze({
38
+ authority: Object.freeze({ worktreeEdit: true }),
39
+ requiredGates: Object.freeze(["permissionEnforcement", "worktreeApi", "directoryRooting", "worktreeEditIsolation"]),
40
+ }),
41
+ "apply-approved-plan": Object.freeze({
42
+ authority: Object.freeze({ edit: true }),
43
+ requiredGates: Object.freeze(["permissionEnforcement", "worktreeApi", "directoryRooting", "worktreeEditIsolation"]),
44
+ }),
45
+ });
46
+
47
+ export function normalizeAutoApproveTier(value) {
48
+ return AUTO_APPROVE_TIERS.includes(value) ? value : false;
49
+ }
50
+
51
+ export function autoApproveTierRank(value) {
52
+ const tier = normalizeAutoApproveTier(value);
53
+ return tier ? AUTO_APPROVE_TIER_RANK[tier] : 0;
54
+ }
55
+
56
+ export function authorityAutoApproveTier(authority = {}) {
57
+ if (authority.integration || authority.network || authority.mcp) return "all";
58
+ if (authority.edit || authority.worktreeEdit) return "worktree";
59
+ return "readOnly";
60
+ }
61
+
62
+ export function effectiveAutoApproveCeiling(configured, requested) {
63
+ const configuredRank = autoApproveTierRank(configured);
64
+ if (configuredRank <= 0) return false;
65
+ if (requested === undefined || requested === null) return AUTO_APPROVE_TIER_BY_RANK[configuredRank] ?? false;
66
+ const requestedRank = autoApproveTierRank(requested);
67
+ if (requestedRank <= 0) return false;
68
+ return AUTO_APPROVE_TIER_BY_RANK[Math.min(configuredRank, requestedRank)] ?? false;
69
+ }
70
+
71
+ export function autoApproveCovers(ceiling, tier) {
72
+ const ceilingRank = autoApproveTierRank(ceiling);
73
+ const tierRank = autoApproveTierRank(tier);
74
+ return ceilingRank > 0 && tierRank > 0 && ceilingRank >= tierRank;
75
+ }
76
+ export const OPENCODE_CHILD_TOOL_IDS = Object.freeze([
77
+ "oc_child_start",
78
+ "oc_child_status",
79
+ "oc_child_stop",
80
+ "oc_child_restart",
81
+ "oc_session_create",
82
+ "oc_prompt",
83
+ "oc_inspect",
84
+ "oc_events",
85
+ "oc_command",
86
+ "oc_shell",
87
+ "oc_permission",
88
+ "oc_plugin_smoke_test",
89
+ ]);
90
+
91
+ export const OPENCODE_CHILD_PERMISSION_KEYS = Object.freeze([
92
+ "opencode-child.start",
93
+ "opencode-child.status",
94
+ "opencode-child.stop",
95
+ "opencode-child.stop.registry-pid-signal",
96
+ "opencode-child.restart",
97
+ "opencode-child.command",
98
+ "opencode-child.shell",
99
+ "opencode-child.permission",
100
+ ]);
101
+
102
+ export const WORKFLOW_INSPECT_TOOLS = [
103
+ "workflow_status",
104
+ "workflow_events",
105
+ "workflow_list",
106
+ "workflow_roles",
107
+ "workflow_templates",
108
+ "workflow_live_gates",
109
+ ];
110
+ export const WORKFLOW_MUTATING_TOOLS = [
111
+ "workflow_run",
112
+ "workflow_cancel",
113
+ "workflow_pause",
114
+ "workflow_reconcile",
115
+ "workflow_save",
116
+ "workflow_cleanup",
117
+ "workflow_apply",
118
+ "workflow_template_save",
119
+ "workflow_salvage",
120
+ ];
121
+ export const WORKFLOW_TOOLS = [...WORKFLOW_INSPECT_TOOLS, ...WORKFLOW_MUTATING_TOOLS];
122
+
123
+ export function parseModel(model) {
124
+ if (!model) return undefined;
125
+ if (typeof model === "object" && model.providerID && (model.modelID || model.id)) {
126
+ return { providerID: String(model.providerID), modelID: String(model.modelID ?? model.id) };
127
+ }
128
+ if (typeof model !== "string") return undefined;
129
+ const slash = model.indexOf("/");
130
+ if (slash <= 0 || slash === model.length - 1) return undefined;
131
+ return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) };
132
+ }
133
+
134
+ export function modelKey(model) {
135
+ if (!model) return undefined;
136
+ if (typeof model === "string") return model;
137
+ return `${model.providerID}/${model.modelID}`;
138
+ }
139
+
140
+ export function allowTools(permission, tools) {
141
+ if (!permission || typeof permission !== "object" || Array.isArray(permission)) return;
142
+ for (const name of tools) permission[name] = "allow";
143
+ }
144
+
145
+ export function denyTools(permission, tools) {
146
+ if (!permission || typeof permission !== "object" || Array.isArray(permission)) return;
147
+ for (const name of tools) permission[name] = "deny";
148
+ }
149
+
150
+ export function configureWorkflowPermissions(cfg) {
151
+ if (!cfg.permission) cfg.permission = {};
152
+ allowTools(cfg.permission, WORKFLOW_TOOLS);
153
+
154
+ cfg.agent = cfg.agent ?? {};
155
+ for (const [name, agent] of Object.entries(cfg.agent)) {
156
+ if (!agent || typeof agent !== "object") continue;
157
+ if (name !== "build" && name !== "plan") continue;
158
+ if (!agent.permission) agent.permission = {};
159
+ if (name === "build") allowTools(agent.permission, WORKFLOW_TOOLS);
160
+ if (name === "plan") {
161
+ allowTools(agent.permission, WORKFLOW_INSPECT_TOOLS);
162
+ denyTools(agent.permission, WORKFLOW_MUTATING_TOOLS);
163
+ }
164
+ }
165
+ }
166
+
167
+ export function assertWriteWorkflowAllowed(context, toolName) {
168
+ if (context?.agent === "plan") {
169
+ throw new WorkflowAuthorityError(`${toolName} is not available in plan mode`);
170
+ }
171
+ }
172
+
173
+ export function resolveRequestedModel(model, label) {
174
+ if (!model) return undefined;
175
+ const parsed = parseModel(model);
176
+ if (!parsed) throw new Error(`Invalid ${label} model. Expected provider/model, got: ${String(model)}`);
177
+ return parsed;
178
+ }
179
+
180
+ export const VALID_TIERS = ["fast", "deep"];
181
+
182
+ // Resolve a lane's model string BEFORE provider/model validation.
183
+ // Precedence: explicit opts.model > run.modelTiers[tier] > run.defaultChildModel.
184
+ // A declared tier with no map entry degrades to the run default (the session model),
185
+ // so legacy lanes (no tier, no model) behave exactly as before.
186
+ export function resolveLaneModel(run, opts = {}) {
187
+ if (typeof opts.model === "string" && opts.model.length > 0) return opts.model;
188
+ const tier = opts.tier;
189
+ if (tier !== undefined) {
190
+ if (!VALID_TIERS.includes(tier)) {
191
+ throw new Error(`Invalid lane tier: ${String(tier)}. Expected one of ${VALID_TIERS.join(", ")}.`);
192
+ }
193
+ const mapped = run.modelTiers && run.modelTiers[tier];
194
+ if (typeof mapped === "string" && mapped.length > 0) return mapped;
195
+ }
196
+ return run.defaultChildModel;
197
+ }
198
+
199
+ // The complete set of keys an agent() / parallel-thunk / pipeline-stage opts object may declare.
200
+ // Anything else is a typo (e.g. `onFailur` for `onFailure`, `readonly` for `readOnly`) that would
201
+ // otherwise be silently dropped and leave the lane running with unintended defaults — most
202
+ // dangerously a write/edit/network toggle that never takes effect, or an `onFailure` swallow that
203
+ // silently turns into a hard run failure. Keep this in lock-step with every opts consumer:
204
+ // - resolveLaneModel: model, tier
205
+ // - computeLaneAuthority: readOnly, edit, allowEdits, worktreeEdit, shell, allowShell,
206
+ // network, allowNetwork, mcp, allowMcp, mcpPolicy, tools, secretGlobs
207
+ // - runChildAgent: agent, agentType, role, effort, retryCount, correctiveRetries, schema,
208
+ // timeoutMs, system, onFailure
209
+ // - laneTaskSummary: taskSummary, summary, label, title
210
+ // - sandbox phase tagging: phase, label (stripped by normalizeAgentOptions below)
211
+ export const ALLOWED_AGENT_OPTION_KEYS = new Set([
212
+ "model", "tier",
213
+ "readOnly", "edit", "allowEdits", "worktreeEdit",
214
+ "shell", "allowShell", "network", "allowNetwork", "mcp", "allowMcp", "mcpPolicy",
215
+ "tools", "secretGlobs",
216
+ "agent", "agentType", "role", "effort", "retryCount", "correctiveRetries", "schema", "timeoutMs", "system", "onFailure",
217
+ "taskSummary", "summary", "label", "title", "phase",
218
+ ]);
219
+
220
+ // Reject unknown agent() option keys so a misspelled opt fails loudly at run time instead of being
221
+ // silently ignored. Strictly additive safety: it narrows nothing about a VALID opts object, it only
222
+ // rejects keys no consumer reads. Authority is still capped downstream by computeLaneAuthority.
223
+ export function assertKnownAgentOptions(opts = {}) {
224
+ if (!opts || typeof opts !== "object" || Array.isArray(opts)) return;
225
+ const unknown = Object.keys(opts).filter((key) => !ALLOWED_AGENT_OPTION_KEYS.has(key));
226
+ if (unknown.length > 0) {
227
+ throw new WorkflowAuthorityError(
228
+ `Unknown agent() option${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}. ` +
229
+ `Allowed options: ${[...ALLOWED_AGENT_OPTION_KEYS].sort().join(", ")}.`,
230
+ );
231
+ }
232
+ }
233
+
234
+ export function normalizeAgentOptions(opts = {}) {
235
+ assertKnownAgentOptions(opts);
236
+ const normalized = { ...opts };
237
+ delete normalized.label;
238
+ delete normalized.phase;
239
+ return normalized;
240
+ }
241
+
242
+ export function normalizePatternList(value, label) {
243
+ const values = value === undefined ? [] : Array.isArray(value) ? value : [value];
244
+ const normalized = [];
245
+ for (const item of values) {
246
+ if (typeof item !== "string" || item.trim() === "") {
247
+ throw new WorkflowAuthorityError(`${label} entries must be non-empty strings`);
248
+ }
249
+ normalized.push(item.trim());
250
+ }
251
+ return [...new Set(normalized)];
252
+ }
253
+
254
+ export function resolveShellPolicy(declaredShell, full = false) {
255
+ if (declaredShell && typeof declaredShell === "object" && !Array.isArray(declaredShell)) {
256
+ const allow = normalizePatternList(declaredShell.allow, "shell.allow");
257
+ return {
258
+ allow: allow.length > 0 ? allow : full ? ["*"] : [],
259
+ deny: normalizePatternList(declaredShell.deny, "shell.deny"),
260
+ };
261
+ }
262
+ if (declaredShell === true || full) return { allow: ["*"], deny: [] };
263
+ return { allow: [], deny: [] };
264
+ }
265
+
266
+ export function shellPolicyForAuthority(authority) {
267
+ if (authority?.shellPolicy) {
268
+ return {
269
+ allow: normalizePatternList(authority.shellPolicy.allow, "shell.allow"),
270
+ deny: normalizePatternList(authority.shellPolicy.deny, "shell.deny"),
271
+ };
272
+ }
273
+ return { allow: authority?.shell ? ["*"] : [], deny: [] };
274
+ }
275
+
276
+ export function resolveMcpPolicy(declaredMcp, full = false) {
277
+ if (declaredMcp && typeof declaredMcp === "object" && !Array.isArray(declaredMcp)) {
278
+ const allow = normalizePatternList(declaredMcp.allow, "mcp.allow");
279
+ return {
280
+ allow: allow.length > 0 ? allow : full ? ["*"] : [],
281
+ deny: normalizePatternList(declaredMcp.deny, "mcp.deny"),
282
+ };
283
+ }
284
+ if (declaredMcp === true || full) return { allow: ["*"], deny: [] };
285
+ return { allow: [], deny: [] };
286
+ }
287
+
288
+ export function mcpPolicyForAuthority(authority) {
289
+ if (authority?.mcpPolicy) {
290
+ return {
291
+ allow: normalizePatternList(authority.mcpPolicy.allow, "mcp.allow"),
292
+ deny: normalizePatternList(authority.mcpPolicy.deny, "mcp.deny"),
293
+ };
294
+ }
295
+ return { allow: authority?.mcp ? ["*"] : [], deny: [] };
296
+ }
297
+
298
+ function wildcardPatternToRegExp(pattern) {
299
+ let regex = "^";
300
+ for (const char of String(pattern)) {
301
+ if (char === "*") regex += ".*";
302
+ else if (char === "?") regex += ".";
303
+ else regex += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
304
+ }
305
+ regex += "$";
306
+ return new RegExp(regex);
307
+ }
308
+
309
+ function policyAllowsPattern(parentPolicy, pattern) {
310
+ return parentPolicy.allow.some((parentPattern) => {
311
+ if (parentPattern === "*" || parentPattern === pattern) return true;
312
+ if (!pattern.includes("*") && !pattern.includes("?")) {
313
+ return wildcardPatternToRegExp(parentPattern).test(pattern);
314
+ }
315
+ const firstWildcard = parentPattern.search(/[*?]/);
316
+ if (firstWildcard < 0) return false;
317
+ const prefix = parentPattern.slice(0, firstWildcard);
318
+ const suffix = parentPattern.slice(firstWildcard + 1);
319
+ return suffix === "" && pattern.startsWith(prefix);
320
+ });
321
+ }
322
+
323
+ export function narrowMcpPolicy(parentPolicy, declaredMcpPolicy = {}) {
324
+ const parent = {
325
+ allow: normalizePatternList(parentPolicy?.allow, "mcp.allow"),
326
+ deny: normalizePatternList(parentPolicy?.deny, "mcp.deny"),
327
+ };
328
+ const declared = declaredMcpPolicy && typeof declaredMcpPolicy === "object" && !Array.isArray(declaredMcpPolicy)
329
+ ? declaredMcpPolicy
330
+ : {};
331
+ const allow = Object.hasOwn(declared, "allow")
332
+ ? normalizePatternList(declared.allow, "mcp.allow")
333
+ : parent.allow;
334
+ for (const pattern of allow) {
335
+ if (!policyAllowsPattern(parent, pattern)) {
336
+ throw new WorkflowAuthorityError(`Lane mcpPolicy allow pattern "${pattern}" exceeds approved workflow mcpPolicy`);
337
+ }
338
+ }
339
+ return {
340
+ allow,
341
+ deny: [...new Set([...parent.deny, ...normalizePatternList(declared.deny, "mcp.deny")])],
342
+ };
343
+ }
344
+
345
+ export function normalizeRequiredGates(gates) {
346
+ return [...new Set((Array.isArray(gates) ? gates : []).filter((gate) => typeof gate === "string" && gate.trim()).map((gate) => gate.trim()))].sort();
347
+ }
348
+
349
+ export function resolveAuthorityProfile(meta = {}, args = {}) {
350
+ const profileName = args.profile ?? meta.profile ?? meta.authorityProfile ?? meta.authority?.profile ?? AD_HOC_AUTHORITY_PROFILE;
351
+ if (profileName === AD_HOC_AUTHORITY_PROFILE) return { name: profileName, authority: {}, requiredGates: [] };
352
+ const profile = WORKFLOW_AUTHORITY_PROFILES[profileName];
353
+ if (!profile) throw new WorkflowAuthorityError(`Unknown workflow authority profile: ${profileName}`);
354
+ return { name: profileName, authority: profile.authority, requiredGates: profile.requiredGates };
355
+ }
356
+
357
+ export function resolveRunAuthority(meta = {}, args = {}) {
358
+ const profile = resolveAuthorityProfile(meta, args);
359
+ const declared = {
360
+ ...profile.authority,
361
+ ...(typeof meta.authority === "object" && meta.authority ? meta.authority : {}),
362
+ ...(typeof args.authority === "object" && args.authority ? args.authority : {}),
363
+ };
364
+ delete declared.profile;
365
+ for (const key of ["readOnly", "shell", "network", "mcp", "mcpPolicy", "edit", "worktreeEdit", "integration"] ) {
366
+ if (Object.hasOwn(meta, key)) declared[key] = meta[key];
367
+ }
368
+ // Precedence for the shell policy:
369
+ // 1. An explicit caller-supplied shell object (authority.shell = { allow, deny }) wins — it is
370
+ // a deliberate per-run override and is honored verbatim via resolveShellPolicy's object branch.
371
+ // 2. Otherwise, when the profile is inspect-with-shell, the audited command-scoped allowlist +
372
+ // denylist is enforced as the runtime permission ruleset (opencode-workflows-public-inspect-
373
+ // shell-scope). This keeps shell:true (the lane gets the bash tool) but narrows it to the
374
+ // documented read-only inspection commands, denying chaining/mutation/network at the rule level.
375
+ // 3. Otherwise the legacy behavior applies (["*"] when shell/full, else []).
376
+ const explicitShellOverride = declared.shell && typeof declared.shell === "object" && !Array.isArray(declared.shell);
377
+ const shellPolicy = (profile.name === "inspect-with-shell" && !explicitShellOverride)
378
+ ? auditedShellPermissionPatterns()
379
+ : resolveShellPolicy(declared.shell, declared.full === true);
380
+ const mcpPolicy = resolveMcpPolicy(declared.mcpPolicy ?? declared.mcp, declared.full === true);
381
+ const authority = {
382
+ readOnly: declared.readOnly !== false && declared.full !== true,
383
+ shell: shellPolicy.allow.length > 0,
384
+ shellPolicy,
385
+ network: declared.network === true || declared.full === true,
386
+ mcp: mcpPolicy.allow.length > 0,
387
+ mcpPolicy,
388
+ edit: declared.edit === true,
389
+ worktreeEdit: declared.worktreeEdit === true,
390
+ integration: declared.integration === true,
391
+ profile: profile.name,
392
+ requiredGates: normalizeRequiredGates([...profile.requiredGates, ...(Array.isArray(declared.requiredGates) ? declared.requiredGates : [])]),
393
+ };
394
+ authority.mode = authority.integration ? "integrationMode" : authority.edit || authority.worktreeEdit ? "editMode" : authority.readOnly ? "readOnly" : "full";
395
+ authority.editGate = authority.edit || authority.worktreeEdit || authority.integration ? "requires workflow_apply approval before primary writes" : "not-requested";
396
+ return authority;
397
+ }
398
+
399
+ const DRAIN_PROFILE_FOR_MODE = Object.freeze({ "dry-run": "drain-dry-run", "autonomous-local": "drain-autonomous-local" });
400
+ const DRAIN_MODE_FOR_PROFILE = Object.freeze({ "drain-dry-run": "dry-run", "drain-autonomous-local": "autonomous-local" });
401
+
402
+ // Canonicalize a drain-harness invocation into one consistent form, so the approval hash, resolved
403
+ // authority, background default, and the workflow body all agree. Reconciles a top-level authority
404
+ // `profile` with args.mode (rejecting conflicts), injects the mode-appropriate profile
405
+ // (drain-dry-run | drain-autonomous-local), and writes the resolved mode back into args.args.mode.
406
+ // Non-drain workflows pass through untouched. Generic over any meta.harness==="drain" workflow.
407
+ export function authorityArgsForWorkflow(meta = {}, args = {}) {
408
+ if (meta.harness !== "drain") return args;
409
+ const rawRuntime = args.args;
410
+ if (rawRuntime !== undefined && rawRuntime !== null && (typeof rawRuntime !== "object" || Array.isArray(rawRuntime))) {
411
+ throw new WorkflowAuthorityError('drain workflow args must be a JSON object when provided; omit args or pass {"mode":"dry-run"} or {"mode":"autonomous-local"}');
412
+ }
413
+ const runtimeArgs = rawRuntime && typeof rawRuntime === "object" && !Array.isArray(rawRuntime) ? rawRuntime : {};
414
+ const hasExplicitMode = Object.hasOwn(runtimeArgs, "mode") || Object.hasOwn(runtimeArgs, "dryRun");
415
+ const modeFromArgs = hasExplicitMode ? resolveDrainMode(runtimeArgs) : undefined;
416
+ const profile = args.profile;
417
+ if (profile !== undefined && !Object.hasOwn(DRAIN_MODE_FOR_PROFILE, profile)) {
418
+ throw new WorkflowAuthorityError(`drain workflow profile must be "drain-dry-run" or "drain-autonomous-local"; got "${String(profile)}"`);
419
+ }
420
+ const modeFromProfile = profile !== undefined ? DRAIN_MODE_FOR_PROFILE[profile] : undefined;
421
+ if (modeFromArgs !== undefined && modeFromProfile !== undefined && modeFromArgs !== modeFromProfile) {
422
+ throw new WorkflowAuthorityError(`conflicting drain invocation: profile "${profile}" implies mode "${modeFromProfile}" but args mode is "${modeFromArgs}"`);
423
+ }
424
+ const mode = modeFromArgs ?? modeFromProfile ?? "dry-run";
425
+ return { ...args, profile: DRAIN_PROFILE_FOR_MODE[mode], args: { ...runtimeArgs, mode } };
426
+ }
427
+
428
+ export function resolveDrainMode(runtimeArgs = {}) {
429
+ const mode = runtimeArgs.mode ?? (runtimeArgs.dryRun === false ? "autonomous-local" : "dry-run");
430
+ if (mode !== "dry-run" && mode !== "autonomous-local") throw new WorkflowAuthorityError('drain mode must be "dry-run" or "autonomous-local"');
431
+ return mode;
432
+ }
433
+
434
+ function patternPolicySummary(policy = {}) {
435
+ const allow = Array.isArray(policy.allow) ? policy.allow : [];
436
+ const deny = Array.isArray(policy.deny) ? policy.deny : [];
437
+ if (allow.includes("*") && deny.length === 0) return "UNRESTRICTED(*)";
438
+ if (allow.length > 0 && deny.length === 0) return `allow:${allow.length},deny:0(empty-deny)`;
439
+ return `allow:${allow.length},deny:${deny.length}`;
440
+ }
441
+
442
+ export function authoritySummary(authority) {
443
+ const flags = [
444
+ `mode=${authority.mode}`,
445
+ `readOnly=${authority.readOnly}`,
446
+ `shell=${authority.shell}`,
447
+ `shellPolicy=${patternPolicySummary(authority.shellPolicy)}`,
448
+ `network=${authority.network}`,
449
+ `mcp=${authority.mcp}`,
450
+ `mcpPolicy=${patternPolicySummary(authority.mcpPolicy)}`,
451
+ `edit=${authority.edit}`,
452
+ `worktreeEdit=${authority.worktreeEdit}`,
453
+ `integration=${authority.integration}`,
454
+ `profile=${authority.profile || AD_HOC_AUTHORITY_PROFILE}`,
455
+ `requiredGates=${authority.requiredGates?.length ? authority.requiredGates.join("|") : "none"}`,
456
+ ];
457
+ if (authority.editGate !== "not-requested") flags.push(authority.editGate);
458
+ return flags.join(", ");
459
+ }
460
+
461
+ export function permissionRulesForAuthority(authority, extraSecretGlobs = []) {
462
+ const rules = [{ permission: "*", pattern: "*", action: "deny" }];
463
+ for (const permission of ["read", "glob", "grep", "list", "lsp"]) {
464
+ rules.push({ permission, pattern: "*", action: "allow" });
465
+ }
466
+ // StructuredOutput is an internal OpenCode tool injected when `format` is set on a
467
+ // prompt. The catch-all `*` deny above hides it from child sessions, which prevents
468
+ // schema-constrained workflow lanes from producing structured results and causes them
469
+ // to time out. The `structured_output` permission key allows the tool under
470
+ // deny-by-default permission rulesets. (Historical provenance: found in the
471
+ // OpenCode binary and confirmed accepted by the server's permission system in
472
+ // the 1.17.7 investigation.)
473
+ rules.push({ permission: "structured_output", pattern: "*", action: "allow" });
474
+ const secretGlobs = [...new Set([...SECRET_GLOBS, ...extraSecretGlobs])];
475
+ for (const glob of secretGlobs) {
476
+ // lsp is granted a broad allow:* above (it is a read-class tool), so it must
477
+ // carry the same secret-glob deny rules as read/grep/glob/list. Otherwise an
478
+ // LSP response that surfaces fragments of .env/credentials/secret/id_rsa files
479
+ // would be reachable by a read-only lane even though direct reads are denied.
480
+ for (const permission of ["read", "grep", "glob", "list", "lsp"]) {
481
+ rules.push({ permission, pattern: glob, action: "deny" });
482
+ }
483
+ }
484
+ rules.push({ permission: "edit", pattern: "*", action: authority.edit || authority.worktreeEdit || authority.integration ? "allow" : "deny" });
485
+ // apply_patch is a separate built-in OpenCode tool (distinct from `edit`) that the
486
+ // GPT/Codex system prompt mandates for manual code edits ("Always use apply_patch for
487
+ // manual code edits"). It carries its own `apply_patch` permission key, so the catch-all
488
+ // `*` deny above hides it from child lanes even when edit authority is granted — a
489
+ // Codex-family lane then follows its prompt, finds no callable apply_patch, and blocks
490
+ // instead of falling back to `edit`. Gate it on the same authority as `edit`. (Same
491
+ // deny-by-default tool-hiding class as the structured_output allow above.)
492
+ rules.push({ permission: "apply_patch", pattern: "*", action: authority.edit || authority.worktreeEdit || authority.integration ? "allow" : "deny" });
493
+ const shellPolicy = shellPolicyForAuthority(authority);
494
+ if (shellPolicy.allow.length === 0) rules.push({ permission: "bash", pattern: "*", action: "deny" });
495
+ for (const pattern of shellPolicy.allow) rules.push({ permission: "bash", pattern, action: "allow" });
496
+ for (const pattern of shellPolicy.deny) rules.push({ permission: "bash", pattern, action: "deny" });
497
+ rules.push({ permission: "webfetch", pattern: "*", action: authority.network ? "allow" : "deny" });
498
+ rules.push({ permission: "websearch", pattern: "*", action: authority.network ? "allow" : "deny" });
499
+ const mcpPolicy = mcpPolicyForAuthority(authority);
500
+ if (mcpPolicy.allow.length === 0) rules.push({ permission: "mcp", pattern: "*", action: "deny" });
501
+ for (const pattern of mcpPolicy.allow) rules.push({ permission: "mcp", pattern, action: "allow" });
502
+ for (const pattern of mcpPolicy.deny) rules.push({ permission: "mcp", pattern, action: "deny" });
503
+ for (const permission of ["task", "skill", "question", "todowrite"]) {
504
+ rules.push({ permission, pattern: "*", action: "deny" });
505
+ }
506
+ rules.push({ permission: "external_directory", pattern: "*", action: "deny" });
507
+ for (const permission of OPENCODE_CHILD_TOOL_IDS) {
508
+ rules.push({ permission, pattern: "*", action: "deny" });
509
+ }
510
+ for (const permission of OPENCODE_CHILD_PERMISSION_KEYS) {
511
+ rules.push({ permission, pattern: "*", action: "deny" });
512
+ }
513
+ return rules;
514
+ }
515
+
516
+ export function toolAuthority(toolName) {
517
+ if (toolName === "bash") return "shell";
518
+ if (toolName === "webfetch" || toolName === "websearch") return "network";
519
+ if (toolName === "mcp" || toolName.startsWith("mcp")) return "mcp";
520
+ if (toolName === "edit") return "edit";
521
+ // apply_patch is the GPT/Codex file-edit tool; treat it as edit-class so an edit-authorized
522
+ // lane that enumerates it is not rejected as an "unknown" tool authority.
523
+ if (toolName === "apply_patch") return "edit";
524
+ if (OPENCODE_CHILD_TOOL_IDS.includes(toolName)) return "delegation";
525
+ if (["task", "skill", "question", "todowrite", "external_directory"].includes(toolName)) return "delegation";
526
+ if (["read", "glob", "grep", "list", "lsp"].includes(toolName)) return "read";
527
+ return "unknown";
528
+ }
529
+
530
+ function laneMcpPolicyOption(opts = {}) {
531
+ if (opts.mcpPolicy !== undefined) {
532
+ if (!opts.mcpPolicy || typeof opts.mcpPolicy !== "object" || Array.isArray(opts.mcpPolicy)) {
533
+ throw new WorkflowAuthorityError("agent() option mcpPolicy must be an object with optional allow/deny arrays");
534
+ }
535
+ return opts.mcpPolicy;
536
+ }
537
+ if (opts.mcp && typeof opts.mcp === "object" && !Array.isArray(opts.mcp)) return opts.mcp;
538
+ return undefined;
539
+ }
540
+
541
+ export function resolveLanePolicy(run, opts = {}) {
542
+ const authority = { ...run.authority, shellPolicy: shellPolicyForAuthority(run.authority), mcpPolicy: mcpPolicyForAuthority(run.authority) };
543
+ authority.edit = false;
544
+ authority.worktreeEdit = false;
545
+ authority.mode = authority.readOnly ? "readOnly" : "full";
546
+ if (opts.readOnly === true) {
547
+ authority.readOnly = true;
548
+ authority.shell = false;
549
+ authority.shellPolicy = { allow: [], deny: [] };
550
+ authority.network = false;
551
+ authority.mcp = false;
552
+ authority.mcpPolicy = { allow: [], deny: [] };
553
+ authority.edit = false;
554
+ authority.worktreeEdit = false;
555
+ // integration also grants edit:* allow in permissionRulesForAuthority, so a
556
+ // readOnly lane on an integration-approved run would otherwise leak edit
557
+ // permission even though tools.edit is false. Zero it so the permission
558
+ // ruleset (the authoritative enforcement) denies edit too.
559
+ authority.integration = false;
560
+ authority.mode = "readOnly";
561
+ }
562
+
563
+ const requested = [];
564
+ if (opts.allowEdits === true || opts.edit === true) requested.push("edit");
565
+ if (opts.worktreeEdit === true) requested.push("worktreeEdit");
566
+ if (opts.shell === true || opts.allowShell === true) requested.push("shell");
567
+ if (opts.network === true || opts.allowNetwork === true) requested.push("network");
568
+ const laneMcpPolicy = laneMcpPolicyOption(opts);
569
+ if (opts.mcp === true || opts.allowMcp === true || laneMcpPolicy) requested.push("mcp");
570
+ for (const [name, enabled] of Object.entries(opts.tools ?? {})) {
571
+ if (enabled !== true) continue;
572
+ const dimension = toolAuthority(name);
573
+ if (dimension === "read") continue;
574
+ requested.push(dimension);
575
+ }
576
+
577
+ for (const dimension of requested) {
578
+ if (dimension === "delegation" || dimension === "unknown") {
579
+ throw new WorkflowAuthorityError(`Lane requested unapproved ${dimension} tool authority`);
580
+ }
581
+ // opts.readOnly is authoritative defense-in-depth narrowing: a lane that
582
+ // explicitly opts into readOnly cannot re-enable shell/network/mcp/edit even
583
+ // if the parent run was approved for them. Silently drop the escalation
584
+ // request (readOnly wins) rather than fail the lane.
585
+ if (opts.readOnly === true && ["shell", "network", "mcp", "edit", "worktreeEdit"].includes(dimension)) {
586
+ continue;
587
+ }
588
+ if (!run.authority[dimension] && !(dimension === "worktreeEdit" && run.authority.integration)) {
589
+ throw new WorkflowAuthorityError(`Lane requested ${dimension} authority beyond approved workflow authority`);
590
+ }
591
+ authority[dimension] = true;
592
+ }
593
+ if (laneMcpPolicy && opts.readOnly !== true) {
594
+ if (!run.authority.mcp) {
595
+ throw new WorkflowAuthorityError("Lane requested mcpPolicy beyond approved workflow authority");
596
+ }
597
+ authority.mcpPolicy = narrowMcpPolicy(mcpPolicyForAuthority(run.authority), laneMcpPolicy);
598
+ authority.mcp = authority.mcpPolicy.allow.length > 0;
599
+ }
600
+ if ((authority.edit || authority.worktreeEdit) && opts.readOnly !== true) {
601
+ authority.mode = run.authority.integration ? "integrationMode" : "editMode";
602
+ authority.readOnly = false;
603
+ }
604
+
605
+ const tools = {
606
+ edit: authority.edit || authority.worktreeEdit,
607
+ apply_patch: authority.edit || authority.worktreeEdit,
608
+ bash: authority.shell,
609
+ webfetch: authority.network,
610
+ websearch: authority.network,
611
+ task: false,
612
+ skill: false,
613
+ todowrite: false,
614
+ question: false,
615
+ ...Object.fromEntries(OPENCODE_CHILD_TOOL_IDS.map((name) => [name, false])),
616
+ ...(opts.tools ?? {}),
617
+ };
618
+ if (opts.readOnly === true) {
619
+ // readOnly is authoritative: strip any escalation tool the caller passed via
620
+ // opts.tools so a readOnly lane can never regain shell/network/mcp/edit (and
621
+ // so the validation loop below cannot throw on a contradictory request).
622
+ for (const name of Object.keys(tools)) {
623
+ if (tools[name] !== true) continue;
624
+ const dimension = toolAuthority(name);
625
+ if (["shell", "network", "mcp", "edit", "worktreeEdit"].includes(dimension)) {
626
+ tools[name] = false;
627
+ }
628
+ }
629
+ }
630
+ for (const [name, enabled] of Object.entries(tools)) {
631
+ if (enabled !== true) continue;
632
+ const dimension = toolAuthority(name);
633
+ if (dimension === "read") continue;
634
+ if (dimension === "delegation" || dimension === "unknown") {
635
+ throw new WorkflowAuthorityError(`Tool ${name} is denied by the workflow authority policy`);
636
+ }
637
+ if ((name === "edit" || name === "apply_patch") && authority.worktreeEdit) continue;
638
+ if (!authority[dimension]) {
639
+ throw new WorkflowAuthorityError(`Tool ${name} requires ${dimension} authority that was not approved`);
640
+ }
641
+ }
642
+
643
+ if (run.capabilities.permissions !== "available") {
644
+ throw new WorkflowAuthorityError("Per-session permission rules are unavailable; child lane authority fails closed (read-only child lanes are contained by a deny-by-default permission ruleset, so the runtime must verify permissionEnforcement before any lane spawns — run a workflow_live_gates permissionEnforcement probe or ensure child-session permission rules are enforced)");
645
+ }
646
+
647
+ const extraSecretGlobs = Array.isArray(opts.secretGlobs) ? opts.secretGlobs : [];
648
+ // Run-level integration authority approves integration MODE (path-disjoint lanes
649
+ // merged by the controller through workflow_apply); it must NOT grant primary-tree
650
+ // edit/apply_patch to an ordinary child lane. Only a lane that is itself an
651
+ // edit/worktreeEdit lane — i.e. the controller created a real edit/integration
652
+ // worktree for it — may edit. A default lane in an integration run keeps
653
+ // authority.integration as run-level metadata, but the permission ruleset (the
654
+ // authoritative session-level enforcement) denies edit/apply_patch so the child
655
+ // session cannot write to the primary tree. (Public-release hardening:
656
+ // opencode-workflows-public-integration-lane-edit-leak.)
657
+ const laneEditCapable = Boolean(authority.edit || authority.worktreeEdit);
658
+ const permissionAuthority = laneEditCapable ? authority : { ...authority, integration: false };
659
+ const permissionRules = permissionRulesForAuthority(permissionAuthority, extraSecretGlobs);
660
+ return {
661
+ mode: authority.mode,
662
+ authority: redactValue(authority),
663
+ shellPolicy: authority.shellPolicy,
664
+ tools,
665
+ permissionRules,
666
+ policyMode: run.capabilities.permissions === "available" ? "permission-ruleset" : "legacy-tools-map",
667
+ mcpPolicy: authority.mcpPolicy,
668
+ secretGlobs: [...new Set([...SECRET_GLOBS, ...extraSecretGlobs])],
669
+ };
670
+ }