@pugi/cli 0.1.0-beta.95 → 0.1.0-beta.97
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/dist/core/engine/tool-bridge.js +60 -0
- package/dist/core/permissions/tool-class.js +14 -0
- package/dist/core/repl/engine-bridge.js +104 -0
- package/dist/core/repl/session.js +65 -7
- package/dist/runtime/cli.js +27 -42
- package/dist/runtime/engine-exit-code.js +50 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tools/http-request.js +336 -0
- package/dist/tools/registry.js +21 -1
- package/dist/tools/server-tools.js +892 -0
- package/dist/tui/repl-render.js +10 -0
- package/package.json +2 -2
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* http_request - Phase 1 CRUD-smoke primitive (PUGI Phase 1).
|
|
3
|
+
*
|
|
4
|
+
* Generic HTTP request tool the model uses to verify a freshly-spun
|
|
5
|
+
* service end-to-end. Distinct from `web_fetch`:
|
|
6
|
+
* - `web_fetch` is an opt-in marketing-grade content fetch (always
|
|
7
|
+
* GET, response folded to Markdown, sentinel wrap, SSRF guard
|
|
8
|
+
* blocks loopback / RFC1918).
|
|
9
|
+
* - `http_request` is a *development-evidence* primitive: any verb,
|
|
10
|
+
* any localhost URL, JSON-aware body+response. The default
|
|
11
|
+
* allow-list permits localhost / 127.0.0.1 / ::1 so the model can
|
|
12
|
+
* drive a CRUD smoke against a server it just started via
|
|
13
|
+
* `server_start`; non-loopback hosts require an explicit
|
|
14
|
+
* `allowExternal` opt-in.
|
|
15
|
+
*
|
|
16
|
+
* Body is serialised to JSON when a plain object is supplied. Headers
|
|
17
|
+
* are merged on top of a small default set (Accept: application/json,
|
|
18
|
+
* Content-Type when body is JSON). Response body is capped at 64 KB to
|
|
19
|
+
* keep the envelope sized; when the response content-type matches
|
|
20
|
+
* `application/json` the dispatcher attempts a parse and surfaces
|
|
21
|
+
* `json` alongside `body`.
|
|
22
|
+
*
|
|
23
|
+
* Brand voice: English only, no emoji, no banned words.
|
|
24
|
+
*/
|
|
25
|
+
import { isIP } from 'node:net';
|
|
26
|
+
export const HTTP_REQUEST_INVALID_ARGS = 'HTTP_REQUEST_INVALID_ARGS';
|
|
27
|
+
export const HTTP_REQUEST_BODY_CAP_BYTES = 64 * 1024;
|
|
28
|
+
export const HTTP_REQUEST_DEFAULT_TIMEOUT_MS = 10_000;
|
|
29
|
+
export const HTTP_REQUEST_MAX_TIMEOUT_MS = 60_000;
|
|
30
|
+
const ALLOWED_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
|
|
31
|
+
/**
|
|
32
|
+
* Validate raw arguments. Returns the typed payload on success or a
|
|
33
|
+
* sentinel string on failure (sleep/brief convention).
|
|
34
|
+
*/
|
|
35
|
+
export function parseHttpRequestArgs(raw) {
|
|
36
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
37
|
+
return `${HTTP_REQUEST_INVALID_ARGS}: arguments must be a JSON object`;
|
|
38
|
+
}
|
|
39
|
+
const obj = raw;
|
|
40
|
+
const method = obj['method'];
|
|
41
|
+
const url = obj['url'];
|
|
42
|
+
if (typeof method !== 'string' || !ALLOWED_METHODS.has(method.toUpperCase())) {
|
|
43
|
+
return `${HTTP_REQUEST_INVALID_ARGS}: method must be one of ${Array.from(ALLOWED_METHODS).join(', ')}`;
|
|
44
|
+
}
|
|
45
|
+
if (typeof url !== 'string' || url.trim() === '') {
|
|
46
|
+
return `${HTTP_REQUEST_INVALID_ARGS}: url must be a non-empty string`;
|
|
47
|
+
}
|
|
48
|
+
const body = obj['body'];
|
|
49
|
+
if (body !== undefined && body !== null) {
|
|
50
|
+
if (typeof body !== 'string' &&
|
|
51
|
+
!(typeof body === 'object')) {
|
|
52
|
+
return `${HTTP_REQUEST_INVALID_ARGS}: body must be a string or a JSON object/array`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const headers = obj['headers'];
|
|
56
|
+
if (headers !== undefined) {
|
|
57
|
+
if (typeof headers !== 'object' || headers === null || Array.isArray(headers)) {
|
|
58
|
+
return `${HTTP_REQUEST_INVALID_ARGS}: headers must be a JSON object of string values`;
|
|
59
|
+
}
|
|
60
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
61
|
+
if (typeof v !== 'string') {
|
|
62
|
+
return `${HTTP_REQUEST_INVALID_ARGS}: headers["${k}"] must be a string`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const timeoutMs = obj['timeoutMs'];
|
|
67
|
+
if (timeoutMs !== undefined) {
|
|
68
|
+
if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
69
|
+
return `${HTTP_REQUEST_INVALID_ARGS}: timeoutMs must be a positive number when provided`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const allowExternal = obj['allowExternal'];
|
|
73
|
+
if (allowExternal !== undefined && typeof allowExternal !== 'boolean') {
|
|
74
|
+
return `${HTTP_REQUEST_INVALID_ARGS}: allowExternal must be a boolean when provided`;
|
|
75
|
+
}
|
|
76
|
+
const result = {
|
|
77
|
+
method: method.toUpperCase(),
|
|
78
|
+
url,
|
|
79
|
+
...(body !== undefined && body !== null
|
|
80
|
+
? { body: body }
|
|
81
|
+
: {}),
|
|
82
|
+
...(headers !== undefined ? { headers: headers } : {}),
|
|
83
|
+
...(typeof timeoutMs === 'number' ? { timeoutMs } : {}),
|
|
84
|
+
...(typeof allowExternal === 'boolean' ? { allowExternal } : {}),
|
|
85
|
+
};
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
function clampTimeout(value) {
|
|
89
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
90
|
+
return HTTP_REQUEST_DEFAULT_TIMEOUT_MS;
|
|
91
|
+
}
|
|
92
|
+
return Math.min(value, HTTP_REQUEST_MAX_TIMEOUT_MS);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Decide whether the target host counts as loopback. Accept the
|
|
96
|
+
* familiar shorthand (`localhost`, `127.0.0.1`, `::1`) plus the
|
|
97
|
+
* full 127.0.0.0/8 IPv4 range so a server bound to e.g. `127.0.0.5`
|
|
98
|
+
* still passes the gate.
|
|
99
|
+
*/
|
|
100
|
+
function isLoopbackHost(host) {
|
|
101
|
+
const lower = host.toLowerCase();
|
|
102
|
+
if (lower === 'localhost' ||
|
|
103
|
+
lower === '127.0.0.1' ||
|
|
104
|
+
lower === '::1' ||
|
|
105
|
+
lower === '[::1]' ||
|
|
106
|
+
// /triple-review P1 (Claude reviewer): IPv4-mapped
|
|
107
|
+
// IPv6 (`::ffff:127.0.0.1`) reaches loopback at the OS level.
|
|
108
|
+
// Recognize so a model that emits the IPv6 form is gated as
|
|
109
|
+
// loopback (rather than blocked as external — safer to allow
|
|
110
|
+
// explicitly recognised loopback and reject everything else).
|
|
111
|
+
lower === '::ffff:127.0.0.1' ||
|
|
112
|
+
lower.startsWith('::ffff:127.')) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
if (isIP(lower) === 4 && lower.startsWith('127.')) {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
// /triple-review P1: `0.0.0.0` resolves к localhost for outbound on
|
|
119
|
+
// Linux and is reachable from inside the host. Treat as loopback so
|
|
120
|
+
// a server bound к `0.0.0.0` is callable from the dispatcher без
|
|
121
|
+
// requiring `allowExternal`. The risk surface is identical к
|
|
122
|
+
// `127.0.0.0/8` — anything reachable via `0.0.0.0` is also reachable
|
|
123
|
+
// via `127.0.0.1` on the same host.
|
|
124
|
+
if (lower === '0.0.0.0') {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
function headersToObject(h) {
|
|
130
|
+
const out = {};
|
|
131
|
+
h.forEach((value, key) => {
|
|
132
|
+
out[key.toLowerCase()] = value;
|
|
133
|
+
});
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Dispatch entry. Pure async - no shell, no filesystem. Loopback URLs
|
|
138
|
+
* always pass; external URLs require `allowExternal: true` so the
|
|
139
|
+
* default posture stays private.
|
|
140
|
+
*/
|
|
141
|
+
export async function dispatchHttpRequest(ctx, raw) {
|
|
142
|
+
const parsed = parseHttpRequestArgs(raw);
|
|
143
|
+
if (typeof parsed === 'string')
|
|
144
|
+
return parsed;
|
|
145
|
+
const args = parsed;
|
|
146
|
+
const now = ctx.now ?? (() => Date.now());
|
|
147
|
+
const fetchImpl = ctx.fetch ?? globalThis.fetch;
|
|
148
|
+
if (typeof fetchImpl !== 'function') {
|
|
149
|
+
const result = {
|
|
150
|
+
ok: false,
|
|
151
|
+
status: 0,
|
|
152
|
+
headers: {},
|
|
153
|
+
body: '',
|
|
154
|
+
durationMs: 0,
|
|
155
|
+
error: 'no_fetch_available',
|
|
156
|
+
};
|
|
157
|
+
return JSON.stringify(result);
|
|
158
|
+
}
|
|
159
|
+
// URL parse + loopback gate.
|
|
160
|
+
let parsedUrl;
|
|
161
|
+
try {
|
|
162
|
+
parsedUrl = new URL(args.url);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
const result = {
|
|
166
|
+
ok: false,
|
|
167
|
+
status: 0,
|
|
168
|
+
headers: {},
|
|
169
|
+
body: '',
|
|
170
|
+
durationMs: 0,
|
|
171
|
+
error: `invalid_url: ${error.message}`,
|
|
172
|
+
};
|
|
173
|
+
return JSON.stringify(result);
|
|
174
|
+
}
|
|
175
|
+
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
|
176
|
+
const result = {
|
|
177
|
+
ok: false,
|
|
178
|
+
status: 0,
|
|
179
|
+
headers: {},
|
|
180
|
+
body: '',
|
|
181
|
+
durationMs: 0,
|
|
182
|
+
error: `unsupported_protocol: ${parsedUrl.protocol}`,
|
|
183
|
+
};
|
|
184
|
+
return JSON.stringify(result);
|
|
185
|
+
}
|
|
186
|
+
const host = parsedUrl.hostname;
|
|
187
|
+
const loopback = isLoopbackHost(host);
|
|
188
|
+
if (!loopback && args.allowExternal !== true) {
|
|
189
|
+
const result = {
|
|
190
|
+
ok: false,
|
|
191
|
+
status: 0,
|
|
192
|
+
headers: {},
|
|
193
|
+
body: '',
|
|
194
|
+
durationMs: 0,
|
|
195
|
+
error: `external_host_blocked: ${host} (set allowExternal: true to permit)`,
|
|
196
|
+
};
|
|
197
|
+
return JSON.stringify(result);
|
|
198
|
+
}
|
|
199
|
+
// Header + body normalisation. The default Accept header pushes
|
|
200
|
+
// servers that content-negotiate toward JSON so the response parse
|
|
201
|
+
// hits more often.
|
|
202
|
+
const defaultHeaders = {
|
|
203
|
+
accept: 'application/json,text/plain;q=0.9,*/*;q=0.5',
|
|
204
|
+
};
|
|
205
|
+
const merged = { ...defaultHeaders };
|
|
206
|
+
if (args.headers) {
|
|
207
|
+
for (const [k, v] of Object.entries(args.headers)) {
|
|
208
|
+
merged[k.toLowerCase()] = v;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
let serialisedBody;
|
|
212
|
+
if (args.body !== undefined && args.body !== null) {
|
|
213
|
+
if (typeof args.body === 'string') {
|
|
214
|
+
serialisedBody = args.body;
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
try {
|
|
218
|
+
serialisedBody = JSON.stringify(args.body);
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
const result = {
|
|
222
|
+
ok: false,
|
|
223
|
+
status: 0,
|
|
224
|
+
headers: {},
|
|
225
|
+
body: '',
|
|
226
|
+
durationMs: 0,
|
|
227
|
+
error: `body_serialise_failed: ${error.message}`,
|
|
228
|
+
};
|
|
229
|
+
return JSON.stringify(result);
|
|
230
|
+
}
|
|
231
|
+
if (!('content-type' in merged)) {
|
|
232
|
+
merged['content-type'] = 'application/json';
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const timeoutMs = clampTimeout(args.timeoutMs);
|
|
237
|
+
const start = now();
|
|
238
|
+
const ac = new AbortController();
|
|
239
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
240
|
+
try {
|
|
241
|
+
const init = {
|
|
242
|
+
method: args.method,
|
|
243
|
+
headers: merged,
|
|
244
|
+
signal: ac.signal,
|
|
245
|
+
// /triple-review P1 (Claude reviewer): default `fetch`
|
|
246
|
+
// follows redirects to ANY target. A loopback service can return
|
|
247
|
+
// 30x with Location pointing к cloud metadata IPs (169.254.169.254,
|
|
248
|
+
// 100.64/10 ranges) or arbitrary external hosts, and `fetch` would
|
|
249
|
+
// chase the redirect and surface the body. SSRF bypass class.
|
|
250
|
+
// `redirect: 'manual'` stops the chase; the dispatcher returns the
|
|
251
|
+
// 30x status + Location header to the model so it can decide
|
|
252
|
+
// whether the redirect target is acceptable.
|
|
253
|
+
redirect: 'manual',
|
|
254
|
+
...(serialisedBody !== undefined ? { body: serialisedBody } : {}),
|
|
255
|
+
};
|
|
256
|
+
const res = await fetchImpl(args.url, init);
|
|
257
|
+
const text = await res.text();
|
|
258
|
+
const truncated = text.length > HTTP_REQUEST_BODY_CAP_BYTES;
|
|
259
|
+
const body = truncated ? text.slice(0, HTTP_REQUEST_BODY_CAP_BYTES) : text;
|
|
260
|
+
const respHeaders = headersToObject(res.headers);
|
|
261
|
+
const contentType = respHeaders['content-type'] ?? '';
|
|
262
|
+
let json;
|
|
263
|
+
if (contentType.includes('application/json') && body.length > 0 && !truncated) {
|
|
264
|
+
try {
|
|
265
|
+
json = JSON.parse(body);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// Body claimed JSON but failed to parse - preserve raw text only.
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const result = {
|
|
272
|
+
ok: res.status >= 200 && res.status < 300,
|
|
273
|
+
status: res.status,
|
|
274
|
+
headers: respHeaders,
|
|
275
|
+
body,
|
|
276
|
+
...(json !== undefined ? { json } : {}),
|
|
277
|
+
durationMs: now() - start,
|
|
278
|
+
...(truncated ? { truncated: true } : {}),
|
|
279
|
+
};
|
|
280
|
+
return JSON.stringify(result);
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
const message = error.message;
|
|
284
|
+
const aborted = error.name === 'AbortError';
|
|
285
|
+
const result = {
|
|
286
|
+
ok: false,
|
|
287
|
+
status: 0,
|
|
288
|
+
headers: {},
|
|
289
|
+
body: '',
|
|
290
|
+
durationMs: now() - start,
|
|
291
|
+
error: aborted ? `timeout_after_${timeoutMs}ms` : `request_failed: ${message}`,
|
|
292
|
+
};
|
|
293
|
+
return JSON.stringify(result);
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
clearTimeout(timer);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
export const httpRequestJsonSchema = {
|
|
300
|
+
type: 'object',
|
|
301
|
+
additionalProperties: false,
|
|
302
|
+
required: ['method', 'url'],
|
|
303
|
+
properties: {
|
|
304
|
+
method: {
|
|
305
|
+
type: 'string',
|
|
306
|
+
enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
|
|
307
|
+
description: 'HTTP verb. Uppercase preferred but the dispatcher accepts any case.',
|
|
308
|
+
},
|
|
309
|
+
url: {
|
|
310
|
+
type: 'string',
|
|
311
|
+
description: 'Fully-qualified http(s) URL. Loopback hosts always pass; external hosts require allowExternal: true.',
|
|
312
|
+
},
|
|
313
|
+
body: {
|
|
314
|
+
description: 'Request body. A string is sent verbatim; an object/array is JSON-serialised and Content-Type defaults to application/json.',
|
|
315
|
+
oneOf: [
|
|
316
|
+
{ type: 'string' },
|
|
317
|
+
{ type: 'object', additionalProperties: true },
|
|
318
|
+
{ type: 'array' },
|
|
319
|
+
],
|
|
320
|
+
},
|
|
321
|
+
headers: {
|
|
322
|
+
type: 'object',
|
|
323
|
+
description: 'Custom request headers. Lower-cased keys overwrite the dispatcher defaults.',
|
|
324
|
+
additionalProperties: { type: 'string' },
|
|
325
|
+
},
|
|
326
|
+
timeoutMs: {
|
|
327
|
+
type: 'number',
|
|
328
|
+
description: `Per-request timeout in ms. Default ${HTTP_REQUEST_DEFAULT_TIMEOUT_MS}, max ${HTTP_REQUEST_MAX_TIMEOUT_MS}.`,
|
|
329
|
+
},
|
|
330
|
+
allowExternal: {
|
|
331
|
+
type: 'boolean',
|
|
332
|
+
description: 'Opt-in flag for non-loopback hosts. Defaults to false so the dispatcher refuses external URLs without explicit consent.',
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
//# sourceMappingURL=http-request.js.map
|
package/dist/tools/registry.js
CHANGED
|
@@ -49,6 +49,14 @@ const registry = [
|
|
|
49
49
|
{ name: 'exit_worktree', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: false },
|
|
50
50
|
{ name: 'glob', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
|
|
51
51
|
{ name: 'grep', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
|
|
52
|
+
// Phase 1 runtime evidence pack (PUGI-291..295): http_request issues a
|
|
53
|
+
// single HTTP call, mostly against loopback URLs produced by
|
|
54
|
+
// `server_start`. permission = 'network' to share the same egress
|
|
55
|
+
// gate as web_fetch; risk = 'medium' because the dispatcher will
|
|
56
|
+
// accept arbitrary verbs (POST/PUT/DELETE) - destructive verbs only
|
|
57
|
+
// when the caller opts in by URL/body. concurrencySafe = true because
|
|
58
|
+
// every dispatch is a fresh fetch with no shared state.
|
|
59
|
+
{ name: 'http_request', permission: 'network', risk: 'medium', concurrencySafe: true, m1: false },
|
|
52
60
|
// : LSP read-only surface. Server runs locally, no Anvil
|
|
53
61
|
// round-trip. Concurrency-safe because every operation reads
|
|
54
62
|
// server state without mutating workspace files.
|
|
@@ -91,7 +99,19 @@ const registry = [
|
|
|
91
99
|
{ name: 'powershell', permission: 'bash', risk: 'high', concurrencySafe: false, m1: false },
|
|
92
100
|
{ name: 'question', permission: 'none', risk: 'low', concurrencySafe: false, m1: true },
|
|
93
101
|
{ name: 'read', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
|
|
94
|
-
|
|
102
|
+
// Phase 1 runtime evidence pack (PUGI-291..295): server_* family.
|
|
103
|
+
// server_start spawns a process under /bin/sh -c and persists pid +
|
|
104
|
+
// log path к .pugi/runs/<runId>/. permission = 'bash' shares the
|
|
105
|
+
// same destructive-classifier gate as the bash tool (the command
|
|
106
|
+
// ultimately runs in a real shell). risk = 'high' for start/stop
|
|
107
|
+
// (process lifecycle mutates the operator's machine) and 'low' for
|
|
108
|
+
// health/logs (read-only probes). concurrencySafe = false for
|
|
109
|
+
// start/stop because the pid registry is not transactional;
|
|
110
|
+
// health/logs are safe to dispatch in parallel.
|
|
111
|
+
{ name: 'server_health', permission: 'network', risk: 'low', concurrencySafe: true, m1: false },
|
|
112
|
+
{ name: 'server_logs', permission: 'read', risk: 'low', concurrencySafe: true, m1: false },
|
|
113
|
+
{ name: 'server_start', permission: 'bash', risk: 'high', concurrencySafe: false, m1: false },
|
|
114
|
+
{ name: 'server_stop', permission: 'bash', risk: 'high', concurrencySafe: false, m1: false },
|
|
95
115
|
// Tool gap pack : wall-clock pause primitive. No
|
|
96
116
|
// filesystem / network / shell side-effects. concurrencySafe = true
|
|
97
117
|
// because every dispatch is a fresh setTimeout closure with no
|