@pugi/cli 0.1.0-beta.96 → 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.
@@ -19,6 +19,12 @@ import { dispatchEnterWorktree, enterWorktreeJsonSchema, } from '../../tools/ent
19
19
  import { dispatchExitWorktree, exitWorktreeJsonSchema, } from '../../tools/exit-worktree.js';
20
20
  import { webFetchTool } from '../../tools/web-fetch.js';
21
21
  import { webSearchTool } from '../../tools/web-search.js';
22
+ // Phase 1 runtime evidence pack (PUGI-291..295): server_* + http_request
23
+ // dispatchers + JSON schema fragments. Every primitive returns a
24
+ // sentinel string on validation failure (sleep/brief convention) so
25
+ // the engine adapter surfaces it as a recoverable tool result.
26
+ import { dispatchHttpRequest, httpRequestJsonSchema, } from '../../tools/http-request.js';
27
+ import { dispatchServerHealth, dispatchServerLogs, dispatchServerStart, dispatchServerStop, serverHealthJsonSchema, serverLogsJsonSchema, serverStartJsonSchema, serverStopJsonSchema, } from '../../tools/server-tools.js';
22
28
  import { agentTool } from '../../tools/agent-tool.js';
23
29
  import { multiEdit } from '../../tools/multi-edit.js';
24
30
  import { recordVerificationCall } from '../session.js';
@@ -217,6 +223,15 @@ const WIRED_TOOLS = new Set([
217
223
  'symbols_signature',
218
224
  'symbols_type_definition',
219
225
  'symbols_workspace_symbols',
226
+ // Phase 1 runtime evidence pack (PUGI-291..295): server_* + http_request.
227
+ // Off by default in plan mode (mutation surface for start/stop; even
228
+ // health/logs/http_request cross the plan-mode read-only contract
229
+ // because they are runtime evidence primitives, not source reads).
230
+ 'http_request',
231
+ 'server_health',
232
+ 'server_logs',
233
+ 'server_start',
234
+ 'server_stop',
220
235
  ]);
221
236
  export function buildToolsSchema(kind, options = { allowFetch: false, allowSearch: false }) {
222
237
  const planMode = kind === 'plan';
@@ -831,6 +846,31 @@ export function buildToolsSchema(kind, options = { allowFetch: false, allowSearc
831
846
  },
832
847
  },
833
848
  },
849
+ },
850
+ // Phase 1 runtime evidence pack (PUGI-291..295) - server lifecycle
851
+ // primitives + generic HTTP request. Off in plan mode because each
852
+ // tool produces side effects (a spawned process, an outbound HTTP
853
+ // call) the plan-mode read-only contract refuses.
854
+ {
855
+ name: 'server_start',
856
+ description: 'Spawn a server via /bin/sh -c <command> in the workspace and poll the health URL until it returns the expected status (default 200) or the timeout elapses. Persists pid + meta to `.pugi/runs/<runId>/server.json` and a stdout/stderr tail to `server.log`. Returns {ok, pid, port, healthStatus, logPath, durationMs, error?}. Use to materialise concrete pid + health 200 evidence rather than asserting a server is up in prose.',
857
+ parameters: serverStartJsonSchema,
858
+ }, {
859
+ name: 'server_stop',
860
+ description: 'Send SIGTERM to the supplied pid, wait graceMs (default 5000ms), then escalate to SIGKILL. Idempotent - a pid that already exited returns ok=true with signal=undefined. Returns {ok, pid, exitCode?, signal?, durationMs, error?}.',
861
+ parameters: serverStopJsonSchema,
862
+ }, {
863
+ name: 'server_health',
864
+ description: 'One-shot HTTP GET against the supplied URL with a short timeout (default 5000ms, max 60000ms). Returns {ok, status, durationMs, body?, error?} where ok=true only when the response status equals expectStatus (default 200). Body is capped at 1 KB so the envelope stays compact.',
865
+ parameters: serverHealthJsonSchema,
866
+ }, {
867
+ name: 'server_logs',
868
+ description: 'Read the trailing N lines of `.pugi/runs/<runId>/server.log`. Defaults to the most recent run when runId is omitted; pid-based lookup walks every run dir. Returns {ok, logPath, lines, totalLines, error?}.',
869
+ parameters: serverLogsJsonSchema,
870
+ }, {
871
+ name: 'http_request',
872
+ description: 'Issue an HTTP request (GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS) against a URL. Loopback hosts (localhost / 127.0.0.0/8 / ::1) always pass; non-loopback hosts require allowExternal: true so the default posture stays private. Body objects/arrays are JSON-serialised. Returns {ok, status, headers, body, json?, durationMs, error?, truncated?}.',
873
+ parameters: httpRequestJsonSchema,
834
874
  });
835
875
  }
836
876
  // β4 M1/M3: append MCP tools last. Plan mode skips them because every
@@ -1235,6 +1275,26 @@ export function buildExecutor(input) {
1235
1275
  sessionId,
1236
1276
  });
1237
1277
  }
1278
+ // Phase 1 runtime evidence pack (PUGI-291..295). Each tool returns
1279
+ // a JSON-string envelope or a sentinel string on validation
1280
+ // failure - both paths are recoverable from the model's POV, so
1281
+ // the engine adapter routes them as plain tool results and the
1282
+ // model can self-correct.
1283
+ if (name === 'server_start') {
1284
+ return dispatchServerStart({ workspaceRoot }, args);
1285
+ }
1286
+ if (name === 'server_stop') {
1287
+ return dispatchServerStop({ workspaceRoot }, args);
1288
+ }
1289
+ if (name === 'server_health') {
1290
+ return dispatchServerHealth({ workspaceRoot }, args);
1291
+ }
1292
+ if (name === 'server_logs') {
1293
+ return dispatchServerLogs({ workspaceRoot }, args);
1294
+ }
1295
+ if (name === 'http_request') {
1296
+ return dispatchHttpRequest({}, args);
1297
+ }
1238
1298
  if (name === 'multi_edit') {
1239
1299
  return dispatchMultiEdit(args, ctx);
1240
1300
  }
@@ -47,11 +47,25 @@ const BUILT_IN_TOOL_CLASSES = Object.freeze({
47
47
  skill: 'read',
48
48
  task_get: 'read',
49
49
  task_list: 'read',
50
+ // Phase 1 runtime evidence pack (PUGI-291..295): server_health and
51
+ // server_logs are read-only probes (HTTP GET + tail of a log file
52
+ // already on disk). Classifying them as `read` keeps the ask-mode
53
+ // prompts honest; the dispatcher still applies the network-perm gate
54
+ // for server_health and the workspace-read gate for server_logs.
55
+ server_health: 'read',
56
+ server_logs: 'read',
50
57
  // Mutating actions.
51
58
  write: 'write',
52
59
  edit: 'write',
53
60
  multi_edit: 'write',
54
61
  bash: 'write',
62
+ // Phase 1 runtime evidence pack: server_start spawns a process,
63
+ // server_stop terminates one, http_request can POST/PUT/DELETE
64
+ // arbitrary data against a localhost service. All three are write
65
+ // class so plan mode refuses and ask mode prompts.
66
+ server_start: 'write',
67
+ server_stop: 'write',
68
+ http_request: 'write',
55
69
  task_create: 'write',
56
70
  task_update: 'write',
57
71
  todo_write: 'write',
@@ -5021,48 +5021,26 @@ function renderLocalSessionList(rows, projectSlug) {
5021
5021
  * 1 is reserved for the credential gate (engine_unavailable) so
5022
5022
  * existing shell wrappers that branch on "any non-zero" still work.
5023
5023
  */
5024
- const ENGINE_EXIT_CODES = {
5025
- done: 0,
5026
- failed: 8,
5027
- blocked: 9,
5028
- engine_unavailable: 1,
5029
- // PUGI-VERIFY-GATE: needs_verification is the engine telling the
5030
- // operator "you did not run a verification command — I cannot
5031
- // confirm correctness". CI scripts treating any non-zero as
5032
- // failure keep working; exit 2 historically means "misuse" (the
5033
- // engine completed but the operator missed a required step),
5034
- // which matches the semantic here.
5035
- needs_verification: 2,
5036
- };
5037
- /**
5038
- * PUGI-VERIFY-GATE Codex dogfood 2026-06-04 surfaced a P0 where
5039
- * `pugi code` returned exit 0 while npm test failed. The spec
5040
- * locks the contract to exit 1 on a verification failure AND exit
5041
- * 2 on `needs_verification`. Legacy callers reading exit 8/9 still
5042
- * see "any non-zero = failure" but the new codes (1/2) are the
5043
- * authoritative signal for the verification gate.
5044
- *
5045
- * The override fires when `result.status` carries the new
5046
- * `needs_verification` literal OR when `result.unverifiedReason`
5047
- * is set to `verification_command_failed` — the latter pins exit
5048
- * 1 even if a future producer left `status: 'failed'` while
5049
- * inferring the cause from the reason field.
5050
- */
5051
- function resolveEngineExitCode(result) {
5052
- if (result.status === 'needs_verification') {
5053
- return ENGINE_EXIT_CODES.needs_verification;
5054
- }
5055
- if (result.unverifiedReason === 'verification_command_failed') {
5056
- // Spec: verified=false because a verification command failed
5057
- // maps to CLI exit 1 (clear "this is broken" signal).
5058
- return 1;
5059
- }
5060
- if (result.status === 'done')
5061
- return ENGINE_EXIT_CODES.done;
5062
- if (result.status === 'failed')
5063
- return ENGINE_EXIT_CODES.failed;
5064
- return ENGINE_EXIT_CODES.blocked;
5065
- }
5024
+ // PUGI-VERIFY-GATE - Codex dogfood 2026-06-04 surfaced a P0 where
5025
+ // `pugi code` returned exit 0 while npm test failed. The spec locks
5026
+ // the contract to exit 1 on a verification failure AND exit 2 on
5027
+ // `needs_verification`. Legacy callers reading exit 8/9 still see
5028
+ // "any non-zero = failure" but the new codes (1/2) are the
5029
+ // authoritative signal for the verification gate.
5030
+ //
5031
+ // Phase 1 (PUGI-299) - runtime invariant additions:
5032
+ // 3. `verificationFailures` non-empty array exit 1
5033
+ // 4. `verified === false && status === 'done'` exit 2
5034
+ //
5035
+ // The two new rules are defensive: today `computeVerificationOutcome`
5036
+ // always populates `unverifiedReason` (rule 2) or downgrades the
5037
+ // status (rule 1) so the new branches never fire, but the invariant
5038
+ // locks the contract for future engine adapters.
5039
+ //
5040
+ // Implementation lives in `engine-exit-code.ts` so the runtime spec
5041
+ // (`test/engine-exit-code.spec.ts`) can exercise it without mounting
5042
+ // this 9700-LOC entry module.
5043
+ import { ENGINE_EXIT_CODES, resolveEngineExitCode, } from './engine-exit-code.js';
5066
5044
  function commandLabel(kind) {
5067
5045
  return kind === 'build_task' ? 'build' : kind;
5068
5046
  }
@@ -8132,5 +8110,12 @@ export const __test__ = {
8132
8110
  // env-driven $PUGI_HOME redirect) without going через the full
8133
8111
  // engine-task dispatch path.
8134
8112
  readPersistedContextTier,
8113
+ // Phase 1 (PUGI-299) - runtime verification gate exit-code resolver.
8114
+ // Re-exported from `engine-exit-code.ts` so the invariant spec can
8115
+ // assert that a `verificationFailures` non-empty array OR a
8116
+ // `verified=false` outcome MUST surface as a non-zero CLI exit
8117
+ // even when a malformed adapter forgets to flip the status.
8118
+ resolveEngineExitCode,
8119
+ ENGINE_EXIT_CODES,
8135
8120
  };
8136
8121
  //# sourceMappingURL=cli.js.map
@@ -0,0 +1,50 @@
1
+ /**
2
+ * engine-exit-code - Phase 1 verification gate invariant (PUGI-299).
3
+ *
4
+ * The runtime resolves the CLI exit code from the engine outcome via a
5
+ * deterministic ladder. Extracted from `cli.ts` so the invariant is
6
+ * exercisable from a focused spec without mounting the 9700-LOC entry
7
+ * module.
8
+ *
9
+ * Contract (in priority order):
10
+ * 1. `status === 'needs_verification'` → exit 2
11
+ * 2. `unverifiedReason === 'verification_command_failed'` → exit 1
12
+ * 3. `verificationFailures` is a non-empty array → exit 1
13
+ * 4. `verified === false && status === 'done'` → exit 2
14
+ * 5. `status === 'done'` → exit 0
15
+ * 6. `status === 'failed'` → exit 8
16
+ * 7. `status === 'blocked'` (catch-all) → exit 9
17
+ *
18
+ * The rule fires defensively: rules 3 + 4 cover producer bugs where a
19
+ * future engine adapter forgets to flip `unverifiedReason` or
20
+ * downgrade the status to `needs_verification`. Today
21
+ * `computeVerificationOutcome` always synthesises one of the two,
22
+ * but the invariant locks the contract.
23
+ */
24
+ export const ENGINE_EXIT_CODES = {
25
+ done: 0,
26
+ failed: 8,
27
+ blocked: 9,
28
+ engine_unavailable: 1,
29
+ needs_verification: 2,
30
+ };
31
+ export function resolveEngineExitCode(result) {
32
+ if (result.status === 'needs_verification') {
33
+ return ENGINE_EXIT_CODES.needs_verification;
34
+ }
35
+ if (result.unverifiedReason === 'verification_command_failed') {
36
+ return 1;
37
+ }
38
+ if (Array.isArray(result.verificationFailures) && result.verificationFailures.length > 0) {
39
+ return 1;
40
+ }
41
+ if (result.verified === false && result.status === 'done') {
42
+ return ENGINE_EXIT_CODES.needs_verification;
43
+ }
44
+ if (result.status === 'done')
45
+ return ENGINE_EXIT_CODES.done;
46
+ if (result.status === 'failed')
47
+ return ENGINE_EXIT_CODES.failed;
48
+ return ENGINE_EXIT_CODES.blocked;
49
+ }
50
+ //# sourceMappingURL=engine-exit-code.js.map
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
44
44
  * during import). When bumping the CLI version BOTH literals must be
45
45
  * updated; the release smoke-test (`pack:smoke`) verifies they agree.
46
46
  */
47
- export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.96');
47
+ export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.97');
48
48
  /**
49
49
  * Outbound: the CLI's installed semver. Read at request time by
50
50
  * `version-interceptor.ts` and injected on every `fetch` call.
@@ -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