harnery 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +193 -0
- package/dist/core/agents/coord-client.d.ts +8 -0
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +18 -2
- package/dist/core/policy/types.d.ts +1 -1
- package/dist/core/policy/types.d.ts.map +1 -1
- package/dist/core/scratch/index.d.ts.map +1 -1
- package/dist/core/scratch/index.js +3 -3
- package/dist/core/workflow/approvals.d.ts +71 -0
- package/dist/core/workflow/approvals.d.ts.map +1 -0
- package/dist/core/workflow/approvals.js +288 -0
- package/dist/core/workflow/engine.d.ts +6 -0
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +224 -43
- package/dist/core/workflow/index.d.ts +6 -2
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/index.js +3 -1
- package/dist/core/workflow/run-state.d.ts +43 -0
- package/dist/core/workflow/run-state.d.ts.map +1 -0
- package/dist/core/workflow/run-state.js +231 -0
- package/dist/core/workflow/types.d.ts +9 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/commands/workflow.ts +226 -0
- package/src/core/agents/coord-client.ts +19 -2
- package/src/core/policy/types.ts +1 -1
- package/src/core/scratch/index.ts +3 -2
- package/src/core/workflow/approvals.ts +393 -0
- package/src/core/workflow/engine.ts +268 -44
- package/src/core/workflow/index.ts +31 -1
- package/src/core/workflow/run-state.ts +287 -0
- package/src/core/workflow/types.ts +9 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { chmodSync, existsSync, linkSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
export const WORKFLOW_APPROVAL_SCHEMA_VERSION = 1;
|
|
5
|
+
const RECORD_LIMIT_BYTES = 128 * 1024;
|
|
6
|
+
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
|
|
7
|
+
const APPROVAL_ID = /^apr-[a-f0-9]{20}$/;
|
|
8
|
+
const DECISION_ID = /^p[1-9][0-9]{0,4}$/;
|
|
9
|
+
const MAX_ACTOR = 200;
|
|
10
|
+
const MAX_REASON = 2_000;
|
|
11
|
+
const MAX_ADDRESSEE = 200;
|
|
12
|
+
export function workflowApprovalId(runId, decisionId) {
|
|
13
|
+
assertRunId(runId);
|
|
14
|
+
if (!DECISION_ID.test(decisionId)) {
|
|
15
|
+
throw new Error(`invalid workflow policy decision id ${JSON.stringify(decisionId)}`);
|
|
16
|
+
}
|
|
17
|
+
return `apr-${createHash("sha256").update(`${runId}\0${decisionId}`).digest("hex").slice(0, 20)}`;
|
|
18
|
+
}
|
|
19
|
+
export function createWorkflowApproval(input) {
|
|
20
|
+
const id = workflowApprovalId(input.runId, input.decisionId);
|
|
21
|
+
const addressedTo = bounded(input.addressedTo ?? "operator", "approval addressee", MAX_ADDRESSEE);
|
|
22
|
+
const requestSha256 = approvalRequestDigest(input.policy.sha256, input.request, input.evaluation);
|
|
23
|
+
const record = {
|
|
24
|
+
schema_version: WORKFLOW_APPROVAL_SCHEMA_VERSION,
|
|
25
|
+
id,
|
|
26
|
+
run_id: input.runId,
|
|
27
|
+
decision_id: input.decisionId,
|
|
28
|
+
requested_at: (input.now ?? (() => new Date().toISOString()))(),
|
|
29
|
+
addressed_to: addressedTo,
|
|
30
|
+
policy: {
|
|
31
|
+
name: bounded(input.policy.name, "approval policy name", MAX_ACTOR),
|
|
32
|
+
sha256: sha256(input.policy.sha256, "approval policy sha256"),
|
|
33
|
+
},
|
|
34
|
+
initial_verdict: "ask",
|
|
35
|
+
request: input.request,
|
|
36
|
+
evaluation: input.evaluation,
|
|
37
|
+
request_sha256: requestSha256,
|
|
38
|
+
};
|
|
39
|
+
const dir = approvalDir(input.coordRoot, id);
|
|
40
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
41
|
+
chmodSync(dir, 0o700);
|
|
42
|
+
const created = writeExclusiveJson(join(dir, "request.json"), record);
|
|
43
|
+
const approval = readWorkflowApproval(input.coordRoot, id);
|
|
44
|
+
if (approval.request.run_id !== record.run_id ||
|
|
45
|
+
approval.request.decision_id !== record.decision_id ||
|
|
46
|
+
approval.request.request_sha256 !== record.request_sha256 ||
|
|
47
|
+
approval.request.policy.sha256 !== record.policy.sha256) {
|
|
48
|
+
throw new Error(`approval ${id} already exists for a different policy request`);
|
|
49
|
+
}
|
|
50
|
+
return { approval, created };
|
|
51
|
+
}
|
|
52
|
+
export function resolveWorkflowApproval(input) {
|
|
53
|
+
const approval = readWorkflowApproval(input.coordRoot, input.approvalId);
|
|
54
|
+
const decision = {
|
|
55
|
+
schema_version: WORKFLOW_APPROVAL_SCHEMA_VERSION,
|
|
56
|
+
approval_id: approval.request.id,
|
|
57
|
+
run_id: approval.request.run_id,
|
|
58
|
+
verdict: input.verdict,
|
|
59
|
+
actor: bounded(input.actor, "approval actor", MAX_ACTOR),
|
|
60
|
+
reason: input.reason === undefined ? undefined : bounded(input.reason, "approval reason", MAX_REASON),
|
|
61
|
+
decided_at: (input.now ?? (() => new Date().toISOString()))(),
|
|
62
|
+
};
|
|
63
|
+
const applied = writeExclusiveJson(join(approvalDir(input.coordRoot, input.approvalId), "decision.json"), decision);
|
|
64
|
+
const resolved = readWorkflowApproval(input.coordRoot, input.approvalId);
|
|
65
|
+
if (resolved.decision?.verdict !== input.verdict) {
|
|
66
|
+
throw new Error(`approval ${input.approvalId} is already ${resolved.status}; conflicting ${input.verdict === "allow" ? "approval" : "denial"} refused`);
|
|
67
|
+
}
|
|
68
|
+
if (applied)
|
|
69
|
+
appendResolutionJournal(input.coordRoot, resolved);
|
|
70
|
+
return { approval: resolved, applied };
|
|
71
|
+
}
|
|
72
|
+
export function readWorkflowApproval(coordRoot, approvalId) {
|
|
73
|
+
assertApprovalId(approvalId);
|
|
74
|
+
const dir = approvalDir(coordRoot, approvalId);
|
|
75
|
+
const request = readJsonRecord(join(dir, "request.json"));
|
|
76
|
+
validateRequest(request, approvalId);
|
|
77
|
+
const decisionPath = join(dir, "decision.json");
|
|
78
|
+
const decision = existsSync(decisionPath)
|
|
79
|
+
? readJsonRecord(decisionPath)
|
|
80
|
+
: undefined;
|
|
81
|
+
if (decision)
|
|
82
|
+
validateDecision(decision, request);
|
|
83
|
+
return {
|
|
84
|
+
request,
|
|
85
|
+
decision,
|
|
86
|
+
status: decision?.verdict === "allow" ? "approved" : decision ? "denied" : "pending",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
export function listWorkflowApprovals(coordRoot, options = {}) {
|
|
90
|
+
if (options.runId)
|
|
91
|
+
assertRunId(options.runId);
|
|
92
|
+
const root = approvalsRoot(coordRoot);
|
|
93
|
+
if (!existsSync(root))
|
|
94
|
+
return [];
|
|
95
|
+
const records = [];
|
|
96
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
97
|
+
if (!entry.isDirectory() || !APPROVAL_ID.test(entry.name))
|
|
98
|
+
continue;
|
|
99
|
+
try {
|
|
100
|
+
const approval = readWorkflowApproval(coordRoot, entry.name);
|
|
101
|
+
if (options.status && approval.status !== options.status)
|
|
102
|
+
continue;
|
|
103
|
+
if (options.runId && approval.request.run_id !== options.runId)
|
|
104
|
+
continue;
|
|
105
|
+
records.push(approval);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// A malformed record fails closed when addressed directly, but one bad
|
|
109
|
+
// directory must not hide the rest of the operator's inbox.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return records.sort((a, b) => b.request.requested_at.localeCompare(a.request.requested_at));
|
|
113
|
+
}
|
|
114
|
+
export function renderWorkflowApproval(approval) {
|
|
115
|
+
const lines = [
|
|
116
|
+
`${approval.request.id}: ${approval.status}`,
|
|
117
|
+
`run: ${approval.request.run_id}`,
|
|
118
|
+
`addressed to: ${approval.request.addressed_to}`,
|
|
119
|
+
`requested: ${approval.request.requested_at}`,
|
|
120
|
+
`policy: ${approval.request.policy.name}`,
|
|
121
|
+
`phase: ${approval.request.request.phase}`,
|
|
122
|
+
`action: ${approval.request.request.action}`,
|
|
123
|
+
`reason: ${approval.request.evaluation.reason}`,
|
|
124
|
+
];
|
|
125
|
+
if (approval.decision) {
|
|
126
|
+
lines.push(`decided: ${approval.decision.decided_at} by ${approval.decision.actor}`);
|
|
127
|
+
if (approval.decision.reason)
|
|
128
|
+
lines.push(`decision reason: ${approval.decision.reason}`);
|
|
129
|
+
}
|
|
130
|
+
return `${lines.join("\n")}\n`;
|
|
131
|
+
}
|
|
132
|
+
export function assertWorkflowRunId(runId) {
|
|
133
|
+
assertRunId(runId);
|
|
134
|
+
}
|
|
135
|
+
function appendResolutionJournal(coordRoot, approval) {
|
|
136
|
+
const journalPath = join(coordRoot, ".harnery", "workflows", approval.request.run_id, "journal.jsonl");
|
|
137
|
+
if (!existsSync(journalPath) || !approval.decision)
|
|
138
|
+
return;
|
|
139
|
+
const event = {
|
|
140
|
+
schema_version: 1,
|
|
141
|
+
run_id: approval.request.run_id,
|
|
142
|
+
ts: approval.decision.decided_at,
|
|
143
|
+
event: "approval.resolved",
|
|
144
|
+
stage: "",
|
|
145
|
+
approval_id: approval.request.id,
|
|
146
|
+
decision_id: approval.request.decision_id,
|
|
147
|
+
verdict: approval.decision.verdict,
|
|
148
|
+
actor: approval.decision.actor,
|
|
149
|
+
reason: approval.decision.reason ?? null,
|
|
150
|
+
};
|
|
151
|
+
writeFileSync(journalPath, `${JSON.stringify(event)}\n`, { encoding: "utf8", flag: "a" });
|
|
152
|
+
}
|
|
153
|
+
function approvalRequestDigest(policySha256, request, evaluation) {
|
|
154
|
+
return createHash("sha256")
|
|
155
|
+
.update(JSON.stringify({ policy_sha256: policySha256, request, evaluation }))
|
|
156
|
+
.digest("hex");
|
|
157
|
+
}
|
|
158
|
+
function writeExclusiveJson(path, value) {
|
|
159
|
+
const body = `${JSON.stringify(value, null, 2)}\n`;
|
|
160
|
+
if (Buffer.byteLength(body) > RECORD_LIMIT_BYTES) {
|
|
161
|
+
throw new Error(`approval record exceeds ${RECORD_LIMIT_BYTES} bytes`);
|
|
162
|
+
}
|
|
163
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
164
|
+
const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
|
165
|
+
try {
|
|
166
|
+
writeFileSync(temporary, body, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
167
|
+
try {
|
|
168
|
+
linkSync(temporary, path);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (error.code === "EEXIST")
|
|
172
|
+
return false;
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
chmodSync(path, 0o600);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
if (existsSync(temporary))
|
|
180
|
+
unlinkSync(temporary);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function readJsonRecord(path) {
|
|
184
|
+
if (!existsSync(path))
|
|
185
|
+
throw new Error(`approval record not found at ${path}`);
|
|
186
|
+
const bytes = statSync(path).size;
|
|
187
|
+
if (bytes <= 0 || bytes > RECORD_LIMIT_BYTES) {
|
|
188
|
+
throw new Error(`approval record at ${path} has invalid size ${bytes}`);
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
throw new Error(`cannot parse approval record at ${path}: ${error.message}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function validateRequest(request, approvalId) {
|
|
198
|
+
if (request.schema_version !== WORKFLOW_APPROVAL_SCHEMA_VERSION ||
|
|
199
|
+
request.id !== approvalId ||
|
|
200
|
+
!RUN_ID.test(request.run_id) ||
|
|
201
|
+
!DECISION_ID.test(request.decision_id) ||
|
|
202
|
+
workflowApprovalId(request.run_id, request.decision_id) !== approvalId ||
|
|
203
|
+
!validTimestamp(request.requested_at) ||
|
|
204
|
+
bounded(request.addressed_to, "approval addressee", MAX_ADDRESSEE) !== request.addressed_to ||
|
|
205
|
+
bounded(request.policy?.name, "approval policy name", MAX_ACTOR) !== request.policy.name ||
|
|
206
|
+
request.initial_verdict !== "ask" ||
|
|
207
|
+
!/^[a-f0-9]{64}$/.test(request.request_sha256) ||
|
|
208
|
+
!/^[a-f0-9]{64}$/.test(request.policy?.sha256) ||
|
|
209
|
+
!validPolicyRequest(request.request) ||
|
|
210
|
+
!validPolicyEvaluation(request.evaluation) ||
|
|
211
|
+
approvalRequestDigest(request.policy.sha256, request.request, request.evaluation) !==
|
|
212
|
+
request.request_sha256) {
|
|
213
|
+
throw new Error(`approval ${approvalId} has an unsupported or mismatched request record`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function validateDecision(decision, request) {
|
|
217
|
+
if (decision.schema_version !== WORKFLOW_APPROVAL_SCHEMA_VERSION ||
|
|
218
|
+
decision.approval_id !== request.id ||
|
|
219
|
+
decision.run_id !== request.run_id ||
|
|
220
|
+
(decision.verdict !== "allow" && decision.verdict !== "deny") ||
|
|
221
|
+
bounded(decision.actor, "approval actor", MAX_ACTOR) !== decision.actor ||
|
|
222
|
+
(decision.reason !== undefined &&
|
|
223
|
+
bounded(decision.reason, "approval reason", MAX_REASON) !== decision.reason) ||
|
|
224
|
+
!validTimestamp(decision.decided_at)) {
|
|
225
|
+
throw new Error(`approval ${request.id} has an unsupported or mismatched decision record`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function validPolicyRequest(request) {
|
|
229
|
+
if (!request || typeof request !== "object")
|
|
230
|
+
return false;
|
|
231
|
+
return ((request.phase === "dispatch" || request.phase === "external_mutation") &&
|
|
232
|
+
typeof request.action === "string" &&
|
|
233
|
+
request.action.length > 0 &&
|
|
234
|
+
["shared", "worktree", "sandbox", "remote"].includes(request.isolation) &&
|
|
235
|
+
["enabled", "disabled", "unknown"].includes(request.network_access));
|
|
236
|
+
}
|
|
237
|
+
function validPolicyEvaluation(evaluation) {
|
|
238
|
+
if (!evaluation || typeof evaluation !== "object")
|
|
239
|
+
return false;
|
|
240
|
+
return (evaluation.verdict === "ask" &&
|
|
241
|
+
typeof evaluation.reason === "string" &&
|
|
242
|
+
evaluation.reason.length > 0 &&
|
|
243
|
+
Array.isArray(evaluation.rules) &&
|
|
244
|
+
evaluation.rules.every((rule) => rule &&
|
|
245
|
+
typeof rule.code === "string" &&
|
|
246
|
+
(rule.verdict === "allow" || rule.verdict === "ask" || rule.verdict === "deny") &&
|
|
247
|
+
typeof rule.reason === "string"));
|
|
248
|
+
}
|
|
249
|
+
function validTimestamp(value) {
|
|
250
|
+
return typeof value === "string" && value.length <= 40 && Number.isFinite(Date.parse(value));
|
|
251
|
+
}
|
|
252
|
+
function approvalsRoot(coordRoot) {
|
|
253
|
+
return join(coordRoot, ".harnery", "approvals");
|
|
254
|
+
}
|
|
255
|
+
function approvalDir(coordRoot, approvalId) {
|
|
256
|
+
assertApprovalId(approvalId);
|
|
257
|
+
return join(approvalsRoot(coordRoot), approvalId);
|
|
258
|
+
}
|
|
259
|
+
function assertApprovalId(approvalId) {
|
|
260
|
+
if (!APPROVAL_ID.test(approvalId)) {
|
|
261
|
+
throw new Error(`invalid workflow approval id ${JSON.stringify(approvalId)}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function assertRunId(runId) {
|
|
265
|
+
if (!RUN_ID.test(runId))
|
|
266
|
+
throw new Error(`invalid workflow run id ${JSON.stringify(runId)}`);
|
|
267
|
+
}
|
|
268
|
+
function sha256(value, field) {
|
|
269
|
+
if (!/^[a-f0-9]{64}$/.test(value))
|
|
270
|
+
throw new Error(`${field} must be a SHA-256 digest`);
|
|
271
|
+
return value;
|
|
272
|
+
}
|
|
273
|
+
function bounded(value, field, max) {
|
|
274
|
+
if (typeof value !== "string")
|
|
275
|
+
throw new Error(`${field} must be a string`);
|
|
276
|
+
const normalized = Array.from(value, (character) => {
|
|
277
|
+
const code = character.charCodeAt(0);
|
|
278
|
+
return code < 32 || code === 127 ? " " : character;
|
|
279
|
+
})
|
|
280
|
+
.join("")
|
|
281
|
+
.replace(/\s+/g, " ")
|
|
282
|
+
.trim();
|
|
283
|
+
if (!normalized)
|
|
284
|
+
throw new Error(`${field} must not be empty`);
|
|
285
|
+
if (normalized.length > max)
|
|
286
|
+
throw new Error(`${field} exceeds ${max} characters`);
|
|
287
|
+
return normalized;
|
|
288
|
+
}
|
|
@@ -22,5 +22,11 @@ export declare class WorkflowRunError extends Error {
|
|
|
22
22
|
readonly proofPath: string;
|
|
23
23
|
constructor(message: string, runId: string, proofPath: string, cause?: unknown);
|
|
24
24
|
}
|
|
25
|
+
export declare class WorkflowParkedError extends Error {
|
|
26
|
+
readonly runId: string;
|
|
27
|
+
readonly approvalId: string;
|
|
28
|
+
readonly journalPath: string;
|
|
29
|
+
constructor(message: string, runId: string, approvalId: string, journalPath: string);
|
|
30
|
+
}
|
|
25
31
|
export declare function runWorkflow(scriptPath: string, opts: EngineOpts): Promise<RunReport>;
|
|
26
32
|
//# sourceMappingURL=engine.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../../src/core/workflow/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../../src/core/workflow/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAoCH,OAAO,KAAK,EAEV,UAAU,EAEV,SAAS,EAOV,MAAM,YAAY,CAAC;AAYpB,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;gBAEf,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO;CAM/E;AAED,qBAAa,mBAAoB,SAAQ,KAAK;IAC5C,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;gBAEjB,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM;CAOpF;AAED,wBAAsB,WAAW,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CA2B1F"}
|