@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.
- package/README.md +55 -6
- package/dist/cli.js +42933 -36064
- package/dist/core.js +1148 -817
- package/dist/proxy-worker.js +338 -63
- package/package.json +1 -1
- package/src/gdskills/bundled/skills/orchestration/flow-orchestrator/SKILL.md +1 -1
- 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": {
|
|
@@ -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
|