@pugi/cli 0.1.0-beta.96 → 0.1.0-beta.98

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.
@@ -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
@@ -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
- { name: 'skill', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
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