@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.
- package/dist/core/engine/tool-bridge.js +60 -0
- package/dist/core/permissions/tool-class.js +14 -0
- 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/package.json +2 -2
|
@@ -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',
|
package/dist/runtime/cli.js
CHANGED
|
@@ -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
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
5035
|
-
|
|
5036
|
-
|
|
5037
|
-
|
|
5038
|
-
|
|
5039
|
-
|
|
5040
|
-
|
|
5041
|
-
|
|
5042
|
-
|
|
5043
|
-
|
|
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
|
package/dist/runtime/version.js
CHANGED
|
@@ -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.
|
|
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
|
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
|