@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,287 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
2
+
3
+ import {
4
+ WORKFLOW_PROGRESS_TOAST_FORCE_MS,
5
+ WORKFLOW_PROGRESS_TOAST_INTERVAL_MS,
6
+ WORKFLOW_TOAST_DURATION_MS,
7
+ } from "./constants.js";
8
+ import { stableStringify, truncateText } from "./text-json.js";
9
+ import { redactFreeTextSecrets } from "./free-text-redactor.js";
10
+ import { addTokens, zeroTokens } from "./budget-accounting.js";
11
+ import {
12
+ renderWorkflowApplyCard,
13
+ renderWorkflowHeartbeatCard,
14
+ renderWorkflowProblemCard,
15
+ renderWorkflowTerminalCard,
16
+ workflowToastCardSignature,
17
+ workflowToastCardSnapshot,
18
+ } from "./notification-toast-cards.js";
19
+ import {
20
+ createWorkflowToastPolicyState,
21
+ evaluateWorkflowToastEventPolicy,
22
+ evaluateWorkflowToastTickPolicy,
23
+ } from "./notification-toast-policy.js";
24
+
25
+ const WORKFLOW_TOAST_MESSAGE_MAX_CHARS = 1_000;
26
+
27
+ const WORKFLOW_TOAST_MAX_LANES = 4;
28
+
29
+ const WORKFLOW_TOAST_DELIVERY_TIMEOUT_MS = 1_000;
30
+
31
+ const activeToastDeliveries = new WeakSet();
32
+
33
+ function hasWorkflowToast(pluginContext) {
34
+ return typeof pluginContext.client?.tui?.showToast === "function" || typeof pluginContext.tui?.ui?.toast === "function";
35
+ }
36
+
37
+ async function showToast(pluginContext, variant, title, message) {
38
+ try {
39
+ const body = {
40
+ variant,
41
+ title: truncateText(redactFreeTextSecrets(title), 120),
42
+ message: truncateText(redactFreeTextSecrets(message), WORKFLOW_TOAST_MESSAGE_MAX_CHARS),
43
+ duration: WORKFLOW_TOAST_DURATION_MS,
44
+ };
45
+ const timeoutMs = Number.isFinite(pluginContext.__workflowToastTimeoutMs) ? pluginContext.__workflowToastTimeoutMs : WORKFLOW_TOAST_DELIVERY_TIMEOUT_MS;
46
+ if (typeof pluginContext.client?.tui?.showToast === "function") {
47
+ if (activeToastDeliveries.has(pluginContext)) return;
48
+ activeToastDeliveries.add(pluginContext);
49
+ const ac = new AbortController();
50
+ const delivery = Promise.resolve(pluginContext.client.tui.showToast({ body, signal: ac.signal })).catch(() => {}).finally(() => {
51
+ activeToastDeliveries.delete(pluginContext);
52
+ ac.abort();
53
+ });
54
+ await Promise.race([
55
+ delivery,
56
+ sleep(timeoutMs, undefined, { signal: ac.signal }).then(() => ac.abort()).catch(() => {}),
57
+ ]);
58
+ return;
59
+ }
60
+ pluginContext.tui?.ui?.toast?.(body);
61
+ } catch {
62
+ // Toasts are best effort; persisted state remains authoritative.
63
+ }
64
+ }
65
+
66
+ function workflowDisplayName(run) {
67
+ const name = typeof run.meta?.name === "string" ? run.meta.name.trim() : "";
68
+ return name || "unnamed";
69
+ }
70
+
71
+ function workflowQueuedAgents(run) {
72
+ return run.waitingAgents?.length ?? run.queuedAgents ?? 0;
73
+ }
74
+
75
+ function workflowTotalCost(run) {
76
+ const cost = Number(run.cost ?? 0) + Number(run.replayedCost ?? 0);
77
+ return Number.isFinite(cost) ? cost : 0;
78
+ }
79
+
80
+ function workflowCostLabel(run) {
81
+ return workflowTotalCost(run).toFixed(4).replace(/\.?0+$/, "") || "0";
82
+ }
83
+
84
+ function workflowTotalTokens(run) {
85
+ const total = addTokens(run.tokens ?? zeroTokens(), run.replayedTokens ?? zeroTokens());
86
+ return (total.input ?? 0) + (total.output ?? 0) + (total.reasoning ?? 0);
87
+ }
88
+
89
+ function compactDuration(ms) {
90
+ if (!Number.isFinite(ms) || ms < 0) return "-";
91
+ const seconds = Math.floor(ms / 1000);
92
+ if (seconds < 60) return `${seconds}s`;
93
+ const minutes = Math.floor(seconds / 60);
94
+ if (minutes < 60) return `${minutes}m`;
95
+ const hours = Math.floor(minutes / 60);
96
+ return `${hours}h${minutes % 60}m`;
97
+ }
98
+
99
+ function laneRecordsForRun(run) {
100
+ if (run.laneRecords instanceof Map) return [...run.laneRecords.values()];
101
+ if (Array.isArray(run.laneRecords)) return run.laneRecords;
102
+ return [];
103
+ }
104
+
105
+ function lanePriority(lane) {
106
+ if (lane.status === "running" || lane.status === "committed") return 0;
107
+ if (["failure", "timeout", "cancelled", "budget_stopped"].includes(lane.outcome) || ["failure", "timeout", "cancelled", "budget_stopped"].includes(lane.status)) return 1;
108
+ if (lane.status === "completed") return 2;
109
+ return 3;
110
+ }
111
+
112
+ function shortModel(model) {
113
+ const text = String(model || "model?");
114
+ return truncateText(text.split("/").pop() || text, 18);
115
+ }
116
+
117
+ function laneRuntimeLabel(lane, now) {
118
+ const start = Date.parse(lane.startedAt ?? "");
119
+ const end = Date.parse(lane.completedAt ?? "");
120
+ if (Number.isFinite(start) && Number.isFinite(end)) return compactDuration(end - start);
121
+ if (Number.isFinite(start)) return compactDuration(now - start);
122
+ return "-";
123
+ }
124
+
125
+ function workflowToastSnapshot(run, options = {}) {
126
+ return workflowToastCardSnapshot(run, options);
127
+ }
128
+
129
+ function workflowBudgetLabel(snapshot) {
130
+ if (Number.isFinite(snapshot.budgetPercent)) return ` budget=${snapshot.budgetPercent}%`;
131
+ const labels = [];
132
+ const maxCost = snapshot.budgetCeilings?.maxCost;
133
+ if (Number.isFinite(maxCost) && maxCost > 0) labels.push(`cost ${Math.round((Number(snapshot.cost) / maxCost) * 100)}%`);
134
+ const maxTokens = snapshot.budgetCeilings?.maxTokens;
135
+ if (Number.isFinite(maxTokens) && maxTokens > 0) labels.push(`tok ${Math.round((snapshot.tokens / maxTokens) * 100)}%`);
136
+ return labels.length ? ` budget=${labels.join(",")}` : "";
137
+ }
138
+
139
+ function workflowToastMessage(snapshot, options = {}) {
140
+ const maxChars = Number.isFinite(options.maxChars) ? options.maxChars : WORKFLOW_TOAST_MESSAGE_MAX_CHARS;
141
+ return truncateText(redactFreeTextSecrets(renderWorkflowHeartbeatCard(snapshot, options).message), maxChars);
142
+ }
143
+
144
+ function workflowPhaseLabel(run) {
145
+ const phase = run.currentPhase ? String(run.currentPhase) : "-";
146
+ const phases = Array.isArray(run.meta?.phases) ? run.meta.phases.map((item) => String(item)) : [];
147
+ if (phase === "-" || phases.length === 0) return phase;
148
+ const index = phases.indexOf(phase);
149
+ return index >= 0 ? `${phase}(${index + 1}/${phases.length})` : phase;
150
+ }
151
+
152
+ function workflowProgressToastMessage(run) {
153
+ return workflowToastMessage(workflowToastSnapshot(run));
154
+ }
155
+
156
+ function workflowHeartbeatToastCard(run, options = {}) {
157
+ return renderWorkflowHeartbeatCard(workflowToastSnapshot(run, options), options);
158
+ }
159
+
160
+ // Preserve exported aliases while keeping one canonical message formatter.
161
+ const workflowStartToastMessage = workflowProgressToastMessage;
162
+
163
+ function workflowTerminalToastMessage(run, options = {}) {
164
+ return renderWorkflowTerminalCard(workflowToastSnapshot(run, options), options).message;
165
+ }
166
+
167
+ function workflowTerminalToastCard(run, options = {}) {
168
+ return renderWorkflowTerminalCard(workflowToastSnapshot(run, options), options);
169
+ }
170
+
171
+ function workflowApplyToastCard(run, options = {}) {
172
+ return renderWorkflowApplyCard(workflowToastSnapshot(run, options), options);
173
+ }
174
+
175
+ function workflowProgressToastSignature(run) {
176
+ return workflowToastCardSignature(workflowToastSnapshot(run));
177
+ }
178
+
179
+ function workflowToastOptions(pluginContext) {
180
+ return { ascii: pluginContext?.__workflowToastAscii === true };
181
+ }
182
+
183
+ function workflowToastPolicyState(run) {
184
+ run.toastPolicyState ??= createWorkflowToastPolicyState();
185
+ return run.toastPolicyState;
186
+ }
187
+
188
+ function renderWorkflowToastDecision(decision, pluginContext) {
189
+ const options = workflowToastOptions(pluginContext);
190
+ if (decision.card === "heartbeat") return renderWorkflowHeartbeatCard(decision.snapshot, options);
191
+ if (decision.card === "problem") return renderWorkflowProblemCard(decision.snapshot, decision.problem, options);
192
+ return undefined;
193
+ }
194
+
195
+ async function deliverWorkflowToastDecisions(pluginContext, decisions) {
196
+ let delivered = false;
197
+ for (const decision of decisions) {
198
+ const card = renderWorkflowToastDecision(decision, pluginContext);
199
+ if (!card) continue;
200
+ await showToast(pluginContext, card.variant, card.title, card.message);
201
+ delivered = true;
202
+ }
203
+ return delivered;
204
+ }
205
+
206
+ function createWorkflowToastEventSink(pluginContext, run, options = {}) {
207
+ if (!hasWorkflowToast(pluginContext)) return undefined;
208
+ return async (event) => {
209
+ if (run.status !== "running") return;
210
+ const now = Number.isFinite(options.now) ? options.now : Date.now();
211
+ const snapshot = workflowToastSnapshot(run, { now });
212
+ const decisions = evaluateWorkflowToastEventPolicy(workflowToastPolicyState(run), event, snapshot, {
213
+ now,
214
+ forceMs: options.forceMs,
215
+ problemCooldownMs: options.problemCooldownMs,
216
+ signature: workflowToastCardSignature(snapshot),
217
+ });
218
+ await deliverWorkflowToastDecisions(pluginContext, decisions);
219
+ };
220
+ }
221
+
222
+ async function maybeShowWorkflowProgressToast(pluginContext, run, options = {}) {
223
+ if (run.status !== "running") return false;
224
+ const now = Number.isFinite(options.now) ? options.now : Date.now();
225
+ const forceMs = Number.isFinite(options.forceMs) && options.forceMs > 0 ? options.forceMs : WORKFLOW_PROGRESS_TOAST_FORCE_MS;
226
+ const snapshot = workflowToastSnapshot(run, { now });
227
+ const signature = workflowToastCardSignature(snapshot);
228
+ const decisions = evaluateWorkflowToastTickPolicy(workflowToastPolicyState(run), snapshot, {
229
+ now,
230
+ forceMs,
231
+ problemCooldownMs: options.problemCooldownMs,
232
+ signature,
233
+ });
234
+ if (decisions.length === 0) return false;
235
+ if (decisions.some((decision) => decision.card === "heartbeat")) {
236
+ run.lastProgressToastSignature = signature;
237
+ run.lastProgressToastAt = now;
238
+ }
239
+ return await deliverWorkflowToastDecisions(pluginContext, decisions);
240
+ }
241
+
242
+ function startWorkflowProgressToasts(pluginContext, run, options = {}) {
243
+ if (!hasWorkflowToast(pluginContext)) return () => {};
244
+ const intervalMs = Number.isFinite(options.intervalMs) && options.intervalMs > 0 ? options.intervalMs : WORKFLOW_PROGRESS_TOAST_INTERVAL_MS;
245
+ const forceMs = Number.isFinite(options.forceMs) && options.forceMs > 0 ? options.forceMs : WORKFLOW_PROGRESS_TOAST_FORCE_MS;
246
+ const timer = setInterval(() => {
247
+ void maybeShowWorkflowProgressToast(pluginContext, run, { forceMs }).catch(() => {
248
+ // Progress toasts are best effort and must not affect workflow execution.
249
+ });
250
+ }, intervalMs);
251
+ timer.unref?.();
252
+ return () => clearInterval(timer);
253
+ }
254
+
255
+ export {
256
+ WORKFLOW_TOAST_MESSAGE_MAX_CHARS,
257
+ WORKFLOW_TOAST_MAX_LANES,
258
+ WORKFLOW_TOAST_DELIVERY_TIMEOUT_MS,
259
+ hasWorkflowToast,
260
+ showToast,
261
+ workflowDisplayName,
262
+ workflowQueuedAgents,
263
+ workflowTotalCost,
264
+ workflowCostLabel,
265
+ workflowTotalTokens,
266
+ compactDuration,
267
+ laneRecordsForRun,
268
+ lanePriority,
269
+ shortModel,
270
+ laneRuntimeLabel,
271
+ workflowToastSnapshot,
272
+ workflowBudgetLabel,
273
+ workflowToastMessage,
274
+ workflowPhaseLabel,
275
+ workflowStartToastMessage,
276
+ workflowHeartbeatToastCard,
277
+ workflowProgressToastMessage,
278
+ workflowTerminalToastMessage,
279
+ workflowTerminalToastCard,
280
+ workflowApplyToastCard,
281
+ workflowProgressToastSignature,
282
+ createWorkflowToastEventSink,
283
+ renderWorkflowToastDecision,
284
+ deliverWorkflowToastDecisions,
285
+ maybeShowWorkflowProgressToast,
286
+ startWorkflowProgressToasts,
287
+ };
@@ -0,0 +1,219 @@
1
+ import fs from "node:fs/promises";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export const DEFAULT_SECRET_GLOBS = [
6
+ "**/.env",
7
+ "**/.env.*",
8
+ ".env",
9
+ ".env.*",
10
+ "**/.aws/credentials",
11
+ "**/.aws/config",
12
+ "**/.kube/config",
13
+ "**/.ssh/id_ecdsa",
14
+ "**/.ssh/id_dsa",
15
+ "**/.netrc",
16
+ "**/.pgpass",
17
+ "**/.config/gcloud/credentials.db",
18
+ "**/.docker/config.json",
19
+ "**/*.pem",
20
+ "**/*.key",
21
+ "**/*credentials*",
22
+ "**/*secret*",
23
+ "**/id_rsa",
24
+ "**/id_ed25519",
25
+ ];
26
+
27
+ const CONTROL_PATH_SEGMENTS = new Set([".git", ".opencode"]);
28
+
29
+ // O_NOFOLLOW guarantees only that the *final* path component is not a symlink.
30
+ // O_DIRECTORY makes the open fail unless the component resolves to a directory.
31
+ const NOFOLLOW_DIR_FLAGS = fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_DIRECTORY;
32
+ // Create-or-truncate the final file, but refuse to follow a symlink at the final
33
+ // component. A swapped-in symlink (e.g. target replaced by a link to /etc/passwd)
34
+ // makes this open fail with ELOOP/ENOTDIR instead of redirecting the write.
35
+ const NOFOLLOW_WRITE_FLAGS =
36
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | fsConstants.O_NOFOLLOW;
37
+
38
+ function escapeRegex(value) {
39
+ return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
40
+ }
41
+
42
+ export function normalizePolicyPath(filePath) {
43
+ return String(filePath ?? "").replace(/\\/g, "/").replace(/^\.\/+/, "");
44
+ }
45
+
46
+ function globToRegExp(glob) {
47
+ const pattern = normalizePolicyPath(glob);
48
+ let output = "^";
49
+ for (let index = 0; index < pattern.length; index += 1) {
50
+ const char = pattern[index];
51
+ const next = pattern[index + 1];
52
+ if (char === "*" && next === "*") {
53
+ const after = pattern[index + 2];
54
+ if (after === "/") {
55
+ output += "(?:.*/)?";
56
+ index += 2;
57
+ } else {
58
+ output += ".*";
59
+ index += 1;
60
+ }
61
+ continue;
62
+ }
63
+ if (char === "*") {
64
+ output += "[^/]*";
65
+ continue;
66
+ }
67
+ if (char === "?") {
68
+ output += "[^/]";
69
+ continue;
70
+ }
71
+ output += escapeRegex(char);
72
+ }
73
+ return new RegExp(`${output}$`, "i");
74
+ }
75
+
76
+ export function matchesPolicyGlob(relativePath, glob) {
77
+ return globToRegExp(glob).test(normalizePolicyPath(relativePath));
78
+ }
79
+
80
+ export function protectedPathReason(relativePath, options = {}) {
81
+ const normalized = normalizePolicyPath(relativePath);
82
+ const segments = normalized.split("/");
83
+ if (segments.some((segment) => CONTROL_PATH_SEGMENTS.has(segment.toLowerCase()))) return "control-path";
84
+ const secretGlobs = Array.isArray(options.secretGlobs) ? options.secretGlobs : DEFAULT_SECRET_GLOBS;
85
+ if (secretGlobs.some((glob) => matchesPolicyGlob(normalized, glob))) return "secret-path";
86
+ return undefined;
87
+ }
88
+
89
+ export function assertWritableWorkflowPath(relativePath, options = {}) {
90
+ const reason = protectedPathReason(relativePath, options);
91
+ if (reason) throw new Error(`Patch target is protected (${reason}): ${relativePath}`);
92
+ }
93
+
94
+ export function pathContains(root, candidate) {
95
+ const relative = path.relative(root, candidate);
96
+ return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative));
97
+ }
98
+
99
+ // Resolve the real path that an *already-open* file descriptor points at, so we can
100
+ // re-confirm containment against the kernel's view of where the fd actually landed
101
+ // (after all symlink/.. resolution) rather than re-resolving a path string that an
102
+ // attacker could swap again. Linux exposes this via /proc/self/fd/<fd>.
103
+ async function fdRealPath(handle) {
104
+ try {
105
+ return await fs.readlink(`/proc/self/fd/${handle.fd}`);
106
+ } catch {
107
+ return undefined;
108
+ }
109
+ }
110
+
111
+ async function requiredFdRealPath(handle, target) {
112
+ const real = await fdRealPath(handle);
113
+ if (real === undefined) {
114
+ throw new Error(`Patch write requires fd realpath support for TOCTOU-safe containment: ${target}`);
115
+ }
116
+ return real;
117
+ }
118
+
119
+ // TOCTOU-safe write for a workflow patch target.
120
+ //
121
+ // validatePatchTargets lstat-checks ancestors + target up front, but the write
122
+ // happens later; a concurrent local actor with tree-write access can swap a
123
+ // validated dir/file for a symlink to /etc between validation and write, landing
124
+ // the write outside `root` (R18 / opencode-workflows-994). The per-run advisory
125
+ // lock does not protect against external processes.
126
+ //
127
+ // Defense, immediately at write time (no exploitable window):
128
+ // 1. Re-validate each ancestor directory with O_NOFOLLOW|O_DIRECTORY opens, and
129
+ // confirm each opened dir fd's real path still lies inside `root`. A swapped-in
130
+ // ancestor symlink makes its O_NOFOLLOW open fail (ELOOP/ENOTDIR); a redirected
131
+ // ancestor makes the fd realpath escape `root`.
132
+ // 2. Open the final component with O_NOFOLLOW|O_CREAT|O_WRONLY|O_TRUNC so a
133
+ // symlinked final component is rejected at open (it is never followed).
134
+ // 3. Open-then-validate-fd: re-confirm the opened *target* fd's real path is still
135
+ // inside `root` before writing a byte; bail (and close) on any escape.
136
+ // 4. Write + fsync-free flush through that exact fd — never re-resolving the path.
137
+ export async function safeWriteFileWithinRoot(root, target, content) {
138
+ const rootReal = await fs.realpath(root);
139
+ let rootHandle;
140
+ try {
141
+ rootHandle = await fs.open(rootReal, NOFOLLOW_DIR_FLAGS);
142
+ const real = await requiredFdRealPath(rootHandle, target);
143
+ if (!pathContains(rootReal, real)) throw new Error(`Patch root escapes primary root: ${target}`);
144
+ } finally {
145
+ await rootHandle?.close();
146
+ }
147
+ // Resolve the target against the *realpathed* root, not the raw `root`. Call sites
148
+ // derive root via path.resolve(context.worktree||directory), which normalizes ./..
149
+ // but does NOT resolve symlinks, so when the apply root has a symlinked ancestor
150
+ // (macOS /tmp->/private/tmp, symlinked $HOME/worktree parent — see
151
+ // tests/drain-runtime.test.mjs R15 and worktree-adapter realpath handling) the
152
+ // un-realpathed targetAbs would diverge from rootReal and the containment check
153
+ // below would reject every legitimate write (R18-followup / opencode-workflows-2gs).
154
+ // Resolving against rootReal keeps the .. / absolute-target escape check intact
155
+ // (path.resolve still collapses ../ and absolute targets) while letting legit
156
+ // nested writes through under a symlinked-ancestor root.
157
+ const targetAbs = path.resolve(rootReal, target);
158
+ if (!pathContains(rootReal, targetAbs)) {
159
+ throw new Error(`Patch target escapes primary root: ${target}`);
160
+ }
161
+
162
+ // Walk + create ancestors with O_NOFOLLOW dir opens so no ancestor component can be
163
+ // a freshly swapped-in symlink redirecting the write tree.
164
+ const relParts = path.relative(rootReal, targetAbs).split(path.sep).filter(Boolean);
165
+ if (relParts.length === 0) {
166
+ throw new Error(`Patch target must name a file: ${target}`);
167
+ }
168
+ const dirParts = relParts.slice(0, -1);
169
+ let currentReal = rootReal;
170
+ for (const part of dirParts) {
171
+ const childPath = path.join(currentReal, part);
172
+ let dirHandle;
173
+ try {
174
+ dirHandle = await fs.open(childPath, NOFOLLOW_DIR_FLAGS);
175
+ } catch (error) {
176
+ if (error && error.code === "ENOENT") {
177
+ // Create the missing directory (mkdir refuses to follow/replace, and a racing
178
+ // symlink at this name makes the subsequent O_NOFOLLOW open below fail).
179
+ await fs.mkdir(childPath);
180
+ dirHandle = await fs.open(childPath, NOFOLLOW_DIR_FLAGS);
181
+ } else if (error && (error.code === "ELOOP" || error.code === "ENOTDIR")) {
182
+ throw new Error(`Patch ancestor is a symlink: ${target}`);
183
+ } else {
184
+ throw error;
185
+ }
186
+ }
187
+ try {
188
+ const real = await requiredFdRealPath(dirHandle, target);
189
+ if (!pathContains(rootReal, real)) {
190
+ throw new Error(`Patch ancestor escapes primary root: ${target}`);
191
+ }
192
+ currentReal = real;
193
+ } finally {
194
+ await dirHandle.close();
195
+ }
196
+ }
197
+
198
+ const finalPath = path.join(currentReal, relParts.at(-1));
199
+ let handle;
200
+ try {
201
+ handle = await fs.open(finalPath, NOFOLLOW_WRITE_FLAGS, 0o644);
202
+ } catch (error) {
203
+ if (error && (error.code === "ELOOP" || error.code === "ENOTDIR")) {
204
+ throw new Error(`Patch target is a symlink: ${target}`);
205
+ }
206
+ throw error;
207
+ }
208
+ try {
209
+ const real = await requiredFdRealPath(handle, target);
210
+ if (!pathContains(rootReal, real)) {
211
+ // An ancestor was redirected after our per-ancestor checks; the fd points
212
+ // outside root. Abort before writing a single byte.
213
+ throw new Error(`Patch target escapes primary root after open: ${target}`);
214
+ }
215
+ await handle.writeFile(content, "utf8");
216
+ } finally {
217
+ await handle.close();
218
+ }
219
+ }
@@ -0,0 +1,106 @@
1
+ import {
2
+ MAX_INLINE_RESULT_BYTES,
3
+ MAX_RESULT_READBACK_BYTES,
4
+ MAX_RESULT_READ_FILE_BYTES,
5
+ } from "./constants.js";
6
+ import { redactValue } from "./text-json.js";
7
+
8
+ const FULL_RESULT_REDACTION = {
9
+ maxDepth: Number.POSITIVE_INFINITY,
10
+ maxString: Number.POSITIVE_INFINITY,
11
+ maxArray: Number.POSITIVE_INFINITY,
12
+ };
13
+
14
+ const PARTIAL_READBACK_LIMITS = [
15
+ { maxDepth: Number.POSITIVE_INFINITY, maxString: 16 * 1024, maxArray: 500 },
16
+ { maxDepth: Number.POSITIVE_INFINITY, maxString: 8 * 1024, maxArray: 250 },
17
+ { maxDepth: 20, maxString: 4 * 1024, maxArray: 100 },
18
+ { maxDepth: 12, maxString: 2 * 1024, maxArray: 50 },
19
+ { maxDepth: 8, maxString: 1024, maxArray: 25 },
20
+ { maxDepth: 6, maxString: 600, maxArray: 10 },
21
+ { maxDepth: 4, maxString: 240, maxArray: 5 },
22
+ ];
23
+
24
+ function normalizeJsonValue(value) {
25
+ return value === undefined ? null : value;
26
+ }
27
+
28
+ function jsonText(value, { pretty = false } = {}) {
29
+ return JSON.stringify(normalizeJsonValue(value), null, pretty ? 2 : 0);
30
+ }
31
+
32
+ function byteLength(text) {
33
+ return Buffer.byteLength(String(text ?? ""), "utf8");
34
+ }
35
+
36
+ function serializedBytes(value) {
37
+ return byteLength(jsonText(value));
38
+ }
39
+
40
+ function resultSummary(value) {
41
+ if (Array.isArray(value)) return { type: "array", length: value.length };
42
+ if (value && typeof value === "object") return { type: "object", keys: Object.keys(value).slice(0, 50) };
43
+ return { type: value === null ? "null" : typeof value };
44
+ }
45
+
46
+ export function redactResultValue(value, options = {}) {
47
+ return redactValue(value, { ...FULL_RESULT_REDACTION, ...options });
48
+ }
49
+
50
+ export function inlineResultProjection(value, { maxBytes = MAX_INLINE_RESULT_BYTES } = {}) {
51
+ const result = redactResultValue(value);
52
+ const text = jsonText(result, { pretty: true });
53
+ const bytes = byteLength(text);
54
+ if (bytes > maxBytes) return { inline: false, bytes, maxBytes };
55
+ return { inline: true, bytes, maxBytes, text, result };
56
+ }
57
+
58
+ export function resultReadbackProjection(value, { maxBytes = MAX_RESULT_READBACK_BYTES } = {}) {
59
+ const full = redactResultValue(value);
60
+ const fullBytes = serializedBytes(full);
61
+ if (fullBytes <= maxBytes) {
62
+ return {
63
+ result: full,
64
+ resultReadback: {
65
+ mode: "full",
66
+ truncated: false,
67
+ bytes: fullBytes,
68
+ maxBytes,
69
+ },
70
+ };
71
+ }
72
+
73
+ for (const limits of PARTIAL_READBACK_LIMITS) {
74
+ const candidate = redactResultValue(value, limits);
75
+ const bytes = serializedBytes(candidate);
76
+ if (bytes <= maxBytes) {
77
+ return {
78
+ result: candidate,
79
+ resultReadback: {
80
+ mode: "partial",
81
+ truncated: true,
82
+ bytes,
83
+ fullBytes,
84
+ maxBytes,
85
+ limits,
86
+ },
87
+ };
88
+ }
89
+ }
90
+
91
+ const summary = {
92
+ status: "truncated",
93
+ reason: `Result readback exceeds ${maxBytes} bytes after bounded projection`,
94
+ summary: resultSummary(full),
95
+ };
96
+ return {
97
+ result: summary,
98
+ resultReadback: {
99
+ mode: "summary",
100
+ truncated: true,
101
+ bytes: serializedBytes(summary),
102
+ fullBytes,
103
+ maxBytes,
104
+ },
105
+ };
106
+ }