@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.
- package/README.md +133 -17
- package/dist/cli.js +37882 -26212
- package/dist/core.js +1490 -803
- package/dist/proxy-worker.js +338 -63
- package/package.json +2 -2
- package/src/gdskills/bundled/rules/core/definition-of-done.mdc +3 -1
- package/src/gdskills/bundled/skills/orchestration/flow-orchestrator/SKILL.md +4 -4
- package/src/gdskills/bundled/skills/platform/scheduled-tasks/SKILL.md +96 -0
package/dist/proxy-worker.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
77
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
97
|
-
const hostname =
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
|
|
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
|
|
142
|
-
const
|
|
143
|
-
const
|
|
144
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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>"
|
|
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>"
|
|
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
|