@mrciphersmith/keryx 0.2.154 → 0.2.156

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.
@@ -6,6 +6,147 @@ import { parentPort, workerData } from "worker_threads";
6
6
  import http from "http";
7
7
  import https from "https";
8
8
  import net from "net";
9
+ import dns from "dns";
10
+ import { unlinkSync } from "fs";
11
+
12
+ // src/harness/policy/engine.ts
13
+ var HARD_DENY_RISKS = new Set([
14
+ "write",
15
+ "shell",
16
+ "network",
17
+ "delegate"
18
+ ]);
19
+
20
+ // src/harness/mutation/guard.ts
21
+ var MAX_U32 = 4294967295;
22
+ function u32ToOctets(n) {
23
+ return [n >>> 24 & 255, n >>> 16 & 255, n >>> 8 & 255, n & 255];
24
+ }
25
+ function isPrivateIPv4(o0, o1, o2, o3) {
26
+ if (o0 === 0 && o1 === 0 && o2 === 0 && o3 === 0)
27
+ return true;
28
+ if (o0 === 127)
29
+ return true;
30
+ if (o0 === 10)
31
+ return true;
32
+ if (o0 === 172 && o1 >= 16 && o1 <= 31)
33
+ return true;
34
+ if (o0 === 192 && o1 === 168)
35
+ return true;
36
+ if (o0 === 169 && o1 === 254)
37
+ return true;
38
+ if (o0 === 100 && o1 >= 64 && o1 <= 127)
39
+ return true;
40
+ return false;
41
+ }
42
+ function isPrivateOrReservedAddress(ip) {
43
+ const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
44
+ if (v4 !== null) {
45
+ const octets = [v4[1], v4[2], v4[3], v4[4]].map((s) => Number(s));
46
+ if (octets.some((o) => o > 255))
47
+ return true;
48
+ return isPrivateIPv4(octets[0], octets[1], octets[2], octets[3]);
49
+ }
50
+ const stripped = ip.replace(/^\[|\]$/g, "").toLowerCase();
51
+ const mapped = stripped.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
52
+ if (mapped !== null)
53
+ return isPrivateOrReservedAddress(mapped[1]);
54
+ const nat64Hex = stripped.match(/^64:ff9b::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
55
+ if (nat64Hex !== null) {
56
+ const hi = parseInt(nat64Hex[1], 16);
57
+ const lo = parseInt(nat64Hex[2], 16);
58
+ return isPrivateOrReservedAddress(`${hi >>> 8 & 255}.${hi & 255}.${lo >>> 8 & 255}.${lo & 255}`);
59
+ }
60
+ const nat64Mixed = stripped.match(/^64:ff9b::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
61
+ if (nat64Mixed !== null)
62
+ return isPrivateOrReservedAddress(nat64Mixed[1]);
63
+ if (/^64:ff9b:1:/.test(stripped))
64
+ return true;
65
+ if (stripped === "::1" || stripped === "::" || stripped === "0:0:0:0:0:0:0:1" || stripped === "0:0:0:0:0:0:0:0")
66
+ return true;
67
+ if (/^fe[89ab][0-9a-f]:/.test(stripped))
68
+ return true;
69
+ if (/^f[cd][0-9a-f]{2}:/.test(stripped))
70
+ return true;
71
+ return false;
72
+ }
73
+ function parseFlatInt(s) {
74
+ let value;
75
+ if (/^0x[0-9a-f]+$/i.test(s))
76
+ value = parseInt(s.slice(2), 16);
77
+ else if (/^0[0-7]+$/.test(s))
78
+ value = parseInt(s.slice(1), 8);
79
+ else if (/^[0-9]+$/.test(s))
80
+ value = parseInt(s, 10);
81
+ else
82
+ return null;
83
+ if (!Number.isFinite(value) || value < 0 || value > MAX_U32)
84
+ return null;
85
+ return value;
86
+ }
87
+ function decodeDottedIPv4(s) {
88
+ const parts = s.split(".");
89
+ if (parts.length < 2 || parts.length > 4)
90
+ return null;
91
+ const nums = [];
92
+ for (const part of parts) {
93
+ const v = parseFlatInt(part);
94
+ if (v === null)
95
+ return null;
96
+ nums.push(v);
97
+ }
98
+ if (parts.length === 4) {
99
+ if (nums.some((x) => x > 255))
100
+ return null;
101
+ return [nums[0], nums[1], nums[2], nums[3]];
102
+ }
103
+ if (parts.length === 3) {
104
+ const [a, b, c] = nums;
105
+ if (a > 255 || b > 255 || c > 65535)
106
+ return null;
107
+ return [a, b, c >>> 8 & 255, c & 255];
108
+ }
109
+ const [a, b] = nums;
110
+ if (a > 255 || b > 16777215)
111
+ return null;
112
+ return [a, b >>> 16 & 255, b >>> 8 & 255, b & 255];
113
+ }
114
+ function looksLikeIpHost(host) {
115
+ const stripped = host.replace(/^\[|\]$/g, "");
116
+ if (stripped.includes(":"))
117
+ return true;
118
+ if (decodeEncodedIPv4(stripped) !== null)
119
+ return true;
120
+ const labels = stripped.split(".");
121
+ const last = labels[labels.length - 1] ?? "";
122
+ if (/^\d+$/.test(last) || /^0x[0-9a-f]+$/i.test(last))
123
+ return true;
124
+ return false;
125
+ }
126
+ function decodeEncodedIPv4(candidate) {
127
+ const mapped = /::ffff:(.+)$/i.exec(candidate);
128
+ if (mapped) {
129
+ const tail = mapped[1];
130
+ if (tail.includes("."))
131
+ return decodeDottedIPv4(tail);
132
+ const groups = tail.split(":");
133
+ if (groups.length === 2 && /^[0-9a-f]{1,4}$/i.test(groups[0]) && /^[0-9a-f]{1,4}$/i.test(groups[1])) {
134
+ const n = parseInt(groups[0], 16) * 65536 + parseInt(groups[1], 16);
135
+ if (n <= MAX_U32)
136
+ return u32ToOctets(n);
137
+ }
138
+ return null;
139
+ }
140
+ if (candidate.includes("."))
141
+ return decodeDottedIPv4(candidate);
142
+ const n = parseFlatInt(candidate);
143
+ if (n === null)
144
+ return null;
145
+ return u32ToOctets(n);
146
+ }
147
+ var ENV_DUMP_ARGV = new Set(["env", "printenv"]);
148
+
149
+ // src/harness/process/sandbox/proxy.ts
9
150
  function normalizeHost(host) {
10
151
  return host.toLowerCase().replace(/\.$/, "");
11
152
  }
@@ -27,6 +168,43 @@ function matchesAllowlist(host, allowed) {
27
168
  }
28
169
  return false;
29
170
  }
171
+ function parsePort(raw) {
172
+ if (raw === undefined || !/^[1-9][0-9]{0,4}$/.test(raw))
173
+ return;
174
+ const n = Number(raw);
175
+ return n >= 1 && n <= 65535 ? n : undefined;
176
+ }
177
+ function portAllowed(port, kind, allowedPorts) {
178
+ if (allowedPorts !== undefined && allowedPorts.length > 0)
179
+ return allowedPorts.includes(port);
180
+ return kind === "connect" ? port === 443 : port === 80;
181
+ }
182
+ function trySync(make, onError) {
183
+ try {
184
+ return make();
185
+ } catch {
186
+ onError();
187
+ return;
188
+ }
189
+ }
190
+ async function defaultResolveHost(hostname) {
191
+ try {
192
+ const { address } = await dns.promises.lookup(hostname);
193
+ return address;
194
+ } catch {
195
+ return;
196
+ }
197
+ }
198
+ async function checkedAddress(hostname, resolveHost) {
199
+ if (looksLikeIpHost(hostname))
200
+ return { reason: "IP-literal targets are refused \u2014 the allowlist is domains only" };
201
+ const address = await resolveHost(hostname);
202
+ if (address === undefined)
203
+ return { reason: "could not be resolved" };
204
+ if (isPrivateOrReservedAddress(address))
205
+ return { reason: `resolved to ${address}, a loopback/private/link-local/metadata address` };
206
+ return { address };
207
+ }
30
208
  function substituteValue(value, masks) {
31
209
  if (value === undefined)
32
210
  return;
@@ -53,57 +231,107 @@ function httpTarget(req) {
53
231
  try {
54
232
  if (/^https?:\/\//i.test(raw)) {
55
233
  const u = new URL(raw);
56
- return { hostname: u.hostname, port: u.port ? Number(u.port) : 80 };
234
+ if (u.hostname.length === 0)
235
+ return { kind: "no-target" };
236
+ if (u.port.length === 0)
237
+ return { kind: "ok", hostname: u.hostname, port: 80 };
238
+ const port = parsePort(u.port);
239
+ return port === undefined ? { kind: "invalid-port", hostname: u.hostname } : { kind: "ok", hostname: u.hostname, port };
57
240
  }
58
241
  } catch {}
59
242
  const hostHeader = req.headers.host;
60
- if (hostHeader) {
61
- const [hostname, port] = hostHeader.split(":");
62
- if (hostname)
63
- return { hostname, port: port ? Number(port) : 80 };
243
+ if (hostHeader !== undefined && hostHeader.length > 0) {
244
+ const colon = hostHeader.lastIndexOf(":");
245
+ const hostname = colon === -1 ? hostHeader : hostHeader.slice(0, colon);
246
+ const portRaw = colon === -1 ? undefined : hostHeader.slice(colon + 1);
247
+ if (hostname.length === 0)
248
+ return { kind: "no-target" };
249
+ if (portRaw === undefined || portRaw.length === 0)
250
+ return { kind: "ok", hostname, port: 80 };
251
+ const port = parsePort(portRaw);
252
+ return port === undefined ? { kind: "invalid-port", hostname } : { kind: "ok", hostname, port };
64
253
  }
65
- return;
254
+ return { kind: "no-target" };
66
255
  }
67
256
  async function createAllowlistProxy(opts) {
68
257
  const host = opts.host ?? "127.0.0.1";
69
258
  const allowed = opts.allowedDomains;
259
+ const hardened = opts.refuseReservedAddresses === true;
260
+ const portRestricted = hardened || opts.allowedPorts !== undefined && opts.allowedPorts.length > 0;
261
+ const resolveHost = opts.resolveHost ?? defaultResolveHost;
70
262
  const decide = (d) => {
71
- opts.onDecision?.(d);
263
+ opts.onDecision?.({ ...d, at: new Date().toISOString() });
72
264
  return d.allowed;
73
265
  };
74
266
  const server = http.createServer((req, res) => {
75
267
  const target = httpTarget(req);
76
- const hostname = target?.hostname ?? "";
77
- if (!target || !decide({ host: hostname, allowed: matchesAllowlist(hostname, allowed), kind: "http" })) {
268
+ const hostname = target.kind === "no-target" ? "" : target.hostname;
269
+ const refuse = (reason) => {
270
+ decide({ host: hostname, allowed: false, kind: "http", ...target.kind === "ok" ? { port: target.port } : {}, reason });
78
271
  res.writeHead(403, { "content-type": "text/plain" });
79
272
  res.end("blocked by keryx sandbox network allowlist");
273
+ };
274
+ if (target.kind === "no-target") {
275
+ refuse("no target host");
80
276
  return;
81
277
  }
82
- const headers = applyMasks(req.headers, opts.masks ?? [], target.hostname);
83
- const upstream = http.request({ host: target.hostname, port: target.port, method: req.method, path: pathFromUrl(req.url), headers }, (up) => {
84
- res.writeHead(up.statusCode ?? 502, up.headers);
85
- up.pipe(res);
86
- });
87
- upstream.on("error", () => {
88
- if (!res.headersSent)
89
- res.writeHead(502);
90
- res.end("upstream error");
278
+ if (target.kind === "invalid-port") {
279
+ refuse("invalid port");
280
+ return;
281
+ }
282
+ if (!matchesAllowlist(hostname, allowed)) {
283
+ refuse("not on the allowlist");
284
+ return;
285
+ }
286
+ if (portRestricted && !portAllowed(target.port, "http", opts.allowedPorts)) {
287
+ refuse(`port ${target.port} is not allowed`);
288
+ return;
289
+ }
290
+ const proceed = (connectHost) => {
291
+ decide({ host: hostname, allowed: true, kind: "http", port: target.port });
292
+ const headers = applyMasks(req.headers, opts.masks ?? [], target.hostname);
293
+ const upstream = trySync(() => http.request({ host: connectHost, port: target.port, method: req.method, path: pathFromUrl(req.url), headers: { ...headers, host: target.hostname } }, (up) => {
294
+ res.writeHead(up.statusCode ?? 502, up.headers);
295
+ up.pipe(res);
296
+ }), () => {
297
+ if (!res.headersSent)
298
+ res.writeHead(502);
299
+ res.end("upstream error");
300
+ });
301
+ if (upstream === undefined)
302
+ return;
303
+ upstream.on("error", () => {
304
+ if (!res.headersSent)
305
+ res.writeHead(502);
306
+ res.end("upstream error");
307
+ });
308
+ req.pipe(upstream);
309
+ };
310
+ if (!hardened) {
311
+ proceed(target.hostname);
312
+ return;
313
+ }
314
+ checkedAddress(target.hostname, resolveHost).then((checked) => {
315
+ if ("reason" in checked)
316
+ refuse(checked.reason);
317
+ else
318
+ proceed(checked.address);
91
319
  });
92
- req.pipe(upstream);
93
320
  });
94
321
  const makeMitmHandler = (pinnedHost, pinnedPort) => (req, res) => {
95
322
  const hostHeader = req.headers.host ?? "";
96
- const [rawHost, rawPort] = hostHeader.split(":");
97
- const hostname = rawHost ?? "";
98
- const upstreamPort = rawPort ? Number(rawPort) : pinnedPort;
99
- const permitted = matchesAllowlist(hostname, allowed) && normalizeHost(hostname) === pinnedHost && upstreamPort === pinnedPort;
100
- if (!decide({ host: hostname, allowed: permitted, kind: "http" })) {
323
+ const colon = hostHeader.lastIndexOf(":");
324
+ const hostname = colon === -1 ? hostHeader : hostHeader.slice(0, colon);
325
+ const rawPort = colon === -1 ? undefined : hostHeader.slice(colon + 1);
326
+ const upstreamPort = rawPort === undefined || rawPort.length === 0 ? pinnedPort : parsePort(rawPort);
327
+ const permitted = upstreamPort !== undefined && matchesAllowlist(hostname, allowed) && normalizeHost(hostname) === pinnedHost && upstreamPort === pinnedPort;
328
+ if (!decide({ host: hostname, allowed: permitted, kind: "http", ...upstreamPort !== undefined ? { port: upstreamPort } : {} })) {
101
329
  res.writeHead(403, { "content-type": "text/plain" });
102
330
  res.end("blocked by keryx sandbox network allowlist");
103
331
  return;
104
332
  }
105
333
  const headers = applyMasks(req.headers, opts.masks ?? [], hostname);
106
- const upstreamReq = https.request({
334
+ const upstreamReq = trySync(() => https.request({
107
335
  host: hostname,
108
336
  port: upstreamPort,
109
337
  method: req.method,
@@ -114,7 +342,13 @@ async function createAllowlistProxy(opts) {
114
342
  }, (up) => {
115
343
  res.writeHead(up.statusCode ?? 502, up.headers);
116
344
  up.pipe(res);
345
+ }), () => {
346
+ if (!res.headersSent)
347
+ res.writeHead(502);
348
+ res.end("upstream error");
117
349
  });
350
+ if (upstreamReq === undefined)
351
+ return;
118
352
  upstreamReq.on("error", () => {
119
353
  if (!res.headersSent)
120
354
  res.writeHead(502);
@@ -138,64 +372,105 @@ async function createAllowlistProxy(opts) {
138
372
  return port;
139
373
  };
140
374
  server.on("connect", (req, clientSocket, head) => {
141
- const [reqHost, reqPort] = (req.url ?? "").split(":");
142
- const hostname = reqHost ?? "";
143
- const port = Number(reqPort) || 443;
144
- if (!decide({ host: hostname, allowed: matchesAllowlist(hostname, allowed), kind: "connect" })) {
375
+ const url = req.url ?? "";
376
+ const colon = url.lastIndexOf(":");
377
+ const hostname = colon === -1 ? url : url.slice(0, colon);
378
+ const portRaw = colon === -1 ? undefined : url.slice(colon + 1);
379
+ const parsedPort = portRaw === undefined || portRaw.length === 0 ? 443 : parsePort(portRaw);
380
+ const refuse = (reason) => {
381
+ decide({ host: hostname, allowed: false, kind: "connect", ...parsedPort !== undefined ? { port: parsedPort } : {}, reason });
145
382
  clientSocket.write(`HTTP/1.1 403 Forbidden\r
146
383
  \r
147
384
  `);
148
385
  clientSocket.end();
386
+ };
387
+ if (parsedPort === undefined) {
388
+ refuse("invalid port");
389
+ return;
390
+ }
391
+ const port = parsedPort;
392
+ if (!matchesAllowlist(hostname, allowed)) {
393
+ refuse("not on the allowlist");
149
394
  return;
150
395
  }
151
- const ca = opts.tlsTerminate;
152
- if (ca) {
153
- (async () => {
154
- try {
155
- const terminatorPort = await mitmPortFor(hostname, port, ca);
156
- clientSocket.write(`HTTP/1.1 200 Connection Established\r
396
+ if (portRestricted && !portAllowed(port, "connect", opts.allowedPorts)) {
397
+ refuse(`port ${port} is not allowed`);
398
+ return;
399
+ }
400
+ const proceed = (connectHost) => {
401
+ decide({ host: hostname, allowed: true, kind: "connect", port });
402
+ const ca = opts.tlsTerminate;
403
+ if (ca) {
404
+ (async () => {
405
+ try {
406
+ const terminatorPort = await mitmPortFor(hostname, port, ca);
407
+ clientSocket.write(`HTTP/1.1 200 Connection Established\r
157
408
  \r
158
409
  `);
159
- const internal = net.connect(terminatorPort, "127.0.0.1", () => {
160
- if (head && head.length > 0)
161
- internal.write(head);
162
- clientSocket.pipe(internal);
163
- internal.pipe(clientSocket);
164
- });
165
- internal.on("error", () => clientSocket.destroy());
166
- clientSocket.on("error", () => internal.destroy());
167
- } catch {
168
- clientSocket.write(`HTTP/1.1 502 Bad Gateway\r
410
+ const internal = net.connect(terminatorPort, "127.0.0.1", () => {
411
+ if (head && head.length > 0)
412
+ internal.write(head);
413
+ clientSocket.pipe(internal);
414
+ internal.pipe(clientSocket);
415
+ });
416
+ internal.on("error", () => clientSocket.destroy());
417
+ clientSocket.on("error", () => internal.destroy());
418
+ } catch {
419
+ clientSocket.write(`HTTP/1.1 502 Bad Gateway\r
169
420
  \r
170
421
  `);
171
- clientSocket.end();
172
- }
173
- })();
174
- return;
175
- }
176
- const upstream = net.connect(port, hostname, () => {
177
- clientSocket.write(`HTTP/1.1 200 Connection Established\r
422
+ clientSocket.end();
423
+ }
424
+ })();
425
+ return;
426
+ }
427
+ const onUpstreamConnect = (upstream) => {
428
+ clientSocket.write(`HTTP/1.1 200 Connection Established\r
178
429
  \r
179
430
  `);
180
- if (head && head.length > 0)
181
- upstream.write(head);
182
- upstream.pipe(clientSocket);
183
- clientSocket.pipe(upstream);
184
- });
185
- upstream.on("error", () => {
186
- clientSocket.write(`HTTP/1.1 502 Bad Gateway\r
431
+ if (head && head.length > 0)
432
+ upstream.write(head);
433
+ upstream.pipe(clientSocket);
434
+ clientSocket.pipe(upstream);
435
+ };
436
+ const onUpstreamFailure = () => {
437
+ clientSocket.write(`HTTP/1.1 502 Bad Gateway\r
187
438
  \r
188
439
  `);
189
- clientSocket.end();
440
+ clientSocket.end();
441
+ };
442
+ const upstream = trySync(() => net.connect(port, connectHost), onUpstreamFailure);
443
+ if (upstream === undefined)
444
+ return;
445
+ upstream.once("connect", () => onUpstreamConnect(upstream));
446
+ upstream.on("error", onUpstreamFailure);
447
+ clientSocket.on("error", () => upstream.destroy());
448
+ };
449
+ if (!hardened) {
450
+ proceed(hostname);
451
+ return;
452
+ }
453
+ checkedAddress(hostname, resolveHost).then((checked) => {
454
+ if ("reason" in checked)
455
+ refuse(checked.reason);
456
+ else
457
+ proceed(checked.address);
190
458
  });
191
- clientSocket.on("error", () => upstream.destroy());
192
459
  });
193
- await new Promise((resolve) => server.listen(opts.port ?? 0, host, () => resolve()));
460
+ if (opts.unixSocketPath !== undefined) {
461
+ try {
462
+ unlinkSync(opts.unixSocketPath);
463
+ } catch {}
464
+ await new Promise((resolve) => server.listen(opts.unixSocketPath, () => resolve()));
465
+ } else {
466
+ await new Promise((resolve) => server.listen(opts.port ?? 0, host, () => resolve()));
467
+ }
194
468
  const addr = server.address();
195
- const port = addr && typeof addr === "object" ? addr.port : 0;
469
+ const port = addr && typeof addr === "object" && addr !== null ? addr.port : 0;
196
470
  return {
197
471
  host,
198
472
  port,
473
+ ...opts.unixSocketPath !== undefined ? { unixSocketPath: opts.unixSocketPath } : {},
199
474
  close: async () => {
200
475
  await new Promise((resolve) => server.close(() => resolve()));
201
476
  await Promise.all([...mitmServers.values()].map(({ server: s }) => new Promise((resolve) => s.close(() => resolve()))));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrciphersmith/keryx",
3
- "version": "0.2.154",
3
+ "version": "0.2.156",
4
4
  "description": "Version-controlled project context for AI coding agents: code graph, architecture wiki, project memory, relevant tests, quality signals, and task flows.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc --noEmit",
46
46
  "typecheck:scripts": "tsc --project tsconfig.scripts.json --noEmit",
47
47
  "test": "bun test",
48
- "test:core": "bun test src/cli src/core src/shell-source-audits.test.ts src/acp/ src/assets/ src/capability/ src/commands/ src/contracts/ src/ctx/ src/eval/ src/flow/ src/forgetting/ src/gdgraph/ src/gdskills/ src/health/ src/job/ src/lib/ src/mcp/ src/memory/ src/metrics/ src/retention/ src/review/ src/sac/ src/security/ src/standard/ src/sync/ src/testing/ src/trigger/ src/wiki/",
48
+ "test:core": "bun test src/cli src/core src/shell-source-audits.test.ts src/acp/ src/assets/ src/capability/ src/commands/ src/contracts/ src/ctx/ src/eval/ src/flow/ src/forgetting/ src/gdgraph/ src/gdskills/ src/governance/ src/health/ src/job/ src/lib/ src/mcp/ src/memory/ src/metrics/ src/retention/ src/review/ src/sac/ src/security/ src/standard/ src/sync/ src/testing/ src/trigger/ src/wiki/",
49
49
  "test:client:terminal": "bun test src/tui/ src/commands/shell",
50
50
  "test:client:streaming": "bun test src/harness/provider/",
51
51
  "test:client:cancel-resume": "bun test src/harness/run/ src/harness/resume/ src/harness/session/ src/session/ src/bus/ src/commands/sessions",
@@ -93,7 +93,9 @@ not done either.
93
93
  When a frozen criterion genuinely conflicts with the bar — it asks for
94
94
  something that cannot be delivered without failing a clause — the criterion is
95
95
  not quietly reworded to match what was built. It changes through
96
- `keryx flow ac update <id> --reason "<why>"`, with the conflict as the reason,
96
+ `keryx flow ac update <id> --reason "<why>"` (or `--criterion ACn --text
97
+ "<criterion>" --reason "<why>"` to rewrite the line itself), with the conflict
98
+ as the reason,
97
99
  per `.metaproject/skills/gdskills/orchestration/flow-orchestrator/SKILL.md`.
98
100
  Rewriting a criterion to describe the work is how a flow loses the only record
99
101
  of what it set out to do.
@@ -60,7 +60,7 @@ CLI-owned files:
60
60
  - task status - only through `keryx flow task done ...`.
61
61
  - task attempt counts - only through `keryx flow task attempt ...`.
62
62
  - frozen acceptance criteria changes - only through
63
- `keryx flow ac update <id> --reason "<why>"`.
63
+ `keryx flow ac update <id> --reason "<why>"` (or `--criterion ACn --text "…"`; unused arguments are refused).
64
64
 
65
65
  Agent-editable files:
66
66
 
@@ -199,7 +199,7 @@ keryx flow init --issue <url>
199
199
  or:
200
200
 
201
201
  ```bash
202
- keryx flow init --title "<short formalized problem>" --base "<branch the work must land on>"
202
+ keryx flow init --title "<short formalized problem>" --base "<branch the work must land on>" [--owner "<name>"]
203
203
  ```
204
204
 
205
205
  5. Run `keryx flow status <id>` and read the flow package.
@@ -631,7 +631,7 @@ keryx flow complete <id>
631
631
 
632
632
  Completion is allowed only after the PR merge has been confirmed. The merge
633
633
  target must be the base branch captured when the flow was created; do not
634
- silently retarget or close against another branch.
634
+ silently retarget or close against another branch. Flows created since the owner gate fail completion while no owner is set (`keryx flow owner set <id> --owner "<name>" --reason "<why>"`); `ac confirm` and `flow complete` append a signature — `--signed-by "<name>"` names the signer, otherwise `KERYX_ACTOR` or the local git identity is recorded, as a claim. A flow created with `--require-confirmation` needs one more gate — `--confirm-token <token>` from `keryx flow confirm <id>`, a short-lived single-use token minted by a typed challenge in a terminal; it proves an interactive step ran outside the agent's tool roster, not that a human ran it. A flow left stuck in `completing` by a crash or interrupted attempt (never a normal gate failure) recovers with `keryx flow recover <id> --reason "<why>"`.
635
635
 
636
636
  If gates fail, the CLI returns the flow to `in-progress`. Add a journal note,
637
637
  create fix tasks, and repeat Phase 2.
@@ -688,7 +688,7 @@ Stop and re-read this skill if you are thinking:
688
688
  |---|---|
689
689
  | "The worker's reply reads like it finished, so the task is done." | The STATUS protocol says read the `STATUS:` line first and never infer the outcome from prose. A reply without one is `NEEDS_CONTEXT` — a confident-sounding summary is exactly what an unusable result looks like. |
690
690
  | "`DONE_WITH_CONCERNS` is still done, so I can move on." | Every concern goes into `journal.md` and gets an explicit continue-or-fix decision before `flow task done`. Concerns dropped at the task boundary are invisible by the completion report, which is where they would have mattered. |
691
- | "The acceptance criterion no longer matches what we built, so I'll reword it." | Frozen AC changes only through `keryx flow ac update <id> --reason "<why>"`. Rewriting a criterion to fit the implementation makes the flow pass a gate it actually failed, and leaves no record that it moved. |
691
+ | "The acceptance criterion no longer matches what we built, so I'll reword it." | Frozen AC changes only through `keryx flow ac update <id> --reason "<why>"`, or `--criterion ACn --text "<criterion>" --reason "<why>"` to rewrite the line itself. Rewriting a criterion to fit the implementation makes the flow pass a gate it actually failed, and leaves no record that it moved. |
692
692
  | "`flow.json` is just a file — editing one field is faster than the CLI." | `flow.json`, status transitions, task status and attempt counts are CLI-owned. A hand-written field desynchronises the durable state from the flow's own history, and the CLI's next gate check reads yours, not reality. |
693
693
  | "Tests pass and the review is clean, so I'll open the PR and complete the flow." | Phase 4 stops and asks the user how the flow should end; not every flow wants a PR. And completion requires a confirmed merge into the base branch captured at creation — not a green local run. |
694
694
  | "The worker returned BLOCKED twice — faster if I implement this task myself." | The implementer never self-accepts and the orchestrator never implements. Block the flow, escalate one concise question, then unblock and re-dispatch. Doing the work here erases the boundary the whole flow model rests on. |
@@ -0,0 +1,96 @@
1
+ ---
2
+ name: scheduled-tasks
3
+ description: "Use when the operator asks for something to happen repeatedly in the background on a cadence (every 4 hours check my PRs, each morning summarise new issues) and a keryx schedule should be proposed with schedule_create. NOT for: one-off work done now (just do it), or git-event triggers declared in triggers.json (see hookify)."
4
+ triggers:
5
+ - "schedule a task"
6
+ - "every 4 hours"
7
+ - "run in the background"
8
+ - "каждые 4 часа"
9
+ - "по расписанию"
10
+ - "check github regularly"
11
+ metadata:
12
+ author: "MrCipherSmith"
13
+ version: "1.0.0"
14
+ category: "platform"
15
+ compatible_harnesses: "cursor,codex,zed,opencode,claude"
16
+ license: "MIT"
17
+ ---
18
+
19
+ # Scheduled Tasks
20
+
21
+ Turn "do this regularly" into a keryx schedule the OPERATOR confirms. keryx runs no
22
+ daemon. After the operator's yes, it installs an OS timer (systemd --user, launchd or
23
+ cron) that runs one unattended agent turn on the prompt and writes a report the
24
+ operator reads in the shell's Schedules section.
25
+
26
+ ## Workflow
27
+
28
+ ### Step 1: Pin down the request
29
+ - **Cadence**: cron, or a phrase keryx translates itself — `every N hours`
30
+ (a divisor of 24), `every N minutes` (5-30), `hourly`, `daily at HH:MM`,
31
+ `weekdays at HH:MM`, `every monday at HH:MM`. Never invent cron from a vague
32
+ "sometimes". Ask.
33
+ - **Prompt**: the task in the operator's words, written for an agent that runs
34
+ alone and whose final message is the report.
35
+ - **Budget**: `rates` (USD per million input/output tokens for the model) and
36
+ `ceilingUsd`. keryx has no price table. If you do not know the rates, ASK.
37
+ Never guess them.
38
+
39
+ ### Step 2: Grant only what the task needs
40
+ - `tools`: pick from the fixed catalogue: `gh.pr.list`, `gh.pr.view`,
41
+ `gh.pr.checks`, `gh.issue.list`, `gh.issue.view`, `gh.run.list`. keryx
42
+ runs them OUTSIDE the sandbox with the operator's credentials, and the
43
+ scheduled agent sees only redacted output. A version-manager shim is refused. A `#!`
44
+ wrapper is pinned together with its interpreter, but its own global config
45
+ (its own config directory) stays outside what keryx pins.
46
+ - `repos`: every repository those tools may touch (`owner/name`).
47
+ - `network`: leave it `off`. Checking GitHub needs no sandbox network — the granted
48
+ tools cover it. `full` hands the shell the whole host network; say why. `allowlist`
49
+ (Linux only, `domains` required) reaches only the named domains, and governs only
50
+ the agent's own shell — never the model call or a granted tool.
51
+ - `permissionMode`: `ask` (the default) is read-only, and granted tools still run.
52
+ Propose `trust` only when the scheduled agent must run shell commands.
53
+
54
+ ### Step 3: Propose with schedule_create
55
+ Call `schedule_create`. The operator then sees a confirmation card: cadence and
56
+ next runs, prompt, runner, budget, network, every granted tool with its binary
57
+ and account, and exactly what gets installed. You do not confirm it. Only the
58
+ operator can, and every permission mode asks, `auto` included.
59
+
60
+ ### Step 4: Report back
61
+ After the operator's answer, say what was scheduled and when it next runs, or
62
+ that nothing was written. Point to `/schedules` for the list and the reports.
63
+
64
+ ## Rules
65
+
66
+ - NEVER try to confirm, retry past a "no", or re-propose the same schedule unchanged after it was declined.
67
+ - NEVER create or install a schedule by any other route (editing
68
+ `.metaproject/data/trigger/schedules.json`, `keryx schedule add --yes` in your
69
+ shell, `systemctl`, `crontab`, `launchctl`). Each of these asks the operator.
70
+ `keryx schedule add` refuses inside your shell, and a hand-written entry lacks
71
+ this machine's signature, so it never runs.
72
+ - NEVER read or copy the schedule signing key (`schedule-hmac.key`), and never try to
73
+ reach the scheduler another way (quoting, `env -u`, a script). The text floor can be
74
+ worked around, and that is exactly why it is not the gate. `keryx schedule add|resume|run`
75
+ need the operator's own terminal, and a schedule without this machine's signature never runs.
76
+ - NEVER run `loginctl enable-linger`. If linger is off, tell the operator the
77
+ timer runs only while they are logged in and that the command is theirs to run.
78
+ - State the limits when they matter: the machine must be on; systemd and launchd
79
+ catch up one missed run after sleep and cron none; `allowlist` is Linux only.
80
+
81
+ ## Red Flags
82
+
83
+ | Rationalization | Why it is wrong |
84
+ |---|---|
85
+ | "The operator said 'every so often' — I'll pick hourly" | A cadence runs and spends unattended. Ask for it |
86
+ | "I'll grant every gh tool so the agent is not stuck" | Grants are the operator's credentials acting unattended. Grant what the prompt needs |
87
+ | "Network full is simpler than granted tools" | It gives the unattended shell the whole host network. Granted tools need none |
88
+ | "I know roughly what the model costs" | A wrong rate makes both spend ceilings wrong by the same factor. Ask |
89
+
90
+ ## Verification
91
+
92
+ Do not report the schedule as created until all of the following hold:
93
+
94
+ - `schedule_create` returned "stored and installed", not "not confirmed" or an error
95
+ - `schedule_list` shows the entry, enabled, with the next run you told the operator
96
+ - The grants you proposed are the ones the card showed, with nothing added afterwards