harnery 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +1 -1
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +2 -0
  4. package/dist/commands/browse.js +1 -3
  5. package/dist/commands/eml.d.ts.map +1 -1
  6. package/dist/commands/eml.js +11 -50
  7. package/dist/commands/policy.d.ts +6 -0
  8. package/dist/commands/policy.d.ts.map +1 -0
  9. package/dist/commands/policy.js +42 -0
  10. package/dist/commands/workflow.d.ts.map +1 -1
  11. package/dist/commands/workflow.js +18 -1
  12. package/dist/core/agents/coord-client.d.ts +7 -0
  13. package/dist/core/agents/coord-client.d.ts.map +1 -1
  14. package/dist/core/agents/coord-client.js +27 -1
  15. package/dist/core/policy/evaluate.d.ts +14 -0
  16. package/dist/core/policy/evaluate.d.ts.map +1 -0
  17. package/dist/core/policy/evaluate.js +238 -0
  18. package/dist/core/policy/file.d.ts +3 -0
  19. package/dist/core/policy/file.d.ts.map +1 -0
  20. package/dist/core/policy/file.js +20 -0
  21. package/dist/core/policy/index.d.ts +5 -0
  22. package/dist/core/policy/index.d.ts.map +1 -0
  23. package/dist/core/policy/index.js +3 -0
  24. package/dist/core/policy/types.d.ts +91 -0
  25. package/dist/core/policy/types.d.ts.map +1 -0
  26. package/dist/core/policy/types.js +1 -0
  27. package/dist/core/scratch/index.d.ts.map +1 -1
  28. package/dist/core/scratch/index.js +14 -4
  29. package/dist/core/workflow/engine.d.ts.map +1 -1
  30. package/dist/core/workflow/engine.js +261 -35
  31. package/dist/core/workflow/index.d.ts +1 -1
  32. package/dist/core/workflow/index.d.ts.map +1 -1
  33. package/dist/core/workflow/proof.d.ts +7 -0
  34. package/dist/core/workflow/proof.d.ts.map +1 -1
  35. package/dist/core/workflow/proof.js +33 -0
  36. package/dist/core/workflow/types.d.ts +33 -0
  37. package/dist/core/workflow/types.d.ts.map +1 -1
  38. package/dist/lib/browser/client.d.ts +1 -1
  39. package/dist/lib/browser/client.d.ts.map +1 -1
  40. package/dist/lib/browser/client.js +7 -1
  41. package/package.json +9 -4
  42. package/src/commander.ts +2 -0
  43. package/src/commands/browse.ts +1 -5
  44. package/src/commands/eml.ts +11 -62
  45. package/src/commands/policy.ts +50 -0
  46. package/src/commands/workflow.ts +29 -1
  47. package/src/core/agents/coord-client.ts +28 -1
  48. package/src/core/policy/evaluate.ts +311 -0
  49. package/src/core/policy/file.ts +20 -0
  50. package/src/core/policy/index.ts +26 -0
  51. package/src/core/policy/types.ts +107 -0
  52. package/src/core/scratch/index.ts +20 -4
  53. package/src/core/workflow/engine.ts +283 -41
  54. package/src/core/workflow/index.ts +1 -0
  55. package/src/core/workflow/proof.ts +51 -0
  56. package/src/core/workflow/types.ts +43 -0
  57. package/src/lib/browser/client.ts +6 -1
@@ -0,0 +1,311 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isAbsolute, relative, resolve } from "node:path";
3
+ import type {
4
+ NormalizedPolicy,
5
+ PolicyEvaluation,
6
+ PolicyIsolation,
7
+ PolicyRequest,
8
+ PolicyRequestSummary,
9
+ PolicyRuleResult,
10
+ PolicySpec,
11
+ PolicyVerdict,
12
+ } from "./types.ts";
13
+ import { POLICY_SCHEMA_VERSION } from "./types.ts";
14
+
15
+ const VERDICTS = ["allow", "ask", "deny"] as const;
16
+ const ISOLATIONS = ["shared", "worktree", "sandbox", "remote"] as const;
17
+ const MAX_NAME = 200;
18
+ const MAX_FIELD = 500;
19
+ const MAX_LIST = 100;
20
+ const POLICY_FIELDS = new Set([
21
+ "schema_version",
22
+ "name",
23
+ "max_cost_usd",
24
+ "unknown_cost",
25
+ "allowed_harnesses",
26
+ "allowed_models",
27
+ "allowed_paths",
28
+ "network",
29
+ "external_actions",
30
+ "allowed_isolation",
31
+ ]);
32
+
33
+ export class PolicyDeniedError extends Error {
34
+ readonly decisionId: string;
35
+
36
+ constructor(message: string, decisionId: string) {
37
+ super(message);
38
+ this.name = "PolicyDeniedError";
39
+ this.decisionId = decisionId;
40
+ }
41
+ }
42
+
43
+ export function normalizePolicy(
44
+ input: PolicySpec,
45
+ options: { baseDir?: string } = {},
46
+ ): Readonly<NormalizedPolicy> {
47
+ if (!isRecord(input)) throw new Error("policy must be an object");
48
+ const unknownFields = Object.keys(input).filter((field) => !POLICY_FIELDS.has(field));
49
+ if (unknownFields.length > 0) {
50
+ throw new Error(`policy contains unknown field(s): ${unknownFields.sort().join(", ")}`);
51
+ }
52
+ const schemaVersion = input.schema_version ?? POLICY_SCHEMA_VERSION;
53
+ if (schemaVersion !== POLICY_SCHEMA_VERSION) {
54
+ throw new Error(`unsupported policy schema_version ${JSON.stringify(schemaVersion)}`);
55
+ }
56
+ const baseDir = resolve(options.baseDir ?? process.cwd());
57
+ const normalized: NormalizedPolicy = {
58
+ schema_version: POLICY_SCHEMA_VERSION,
59
+ name: boundedString(input.name ?? "workflow policy", "policy name", MAX_NAME),
60
+ max_cost_usd: optionalMoney(input.max_cost_usd, "max_cost_usd"),
61
+ unknown_cost: enumValue(input.unknown_cost ?? "deny", ["ask", "deny"], "unknown_cost"),
62
+ allowed_harnesses: optionalStringList(input.allowed_harnesses, "allowed_harnesses"),
63
+ allowed_models: optionalStringList(input.allowed_models, "allowed_models"),
64
+ allowed_paths: optionalPathList(input.allowed_paths, baseDir),
65
+ network: enumValue(input.network ?? "deny", VERDICTS, "network"),
66
+ external_actions: enumValue(input.external_actions ?? "deny", VERDICTS, "external_actions"),
67
+ allowed_isolation: optionalEnumList(input.allowed_isolation, ISOLATIONS, "allowed_isolation"),
68
+ };
69
+ return deepFreeze(normalized);
70
+ }
71
+
72
+ export function policyDigest(policy: NormalizedPolicy): string {
73
+ return createHash("sha256").update(JSON.stringify(policy)).digest("hex");
74
+ }
75
+
76
+ export function evaluatePolicy(
77
+ policy: Readonly<NormalizedPolicy>,
78
+ rawRequest: PolicyRequest,
79
+ ): PolicyEvaluation {
80
+ const request = summarizePolicyRequest(rawRequest);
81
+ const rules: PolicyRuleResult[] = [];
82
+ const add = (code: string, verdict: PolicyVerdict, reason: string): void => {
83
+ rules.push({ code, verdict, reason });
84
+ };
85
+
86
+ if (policy.allowed_harnesses && request.phase === "dispatch") {
87
+ if (!request.harness || !policy.allowed_harnesses.includes(request.harness)) {
88
+ add(
89
+ "harness_not_allowed",
90
+ "deny",
91
+ `harness ${JSON.stringify(request.harness ?? "unknown")} is not allowed`,
92
+ );
93
+ }
94
+ }
95
+ if (policy.allowed_models && request.phase === "dispatch") {
96
+ if (!request.model || !policy.allowed_models.includes(request.model)) {
97
+ add(
98
+ "model_not_allowed",
99
+ "deny",
100
+ `model ${JSON.stringify(request.model ?? "unknown")} is not allowed`,
101
+ );
102
+ }
103
+ }
104
+ if (policy.allowed_paths) {
105
+ if (!request.path || !policy.allowed_paths.some((root) => contains(root, request.path!))) {
106
+ add(
107
+ "path_not_allowed",
108
+ "deny",
109
+ `path ${JSON.stringify(request.path ?? "unknown")} is outside the allowed roots`,
110
+ );
111
+ }
112
+ }
113
+ if (policy.allowed_isolation && !policy.allowed_isolation.includes(request.isolation)) {
114
+ add(
115
+ "isolation_not_allowed",
116
+ "deny",
117
+ `isolation ${JSON.stringify(request.isolation)} is not allowed`,
118
+ );
119
+ }
120
+ if (request.network_access !== "disabled" && policy.network !== "allow") {
121
+ add(
122
+ request.network_access === "unknown" ? "network_unknown" : "network_restricted",
123
+ policy.network,
124
+ request.network_access === "unknown"
125
+ ? "network access is unknown"
126
+ : "network access requires policy authorization",
127
+ );
128
+ }
129
+ if (request.phase === "external_mutation" && policy.external_actions !== "allow") {
130
+ add(
131
+ "external_action_restricted",
132
+ policy.external_actions,
133
+ "external mutation requires policy authorization",
134
+ );
135
+ }
136
+ if (policy.max_cost_usd !== undefined && request.phase === "dispatch") {
137
+ const current = request.current_cost_usd ?? 0;
138
+ if (current >= policy.max_cost_usd) {
139
+ add(
140
+ "cost_budget_exhausted",
141
+ "deny",
142
+ `run cost $${current.toFixed(4)} has reached the $${policy.max_cost_usd.toFixed(4)} ceiling`,
143
+ );
144
+ } else if (request.projected_cost_usd === null || request.projected_cost_usd === undefined) {
145
+ add(
146
+ "dispatch_cost_unknown",
147
+ policy.unknown_cost,
148
+ `dispatch cost is unknown under a $${policy.max_cost_usd.toFixed(4)} ceiling`,
149
+ );
150
+ } else if (current + request.projected_cost_usd > policy.max_cost_usd) {
151
+ add(
152
+ "cost_budget_exceeded",
153
+ "deny",
154
+ `projected run cost $${(current + request.projected_cost_usd).toFixed(4)} exceeds the $${policy.max_cost_usd.toFixed(4)} ceiling`,
155
+ );
156
+ }
157
+ }
158
+
159
+ if (rules.length === 0) {
160
+ rules.push({ code: "policy_allow", verdict: "allow", reason: "all configured rules allow" });
161
+ }
162
+ const verdict = rules.some((rule) => rule.verdict === "deny")
163
+ ? "deny"
164
+ : rules.some((rule) => rule.verdict === "ask")
165
+ ? "ask"
166
+ : "allow";
167
+ const decisive = rules.filter((rule) => rule.verdict === verdict);
168
+ return {
169
+ verdict,
170
+ reason: decisive.map((rule) => rule.reason).join("; "),
171
+ rules,
172
+ };
173
+ }
174
+
175
+ /** Bound and normalize the receipt-safe subset before it reaches a journal or proof. */
176
+ export function summarizePolicyRequest(input: PolicyRequest): PolicyRequestSummary {
177
+ if (!isRecord(input)) throw new Error("policy request must be an object");
178
+ const phase = enumValue(input.phase, ["dispatch", "external_mutation"], "policy phase");
179
+ const isolation = enumValue(input.isolation, ISOLATIONS, "policy isolation");
180
+ const networkAccess = enumValue(
181
+ input.network_access,
182
+ ["enabled", "disabled", "unknown"],
183
+ "policy network_access",
184
+ );
185
+ return {
186
+ phase,
187
+ action: boundedString(input.action, "policy action", MAX_FIELD),
188
+ path: optionalBounded(input.path, "policy path"),
189
+ harness: optionalBounded(input.harness, "policy harness"),
190
+ model: optionalBounded(input.model, "policy model"),
191
+ effort: optionalBounded(input.effort, "policy effort"),
192
+ max_attempts: optionalWholeNumber(input.max_attempts, "policy max_attempts", 1),
193
+ max_turns: optionalWholeNumber(input.max_turns, "policy max_turns", 1),
194
+ timeout_ms: optionalWholeNumber(input.timeout_ms, "policy timeout_ms", 1),
195
+ prompt_bytes: optionalWholeNumber(input.prompt_bytes, "policy prompt_bytes", 0),
196
+ isolation,
197
+ network_access: networkAccess,
198
+ service: optionalBounded(input.service, "policy service"),
199
+ target: optionalBounded(safeTarget(input.target), "policy target"),
200
+ current_cost_usd: optionalMoney(input.current_cost_usd, "current_cost_usd"),
201
+ projected_cost_usd:
202
+ input.projected_cost_usd === null
203
+ ? null
204
+ : optionalMoney(input.projected_cost_usd, "projected_cost_usd"),
205
+ };
206
+ }
207
+
208
+ function contains(root: string, candidate: string): boolean {
209
+ const rel = relative(resolve(root), resolve(candidate));
210
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
211
+ }
212
+
213
+ function optionalPathList(value: unknown, baseDir: string): readonly string[] | undefined {
214
+ const values = optionalStringList(value, "allowed_paths");
215
+ if (!values) return undefined;
216
+ return [...new Set(values.map((item) => resolve(baseDir, item)))].sort();
217
+ }
218
+
219
+ function optionalStringList(value: unknown, field: string): readonly string[] | undefined {
220
+ if (value === undefined) return undefined;
221
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_LIST) {
222
+ throw new Error(`${field} must be a non-empty array with at most ${MAX_LIST} entries`);
223
+ }
224
+ return [
225
+ ...new Set(value.map((item, index) => boundedString(item, `${field}[${index}]`, MAX_FIELD))),
226
+ ].sort();
227
+ }
228
+
229
+ function optionalEnumList<T extends string>(
230
+ value: unknown,
231
+ allowed: readonly T[],
232
+ field: string,
233
+ ): readonly T[] | undefined {
234
+ if (value === undefined) return undefined;
235
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_LIST) {
236
+ throw new Error(`${field} must be a non-empty array with at most ${MAX_LIST} entries`);
237
+ }
238
+ return [...new Set(value.map((item) => enumValue(item, allowed, field)))].sort();
239
+ }
240
+
241
+ function enumValue<T extends string>(value: unknown, values: readonly T[], field: string): T {
242
+ if (typeof value !== "string" || !values.includes(value as T)) {
243
+ throw new Error(`${field} must be one of: ${values.join(", ")}`);
244
+ }
245
+ return value as T;
246
+ }
247
+
248
+ function optionalMoney(value: unknown, field: string): number | undefined {
249
+ if (value === undefined) return undefined;
250
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
251
+ throw new Error(`${field} must be a finite non-negative number`);
252
+ }
253
+ return value;
254
+ }
255
+
256
+ function optionalWholeNumber(value: unknown, field: string, minimum: number): number | undefined {
257
+ if (value === undefined) return undefined;
258
+ if (!Number.isSafeInteger(value) || (value as number) < minimum) {
259
+ throw new Error(`${field} must be a safe integer greater than or equal to ${minimum}`);
260
+ }
261
+ return value as number;
262
+ }
263
+
264
+ function boundedString(value: unknown, field: string, max: number): string {
265
+ if (typeof value !== "string") throw new Error(`${field} must be a string`);
266
+ const normalized = withoutControls(value).replace(/\s+/g, " ").trim();
267
+ if (!normalized) throw new Error(`${field} must not be empty`);
268
+ if (normalized.length > max) throw new Error(`${field} exceeds ${max} characters`);
269
+ return normalized;
270
+ }
271
+
272
+ function optionalBounded(value: unknown, field: string): string | undefined {
273
+ return value === undefined ? undefined : boundedString(value, field, MAX_FIELD);
274
+ }
275
+
276
+ function safeTarget(value: unknown): unknown {
277
+ if (typeof value !== "string") return value;
278
+ try {
279
+ const url = new URL(value);
280
+ return `${url.origin}${url.pathname}`;
281
+ } catch {
282
+ const query = value.indexOf("?");
283
+ const fragment = value.indexOf("#");
284
+ const boundary = Math.min(
285
+ query === -1 ? value.length : query,
286
+ fragment === -1 ? value.length : fragment,
287
+ );
288
+ return value.slice(0, boundary);
289
+ }
290
+ }
291
+
292
+ function isRecord(value: unknown): value is Record<string, unknown> {
293
+ return typeof value === "object" && value !== null && !Array.isArray(value);
294
+ }
295
+
296
+ function withoutControls(value: string): string {
297
+ return Array.from(value, (character) => {
298
+ const code = character.charCodeAt(0);
299
+ return code < 32 || code === 127 ? " " : character;
300
+ }).join("");
301
+ }
302
+
303
+ function deepFreeze<T>(value: T): Readonly<T> {
304
+ if (value && typeof value === "object") {
305
+ for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child);
306
+ Object.freeze(value);
307
+ }
308
+ return value;
309
+ }
310
+
311
+ export type { PolicyIsolation };
@@ -0,0 +1,20 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { stripJsonComments } from "../config.ts";
4
+ import { normalizePolicy } from "./evaluate.ts";
5
+ import type { NormalizedPolicy, PolicySpec } from "./types.ts";
6
+
7
+ export function loadPolicyFile(path: string): Readonly<NormalizedPolicy> {
8
+ const absolute = resolve(path);
9
+ let parsed: PolicySpec;
10
+ try {
11
+ parsed = JSON.parse(stripJsonComments(readFileSync(absolute, "utf8"))) as PolicySpec;
12
+ } catch (error) {
13
+ throw new Error(`cannot parse policy at ${absolute}: ${(error as Error).message}`);
14
+ }
15
+ try {
16
+ return normalizePolicy(parsed, { baseDir: dirname(absolute) });
17
+ } catch (error) {
18
+ throw new Error(`invalid policy at ${absolute}: ${(error as Error).message}`);
19
+ }
20
+ }
@@ -0,0 +1,26 @@
1
+ export {
2
+ evaluatePolicy,
3
+ normalizePolicy,
4
+ PolicyDeniedError,
5
+ policyDigest,
6
+ summarizePolicyRequest,
7
+ } from "./evaluate.ts";
8
+ export { loadPolicyFile } from "./file.ts";
9
+ export type {
10
+ DispatchCostEstimator,
11
+ ExternalMutationRequest,
12
+ NormalizedPolicy,
13
+ PolicyAskResolution,
14
+ PolicyAskResolver,
15
+ PolicyDecision,
16
+ PolicyEvaluation,
17
+ PolicyIsolation,
18
+ PolicyNetworkAccess,
19
+ PolicyPhase,
20
+ PolicyRequest,
21
+ PolicyRequestSummary,
22
+ PolicyRuleResult,
23
+ PolicySpec,
24
+ PolicyVerdict,
25
+ } from "./types.ts";
26
+ export { POLICY_SCHEMA_VERSION } from "./types.ts";
@@ -0,0 +1,107 @@
1
+ export const POLICY_SCHEMA_VERSION = 1 as const;
2
+
3
+ export type PolicyVerdict = "allow" | "ask" | "deny";
4
+ export type PolicyPhase = "dispatch" | "external_mutation";
5
+ export type PolicyIsolation = "shared" | "worktree" | "sandbox" | "remote";
6
+ export type PolicyNetworkAccess = "enabled" | "disabled" | "unknown";
7
+
8
+ /** Host-owned policy document. Workflow metadata cannot supply this value. */
9
+ export interface PolicySpec {
10
+ schema_version?: typeof POLICY_SCHEMA_VERSION;
11
+ name?: string;
12
+ max_cost_usd?: number;
13
+ /** Applied when a cost ceiling exists but the host cannot price a dispatch. */
14
+ unknown_cost?: Exclude<PolicyVerdict, "allow">;
15
+ allowed_harnesses?: readonly string[];
16
+ allowed_models?: readonly string[];
17
+ /** Absolute paths, or paths relative to the policy file. */
18
+ allowed_paths?: readonly string[];
19
+ network?: PolicyVerdict;
20
+ external_actions?: PolicyVerdict;
21
+ /** Allowed host-declared execution boundaries. No strength ordering is inferred. */
22
+ allowed_isolation?: readonly PolicyIsolation[];
23
+ }
24
+
25
+ export interface NormalizedPolicy {
26
+ schema_version: typeof POLICY_SCHEMA_VERSION;
27
+ name: string;
28
+ max_cost_usd?: number;
29
+ unknown_cost: Exclude<PolicyVerdict, "allow">;
30
+ allowed_harnesses?: readonly string[];
31
+ allowed_models?: readonly string[];
32
+ allowed_paths?: readonly string[];
33
+ network: PolicyVerdict;
34
+ external_actions: PolicyVerdict;
35
+ allowed_isolation?: readonly PolicyIsolation[];
36
+ }
37
+
38
+ export interface PolicyRequest {
39
+ phase: PolicyPhase;
40
+ action: string;
41
+ path?: string;
42
+ harness?: string;
43
+ model?: string;
44
+ effort?: string;
45
+ max_attempts?: number;
46
+ max_turns?: number;
47
+ timeout_ms?: number;
48
+ /** Prompt size for host estimation. Prompt content is never included. */
49
+ prompt_bytes?: number;
50
+ isolation: PolicyIsolation;
51
+ network_access: PolicyNetworkAccess;
52
+ service?: string;
53
+ target?: string;
54
+ current_cost_usd?: number;
55
+ projected_cost_usd?: number | null;
56
+ }
57
+
58
+ export type PolicyRequestSummary = PolicyRequest;
59
+
60
+ export interface PolicyRuleResult {
61
+ code: string;
62
+ verdict: PolicyVerdict;
63
+ reason: string;
64
+ }
65
+
66
+ export interface PolicyEvaluation {
67
+ verdict: PolicyVerdict;
68
+ reason: string;
69
+ rules: PolicyRuleResult[];
70
+ }
71
+
72
+ export interface PolicyAskResolution {
73
+ verdict: "allow" | "deny";
74
+ reason?: string;
75
+ }
76
+
77
+ export type PolicyAskResolver = (
78
+ request: Readonly<PolicyRequestSummary>,
79
+ evaluation: Readonly<PolicyEvaluation>,
80
+ ) => Promise<PolicyAskResolution> | PolicyAskResolution;
81
+
82
+ export interface PolicyDecision {
83
+ id: string;
84
+ checked_at: string;
85
+ policy: string;
86
+ phase: PolicyPhase;
87
+ initial_verdict: PolicyVerdict;
88
+ verdict: "allow" | "deny";
89
+ resolved_by: "policy" | "host" | "fail_closed";
90
+ reason: string;
91
+ rule_codes: string[];
92
+ request: PolicyRequestSummary;
93
+ }
94
+
95
+ export interface ExternalMutationRequest {
96
+ /** Short verb phrase, for example "publish release" or "write report". */
97
+ action: string;
98
+ path?: string;
99
+ network?: boolean;
100
+ service?: string;
101
+ /** Bounded destination descriptor. Do not include credentials or payloads. */
102
+ target?: string;
103
+ }
104
+
105
+ export type DispatchCostEstimator = (
106
+ request: Readonly<PolicyRequestSummary>,
107
+ ) => Promise<number | null> | number | null;
@@ -10,7 +10,12 @@ import {
10
10
  } from "node:fs";
11
11
  import { dirname, resolve } from "node:path";
12
12
  import { resolveMachineLabel } from "../../lib/machine.ts";
13
- import { monorepoRoot, readHeartbeat, resolveOwner } from "../agents/index.ts";
13
+ import {
14
+ assertSafeInstanceId,
15
+ monorepoRoot,
16
+ readHeartbeat,
17
+ resolveOwner,
18
+ } from "../agents/index.ts";
14
19
 
15
20
  /**
16
21
  * Agent scratchpad: per-agent markdown journal at `.harnery/scratch/<instance_id>.md`.
@@ -88,6 +93,7 @@ export function archiveDir(): string {
88
93
  }
89
94
 
90
95
  export function scratchPath(instanceId: string): string {
96
+ assertSafeInstanceId(instanceId);
91
97
  return resolve(scratchDir(), `${instanceId}.md`);
92
98
  }
93
99
 
@@ -144,9 +150,18 @@ export function parseScratch(path: string, content: string): ScratchDoc {
144
150
  i++;
145
151
  break;
146
152
  }
147
- const m = line.match(/^(session_id|machine|started|last_updated):\s*(.+?)\s*$/);
148
- if (m) {
149
- (header as unknown as Record<string, string>)[m[1]] = m[2].trim();
153
+ const separator = line.indexOf(":");
154
+ if (separator > 0) {
155
+ const key = line.slice(0, separator);
156
+ if (
157
+ key === "session_id" ||
158
+ key === "machine" ||
159
+ key === "started" ||
160
+ key === "last_updated"
161
+ ) {
162
+ const value = line.slice(separator + 1).trim();
163
+ if (value) (header as unknown as Record<string, string>)[key] = value;
164
+ }
150
165
  }
151
166
  }
152
167
 
@@ -366,6 +381,7 @@ export function appendEntry(
366
381
 
367
382
  /** Move `<owner>.md` → `archived/<owner>-<ts>.md`. No-op if missing. Returns archive path (or null). */
368
383
  export function archiveScratch(instanceId: string): string | null {
384
+ assertSafeInstanceId(instanceId);
369
385
  const src = scratchPath(instanceId);
370
386
  if (!existsSync(src)) return null;
371
387
  const ts = new Date().toISOString().replace(/[:.]/g, "-");