@cspeach/cli 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +8 -0
- package/README.md +108 -0
- package/dist/agent/anthropic-provider.js +59 -0
- package/dist/agent/llm-provider.js +1 -0
- package/dist/agent/loop.js +709 -0
- package/dist/agent/maybe-build-project-context.js +126 -0
- package/dist/agent/providers/ai-hub-provider.js +58 -0
- package/dist/agent/providers/byok-provider.js +53 -0
- package/dist/agent/providers/factory.js +13 -0
- package/dist/agent/providers/local-provider.js +125 -0
- package/dist/agent/repair-partial.js +31 -0
- package/dist/agent/retry-key.js +58 -0
- package/dist/agent/retry.js +17 -0
- package/dist/agent/sap-connection-adapter.js +82 -0
- package/dist/agent/skill-checkpoint.js +119 -0
- package/dist/agent/tool-dispatch.js +47 -0
- package/dist/agent/turn-assistant-text.js +49 -0
- package/dist/agent/turn-error-ux.js +126 -0
- package/dist/agent/turn-stream.js +79 -0
- package/dist/agent/turn-watchdog.js +71 -0
- package/dist/approvals/advisory-prompt.js +40 -0
- package/dist/approvals/advisory-render.js +38 -0
- package/dist/approvals/approval-prompt.js +100 -0
- package/dist/approvals/jwt.js +33 -0
- package/dist/approvals/render.js +211 -0
- package/dist/approvals/risk-floor.js +26 -0
- package/dist/auth/api-key.js +40 -0
- package/dist/auth/auth-file.js +59 -0
- package/dist/auth/device.js +8 -0
- package/dist/auth/me.js +19 -0
- package/dist/classifier/client.js +58 -0
- package/dist/cli-args.js +38 -0
- package/dist/cli.js +148 -0
- package/dist/commands/config-set.js +245 -0
- package/dist/commands/config-show.js +159 -0
- package/dist/commands/help.js +93 -0
- package/dist/commands/login.js +122 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/project-context-impact.js +215 -0
- package/dist/commands/reroute.js +60 -0
- package/dist/commands/spec-gap-status.js +52 -0
- package/dist/commands/whoami.js +35 -0
- package/dist/config/loader.js +67 -0
- package/dist/config/paths.js +20 -0
- package/dist/doctor/checks/_http-probe.js +56 -0
- package/dist/doctor/checks/auth.js +15 -0
- package/dist/doctor/checks/cert.js +24 -0
- package/dist/doctor/checks/forge-rules.js +102 -0
- package/dist/doctor/checks/keychain-fallback.js +14 -0
- package/dist/doctor/checks/keychain.js +23 -0
- package/dist/doctor/checks/llm-mode.js +27 -0
- package/dist/doctor/checks/proxy.js +13 -0
- package/dist/doctor/checks/sap.js +33 -0
- package/dist/doctor/checks/skill.js +24 -0
- package/dist/doctor/checks/write-mode.js +34 -0
- package/dist/doctor/checks/zcspeach.js +76 -0
- package/dist/doctor/run.js +46 -0
- package/dist/errors/codes.js +12 -0
- package/dist/index.js +6 -0
- package/dist/lock-contention.js +22 -0
- package/dist/one-shot.js +104 -0
- package/dist/project-context/conventions.js +309 -0
- package/dist/project-context/detect.js +250 -0
- package/dist/project-context/domain/abap-cloud.js +26 -0
- package/dist/project-context/domain/abapgit.js +177 -0
- package/dist/project-context/domain/cap.js +164 -0
- package/dist/project-context/domain/fiori.js +326 -0
- package/dist/project-context/git.js +115 -0
- package/dist/project-context/index-files.js +235 -0
- package/dist/project-context/index.js +117 -0
- package/dist/project-context/render.js +308 -0
- package/dist/project-context/types.js +14 -0
- package/dist/projects/build.js +20 -0
- package/dist/projects/canonicalize.js +39 -0
- package/dist/projects/email-template.js +54 -0
- package/dist/projects/extract-cca.js +139 -0
- package/dist/projects/extract-design.js +107 -0
- package/dist/projects/extract-estimate.js +93 -0
- package/dist/projects/extract-modernize.js +130 -0
- package/dist/projects/extract-spec-gap.js +101 -0
- package/dist/projects/extract-test-coverage.js +137 -0
- package/dist/projects/extract-upgrade.js +230 -0
- package/dist/projects/filename.js +18 -0
- package/dist/projects/index.js +8 -0
- package/dist/projects/migration.js +111 -0
- package/dist/projects/promote-command.js +96 -0
- package/dist/projects/promote.js +107 -0
- package/dist/projects/save-command.js +124 -0
- package/dist/projects/save.js +21 -0
- package/dist/projects/status.js +170 -0
- package/dist/projects/types.js +1 -0
- package/dist/projects/validate.js +146 -0
- package/dist/projects/workspace.js +478 -0
- package/dist/renderer/abap-inline.js +121 -0
- package/dist/renderer/banners.js +39 -0
- package/dist/renderer/highlighters/abap.js +126 -0
- package/dist/renderer/highlighters/bdef.js +81 -0
- package/dist/renderer/highlighters/cds.js +91 -0
- package/dist/renderer/markdown.js +291 -0
- package/dist/renderer/pipeline.js +201 -0
- package/dist/renderer/progress-chatter.js +237 -0
- package/dist/renderer/question-normalizer.js +306 -0
- package/dist/renderer/severity.js +61 -0
- package/dist/renderer/status-footer.js +50 -0
- package/dist/renderer/syntax.js +58 -0
- package/dist/renderer/tables.js +55 -0
- package/dist/renderer/thinking-heartbeat.js +70 -0
- package/dist/renderer/tool-widget.js +199 -0
- package/dist/renderer/tty.js +66 -0
- package/dist/renderer/widget-extractor.js +87 -0
- package/dist/renderer/widget-fallback.js +78 -0
- package/dist/renderer/widget-schemas.js +43 -0
- package/dist/repl/at-completer.js +64 -0
- package/dist/repl/at-picker.js +122 -0
- package/dist/repl/bracketed-paste.js +284 -0
- package/dist/repl/current-transport.js +46 -0
- package/dist/repl/diff-display.js +41 -0
- package/dist/repl/file-picker.js +219 -0
- package/dist/repl/inquirer-guard.js +130 -0
- package/dist/repl/inquirer-theme.js +41 -0
- package/dist/repl/rule8-detector.js +99 -0
- package/dist/repl/safety-confirm.js +106 -0
- package/dist/repl/safety-mode-state.js +36 -0
- package/dist/repl/slash-completer.js +59 -0
- package/dist/repl/slash-picker.js +124 -0
- package/dist/repl/update-method-preview-hook.js +45 -0
- package/dist/repl.js +1383 -0
- package/dist/router/classifier.js +38 -0
- package/dist/router/intent-extractor.js +140 -0
- package/dist/router/routing-decision.js +19 -0
- package/dist/sap/connection-manager.js +52 -0
- package/dist/sap/onboarding.js +178 -0
- package/dist/sap/system-info.js +515 -0
- package/dist/session/awaiting-answer.js +73 -0
- package/dist/session/gc.js +28 -0
- package/dist/session/pending.js +37 -0
- package/dist/session/resume.js +77 -0
- package/dist/session/schema.js +20 -0
- package/dist/session/store.js +147 -0
- package/dist/session/time-ago.js +41 -0
- package/dist/skill-catalog.js +222 -0
- package/dist/skills/bundled-skills.js +1 -0
- package/dist/skills/canonical.js +12 -0
- package/dist/skills/manifest-client.js +93 -0
- package/dist/skills/promotion-dispatch.js +24 -0
- package/dist/skills/signing-public-key.js +4 -0
- package/dist/skills/source-bundled.js +20 -0
- package/dist/skills/source-managed.js +26 -0
- package/dist/skills/source-manifest.js +26 -0
- package/dist/tools/_command-shared.js +110 -0
- package/dist/tools/_filesystem-shared.js +81 -0
- package/dist/tools/_flag.js +39 -0
- package/dist/tools/approval.js +228 -0
- package/dist/tools/ask-question.js +205 -0
- package/dist/tools/dispatch-skill.js +81 -0
- package/dist/tools/filesystem/file-edit.js +140 -0
- package/dist/tools/filesystem/file-read.js +89 -0
- package/dist/tools/filesystem/file-write.js +128 -0
- package/dist/tools/filesystem/glob.js +177 -0
- package/dist/tools/filesystem/grep.js +163 -0
- package/dist/tools/index.js +32 -0
- package/dist/tools/project/convention_get.js +91 -0
- package/dist/tools/project/playbook_get.js +132 -0
- package/dist/tools/project/project_context_get.js +101 -0
- package/dist/tools/sap-read.js +454 -0
- package/dist/tools/sap-write.js +746 -0
- package/dist/tools/shell/shell_exec.js +209 -0
- package/dist/tools/snapshot.js +107 -0
- package/dist/tools/subagent/_background-shared.js +133 -0
- package/dist/tools/subagent/agent_run.js +186 -0
- package/dist/tools/subagent/background_run.js +143 -0
- package/dist/tools/subagent/monitor_emit.js +65 -0
- package/dist/tools/subagent/schedule_create.js +131 -0
- package/dist/tools/transport.js +233 -0
- package/dist/tools/update-method-intercept.js +119 -0
- package/dist/tools/verify.js +39 -0
- package/dist/tools/web/_web-shared.js +251 -0
- package/dist/tools/web/web_fetch.js +257 -0
- package/dist/tools/web/web_search.js +195 -0
- package/dist/tools/write-mode.js +22 -0
- package/dist/ui/app.js +95 -0
- package/dist/ui/approval-emitter.js +10 -0
- package/dist/ui/approval-modal.js +53 -0
- package/dist/ui/ascii-chars.js +6 -0
- package/dist/ui/body.js +102 -0
- package/dist/ui/coaching-picker-classic.js +36 -0
- package/dist/ui/coaching-picker-emitter.js +27 -0
- package/dist/ui/command-palette.js +34 -0
- package/dist/ui/error-emitter.js +21 -0
- package/dist/ui/footer.js +103 -0
- package/dist/ui/header.js +17 -0
- package/dist/ui/ink-classifier-route.js +19 -0
- package/dist/ui/login-banner.js +72 -0
- package/dist/ui/rich-error-box.js +9 -0
- package/dist/ui/sap-state-store.js +65 -0
- package/dist/ui/session-timeline.js +31 -0
- package/dist/ui/sidebar.js +10 -0
- package/dist/ui/skill-picker.js +50 -0
- package/dist/ui/status-row.js +12 -0
- package/dist/ui/widget-control.js +4 -0
- package/dist/ui/widgets/bar-chart.js +15 -0
- package/dist/ui/widgets/coaching-picker.js +41 -0
- package/dist/ui/widgets/component-registry.js +12 -0
- package/dist/ui/widgets/dep-graph.js +9 -0
- package/dist/ui/widgets/diff-viewer.js +11 -0
- package/dist/ui/widgets/question-card.js +11 -0
- package/dist/ui/widgets/stack-frames.js +5 -0
- package/dist/upgrade-check.js +28 -0
- package/dist/upgrade.js +13 -0
- package/package.json +83 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preview interceptor for sap_update_method calls. Forge Rule 7a in practice:
|
|
3
|
+
* before any surgical method-source write reaches SAP, fetch the current
|
|
4
|
+
* source, extract the named method's body, render a unified diff vs the
|
|
5
|
+
* proposed source, and gate on a user-confirm hook. The REPL composes this
|
|
6
|
+
* with renderDiff (./repl/diff-display) and an inquirer confirm prompt.
|
|
7
|
+
*
|
|
8
|
+
* Pure orchestration — no I/O of its own. The caller passes in:
|
|
9
|
+
* - `client`: a minimal MCP-tool wrapper that can call sap_get_source
|
|
10
|
+
* - `call`: the proposed sap_update_method invocation (parameters)
|
|
11
|
+
* - `hook`: receives current vs proposed and returns { confirmed }
|
|
12
|
+
*
|
|
13
|
+
* Returns the proposed call unchanged when the hook confirms, or throws
|
|
14
|
+
* a CancelledByUserError when the hook rejects.
|
|
15
|
+
*/
|
|
16
|
+
export class CancelledByUserError extends Error {
|
|
17
|
+
call;
|
|
18
|
+
constructor(call) {
|
|
19
|
+
super(`User declined sap_update_method on ${call.className}->${call.methodName}`);
|
|
20
|
+
this.call = call;
|
|
21
|
+
this.name = 'CancelledByUserError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Runs the preview gate. On confirm, returns the original call. On rejection,
|
|
26
|
+
* throws CancelledByUserError. Throws if the class source can't be fetched
|
|
27
|
+
* (caller should treat as an unrecoverable error before any write).
|
|
28
|
+
*/
|
|
29
|
+
export async function interceptUpdateMethod(client, call, hook) {
|
|
30
|
+
const classSource = await client.getSource({ type: 'CLAS', name: call.className });
|
|
31
|
+
const currentBody = extractMethodBody(classSource, call.methodName);
|
|
32
|
+
const proposedBody = call.methodSource;
|
|
33
|
+
const result = await hook({
|
|
34
|
+
className: call.className,
|
|
35
|
+
methodName: call.methodName,
|
|
36
|
+
currentBody,
|
|
37
|
+
proposedBody,
|
|
38
|
+
});
|
|
39
|
+
if (!result.confirmed) {
|
|
40
|
+
throw new CancelledByUserError(call);
|
|
41
|
+
}
|
|
42
|
+
return call;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Extracts the source of a single method by name from a full class source.
|
|
46
|
+
* Returns the slice from `METHOD <methodName>.` through the matching
|
|
47
|
+
* `ENDMETHOD.` (inclusive). If the method isn't found, returns an empty
|
|
48
|
+
* string — callers can render that as "(method not found in current source)"
|
|
49
|
+
* which still produces a useful diff against the proposed body.
|
|
50
|
+
*
|
|
51
|
+
* The parser is line-based and respects:
|
|
52
|
+
* - Case-insensitive keyword matching (METHOD, ENDMETHOD).
|
|
53
|
+
* - Comment lines starting with `*` in column 1 (skipped during scanning).
|
|
54
|
+
* - Inline `"` ABAP comments (text after `"` on a code line is ignored when
|
|
55
|
+
* matching keywords).
|
|
56
|
+
* - Quoted string literals `'...'` and ``...`` (their contents are ignored).
|
|
57
|
+
*
|
|
58
|
+
* Does NOT respect macros or includes — those would expand source from
|
|
59
|
+
* elsewhere and aren't materialized in the class-source GET response.
|
|
60
|
+
*/
|
|
61
|
+
export function extractMethodBody(classSource, methodName) {
|
|
62
|
+
const lines = classSource.split(/\r?\n/);
|
|
63
|
+
const target = methodName.toUpperCase();
|
|
64
|
+
const startRe = new RegExp(`^\\s*METHOD\\s+${escapeRegex(target)}\\s*[.(]`, 'i');
|
|
65
|
+
const endRe = /^\s*ENDMETHOD\s*\./i;
|
|
66
|
+
let start = -1;
|
|
67
|
+
for (let i = 0; i < lines.length; i++) {
|
|
68
|
+
const line = stripCommentsAndStrings(lines[i]);
|
|
69
|
+
if (line.trim().startsWith('*'))
|
|
70
|
+
continue;
|
|
71
|
+
if (startRe.test(line)) {
|
|
72
|
+
start = i;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (start === -1)
|
|
77
|
+
return '';
|
|
78
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
79
|
+
const line = stripCommentsAndStrings(lines[i]);
|
|
80
|
+
if (line.trim().startsWith('*'))
|
|
81
|
+
continue;
|
|
82
|
+
if (endRe.test(line)) {
|
|
83
|
+
return lines.slice(start, i + 1).join('\n');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// METHOD without matching ENDMETHOD — return the rest as a fallback so the
|
|
87
|
+
// diff still surfaces the partial body to the user.
|
|
88
|
+
return lines.slice(start).join('\n');
|
|
89
|
+
}
|
|
90
|
+
function stripCommentsAndStrings(line) {
|
|
91
|
+
// Walk the line and replace string-literal contents and trailing `"` comments
|
|
92
|
+
// with spaces so keyword matching ignores them. Cheap and good enough for
|
|
93
|
+
// ABAP's two quote styles.
|
|
94
|
+
let out = '';
|
|
95
|
+
let inSingle = false;
|
|
96
|
+
let inBacktick = false;
|
|
97
|
+
for (let i = 0; i < line.length; i++) {
|
|
98
|
+
const ch = line[i];
|
|
99
|
+
if (!inSingle && !inBacktick && ch === '"') {
|
|
100
|
+
// Trailing comment — drop the rest.
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
if (!inBacktick && ch === "'") {
|
|
104
|
+
inSingle = !inSingle;
|
|
105
|
+
out += ch;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!inSingle && ch === '`') {
|
|
109
|
+
inBacktick = !inBacktick;
|
|
110
|
+
out += ch;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
out += inSingle || inBacktick ? ' ' : ch;
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
function escapeRegex(s) {
|
|
118
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
119
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify helpers — syntax check gate and activation-status gate.
|
|
3
|
+
*
|
|
4
|
+
* AdtClient.syntaxCheck(type, name) → SyntaxResult { hasErrors, hasWarnings, messages[] }
|
|
5
|
+
* messages[].severity: string ('error' | 'warning' | 'info')
|
|
6
|
+
* messages[].line: number
|
|
7
|
+
* messages[].text: string
|
|
8
|
+
*
|
|
9
|
+
* AdtClient.inactiveObjects() → InactiveObject[] { name, type, uri, user }
|
|
10
|
+
*
|
|
11
|
+
* (Confirmed from sap-client/src/adt-client.ts lines 82-87, 224-228, 547.)
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Run a syntax check and return a normalised VerifyResult.
|
|
15
|
+
* Errors block activation; warnings are surfaced but do not block.
|
|
16
|
+
*/
|
|
17
|
+
export async function verifySyntax(adt, name, type) {
|
|
18
|
+
const res = await adt.syntaxCheck(type, name);
|
|
19
|
+
return {
|
|
20
|
+
ok: !res.hasErrors,
|
|
21
|
+
errors: res.messages
|
|
22
|
+
.filter((m) => m.severity === 'error')
|
|
23
|
+
.map((m) => `L${m.line}: ${m.text}`),
|
|
24
|
+
warnings: res.messages
|
|
25
|
+
.filter((m) => m.severity === 'warning')
|
|
26
|
+
.map((m) => `L${m.line}: ${m.text}`),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export async function verifyActive(adt, name, type) {
|
|
30
|
+
let inactive;
|
|
31
|
+
try {
|
|
32
|
+
inactive = await adt.inactiveObjects();
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
return { active: false, verifyError: String(err?.message ?? err) };
|
|
36
|
+
}
|
|
37
|
+
const stillInactive = inactive.some((o) => o.name.toUpperCase() === name.toUpperCase() && o.type.toUpperCase() === type.toUpperCase());
|
|
38
|
+
return { active: !stillInactive };
|
|
39
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/web/_web-shared.ts
|
|
2
|
+
/**
|
|
3
|
+
* Phase 3 / Chunk 3C — shared URL validator + DNS pin-lookup helper.
|
|
4
|
+
*
|
|
5
|
+
* Used by web_fetch (this chunk) and, eventually, web_search (chunk-3C2).
|
|
6
|
+
* Centralising the SSRF defenses in one module means the security guard
|
|
7
|
+
* cannot drift per-tool — any web tool calls validateUrl() and passes
|
|
8
|
+
* the returned pinnedLookup to https.request.
|
|
9
|
+
*
|
|
10
|
+
* Threat model (full table in 2026-05-11-track-b-phase-3-chunk-3c-plan.md):
|
|
11
|
+
* - HTTPS-only protocol
|
|
12
|
+
* - hostname-string blocklist (localhost, *.local, metadata.google.internal)
|
|
13
|
+
* - DNS-resolved-IP blocklist (IPv4 RFC1918 + loopback + link-local +
|
|
14
|
+
* multicast + reserved; IPv6 loopback + ULA + link-local + unspecified)
|
|
15
|
+
* - DNS pinning — the resolved address is passed back as a `lookup`
|
|
16
|
+
* callback so https.request doesn't re-resolve the hostname at
|
|
17
|
+
* connect time (closes the DNS-rebinding hole)
|
|
18
|
+
*
|
|
19
|
+
* Escape hatch: CSPEACH_WEB_ALLOW_PRIVATE=on lifts the private-IP check
|
|
20
|
+
* (still HTTPS-only). Off by default.
|
|
21
|
+
*/
|
|
22
|
+
import { promises as dnsPromises } from 'node:dns';
|
|
23
|
+
import * as net from 'node:net';
|
|
24
|
+
export class MalformedUrlError extends Error {
|
|
25
|
+
constructor(input, reason) {
|
|
26
|
+
super(`Malformed URL "${input}": ${reason}`);
|
|
27
|
+
this.name = 'MalformedUrlError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export class BlockedProtocolError extends Error {
|
|
31
|
+
constructor(protocol) {
|
|
32
|
+
super(`Protocol "${protocol}" is not allowed (HTTPS only).`);
|
|
33
|
+
this.name = 'BlockedProtocolError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export class BlockedHostError extends Error {
|
|
37
|
+
constructor(host, reason) {
|
|
38
|
+
super(`Host "${host}" is blocked: ${reason}`);
|
|
39
|
+
this.name = 'BlockedHostError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Hostname-string blocklist. Applied BEFORE DNS resolution. These names
|
|
44
|
+
* are always private regardless of how a hostile resolver answers, so we
|
|
45
|
+
* fail fast on the literal name.
|
|
46
|
+
*
|
|
47
|
+
* Suffix matches: '*.local' (mDNS), '*.localhost' (RFC 6761), '*.localdomain'.
|
|
48
|
+
* Exact matches: 'localhost', 'localhost6', 'metadata.google.internal',
|
|
49
|
+
* 'metadata' (some Azure setups), '0' (Linux-legacy alias for 0.0.0.0).
|
|
50
|
+
*/
|
|
51
|
+
const BLOCKED_HOSTNAME_EXACT = new Set([
|
|
52
|
+
'localhost',
|
|
53
|
+
'localhost6',
|
|
54
|
+
'localhost.localdomain',
|
|
55
|
+
'metadata',
|
|
56
|
+
'metadata.google.internal',
|
|
57
|
+
'metadata.azure.com',
|
|
58
|
+
'0',
|
|
59
|
+
'ip6-localhost',
|
|
60
|
+
'ip6-loopback',
|
|
61
|
+
]);
|
|
62
|
+
const BLOCKED_HOSTNAME_SUFFIXES = [
|
|
63
|
+
'.local',
|
|
64
|
+
'.localhost',
|
|
65
|
+
'.localdomain',
|
|
66
|
+
];
|
|
67
|
+
function isBlockedHostname(hostname) {
|
|
68
|
+
const h = hostname.toLowerCase();
|
|
69
|
+
if (BLOCKED_HOSTNAME_EXACT.has(h))
|
|
70
|
+
return true;
|
|
71
|
+
for (const suffix of BLOCKED_HOSTNAME_SUFFIXES) {
|
|
72
|
+
if (h.endsWith(suffix))
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Private-range IP check. Covers both IPv4 and IPv6 ranges that an
|
|
79
|
+
* attacker might target via SSRF. Returns true if the address is private,
|
|
80
|
+
* loopback, link-local, multicast, reserved, or otherwise non-public.
|
|
81
|
+
*/
|
|
82
|
+
export function isPrivateIp(addr) {
|
|
83
|
+
const family = net.isIP(addr);
|
|
84
|
+
if (family === 4)
|
|
85
|
+
return isPrivateIpv4(addr);
|
|
86
|
+
if (family === 6)
|
|
87
|
+
return isPrivateIpv6(addr);
|
|
88
|
+
// Not a valid IP at all — treat as suspicious.
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
function isPrivateIpv4(addr) {
|
|
92
|
+
const parts = addr.split('.').map((p) => Number(p));
|
|
93
|
+
if (parts.length !== 4 || parts.some((p) => Number.isNaN(p) || p < 0 || p > 255))
|
|
94
|
+
return true;
|
|
95
|
+
const [a, b] = parts;
|
|
96
|
+
// 0.0.0.0/8 — "this network"
|
|
97
|
+
if (a === 0)
|
|
98
|
+
return true;
|
|
99
|
+
// 10.0.0.0/8 — RFC1918
|
|
100
|
+
if (a === 10)
|
|
101
|
+
return true;
|
|
102
|
+
// 100.64.0.0/10 — CGNAT / shared address space (RFC 6598)
|
|
103
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
104
|
+
return true;
|
|
105
|
+
// 127.0.0.0/8 — loopback
|
|
106
|
+
if (a === 127)
|
|
107
|
+
return true;
|
|
108
|
+
// 169.254.0.0/16 — link-local + cloud metadata
|
|
109
|
+
if (a === 169 && b === 254)
|
|
110
|
+
return true;
|
|
111
|
+
// 172.16.0.0/12 — RFC1918 (b in [16, 31])
|
|
112
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
113
|
+
return true;
|
|
114
|
+
// 192.168.0.0/16 — RFC1918
|
|
115
|
+
if (a === 192 && b === 168)
|
|
116
|
+
return true;
|
|
117
|
+
// 224.0.0.0/4 — multicast
|
|
118
|
+
if (a >= 224 && a <= 239)
|
|
119
|
+
return true;
|
|
120
|
+
// 240.0.0.0/4 — reserved
|
|
121
|
+
if (a >= 240)
|
|
122
|
+
return true;
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
function isPrivateIpv6(addr) {
|
|
126
|
+
// Normalize: lowercase, expand the most common shortcuts. Node's net.isIP
|
|
127
|
+
// has already accepted this as IPv6, so we trust the shape — we just need
|
|
128
|
+
// to check well-known private prefixes.
|
|
129
|
+
const a = addr.toLowerCase();
|
|
130
|
+
// Loopback
|
|
131
|
+
if (a === '::1')
|
|
132
|
+
return true;
|
|
133
|
+
// Unspecified
|
|
134
|
+
if (a === '::' || a === '0:0:0:0:0:0:0:0')
|
|
135
|
+
return true;
|
|
136
|
+
// IPv4-mapped IPv6 (::ffff:X.X.X.X) — check the embedded IPv4
|
|
137
|
+
const v4mapped = a.match(/^::ffff:([0-9.]+)$/);
|
|
138
|
+
if (v4mapped)
|
|
139
|
+
return isPrivateIpv4(v4mapped[1]);
|
|
140
|
+
// ::ffff:X.X.X.X (dotted-decimal form) — caught by existing regex above
|
|
141
|
+
// ::ffff:XXXX:XXXX (hex form — Node URL parser normalizes to this) — must catch here
|
|
142
|
+
const v4mappedHex = a.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
143
|
+
if (v4mappedHex) {
|
|
144
|
+
const hi = parseInt(v4mappedHex[1], 16);
|
|
145
|
+
const lo = parseInt(v4mappedHex[2], 16);
|
|
146
|
+
if (Number.isNaN(hi) || Number.isNaN(lo) || hi > 0xffff || lo > 0xffff)
|
|
147
|
+
return true;
|
|
148
|
+
const ipv4 = `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
|
|
149
|
+
return isPrivateIpv4(ipv4);
|
|
150
|
+
}
|
|
151
|
+
// fc00::/7 — Unique local addresses
|
|
152
|
+
if (a.startsWith('fc') || a.startsWith('fd'))
|
|
153
|
+
return true;
|
|
154
|
+
// fe80::/10 — Link-local (fe80, fe81, ..., febf). The fourth nibble is
|
|
155
|
+
// mandatory: 'fe8:' / 'feb:' are public global unicast, not link-local.
|
|
156
|
+
if (a.match(/^fe[89ab][0-9a-f]:/))
|
|
157
|
+
return true;
|
|
158
|
+
// ff00::/8 — Multicast
|
|
159
|
+
if (a.startsWith('ff'))
|
|
160
|
+
return true;
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
const DEFAULT_DEPS = {
|
|
164
|
+
resolveAll: async (hostname) => dnsPromises.lookup(hostname, { all: true, verbatim: true }),
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* Parse, validate, and DNS-pin a URL for safe outbound fetching.
|
|
168
|
+
*
|
|
169
|
+
* Steps (each is a security boundary; failure throws):
|
|
170
|
+
* 1. Parse — throws MalformedUrlError on invalid URL string.
|
|
171
|
+
* 2. Protocol check — must be HTTPS — throws BlockedProtocolError.
|
|
172
|
+
* 3. Hostname-string blocklist — throws BlockedHostError.
|
|
173
|
+
* 4. DNS resolve (deps.resolveAll) — single round-trip, all addresses.
|
|
174
|
+
* 5. Per-address private-range check — throws BlockedHostError if any
|
|
175
|
+
* resolved address is private, UNLESS process.env.CSPEACH_WEB_ALLOW_PRIVATE
|
|
176
|
+
* is exactly the string 'on'.
|
|
177
|
+
* 6. Build pinnedLookup closure over the first resolved address.
|
|
178
|
+
*
|
|
179
|
+
* The deps.resolveAll injection point exists for tests; production
|
|
180
|
+
* callers pass nothing and DEFAULT_DEPS calls dnsPromises.lookup.
|
|
181
|
+
*/
|
|
182
|
+
export async function validateUrl(input, opts = {}) {
|
|
183
|
+
let url;
|
|
184
|
+
try {
|
|
185
|
+
url = new URL(input);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
throw new MalformedUrlError(input, 'invalid URL syntax');
|
|
189
|
+
}
|
|
190
|
+
if (url.protocol !== 'https:') {
|
|
191
|
+
throw new BlockedProtocolError(url.protocol);
|
|
192
|
+
}
|
|
193
|
+
const hostname = url.hostname;
|
|
194
|
+
// Strip IPv6 brackets if any — net.isIP doesn't want them, and our
|
|
195
|
+
// hostname blocklist is for textual names not bracketed IPs.
|
|
196
|
+
const cleanHost = hostname.startsWith('[') && hostname.endsWith(']')
|
|
197
|
+
? hostname.slice(1, -1)
|
|
198
|
+
: hostname;
|
|
199
|
+
const allowPrivate = process.env.CSPEACH_WEB_ALLOW_PRIVATE === 'on';
|
|
200
|
+
// If hostname IS a literal IP, skip DNS — just check the IP directly.
|
|
201
|
+
const ipFamily = net.isIP(cleanHost);
|
|
202
|
+
if (ipFamily !== 0) {
|
|
203
|
+
if (!allowPrivate && isPrivateIp(cleanHost)) {
|
|
204
|
+
throw new BlockedHostError(cleanHost, `IP address is in a private/loopback/reserved range`);
|
|
205
|
+
}
|
|
206
|
+
return buildValidated(url, cleanHost, ipFamily);
|
|
207
|
+
}
|
|
208
|
+
// Hostname-string blocklist — applied BEFORE DNS so we never query
|
|
209
|
+
// for known-private names.
|
|
210
|
+
if (isBlockedHostname(cleanHost)) {
|
|
211
|
+
throw new BlockedHostError(cleanHost, `hostname is on the literal-name blocklist`);
|
|
212
|
+
}
|
|
213
|
+
const deps = opts.deps ?? DEFAULT_DEPS;
|
|
214
|
+
let addresses;
|
|
215
|
+
try {
|
|
216
|
+
addresses = await deps.resolveAll(cleanHost);
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
throw new BlockedHostError(cleanHost, `DNS lookup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
220
|
+
}
|
|
221
|
+
if (addresses.length === 0) {
|
|
222
|
+
throw new BlockedHostError(cleanHost, 'DNS returned no addresses');
|
|
223
|
+
}
|
|
224
|
+
if (!allowPrivate) {
|
|
225
|
+
for (const a of addresses) {
|
|
226
|
+
if (isPrivateIp(a.address)) {
|
|
227
|
+
throw new BlockedHostError(cleanHost, `resolved to private address ${a.address} (any private address in the set fails the check)`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return buildValidated(url, addresses[0].address, addresses[0].family);
|
|
232
|
+
}
|
|
233
|
+
function buildValidated(url, pinnedAddress, pinnedAddressFamily) {
|
|
234
|
+
return {
|
|
235
|
+
url,
|
|
236
|
+
pinnedAddress,
|
|
237
|
+
pinnedAddressFamily,
|
|
238
|
+
pinnedLookup: (hostname, _opts, cb) => {
|
|
239
|
+
// We don't care about opts.family / opts.hints — we already validated
|
|
240
|
+
// the address. Return it verbatim. Caller passes the hostname that
|
|
241
|
+
// matches url.hostname; if it doesn't, surface as ENOTFOUND.
|
|
242
|
+
if (hostname !== url.hostname) {
|
|
243
|
+
const e = new Error(`pinnedLookup called with hostname "${hostname}" but pinned hostname is "${url.hostname}"`);
|
|
244
|
+
e.code = 'ENOTFOUND';
|
|
245
|
+
cb(e);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
cb(null, pinnedAddress, pinnedAddressFamily);
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/web/web_fetch.ts
|
|
2
|
+
/**
|
|
3
|
+
* web_fetch — Phase 3 / Chunk 3C web tool.
|
|
4
|
+
*
|
|
5
|
+
* Fetches an arbitrary HTTPS resource (SAP help pages, OSS Notes,
|
|
6
|
+
* GitHub raw content, etc.) with hardened SSRF defenses. Defers all
|
|
7
|
+
* URL / DNS / private-range validation to _web-shared.validateUrl;
|
|
8
|
+
* this file handles the network round-trip, content-type filtering,
|
|
9
|
+
* size cap, timeout, and redirect-loop control.
|
|
10
|
+
*
|
|
11
|
+
* Defaults:
|
|
12
|
+
* - HTTPS only
|
|
13
|
+
* - 10s wall-clock timeout per hop
|
|
14
|
+
* - 1 MB response cap (streamed; request destroyed when threshold crossed)
|
|
15
|
+
* - 5 redirect hops max; each hop re-runs full validateUrl
|
|
16
|
+
* - Content-Type allowlist: text/*, application/json, application/xml
|
|
17
|
+
* - User-Agent: cspeach/0.5 (+https://cspeach.dev)
|
|
18
|
+
*
|
|
19
|
+
* Escape hatch: CSPEACH_WEB_ALLOW_PRIVATE=on lifts the private-IP check
|
|
20
|
+
* (still HTTPS-only). Off by default.
|
|
21
|
+
*
|
|
22
|
+
* Flag-gated: invisible to listTools() unless CSPEACH_TOOL_WEB_FETCH=on.
|
|
23
|
+
* isMutating: false — reads only.
|
|
24
|
+
*/
|
|
25
|
+
import * as https from 'node:https';
|
|
26
|
+
import { registerTool } from '../index.js';
|
|
27
|
+
import { validateUrl, BlockedHostError, BlockedProtocolError, MalformedUrlError, } from './_web-shared.js';
|
|
28
|
+
export const USER_AGENT = 'cspeach/0.5 (+https://cspeach.dev)';
|
|
29
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
30
|
+
const MAX_RESPONSE_BYTES = 1_000_000;
|
|
31
|
+
const MAX_REDIRECTS = 5;
|
|
32
|
+
/**
|
|
33
|
+
* Content-Type prefixes we accept. The full header is matched as
|
|
34
|
+
* prefix + ';' or end-of-string so 'application/jsonblah' won't match
|
|
35
|
+
* but 'application/json; charset=utf-8' will.
|
|
36
|
+
*/
|
|
37
|
+
const ALLOWED_CONTENT_TYPES = [
|
|
38
|
+
'text/',
|
|
39
|
+
'application/json',
|
|
40
|
+
'application/xml',
|
|
41
|
+
];
|
|
42
|
+
function isAllowedContentType(header) {
|
|
43
|
+
if (!header)
|
|
44
|
+
return false;
|
|
45
|
+
const h = header.toLowerCase().trim();
|
|
46
|
+
for (const allowed of ALLOWED_CONTENT_TYPES) {
|
|
47
|
+
if (allowed.endsWith('/')) {
|
|
48
|
+
// Prefix match (text/, etc.)
|
|
49
|
+
if (h.startsWith(allowed))
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
// Exact prefix followed by either ';' (params) or end-of-string
|
|
54
|
+
if (h === allowed || h.startsWith(`${allowed};`) || h.startsWith(`${allowed} `))
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
async function fetchOnce(validated, timeoutMs) {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
const url = validated.url;
|
|
63
|
+
let buf = '';
|
|
64
|
+
let bufBytes = 0;
|
|
65
|
+
let truncated = false;
|
|
66
|
+
let settled = false;
|
|
67
|
+
const settle = (result) => {
|
|
68
|
+
if (settled)
|
|
69
|
+
return;
|
|
70
|
+
settled = true;
|
|
71
|
+
resolve(result);
|
|
72
|
+
};
|
|
73
|
+
const req = https.request({
|
|
74
|
+
method: 'GET',
|
|
75
|
+
hostname: url.hostname,
|
|
76
|
+
port: url.port ? Number(url.port) : 443,
|
|
77
|
+
path: `${url.pathname}${url.search}`,
|
|
78
|
+
// ValidatedUrl.pinnedLookup is typed against `LookupOneOptions`
|
|
79
|
+
// (the form we need — single-address callback). Node's
|
|
80
|
+
// https.request `lookup` field is typed `LookupFunction` whose
|
|
81
|
+
// options use `LookupOptions` (`all?: boolean`). The two are
|
|
82
|
+
// structurally compatible at runtime — Node calls the lookup
|
|
83
|
+
// with whichever options object it builds and passes the result
|
|
84
|
+
// to our (err, address, family) callback. The cast bridges the
|
|
85
|
+
// type-system gap without weakening the validated guarantee.
|
|
86
|
+
lookup: validated.pinnedLookup,
|
|
87
|
+
headers: {
|
|
88
|
+
'User-Agent': USER_AGENT,
|
|
89
|
+
Accept: 'text/*, application/json, application/xml',
|
|
90
|
+
'Accept-Encoding': 'identity', // disable gzip — keeps the streaming cap accurate
|
|
91
|
+
},
|
|
92
|
+
// Reject non-2xx is not used; we surface the status to the caller.
|
|
93
|
+
}, (res) => {
|
|
94
|
+
const statusCode = res.statusCode ?? 0;
|
|
95
|
+
const headers = {};
|
|
96
|
+
for (const [k, v] of Object.entries(res.headers)) {
|
|
97
|
+
if (typeof v === 'string')
|
|
98
|
+
headers[k] = v;
|
|
99
|
+
else if (Array.isArray(v))
|
|
100
|
+
headers[k] = v.join(', ');
|
|
101
|
+
}
|
|
102
|
+
// Redirect — surface to caller, no body capture.
|
|
103
|
+
if (statusCode >= 300 && statusCode < 400 && headers['location']) {
|
|
104
|
+
res.destroy?.();
|
|
105
|
+
settle({ kind: 'redirect', statusCode, location: headers['location'] });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// Content-Type filter — applies to 2xx responses. Error responses
|
|
109
|
+
// can be any type; we still surface a small body for the agent.
|
|
110
|
+
if (statusCode >= 200 && statusCode < 300) {
|
|
111
|
+
if (!isAllowedContentType(headers['content-type'])) {
|
|
112
|
+
res.destroy?.();
|
|
113
|
+
settle({
|
|
114
|
+
kind: 'error',
|
|
115
|
+
message: `response content-type "${headers['content-type'] ?? 'missing'}" is not in the allowlist (text/*, application/json, application/xml)`,
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Content-Encoding guard — we sent Accept-Encoding: identity, but a
|
|
121
|
+
// misbehaving CDN may gzip anyway. toString('utf-8') on compressed
|
|
122
|
+
// bytes produces U+FFFD garbage that would poison the model's
|
|
123
|
+
// context window. Reject explicitly instead. Only meaningful for
|
|
124
|
+
// responses that carry a body (2xx); 3xx redirects are surfaced
|
|
125
|
+
// above and don't reach this point.
|
|
126
|
+
const contentEncoding = headers['content-encoding']?.toLowerCase();
|
|
127
|
+
if (contentEncoding && contentEncoding !== 'identity') {
|
|
128
|
+
res.destroy?.();
|
|
129
|
+
settle({
|
|
130
|
+
kind: 'error',
|
|
131
|
+
message: `server returned unsupported Content-Encoding "${headers['content-encoding']}" — only "identity" is accepted (Accept-Encoding: identity sent)`,
|
|
132
|
+
});
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
res.on('data', (chunk) => {
|
|
136
|
+
if (truncated)
|
|
137
|
+
return;
|
|
138
|
+
const remaining = MAX_RESPONSE_BYTES - bufBytes;
|
|
139
|
+
// Truncation paths must settle the promise NOW — Node's
|
|
140
|
+
// stream.destroy() emits 'close' but NOT 'end', so relying
|
|
141
|
+
// on the 'end' handler to settle would hang the promise
|
|
142
|
+
// until req.setTimeout fires (~10s) and surface as a
|
|
143
|
+
// spurious timeout instead of a truncated-success result.
|
|
144
|
+
// settle()'s once-guard makes the call idempotent.
|
|
145
|
+
if (remaining <= 0) {
|
|
146
|
+
// Buffer already at cap (a prior chunk landed exactly on
|
|
147
|
+
// the boundary) — drop this chunk entirely.
|
|
148
|
+
truncated = true;
|
|
149
|
+
res.destroy?.();
|
|
150
|
+
settle({ kind: 'response', statusCode, headers, body: buf, truncated: true });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (chunk.length <= remaining) {
|
|
154
|
+
buf += chunk.toString('utf-8');
|
|
155
|
+
bufBytes += chunk.length;
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
// This chunk pushes past the cap — keep the head, drop the tail.
|
|
159
|
+
buf += chunk.slice(0, remaining).toString('utf-8');
|
|
160
|
+
bufBytes += remaining;
|
|
161
|
+
truncated = true;
|
|
162
|
+
res.destroy?.();
|
|
163
|
+
settle({ kind: 'response', statusCode, headers, body: buf, truncated: true });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
res.on('end', () => {
|
|
167
|
+
settle({ kind: 'response', statusCode, headers, body: buf, truncated });
|
|
168
|
+
});
|
|
169
|
+
res.on('error', (err) => {
|
|
170
|
+
settle({ kind: 'error', message: `response stream error: ${err.message}` });
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
req.setTimeout(timeoutMs, () => {
|
|
174
|
+
settle({ kind: 'error', message: `timeout after ${timeoutMs} ms — request aborted` });
|
|
175
|
+
// Node's ClientRequest.destroy() is documented as idempotent and
|
|
176
|
+
// does not throw — no try/catch needed.
|
|
177
|
+
req.destroy();
|
|
178
|
+
});
|
|
179
|
+
req.on('error', (err) => {
|
|
180
|
+
settle({ kind: 'error', message: `request error: ${err.message}` });
|
|
181
|
+
});
|
|
182
|
+
req.end();
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
export async function webFetchHandler(args, _ctx, opts = {}) {
|
|
186
|
+
if (!args.url || typeof args.url !== 'string') {
|
|
187
|
+
return { content: 'error: url is required (non-empty string)', is_error: true };
|
|
188
|
+
}
|
|
189
|
+
const timeoutMs = opts._timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
190
|
+
let currentUrl = args.url;
|
|
191
|
+
let redirects = 0;
|
|
192
|
+
for (;;) {
|
|
193
|
+
let validated;
|
|
194
|
+
try {
|
|
195
|
+
validated = await validateUrl(currentUrl, { deps: opts._deps });
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
if (err instanceof BlockedProtocolError || err instanceof BlockedHostError || err instanceof MalformedUrlError) {
|
|
199
|
+
return { content: `error: ${err.message}`, is_error: true };
|
|
200
|
+
}
|
|
201
|
+
return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
|
|
202
|
+
}
|
|
203
|
+
const hop = await fetchOnce(validated, timeoutMs);
|
|
204
|
+
if (hop.kind === 'error') {
|
|
205
|
+
return { content: `error: ${hop.message}`, is_error: true };
|
|
206
|
+
}
|
|
207
|
+
if (hop.kind === 'redirect') {
|
|
208
|
+
if (redirects >= MAX_REDIRECTS) {
|
|
209
|
+
return { content: `error: redirect limit (${MAX_REDIRECTS}) exceeded — last hop ${hop.statusCode} → ${hop.location}`, is_error: true };
|
|
210
|
+
}
|
|
211
|
+
redirects++;
|
|
212
|
+
// Resolve relative redirects against the current URL.
|
|
213
|
+
let nextUrl;
|
|
214
|
+
try {
|
|
215
|
+
nextUrl = new URL(hop.location, validated.url.toString()).toString();
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return { content: `error: redirect Location header "${hop.location}" is not a valid URL`, is_error: true };
|
|
219
|
+
}
|
|
220
|
+
currentUrl = nextUrl;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
// hop.kind === 'response'
|
|
224
|
+
const hdrLines = Object.entries(hop.headers)
|
|
225
|
+
.filter(([k]) => ['content-type', 'content-length', 'etag', 'last-modified', 'location'].includes(k.toLowerCase()))
|
|
226
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
227
|
+
.join('\n');
|
|
228
|
+
const trailer = hop.truncated ? `\n[truncated — exceeded ${MAX_RESPONSE_BYTES} byte cap]` : '';
|
|
229
|
+
const redirectLine = redirects > 0 ? `\nredirects: ${redirects}` : '';
|
|
230
|
+
const finalUrlLine = redirects > 0 ? `\nfinal url: ${validated.url.toString()}` : '';
|
|
231
|
+
return {
|
|
232
|
+
content: `status: ${hop.statusCode}${redirectLine}${finalUrlLine}\n${hdrLines}\n\n${hop.body}${trailer}`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
registerTool({
|
|
237
|
+
name: 'web_fetch',
|
|
238
|
+
description: 'Fetch an HTTPS URL and return its body + status + headers. HTTPS only; ' +
|
|
239
|
+
'private IPs / loopback / cloud-metadata addresses blocked (escape hatch: ' +
|
|
240
|
+
'CSPEACH_WEB_ALLOW_PRIVATE=on). 10s timeout, 1 MB response cap, up to 5 ' +
|
|
241
|
+
'redirects (each hop re-validated). Allowed content-types: text/*, ' +
|
|
242
|
+
'application/json, application/xml.',
|
|
243
|
+
isMutating: false,
|
|
244
|
+
category: 'web',
|
|
245
|
+
flagGated: true,
|
|
246
|
+
input_schema: {
|
|
247
|
+
type: 'object',
|
|
248
|
+
properties: {
|
|
249
|
+
url: {
|
|
250
|
+
type: 'string',
|
|
251
|
+
description: 'Full HTTPS URL to fetch. Must be public; private IPs / loopback / cloud-metadata addresses are refused.',
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
required: ['url'],
|
|
255
|
+
},
|
|
256
|
+
handler: (args, ctx) => webFetchHandler(args, ctx),
|
|
257
|
+
});
|