@mrciphersmith/keryx 0.2.155 → 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.155",
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": {
@@ -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. 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.
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.
@@ -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