@claude-flow/cli 3.65.0 → 3.66.0
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/.claude/helpers/helpers.manifest.json +5 -4
- package/.claude/helpers/hook-handler.cjs +66 -0
- package/.claude/helpers/learning-loop.cjs +594 -0
- package/catalog-manifest.json +2 -2
- package/dist/src/commands/hooks.js +4 -1
- package/dist/src/init/executor.js +1 -1
- package/dist/src/init/helper-refresh.js +4 -0
- package/dist/src/mcp-tools/http-fetch-pinned.d.ts +44 -0
- package/dist/src/mcp-tools/http-fetch-pinned.js +148 -0
- package/dist/src/mcp-tools/http-fetch-tools.d.ts +11 -2
- package/dist/src/mcp-tools/http-fetch-tools.js +19 -70
- package/dist/src/memory/sona-optimizer.d.ts +7 -0
- package/dist/src/memory/sona-optimizer.js +36 -6
- package/dist/src/memory/verified-lessons.d.ts +80 -0
- package/dist/src/memory/verified-lessons.js +113 -0
- package/dist/src/memory/verified-outcomes.d.ts +9 -47
- package/dist/src/memory/verified-outcomes.js +11 -46
- package/dist/src/update/rate-limiter.d.ts +6 -0
- package/dist/src/update/rate-limiter.js +12 -1
- package/package.json +1 -1
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* http_fetch's address checks and connection-pinned transport (ADR-164 §5.1.8).
|
|
3
|
+
*
|
|
4
|
+
* Checking the URL's hostname string is not enough: a public-looking name can resolve to
|
|
5
|
+
* loopback, RFC-1918 or cloud metadata (169.254.169.254), and `fetch()` resolves the name
|
|
6
|
+
* again on its own, so even a pre-resolution check can be rebound (TTL-0 DNS) between the
|
|
7
|
+
* check and the connect. Here the socket's DNS lookup *is* the check: `pinnedLookup`
|
|
8
|
+
* resolves the name, refuses it when any address is private, and hands the socket only the
|
|
9
|
+
* addresses it checked, so the connection cannot land anywhere else. IP-literal hosts never
|
|
10
|
+
* reach a lookup; `validateUrl` checks those by text, with the same `isPrivateOrLoopback`.
|
|
11
|
+
*
|
|
12
|
+
* @module @claude-flow/cli/mcp-tools/http-fetch-pinned
|
|
13
|
+
*/
|
|
14
|
+
import { type LookupFunction } from 'node:net';
|
|
15
|
+
/** Raised when a hostname resolves to an address the allowlist refuses. */
|
|
16
|
+
export declare class PrivateAddressError extends Error {
|
|
17
|
+
readonly code = "PRIVATE_ADDRESS";
|
|
18
|
+
constructor(host: string, address: string);
|
|
19
|
+
}
|
|
20
|
+
export declare const allowPrivateAddresses: () => boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Whether a host (an IP literal, bracketed or not, or `localhost`) is one http_fetch refuses
|
|
23
|
+
* by default. A name that is not an IP literal is `false` here: what it resolves to is
|
|
24
|
+
* checked at connect time by `pinnedLookup`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function isPrivateOrLoopback(host: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* The socket's DNS lookup: resolve every address, refuse the host when any of them is
|
|
29
|
+
* private (unless opted in), and return only those checked addresses to the socket.
|
|
30
|
+
*/
|
|
31
|
+
export declare const pinnedLookup: LookupFunction;
|
|
32
|
+
export interface PinnedInit {
|
|
33
|
+
method: string;
|
|
34
|
+
headers: Record<string, string>;
|
|
35
|
+
body?: string;
|
|
36
|
+
signal: AbortSignal;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* One HTTP(S) request whose connection goes only to an address `pinnedLookup` checked, with
|
|
40
|
+
* no redirect following and a fresh connection every time (no pooled socket from elsewhere).
|
|
41
|
+
* Returns a standard `Response` so the caller reads it as it would a `fetch()` result.
|
|
42
|
+
*/
|
|
43
|
+
export declare function pinnedRequest(url: string, init: PinnedInit): Promise<Response>;
|
|
44
|
+
//# sourceMappingURL=http-fetch-pinned.d.ts.map
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* http_fetch's address checks and connection-pinned transport (ADR-164 §5.1.8).
|
|
3
|
+
*
|
|
4
|
+
* Checking the URL's hostname string is not enough: a public-looking name can resolve to
|
|
5
|
+
* loopback, RFC-1918 or cloud metadata (169.254.169.254), and `fetch()` resolves the name
|
|
6
|
+
* again on its own, so even a pre-resolution check can be rebound (TTL-0 DNS) between the
|
|
7
|
+
* check and the connect. Here the socket's DNS lookup *is* the check: `pinnedLookup`
|
|
8
|
+
* resolves the name, refuses it when any address is private, and hands the socket only the
|
|
9
|
+
* addresses it checked, so the connection cannot land anywhere else. IP-literal hosts never
|
|
10
|
+
* reach a lookup; `validateUrl` checks those by text, with the same `isPrivateOrLoopback`.
|
|
11
|
+
*
|
|
12
|
+
* @module @claude-flow/cli/mcp-tools/http-fetch-pinned
|
|
13
|
+
*/
|
|
14
|
+
import { lookup as dnsLookup } from 'node:dns';
|
|
15
|
+
import * as http from 'node:http';
|
|
16
|
+
import * as https from 'node:https';
|
|
17
|
+
import { isIP } from 'node:net';
|
|
18
|
+
import { Readable } from 'node:stream';
|
|
19
|
+
/** Raised when a hostname resolves to an address the allowlist refuses. */
|
|
20
|
+
export class PrivateAddressError extends Error {
|
|
21
|
+
code = 'PRIVATE_ADDRESS';
|
|
22
|
+
constructor(host, address) {
|
|
23
|
+
super(`host ${host} resolves to ${address}, which is loopback/private/link-local; set CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE=1 to override`);
|
|
24
|
+
this.name = 'PrivateAddressError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export const allowPrivateAddresses = () => process.env.CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE === '1';
|
|
28
|
+
/** The 16-bit groups of an IPv6 address (brackets stripped), or null when it is not one. */
|
|
29
|
+
function ipv6Groups(addr) {
|
|
30
|
+
if (isIP(addr) !== 6)
|
|
31
|
+
return null;
|
|
32
|
+
let text = addr.split('%')[0];
|
|
33
|
+
// A trailing dotted IPv4 (`::ffff:1.2.3.4`) is two groups.
|
|
34
|
+
const v4 = text.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
35
|
+
if (v4) {
|
|
36
|
+
const [a, b, c, d] = v4.slice(1).map(Number);
|
|
37
|
+
text = `${text.slice(0, v4.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
|
|
38
|
+
}
|
|
39
|
+
const [head, tail] = text.includes('::') ? text.split('::') : [text, undefined];
|
|
40
|
+
const parse = (s) => (s ? s.split(':').map((g) => parseInt(g, 16)) : []);
|
|
41
|
+
const h = parse(head);
|
|
42
|
+
const t = tail === undefined ? [] : parse(tail);
|
|
43
|
+
const groups = tail === undefined ? h : [...h, ...new Array(8 - h.length - t.length).fill(0), ...t];
|
|
44
|
+
return groups.length === 8 && groups.every((g) => Number.isInteger(g) && g >= 0 && g <= 0xffff) ? groups : null;
|
|
45
|
+
}
|
|
46
|
+
function privateIPv4(a, b) {
|
|
47
|
+
return a === 0 // 0.0.0.0/8 ("this" network)
|
|
48
|
+
|| a === 127 // loopback
|
|
49
|
+
|| a === 10 // RFC 1918
|
|
50
|
+
|| (a === 172 && b >= 16 && b <= 31) // RFC 1918
|
|
51
|
+
|| (a === 192 && b === 168) // RFC 1918
|
|
52
|
+
|| (a === 169 && b === 254) // link-local, cloud metadata
|
|
53
|
+
|| (a === 100 && b >= 64 && b <= 127) // CGNAT
|
|
54
|
+
|| (a === 198 && (b === 18 || b === 19)) // benchmarking
|
|
55
|
+
|| a >= 224; // multicast, reserved, broadcast
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Whether a host (an IP literal, bracketed or not, or `localhost`) is one http_fetch refuses
|
|
59
|
+
* by default. A name that is not an IP literal is `false` here: what it resolves to is
|
|
60
|
+
* checked at connect time by `pinnedLookup`.
|
|
61
|
+
*/
|
|
62
|
+
export function isPrivateOrLoopback(host) {
|
|
63
|
+
const lower = host.toLowerCase();
|
|
64
|
+
if (lower === 'localhost' || lower === 'localhost.localdomain' || lower.endsWith('.localhost'))
|
|
65
|
+
return true;
|
|
66
|
+
const bare = lower.startsWith('[') && lower.endsWith(']') ? lower.slice(1, -1) : lower;
|
|
67
|
+
const v4 = bare.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
68
|
+
if (v4)
|
|
69
|
+
return privateIPv4(Number(v4[1]), Number(v4[2]));
|
|
70
|
+
const g = ipv6Groups(bare);
|
|
71
|
+
if (!g)
|
|
72
|
+
return false;
|
|
73
|
+
const embedded = (hi) => privateIPv4(hi >> 8, hi & 0xff);
|
|
74
|
+
if (g.slice(0, 7).every((x) => x === 0) && g[7] <= 1)
|
|
75
|
+
return true; // :: and ::1
|
|
76
|
+
if (g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff)
|
|
77
|
+
return embedded(g[6]); // ::ffff:a.b.c.d (IPv4-mapped)
|
|
78
|
+
if (g.slice(0, 6).every((x) => x === 0))
|
|
79
|
+
return true; // ::a.b.c.d (deprecated IPv4-compatible)
|
|
80
|
+
if (g[0] === 0x64 && g[1] === 0xff9b)
|
|
81
|
+
return true; // 64:ff9b::/96 and /48 NAT64: may wrap any IPv4
|
|
82
|
+
if (g[0] === 0x2002)
|
|
83
|
+
return embedded(g[1]); // 6to4 carries an IPv4
|
|
84
|
+
if ((g[0] & 0xfe00) === 0xfc00)
|
|
85
|
+
return true; // fc00::/7 unique local
|
|
86
|
+
if ((g[0] & 0xffc0) === 0xfe80 || (g[0] & 0xffc0) === 0xfec0)
|
|
87
|
+
return true; // fe80::/10 link-local, fec0::/10 site-local
|
|
88
|
+
if ((g[0] & 0xff00) === 0xff00)
|
|
89
|
+
return true; // ff00::/8 multicast
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The socket's DNS lookup: resolve every address, refuse the host when any of them is
|
|
94
|
+
* private (unless opted in), and return only those checked addresses to the socket.
|
|
95
|
+
*/
|
|
96
|
+
export const pinnedLookup = (hostname, options, callback) => {
|
|
97
|
+
dnsLookup(hostname, { ...options, all: true }, (err, addresses) => {
|
|
98
|
+
if (err)
|
|
99
|
+
return callback(err, []);
|
|
100
|
+
const list = addresses;
|
|
101
|
+
if (!allowPrivateAddresses()) {
|
|
102
|
+
const bad = list.find((a) => isPrivateOrLoopback(a.address));
|
|
103
|
+
if (bad || list.length === 0) {
|
|
104
|
+
return callback(new PrivateAddressError(hostname, bad?.address ?? 'no address'), []);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (options.all)
|
|
108
|
+
return callback(null, list);
|
|
109
|
+
callback(null, list[0].address, list[0].family);
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
/** Statuses whose Response must not carry a body. */
|
|
113
|
+
const NULL_BODY = new Set([101, 103, 204, 205, 304]);
|
|
114
|
+
/**
|
|
115
|
+
* One HTTP(S) request whose connection goes only to an address `pinnedLookup` checked, with
|
|
116
|
+
* no redirect following and a fresh connection every time (no pooled socket from elsewhere).
|
|
117
|
+
* Returns a standard `Response` so the caller reads it as it would a `fetch()` result.
|
|
118
|
+
*/
|
|
119
|
+
export function pinnedRequest(url, init) {
|
|
120
|
+
const target = new URL(url);
|
|
121
|
+
const client = target.protocol === 'https:' ? https : http;
|
|
122
|
+
const headers = { accept: '*/*', ...init.headers };
|
|
123
|
+
if (init.body !== undefined && !Object.keys(headers).some((k) => k.toLowerCase() === 'content-length')) {
|
|
124
|
+
headers['content-length'] = String(Buffer.byteLength(init.body));
|
|
125
|
+
}
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
const req = client.request(target, { method: init.method, headers, lookup: pinnedLookup, agent: false, signal: init.signal }, (res) => {
|
|
128
|
+
const out = new Headers();
|
|
129
|
+
for (const [key, value] of Object.entries(res.headers)) {
|
|
130
|
+
for (const v of Array.isArray(value) ? value : value === undefined ? [] : [value])
|
|
131
|
+
out.append(key, v);
|
|
132
|
+
}
|
|
133
|
+
const status = res.statusCode ?? 0;
|
|
134
|
+
if (status < 200 || status > 599) {
|
|
135
|
+
res.destroy();
|
|
136
|
+
reject(new Error(`unsupported HTTP status ${status}`));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const body = NULL_BODY.has(status) || init.method === 'HEAD' ? null : Readable.toWeb(res);
|
|
140
|
+
if (body === null)
|
|
141
|
+
res.resume();
|
|
142
|
+
resolve(new Response(body, { status, statusText: res.statusMessage ?? '', headers: out }));
|
|
143
|
+
});
|
|
144
|
+
req.on('error', reject);
|
|
145
|
+
req.end(init.body);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=http-fetch-pinned.js.map
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
* AbortController, response truncation, default User-Agent.
|
|
12
12
|
*
|
|
13
13
|
* Architectural constraints (load-bearing):
|
|
14
|
-
* - DEFAULT-REFUSES private addresses + loopback (CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE=1 opt-in)
|
|
14
|
+
* - DEFAULT-REFUSES private addresses + loopback (CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE=1 opt-in):
|
|
15
|
+
* IP literals by text here, and every resolved address of a hostname at connect time, on the
|
|
16
|
+
* connection itself (http-fetch-pinned.ts), so DNS that points at, or rebinds to, a private
|
|
17
|
+
* address is refused too
|
|
15
18
|
* - DEFAULT-REFUSES Authorization / Cookie / X-Auth-* (CLAUDE_FLOW_HTTP_FETCH_ALLOW_AUTH=1 opt-in)
|
|
16
19
|
* - hard timeout 30s default, 60s ceiling
|
|
17
20
|
* - response truncated to 256KB default, 1MB ceiling
|
|
@@ -20,6 +23,8 @@
|
|
|
20
23
|
* @module @claude-flow/cli/mcp-tools/http-fetch
|
|
21
24
|
*/
|
|
22
25
|
import type { MCPTool } from './types.js';
|
|
26
|
+
import { type PinnedInit } from './http-fetch-pinned.js';
|
|
27
|
+
export { isPrivateOrLoopback } from './http-fetch-pinned.js';
|
|
23
28
|
export declare class HttpFetchValidationError extends Error {
|
|
24
29
|
readonly code: string;
|
|
25
30
|
constructor(message: string, code: string);
|
|
@@ -45,11 +50,15 @@ export interface HttpFetchResult {
|
|
|
45
50
|
error?: string;
|
|
46
51
|
errorCode?: string;
|
|
47
52
|
}
|
|
53
|
+
/** How one request is sent; tests replace it to script responses. The default pins each connection to checked addresses. */
|
|
54
|
+
export type HttpFetchRequest = (url: string, init: PinnedInit) => Promise<Response>;
|
|
48
55
|
/**
|
|
49
56
|
* Pure execution path so tests can call it without going through the MCP
|
|
50
57
|
* dispatcher. Returns a result object (does not throw on validation failure
|
|
51
58
|
* — it returns success: false with an errorCode).
|
|
52
59
|
*/
|
|
53
|
-
export declare function httpFetchExecute(input: Record<string, unknown
|
|
60
|
+
export declare function httpFetchExecute(input: Record<string, unknown>, deps?: {
|
|
61
|
+
request?: HttpFetchRequest;
|
|
62
|
+
}): Promise<HttpFetchResult>;
|
|
54
63
|
export declare const httpFetchTools: MCPTool[];
|
|
55
64
|
//# sourceMappingURL=http-fetch-tools.d.ts.map
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
* AbortController, response truncation, default User-Agent.
|
|
12
12
|
*
|
|
13
13
|
* Architectural constraints (load-bearing):
|
|
14
|
-
* - DEFAULT-REFUSES private addresses + loopback (CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE=1 opt-in)
|
|
14
|
+
* - DEFAULT-REFUSES private addresses + loopback (CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE=1 opt-in):
|
|
15
|
+
* IP literals by text here, and every resolved address of a hostname at connect time, on the
|
|
16
|
+
* connection itself (http-fetch-pinned.ts), so DNS that points at, or rebinds to, a private
|
|
17
|
+
* address is refused too
|
|
15
18
|
* - DEFAULT-REFUSES Authorization / Cookie / X-Auth-* (CLAUDE_FLOW_HTTP_FETCH_ALLOW_AUTH=1 opt-in)
|
|
16
19
|
* - hard timeout 30s default, 60s ceiling
|
|
17
20
|
* - response truncated to 256KB default, 1MB ceiling
|
|
@@ -19,6 +22,8 @@
|
|
|
19
22
|
*
|
|
20
23
|
* @module @claude-flow/cli/mcp-tools/http-fetch
|
|
21
24
|
*/
|
|
25
|
+
import { allowPrivateAddresses, isPrivateOrLoopback, pinnedRequest, PrivateAddressError } from './http-fetch-pinned.js';
|
|
26
|
+
export { isPrivateOrLoopback } from './http-fetch-pinned.js';
|
|
22
27
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
23
28
|
const MAX_TIMEOUT_MS = 60_000;
|
|
24
29
|
const DEFAULT_MAX_RESPONSE_BYTES = 256 * 1024; // 256 KB
|
|
@@ -54,75 +59,11 @@ export function validateUrl(rawUrl) {
|
|
|
54
59
|
throw new HttpFetchValidationError(`protocol ${parsed.protocol} not allowed (only http: and https:)`, 'FORBIDDEN_PROTOCOL');
|
|
55
60
|
}
|
|
56
61
|
const host = parsed.hostname.toLowerCase();
|
|
57
|
-
|
|
58
|
-
if (!allowPrivate && isPrivateOrLoopback(host)) {
|
|
62
|
+
if (!allowPrivateAddresses() && isPrivateOrLoopback(host)) {
|
|
59
63
|
throw new HttpFetchValidationError(`host ${host} is loopback/private/link-local; set CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE=1 to override`, 'PRIVATE_ADDRESS');
|
|
60
64
|
}
|
|
61
65
|
return parsed;
|
|
62
66
|
}
|
|
63
|
-
function isPrivateOrLoopback(host) {
|
|
64
|
-
if (host === 'localhost' || host === 'localhost.localdomain')
|
|
65
|
-
return true;
|
|
66
|
-
// Node's URL parser keeps brackets in `.hostname` for every IPv6 literal
|
|
67
|
-
// (e.g. "[::1]", "[fc00::1]", "[::ffff:7f00:1]"). Strip them once so every
|
|
68
|
-
// check below operates on the bare address — without this, the fc00::/7
|
|
69
|
-
// and fe80::/10 checks further down never match anything (they test
|
|
70
|
-
// `startsWith('fc'/'fe80:')` against a string starting with "["), and an
|
|
71
|
-
// IPv4-mapped-IPv6 literal (`::ffff:a.b.c.d`, which Node normalizes to a
|
|
72
|
-
// canonical `::ffff:xxxx:yyyy` hex form) sails through both the IPv4 and
|
|
73
|
-
// IPv6 branches untested — a live SSRF bypass (e.g. `::ffff:127.0.0.1` or
|
|
74
|
-
// `::ffff:169.254.169.254` reaching loopback / cloud-metadata targets).
|
|
75
|
-
const bare = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
|
|
76
|
-
if (bare === '::1')
|
|
77
|
-
return true; // IPv6 loopback
|
|
78
|
-
// IPv4-mapped IPv6 — decode back to dotted-decimal and recurse into the
|
|
79
|
-
// IPv4 check below so it inherits every existing private/loopback/
|
|
80
|
-
// link-local/CGNAT rule instead of needing its own copy.
|
|
81
|
-
const mappedHex = bare.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
82
|
-
if (mappedHex) {
|
|
83
|
-
const hi = parseInt(mappedHex[1], 16);
|
|
84
|
-
const lo = parseInt(mappedHex[2], 16);
|
|
85
|
-
const decoded = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
|
|
86
|
-
return isPrivateOrLoopback(decoded);
|
|
87
|
-
}
|
|
88
|
-
const mappedDecimal = bare.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
89
|
-
if (mappedDecimal)
|
|
90
|
-
return isPrivateOrLoopback(mappedDecimal[1]);
|
|
91
|
-
// IPv4 numeric checks
|
|
92
|
-
const m = bare.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
93
|
-
if (m) {
|
|
94
|
-
const a = Number(m[1]);
|
|
95
|
-
const b = Number(m[2]);
|
|
96
|
-
if (a === 0)
|
|
97
|
-
return true; // 0.0.0.0/8
|
|
98
|
-
if (a === 127)
|
|
99
|
-
return true; // loopback
|
|
100
|
-
if (a === 10)
|
|
101
|
-
return true; // RFC1918
|
|
102
|
-
if (a === 172 && b >= 16 && b <= 31)
|
|
103
|
-
return true; // RFC1918
|
|
104
|
-
if (a === 192 && b === 168)
|
|
105
|
-
return true; // RFC1918
|
|
106
|
-
if (a === 169 && b === 254)
|
|
107
|
-
return true; // link-local
|
|
108
|
-
if (a === 100 && b >= 64 && b <= 127)
|
|
109
|
-
return true; // CGNAT
|
|
110
|
-
return false;
|
|
111
|
-
}
|
|
112
|
-
// IPv6 special forms — be conservative and accept only public-looking
|
|
113
|
-
// literals. Reject obvious private/local forms.
|
|
114
|
-
if (bare.startsWith('fc') || bare.startsWith('fd'))
|
|
115
|
-
return true; // fc00::/7 ULA
|
|
116
|
-
if (bare.startsWith('fe80:'))
|
|
117
|
-
return true; // link-local
|
|
118
|
-
// KNOWN LIMITATION (pre-existing, not introduced by the bracket-stripping /
|
|
119
|
-
// IPv4-mapped-IPv6 fix above; flagged by an independent adversarial review):
|
|
120
|
-
// `::` (IPv6 unspecified/"any", the analog of 0.0.0.0, which the IPv4 branch
|
|
121
|
-
// above does block), IPv6 multicast (ff00::/8), and NAT64 (64:ff9b::/96) are
|
|
122
|
-
// not covered. Left as a follow-up hardening item rather than scope-creeping
|
|
123
|
-
// this patch.
|
|
124
|
-
return false;
|
|
125
|
-
}
|
|
126
67
|
export function validateHeaders(headers) {
|
|
127
68
|
const allowAuth = process.env.CLAUDE_FLOW_HTTP_FETCH_ALLOW_AUTH === '1';
|
|
128
69
|
const out = {};
|
|
@@ -156,7 +97,8 @@ function clampNumber(raw, defaultValue, max) {
|
|
|
156
97
|
* dispatcher. Returns a result object (does not throw on validation failure
|
|
157
98
|
* — it returns success: false with an errorCode).
|
|
158
99
|
*/
|
|
159
|
-
export async function httpFetchExecute(input) {
|
|
100
|
+
export async function httpFetchExecute(input, deps = {}) {
|
|
101
|
+
const send = deps.request ?? pinnedRequest;
|
|
160
102
|
const startedAt = Date.now();
|
|
161
103
|
const url = String(input.url ?? '');
|
|
162
104
|
const method = String(input.method ?? 'GET').toUpperCase();
|
|
@@ -242,12 +184,13 @@ export async function httpFetchExecute(input) {
|
|
|
242
184
|
// protocols after passing the allowlist. Every hop is validated with the
|
|
243
185
|
// same rules; sensitive headers are dropped when the origin changes.
|
|
244
186
|
while (true) {
|
|
245
|
-
|
|
187
|
+
// Never fetch(): it resolves the hostname again on its own, so what it connects to is
|
|
188
|
+
// not what was checked. `send` connects only to addresses its DNS lookup checked.
|
|
189
|
+
response = await send(currentUrl.toString(), {
|
|
246
190
|
method: currentMethod,
|
|
247
191
|
headers,
|
|
248
192
|
body: currentMethod === 'GET' || currentMethod === 'HEAD' ? undefined : currentBody,
|
|
249
193
|
signal: controller.signal,
|
|
250
|
-
redirect: 'manual',
|
|
251
194
|
});
|
|
252
195
|
if (!REDIRECT_STATUSES.has(response.status))
|
|
253
196
|
break;
|
|
@@ -378,6 +321,12 @@ export async function httpFetchExecute(input) {
|
|
|
378
321
|
}
|
|
379
322
|
catch (e) {
|
|
380
323
|
const msg = e instanceof Error ? e.message : String(e);
|
|
324
|
+
if (e instanceof PrivateAddressError) {
|
|
325
|
+
return {
|
|
326
|
+
success: false, status: 0, statusText: '', headers: {}, body: '', bodyTruncated: false, bytesRead: 0,
|
|
327
|
+
durationMs: Date.now() - startedAt, url: parsed.toString(), method, error: msg, errorCode: 'PRIVATE_ADDRESS',
|
|
328
|
+
};
|
|
329
|
+
}
|
|
381
330
|
const aborted = msg.includes('aborted') || e?.name === 'AbortError';
|
|
382
331
|
return {
|
|
383
332
|
success: false,
|
|
@@ -401,7 +350,7 @@ export async function httpFetchExecute(input) {
|
|
|
401
350
|
export const httpFetchTools = [
|
|
402
351
|
{
|
|
403
352
|
name: 'http_fetch',
|
|
404
|
-
description: 'ADR-164 §5.1.8 — HTTP probe primitive for business-pod ops benches (synthetic 200/500 endpoint checks, third-party status pages). Default-secure: blocks file://, ftp://, RFC-1918 / loopback / link-local hosts unless CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE=1, and rejects Authorization / Cookie / X-Auth-* headers unless CLAUDE_FLOW_HTTP_FETCH_ALLOW_AUTH=1. Hard 30s timeout (60s ceiling), response truncated to 256 KB (1 MB ceiling), default User-Agent ruflo-http-fetch/1.0. Use when a pod or smoke contract needs a guarded HTTP probe — calling Node fetch() directly is wrong because it skips the URL allowlist and header sanitization that ADR-164 mandates for autopilot mode. Pair with the ops-pod bench in plugins/ruflo-business-pods/templates/ops.json (the §4.4 synthetic-endpoint test).',
|
|
353
|
+
description: 'ADR-164 §5.1.8 — HTTP probe primitive for business-pod ops benches (synthetic 200/500 endpoint checks, third-party status pages). Default-secure: blocks file://, ftp://, and RFC-1918 / loopback / link-local hosts, whether given as an address or as a name that resolves to one (checked on the connection itself, every redirect hop included), unless CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE=1, and rejects Authorization / Cookie / X-Auth-* headers unless CLAUDE_FLOW_HTTP_FETCH_ALLOW_AUTH=1. Hard 30s timeout (60s ceiling), response truncated to 256 KB (1 MB ceiling), default User-Agent ruflo-http-fetch/1.0. Use when a pod or smoke contract needs a guarded HTTP probe — calling Node fetch() directly is wrong because it skips the URL allowlist and header sanitization that ADR-164 mandates for autopilot mode. Pair with the ops-pod bench in plugins/ruflo-business-pods/templates/ops.json (the §4.4 synthetic-endpoint test).',
|
|
405
354
|
category: 'business-pods',
|
|
406
355
|
tags: ['business-pods', 'http', 'fetch', 'adr-164', 'security', 'ops-pod'],
|
|
407
356
|
inputSchema: {
|
|
@@ -29,6 +29,11 @@ export interface TrajectoryOutcome {
|
|
|
29
29
|
}>;
|
|
30
30
|
feedback?: string;
|
|
31
31
|
duration?: number;
|
|
32
|
+
/**
|
|
33
|
+
* ADR-511: files the task's verified fix edited (project-relative). Never keywords: they only decide which pattern a
|
|
34
|
+
* trajectory reinforces, so two wordings of one task that fixed the same file learn together.
|
|
35
|
+
*/
|
|
36
|
+
anchors?: string[];
|
|
32
37
|
}
|
|
33
38
|
/**
|
|
34
39
|
* Learned routing pattern
|
|
@@ -48,6 +53,8 @@ export interface LearnedPattern {
|
|
|
48
53
|
lastUsed: number;
|
|
49
54
|
/** Pattern creation time */
|
|
50
55
|
createdAt: number;
|
|
56
|
+
/** ADR-511: files verified fixes for this pattern edited; absent in patterns saved before it. */
|
|
57
|
+
anchors?: string[];
|
|
51
58
|
}
|
|
52
59
|
/**
|
|
53
60
|
* Routing suggestion result
|
|
@@ -28,6 +28,14 @@ const DECAY_RATE = 0.01; // Per day
|
|
|
28
28
|
const MAX_PATTERNS = 1000;
|
|
29
29
|
/** #4134: a trajectory reinforces an existing pattern of the same agent whose keyword set overlaps at least this much (Jaccard). */
|
|
30
30
|
const NEIGHBOUR_MIN_JACCARD = 0.6;
|
|
31
|
+
/** ADR-511: files a pattern remembers as anchors, at most. */
|
|
32
|
+
const MAX_ANCHORS = 20;
|
|
33
|
+
/** ADR-511: words too common in task prompts to say two tasks are alike, even when they fixed the same file. */
|
|
34
|
+
const GENERIC_WORDS = new Set(['test', 'tests', 'fix', 'fixes', 'bug', 'bugs', 'run', 'make', 'find', 'check', 'confirm', 'please', 'wrong', 'issue', 'problem', 'error', 'code', 'file', 'files', 'project', 'cause', 'track', 'down', 'need', 'needs', 'some', 'something', 'again', 'npm', 'node']);
|
|
35
|
+
/** ADR-511: non-generic keywords two anchor-sharing tasks must also share to reinforce one pattern. */
|
|
36
|
+
const ANCHOR_MIN_SHARED = 2;
|
|
37
|
+
/** Anchors as stored: lower case, path characters only, at most five per trajectory. */
|
|
38
|
+
const anchorList = (raw) => Array.isArray(raw) ? [...new Set(raw.filter((a) => typeof a === 'string' && /^[a-z0-9_][a-z0-9_./-]{0,119}$/i.test(a) && !a.split('/').includes('..')).map(a => a.toLowerCase()))].slice(0, 5) : [];
|
|
31
39
|
/** The routing gate in getRoutingSuggestion: confidence x matchRatio must reach this for a learned pattern to be used. */
|
|
32
40
|
export const SONA_ROUTING_GATE = 0.6;
|
|
33
41
|
// ============================================================================
|
|
@@ -215,6 +223,7 @@ export class SONAOptimizer {
|
|
|
215
223
|
*/
|
|
216
224
|
processTrajectoryOutcome(outcome) {
|
|
217
225
|
const { task, agent, success } = outcome;
|
|
226
|
+
const anchors = anchorList(outcome.anchors);
|
|
218
227
|
// Extract keywords from task
|
|
219
228
|
const keywords = this.extractKeywords(task);
|
|
220
229
|
if (keywords.length === 0) {
|
|
@@ -232,7 +241,7 @@ export class SONAOptimizer {
|
|
|
232
241
|
// the routing gate. Reinforce the closest existing pattern of the same agent instead.
|
|
233
242
|
let reinforcedNeighbour = false;
|
|
234
243
|
if (!this.patterns.has(patternKey)) {
|
|
235
|
-
const neighbour = this.findNeighbourPattern(keywords, agent);
|
|
244
|
+
const neighbour = this.findNeighbourPattern(keywords, agent, anchors);
|
|
236
245
|
if (neighbour) {
|
|
237
246
|
patternKey = neighbour;
|
|
238
247
|
reinforcedNeighbour = true;
|
|
@@ -263,6 +272,13 @@ export class SONAOptimizer {
|
|
|
263
272
|
this.failedRoutings++;
|
|
264
273
|
}
|
|
265
274
|
pattern.lastUsed = Date.now();
|
|
275
|
+
if (anchors.length > 0) {
|
|
276
|
+
const kept = pattern.anchors ?? [];
|
|
277
|
+
for (const a of anchors)
|
|
278
|
+
if (!kept.includes(a) && kept.length < MAX_ANCHORS)
|
|
279
|
+
kept.push(a);
|
|
280
|
+
pattern.anchors = kept;
|
|
281
|
+
}
|
|
266
282
|
// Store pattern
|
|
267
283
|
this.patterns.set(patternKey, pattern);
|
|
268
284
|
this.trajectoriesProcessed++;
|
|
@@ -566,20 +582,34 @@ export class SONAOptimizer {
|
|
|
566
582
|
* `keywords` the most, if that overlap (Jaccard) reaches NEIGHBOUR_MIN_JACCARD.
|
|
567
583
|
* Ties go to the more confident pattern. Linear in the pattern count (<= 1000).
|
|
568
584
|
*/
|
|
569
|
-
findNeighbourPattern(keywords, agent) {
|
|
585
|
+
findNeighbourPattern(keywords, agent, anchors = []) {
|
|
570
586
|
const wanted = new Set(keywords);
|
|
587
|
+
// ADR-511: a keyword with a `/` names a file or directory (`src/slugify`). Two tasks that each name one and share
|
|
588
|
+
// none are about different code, however alike the rest of their wording: they never reinforce one pattern.
|
|
589
|
+
const paths = keywords.filter(k => k.includes('/'));
|
|
571
590
|
let bestKey = null;
|
|
572
591
|
let bestJaccard = 0;
|
|
573
592
|
let bestConfidence = -1;
|
|
574
593
|
for (const [key, pattern] of this.patterns) {
|
|
575
594
|
if (pattern.agent !== agent)
|
|
576
595
|
continue;
|
|
596
|
+
if (paths.length > 0 && pattern.keywords.some(k => k.includes('/')) && !paths.some(k => pattern.keywords.includes(k)))
|
|
597
|
+
continue;
|
|
577
598
|
let shared = 0;
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
599
|
+
let distinctive = 0;
|
|
600
|
+
for (const k of pattern.keywords) {
|
|
601
|
+
if (!wanted.has(k))
|
|
602
|
+
continue;
|
|
603
|
+
shared++;
|
|
604
|
+
if (!GENERIC_WORDS.has(k))
|
|
605
|
+
distinctive++;
|
|
606
|
+
}
|
|
581
607
|
const union = wanted.size + pattern.keywords.length - shared;
|
|
582
|
-
|
|
608
|
+
let jaccard = union === 0 ? 0 : shared / union;
|
|
609
|
+
// ADR-511: a verified fix to the same file, worded differently, is the same task when the wordings also share
|
|
610
|
+
// what makes them specific (two unrelated tasks that both touched a README are not).
|
|
611
|
+
if (jaccard < NEIGHBOUR_MIN_JACCARD && distinctive >= ANCHOR_MIN_SHARED && Array.isArray(pattern.anchors) && anchors.some(a => pattern.anchors.includes(a)))
|
|
612
|
+
jaccard = NEIGHBOUR_MIN_JACCARD;
|
|
583
613
|
if (jaccard >= NEIGHBOUR_MIN_JACCARD &&
|
|
584
614
|
(jaccard > bestJaccard || (jaccard === bestJaccard && pattern.confidence > bestConfidence))) {
|
|
585
615
|
bestKey = key;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lessons file the learning loop writes (#4581 ADR-502, ADR-511): SONA patterns that verified outcomes trained,
|
|
3
|
+
* at or above the routing gate as lessons, repeated verified failures as cautions.
|
|
4
|
+
*
|
|
5
|
+
* ADR-511 adds what a lesson can act on: the project files the passing fixes edited, counted per pattern in the
|
|
6
|
+
* ingest ledger and named (at most three, most often first) in the lesson. A path reaches the file only if it passes
|
|
7
|
+
* `lessonFile`, the same screen the mod applies when it records and again when it reads.
|
|
8
|
+
*/
|
|
9
|
+
import { type HoldReason, type PatternStats } from './verified-guards.js';
|
|
10
|
+
/** The SONA routing gate (sona-optimizer.ts `SONA_ROUTING_GATE`); a lesson below it never influences routing. */
|
|
11
|
+
export declare const LESSON_GATE = 0.6;
|
|
12
|
+
export declare const MAX_LESSONS = 20;
|
|
13
|
+
/** Verified failures (and more failures than successes) before a pattern becomes a caution. */
|
|
14
|
+
export declare const CAUTION_MIN_FAILURES = 2;
|
|
15
|
+
/** ADR-511: files a record may carry, files a pattern counts, and files a lesson names. */
|
|
16
|
+
export declare const MAX_RECORD_FILES = 5;
|
|
17
|
+
export declare const MAX_PATTERN_FILES = 20;
|
|
18
|
+
export declare const MAX_LESSON_FILES = 3;
|
|
19
|
+
export interface Lesson {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
readonly kind: 'lesson' | 'caution';
|
|
22
|
+
readonly agent: string;
|
|
23
|
+
readonly keywords: string[];
|
|
24
|
+
readonly confidence: number;
|
|
25
|
+
readonly successes: number;
|
|
26
|
+
readonly failures: number;
|
|
27
|
+
readonly evidence: string;
|
|
28
|
+
/** ADR-511: files the passing fixes edited, most often first (a lesson only). */
|
|
29
|
+
readonly files?: string[];
|
|
30
|
+
}
|
|
31
|
+
export interface LessonsFile {
|
|
32
|
+
version: 1;
|
|
33
|
+
gate: number;
|
|
34
|
+
generatedAt: number;
|
|
35
|
+
lessons: Lesson[];
|
|
36
|
+
/** Verified patterns still below the gate, for the cold-start progress line. */
|
|
37
|
+
belowGate: number;
|
|
38
|
+
/** Patterns at or above the gate that the anomaly gate kept out of the prompt (ADR-503 §2.4). */
|
|
39
|
+
held: Array<{
|
|
40
|
+
id: string;
|
|
41
|
+
reason: HoldReason;
|
|
42
|
+
}>;
|
|
43
|
+
}
|
|
44
|
+
/** What `SonaLike.exportPatterns()` returns. */
|
|
45
|
+
export type PatternExport = Record<string, {
|
|
46
|
+
keywords: string[];
|
|
47
|
+
agent: string;
|
|
48
|
+
confidence: number;
|
|
49
|
+
successCount: number;
|
|
50
|
+
failureCount: number;
|
|
51
|
+
}>;
|
|
52
|
+
/** The ledger's view of one verified pattern that buildLessons reads. */
|
|
53
|
+
export interface VerifiedPattern {
|
|
54
|
+
evidence: string;
|
|
55
|
+
at: number;
|
|
56
|
+
stats?: PatternStats;
|
|
57
|
+
/** ADR-511: edited file → verified successes that edited it. */
|
|
58
|
+
files?: Record<string, number>;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* ADR-511: whether a path may be named in a lesson. Project-relative source files only: no absolute path, no `..`,
|
|
62
|
+
* no dot-file or dot-directory, no name that may hold a secret, no test file and no verifier config.
|
|
63
|
+
*/
|
|
64
|
+
export declare function lessonFile(path: unknown): path is string;
|
|
65
|
+
/** A record's `files`, screened. */
|
|
66
|
+
export declare const recordFiles: (raw: unknown) => string[];
|
|
67
|
+
/** A ledger pattern's file counts, screened; a hostile ledger keeps nothing it should not. */
|
|
68
|
+
export declare function parseFileCounts(raw: unknown): Record<string, number>;
|
|
69
|
+
/** Counts one verified success's files; past the cap, the least-counted file makes room. */
|
|
70
|
+
export declare function addFiles(counts: Record<string, number>, files: readonly string[]): void;
|
|
71
|
+
/** The files a lesson names: most often first, ties by name, at most three. */
|
|
72
|
+
export declare const topFiles: (counts: Record<string, number> | undefined) => string[];
|
|
73
|
+
/** A lesson's citation: a short hash of the SONA pattern key, so the key's own text never reaches a prompt. */
|
|
74
|
+
export declare const lessonId: (patternKey: string) => string;
|
|
75
|
+
/**
|
|
76
|
+
* Builds the lessons file from SONA's patterns, keeping only the ones verified outcomes trained. A would-be lesson the
|
|
77
|
+
* anomaly gate holds (ADR-503 §2.4) is listed under `held` instead: it stays in SONA, never in a prompt.
|
|
78
|
+
*/
|
|
79
|
+
export declare function buildLessons(patterns: PatternExport, verified: Record<string, VerifiedPattern>, now: number): LessonsFile;
|
|
80
|
+
//# sourceMappingURL=verified-lessons.d.ts.map
|