@sweny-ai/core 0.1.102 → 0.1.104
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/browser.d.ts +3 -2
- package/dist/browser.js +6 -1
- package/dist/claude.d.ts +66 -3
- package/dist/claude.js +334 -29
- package/dist/cli/check.d.ts +7 -0
- package/dist/cli/check.js +91 -5
- package/dist/cli/config-file.js +5 -1
- package/dist/cli/config.d.ts +11 -0
- package/dist/cli/config.js +19 -1
- package/dist/cli/e2e.d.ts +14 -1
- package/dist/cli/e2e.js +40 -6
- package/dist/cli/main.d.ts +3 -1
- package/dist/cli/main.js +26 -16
- package/dist/cli/new.js +7 -0
- package/dist/eval/value.js +12 -1
- package/dist/executor.d.ts +16 -0
- package/dist/executor.js +81 -18
- package/dist/index.d.ts +8 -4
- package/dist/index.js +10 -2
- package/dist/inputs.js +18 -0
- package/dist/loader.js +15 -1
- package/dist/model.d.ts +11 -0
- package/dist/model.js +19 -0
- package/dist/retry.d.ts +5 -0
- package/dist/retry.js +2 -2
- package/dist/schema.d.ts +163 -6
- package/dist/schema.js +125 -10
- package/dist/skills/custom-loader.d.ts +3 -3
- package/dist/skills/custom-loader.js +62 -20
- package/dist/skills/github.js +8 -2
- package/dist/skills/linear.js +26 -0
- package/dist/skills/notification.d.ts +17 -0
- package/dist/skills/notification.js +67 -5
- package/dist/source-resolver.js +231 -6
- package/dist/sources.d.ts +33 -0
- package/dist/types.d.ts +29 -1
- package/dist/types.js +17 -0
- package/package.json +4 -4
|
@@ -20,6 +20,18 @@ import { builtinSkills, isSkillConfigured } from "./index.js";
|
|
|
20
20
|
* side cannot disagree on what counts as a skill harness directory.
|
|
21
21
|
*/
|
|
22
22
|
const SKILL_DIRS = SKILL_HARNESSES.map((h) => h.path);
|
|
23
|
+
/**
|
|
24
|
+
* Opt-in env flag that allows discovered SKILL.md files to wire a local stdio
|
|
25
|
+
* `mcp.command`. Defaults OFF: an unvetted discovered stdio command is a code
|
|
26
|
+
* execution sink (the harness runs with `permissionMode: "bypassPermissions"`),
|
|
27
|
+
* and SKILL.md files can arrive via a PR or supply-chain path that the workflow
|
|
28
|
+
* author never reviewed per run.
|
|
29
|
+
*/
|
|
30
|
+
const STDIO_OPT_IN_ENV = "SWENY_ALLOW_SKILL_STDIO_COMMAND";
|
|
31
|
+
function stdioCommandAllowed(env) {
|
|
32
|
+
const v = (env[STDIO_OPT_IN_ENV] ?? "").trim().toLowerCase();
|
|
33
|
+
return v === "1" || v === "true" || v === "yes";
|
|
34
|
+
}
|
|
23
35
|
/**
|
|
24
36
|
* Discover custom skills and surface diagnostics for broken ones.
|
|
25
37
|
*
|
|
@@ -27,9 +39,10 @@ const SKILL_DIRS = SKILL_HARNESSES.map((h) => h.path);
|
|
|
27
39
|
* to users. `discoverSkills` stays as a backward-compatible wrapper for
|
|
28
40
|
* callers that only want the happy-path skill list.
|
|
29
41
|
*/
|
|
30
|
-
export function discoverSkillsWithDiagnostics(cwd = process.cwd()) {
|
|
42
|
+
export function discoverSkillsWithDiagnostics(cwd = process.cwd(), env = process.env) {
|
|
31
43
|
const skillMap = new Map();
|
|
32
44
|
const warnings = [];
|
|
45
|
+
const allowStdio = stdioCommandAllowed(env);
|
|
33
46
|
for (const relDir of SKILL_DIRS) {
|
|
34
47
|
const skillsDir = join(cwd, relDir);
|
|
35
48
|
if (!existsSync(skillsDir))
|
|
@@ -60,8 +73,10 @@ export function discoverSkillsWithDiagnostics(cwd = process.cwd()) {
|
|
|
60
73
|
});
|
|
61
74
|
continue;
|
|
62
75
|
}
|
|
63
|
-
const parsed = parseSkillMd(content, skillFile);
|
|
76
|
+
const parsed = parseSkillMd(content, skillFile, allowStdio);
|
|
64
77
|
if (parsed.kind === "ok") {
|
|
78
|
+
if (parsed.diagnostics)
|
|
79
|
+
warnings.push(...parsed.diagnostics);
|
|
65
80
|
if (skillMap.has(parsed.skill.id)) {
|
|
66
81
|
warnings.push({
|
|
67
82
|
kind: "duplicate-id",
|
|
@@ -84,10 +99,10 @@ export function discoverSkillsWithDiagnostics(cwd = process.cwd()) {
|
|
|
84
99
|
* Backwards-compatible wrapper that drops diagnostics. Use
|
|
85
100
|
* `discoverSkillsWithDiagnostics` when you want to surface warnings.
|
|
86
101
|
*/
|
|
87
|
-
export function discoverSkills(cwd = process.cwd()) {
|
|
88
|
-
return discoverSkillsWithDiagnostics(cwd).skills;
|
|
102
|
+
export function discoverSkills(cwd = process.cwd(), env = process.env) {
|
|
103
|
+
return discoverSkillsWithDiagnostics(cwd, env).skills;
|
|
89
104
|
}
|
|
90
|
-
function parseSkillMd(content, path) {
|
|
105
|
+
function parseSkillMd(content, path, allowStdio) {
|
|
91
106
|
const raw = parseFrontmatter(content);
|
|
92
107
|
if (raw === "invalid") {
|
|
93
108
|
return {
|
|
@@ -118,18 +133,38 @@ function parseSkillMd(content, path) {
|
|
|
118
133
|
};
|
|
119
134
|
}
|
|
120
135
|
const instruction = extractBody(content);
|
|
136
|
+
const diagnostics = [];
|
|
121
137
|
let mcp;
|
|
122
138
|
if (fm.mcp && typeof fm.mcp === "object") {
|
|
123
139
|
const m = fm.mcp;
|
|
124
140
|
if (m.command || m.url) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
141
|
+
const isStdio = (m.type ?? (m.command ? "stdio" : "http")) === "stdio";
|
|
142
|
+
// A discovered SKILL.md that declares a local stdio `command` is a code
|
|
143
|
+
// execution sink — the harness runs with bypassPermissions, so the
|
|
144
|
+
// command spawns with no interactive gate. Surface it loudly and, unless
|
|
145
|
+
// explicitly opted in via SWENY_ALLOW_SKILL_STDIO_COMMAND, do NOT wire it
|
|
146
|
+
// as a launchable server (drop the mcp config). HTTP-type skills and
|
|
147
|
+
// skills with no command are unaffected.
|
|
148
|
+
if (isStdio && m.command) {
|
|
149
|
+
diagnostics.push({
|
|
150
|
+
kind: "stdio-command-declared",
|
|
151
|
+
path,
|
|
152
|
+
message: `Skill "${id}" declares a local stdio command (${String(m.command)}). ` +
|
|
153
|
+
(allowStdio
|
|
154
|
+
? `Honoring it because ${STDIO_OPT_IN_ENV} is set — this spawns a local process with bypassPermissions.`
|
|
155
|
+
: `Refusing to wire it as a launchable MCP server. Set ${STDIO_OPT_IN_ENV}=1 to opt in (the command runs with bypassPermissions).`),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (!isStdio || !m.command || allowStdio) {
|
|
159
|
+
mcp = {
|
|
160
|
+
type: m.type ?? (m.command ? "stdio" : "http"),
|
|
161
|
+
...(m.command ? { command: m.command } : {}),
|
|
162
|
+
...(m.args ? { args: m.args } : {}),
|
|
163
|
+
...(m.url ? { url: m.url } : {}),
|
|
164
|
+
...(m.headers ? { headers: m.headers } : {}),
|
|
165
|
+
...(m.env ? { env: m.env } : {}),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
133
168
|
}
|
|
134
169
|
}
|
|
135
170
|
// Parse config fields from frontmatter
|
|
@@ -158,6 +193,7 @@ function parseSkillMd(content, path) {
|
|
|
158
193
|
instruction: instruction || undefined,
|
|
159
194
|
mcp,
|
|
160
195
|
},
|
|
196
|
+
diagnostics: diagnostics.length > 0 ? diagnostics : undefined,
|
|
161
197
|
};
|
|
162
198
|
}
|
|
163
199
|
/**
|
|
@@ -209,18 +245,24 @@ export function loadCustomSkills(cwd = process.cwd()) {
|
|
|
209
245
|
*/
|
|
210
246
|
export function configuredSkillsWithDiagnostics(env = process.env, cwd) {
|
|
211
247
|
const configured = builtinSkills.filter((s) => isSkillConfigured(s, env));
|
|
212
|
-
const { skills: custom, warnings } = discoverSkillsWithDiagnostics(cwd);
|
|
248
|
+
const { skills: custom, warnings } = discoverSkillsWithDiagnostics(cwd, env);
|
|
213
249
|
// Custom skills can override built-in IDs. When overriding, merge:
|
|
214
250
|
// keep the built-in's tools and config, add the custom instruction/mcp.
|
|
215
251
|
const configuredMap = new Map(configured.map((s) => [s.id, s]));
|
|
252
|
+
// Resolve override targets against the FULL built-in set, not the
|
|
253
|
+
// env-filtered `configured` list. Otherwise a custom override of a built-in
|
|
254
|
+
// whose required env var happens to be unset falls through to the
|
|
255
|
+
// "net-new custom skill" branch and silently drops the built-in's tools.
|
|
256
|
+
// The tool surface of a built-in must not hinge on an unrelated env check.
|
|
257
|
+
const builtinById = new Map(builtinSkills.map((s) => [s.id, s]));
|
|
216
258
|
for (const skill of custom) {
|
|
217
|
-
const
|
|
218
|
-
if (
|
|
259
|
+
const base = configuredMap.get(skill.id) ?? builtinById.get(skill.id);
|
|
260
|
+
if (base) {
|
|
219
261
|
configuredMap.set(skill.id, {
|
|
220
|
-
...
|
|
221
|
-
config: { ...
|
|
222
|
-
instruction: skill.instruction ??
|
|
223
|
-
mcp: skill.mcp ??
|
|
262
|
+
...base,
|
|
263
|
+
config: { ...base.config, ...skill.config },
|
|
264
|
+
instruction: skill.instruction ?? base.instruction,
|
|
265
|
+
mcp: skill.mcp ?? base.mcp,
|
|
224
266
|
});
|
|
225
267
|
}
|
|
226
268
|
else {
|
package/dist/skills/github.js
CHANGED
|
@@ -185,8 +185,14 @@ export const github = {
|
|
|
185
185
|
});
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
|
-
catch {
|
|
189
|
-
// Label failure is non-fatal
|
|
188
|
+
catch (err) {
|
|
189
|
+
// Label failure is non-fatal: the PR is already created and labeling
|
|
190
|
+
// is a best-effort followup. Surface a single warn line naming the
|
|
191
|
+
// PR + failure so operators can tell a label-misconfigured run from
|
|
192
|
+
// a clean one. Without this log, a 5xx from /issues/:n/labels was
|
|
193
|
+
// indistinguishable from success in CI output.
|
|
194
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
195
|
+
ctx.logger?.warn?.(` github_create_pr: label POST failed for ${pr.html_url ?? `#${pr.number}`} — ${msg}`);
|
|
190
196
|
}
|
|
191
197
|
return reused ? { ...pr, reused: true } : pr;
|
|
192
198
|
},
|
package/dist/skills/linear.js
CHANGED
|
@@ -107,6 +107,31 @@ export const linear = {
|
|
|
107
107
|
}
|
|
108
108
|
}`, { id: input.id }, ctx),
|
|
109
109
|
},
|
|
110
|
+
{
|
|
111
|
+
name: "linear_list_comments",
|
|
112
|
+
description: "List comments on a Linear issue (returns id, body, author, and timestamp for each)",
|
|
113
|
+
input_schema: {
|
|
114
|
+
type: "object",
|
|
115
|
+
properties: {
|
|
116
|
+
issueId: { type: "string", description: "Linear issue ID (UUID) or identifier (e.g. 'OFF-1020')" },
|
|
117
|
+
limit: { type: "number", description: "Max comments to return (default: 50)" },
|
|
118
|
+
},
|
|
119
|
+
required: ["issueId"],
|
|
120
|
+
},
|
|
121
|
+
handler: async (input, ctx) => {
|
|
122
|
+
const data = await linearGql(`query($id: String!, $first: Int) {
|
|
123
|
+
issue(id: $id) {
|
|
124
|
+
comments(first: $first) {
|
|
125
|
+
nodes { id body createdAt user { name } }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}`, { id: input.issueId, first: input.limit ?? 50 }, ctx);
|
|
129
|
+
if (!data?.issue) {
|
|
130
|
+
throw new Error(`[Linear] linear_list_comments found no issue "${input.issueId}". Raw response: ${JSON.stringify(data)}`);
|
|
131
|
+
}
|
|
132
|
+
return data;
|
|
133
|
+
},
|
|
134
|
+
},
|
|
110
135
|
{
|
|
111
136
|
name: "linear_list_teams",
|
|
112
137
|
description: "List Linear teams (needed for teamId when creating issues)",
|
|
@@ -181,6 +206,7 @@ export const linear = {
|
|
|
181
206
|
linear_update_issue: ["save_issue"],
|
|
182
207
|
linear_search_issues: ["list_issues"],
|
|
183
208
|
linear_add_comment: ["save_comment"],
|
|
209
|
+
linear_list_comments: ["list_comments"],
|
|
184
210
|
linear_list_teams: ["list_teams"],
|
|
185
211
|
linear_list_labels: ["list_issue_labels"],
|
|
186
212
|
},
|
|
@@ -8,4 +8,21 @@
|
|
|
8
8
|
* This skill handles generic webhooks, Discord, Teams, and email.
|
|
9
9
|
*/
|
|
10
10
|
import type { Skill } from "../types.js";
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the destination URL for `notify_webhook`.
|
|
13
|
+
*
|
|
14
|
+
* Trust model: the LLM controls tool arguments at runtime, and resolved source
|
|
15
|
+
* content is attacker-influenceable (prompt injection). A free-form `url`
|
|
16
|
+
* override would turn `notify_webhook` into an arbitrary exfiltration sink, so
|
|
17
|
+
* the destination is locked to operator-configured values:
|
|
18
|
+
*
|
|
19
|
+
* - With no override, the configured `NOTIFICATION_WEBHOOK_URL` is used.
|
|
20
|
+
* - An `override` is only honored when its host matches the configured
|
|
21
|
+
* webhook's host, or a host on the `NOTIFICATION_WEBHOOK_ALLOWED_HOSTS`
|
|
22
|
+
* allowlist (comma-separated, host[:port], case-insensitive).
|
|
23
|
+
* - Anything else is rejected with a clear error.
|
|
24
|
+
*
|
|
25
|
+
* Returns the URL to POST to, or throws an `Error` the caller surfaces.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveWebhookUrl(override: string | undefined, config: Record<string, string | undefined>): string;
|
|
11
28
|
export declare const notification: Skill;
|
|
@@ -7,6 +7,58 @@
|
|
|
7
7
|
* For Slack-specific notifications, use the Slack skill instead.
|
|
8
8
|
* This skill handles generic webhooks, Discord, Teams, and email.
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* Resolve the destination URL for `notify_webhook`.
|
|
12
|
+
*
|
|
13
|
+
* Trust model: the LLM controls tool arguments at runtime, and resolved source
|
|
14
|
+
* content is attacker-influenceable (prompt injection). A free-form `url`
|
|
15
|
+
* override would turn `notify_webhook` into an arbitrary exfiltration sink, so
|
|
16
|
+
* the destination is locked to operator-configured values:
|
|
17
|
+
*
|
|
18
|
+
* - With no override, the configured `NOTIFICATION_WEBHOOK_URL` is used.
|
|
19
|
+
* - An `override` is only honored when its host matches the configured
|
|
20
|
+
* webhook's host, or a host on the `NOTIFICATION_WEBHOOK_ALLOWED_HOSTS`
|
|
21
|
+
* allowlist (comma-separated, host[:port], case-insensitive).
|
|
22
|
+
* - Anything else is rejected with a clear error.
|
|
23
|
+
*
|
|
24
|
+
* Returns the URL to POST to, or throws an `Error` the caller surfaces.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveWebhookUrl(override, config) {
|
|
27
|
+
const configured = config.NOTIFICATION_WEBHOOK_URL;
|
|
28
|
+
if (override === undefined || override === "") {
|
|
29
|
+
if (!configured) {
|
|
30
|
+
throw new Error("[Notification] No webhook URL configured (set NOTIFICATION_WEBHOOK_URL)");
|
|
31
|
+
}
|
|
32
|
+
return configured;
|
|
33
|
+
}
|
|
34
|
+
// An override was supplied — only honor it if its host is allowlisted.
|
|
35
|
+
let overrideHost;
|
|
36
|
+
try {
|
|
37
|
+
overrideHost = new URL(override).host.toLowerCase();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new Error(`[Notification] Invalid webhook url override: ${override}`);
|
|
41
|
+
}
|
|
42
|
+
const allowed = new Set();
|
|
43
|
+
if (configured) {
|
|
44
|
+
try {
|
|
45
|
+
allowed.add(new URL(configured).host.toLowerCase());
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Misconfigured NOTIFICATION_WEBHOOK_URL — ignore for allowlist purposes.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (const host of (config.NOTIFICATION_WEBHOOK_ALLOWED_HOSTS ?? "").split(",")) {
|
|
52
|
+
const h = host.trim().toLowerCase();
|
|
53
|
+
if (h)
|
|
54
|
+
allowed.add(h);
|
|
55
|
+
}
|
|
56
|
+
if (allowed.has(overrideHost))
|
|
57
|
+
return override;
|
|
58
|
+
throw new Error(`[Notification] Refusing to POST to non-allowlisted host "${overrideHost}". ` +
|
|
59
|
+
`Destination is locked to NOTIFICATION_WEBHOOK_URL; ` +
|
|
60
|
+
`add the host to NOTIFICATION_WEBHOOK_ALLOWED_HOSTS to permit it.`);
|
|
61
|
+
}
|
|
10
62
|
export const notification = {
|
|
11
63
|
id: "notification",
|
|
12
64
|
name: "Notification",
|
|
@@ -18,6 +70,12 @@ export const notification = {
|
|
|
18
70
|
required: false,
|
|
19
71
|
env: "NOTIFICATION_WEBHOOK_URL",
|
|
20
72
|
},
|
|
73
|
+
NOTIFICATION_WEBHOOK_ALLOWED_HOSTS: {
|
|
74
|
+
description: "Comma-separated host allowlist (host[:port]) for the notify_webhook url override. " +
|
|
75
|
+
"The configured NOTIFICATION_WEBHOOK_URL host is always allowed.",
|
|
76
|
+
required: false,
|
|
77
|
+
env: "NOTIFICATION_WEBHOOK_ALLOWED_HOSTS",
|
|
78
|
+
},
|
|
21
79
|
DISCORD_WEBHOOK_URL: {
|
|
22
80
|
description: "Discord webhook URL",
|
|
23
81
|
required: false,
|
|
@@ -37,19 +95,23 @@ export const notification = {
|
|
|
37
95
|
tools: [
|
|
38
96
|
{
|
|
39
97
|
name: "notify_webhook",
|
|
40
|
-
description: "Send a JSON payload to
|
|
98
|
+
description: "Send a JSON payload to the configured notification webhook (NOTIFICATION_WEBHOOK_URL). " +
|
|
99
|
+
"The destination is fixed: an optional `url` is only honored when its host is on the " +
|
|
100
|
+
"NOTIFICATION_WEBHOOK_ALLOWED_HOSTS allowlist; arbitrary hosts are rejected.",
|
|
41
101
|
input_schema: {
|
|
42
102
|
type: "object",
|
|
43
103
|
properties: {
|
|
44
|
-
url: {
|
|
104
|
+
url: {
|
|
105
|
+
type: "string",
|
|
106
|
+
description: "Optional webhook URL. Only honored when its host matches NOTIFICATION_WEBHOOK_URL " +
|
|
107
|
+
"or an entry in NOTIFICATION_WEBHOOK_ALLOWED_HOSTS; otherwise rejected.",
|
|
108
|
+
},
|
|
45
109
|
payload: { type: "object", description: "JSON payload to send" },
|
|
46
110
|
},
|
|
47
111
|
required: ["payload"],
|
|
48
112
|
},
|
|
49
113
|
handler: async (input, ctx) => {
|
|
50
|
-
const url = input.url
|
|
51
|
-
if (!url)
|
|
52
|
-
throw new Error("No webhook URL provided or configured");
|
|
114
|
+
const url = resolveWebhookUrl(input.url, ctx.config);
|
|
53
115
|
const res = await fetch(url, {
|
|
54
116
|
method: "POST",
|
|
55
117
|
headers: { "Content-Type": "application/json" },
|
package/dist/source-resolver.js
CHANGED
|
@@ -6,9 +6,17 @@
|
|
|
6
6
|
* (Zod schema, classifier, types) can be imported without pulling `node:fs`
|
|
7
7
|
* or `node:crypto` into bundlers that target the browser.
|
|
8
8
|
*/
|
|
9
|
+
import * as dns from "node:dns/promises";
|
|
9
10
|
import * as fs from "node:fs/promises";
|
|
10
11
|
import * as path from "node:path";
|
|
11
12
|
import { normalizeSource } from "./sources.js";
|
|
13
|
+
/** Schemes a `url:` source is allowed to use. Everything else is rejected. */
|
|
14
|
+
const ALLOWED_URL_SCHEMES = new Set(["http:", "https:"]);
|
|
15
|
+
/** Default DNS resolver used when the context does not inject one. */
|
|
16
|
+
const defaultDnsLookup = async (hostname) => {
|
|
17
|
+
const records = await dns.lookup(hostname, { all: true });
|
|
18
|
+
return records.map((r) => r.address);
|
|
19
|
+
};
|
|
12
20
|
/**
|
|
13
21
|
* Produce a stable 16-hex-char hash of `content` for use as a cache key /
|
|
14
22
|
* change-detection ID.
|
|
@@ -46,6 +54,7 @@ export async function resolveSource(source, fieldPath, ctx) {
|
|
|
46
54
|
}
|
|
47
55
|
if (kind === "file") {
|
|
48
56
|
const absolute = path.isAbsolute(value) ? value : path.resolve(ctx.cwd, value);
|
|
57
|
+
assertFileWithinRoot(absolute, ctx, fieldPath);
|
|
49
58
|
let content;
|
|
50
59
|
try {
|
|
51
60
|
content = await fs.readFile(absolute, "utf-8");
|
|
@@ -78,8 +87,12 @@ export async function resolveSource(source, fieldPath, ctx) {
|
|
|
78
87
|
throw new Error(`SOURCE_INVALID_TYPE: unknown resolver type "${source.type}" on ${value} (referenced by ${fieldPath}). v1 supports only "fetch".`);
|
|
79
88
|
}
|
|
80
89
|
const url = value;
|
|
81
|
-
|
|
82
|
-
|
|
90
|
+
assertAllowedScheme(url, fieldPath);
|
|
91
|
+
// Validate the initial host before sending anything. Redirect hops are
|
|
92
|
+
// re-validated per-hop inside fetchWithRetry (redirect: "manual").
|
|
93
|
+
await assertSafeHost(url, ctx, fieldPath);
|
|
94
|
+
const headers = await buildFetchHeaders(url, ctx);
|
|
95
|
+
const res = await fetchWithRetry(url, headers, fieldPath, ctx);
|
|
83
96
|
if (res.status === 401 || res.status === 403) {
|
|
84
97
|
throw new Error(`SOURCE_URL_AUTH_REQUIRED: ${url} returned HTTP ${res.status} (referenced by ${fieldPath}). Configure fetch.auth in .sweny.yml or set SWENY_FETCH_TOKEN.`);
|
|
85
98
|
}
|
|
@@ -124,11 +137,19 @@ function canonicalKey(source, ctx) {
|
|
|
124
137
|
return null;
|
|
125
138
|
}
|
|
126
139
|
const RETRY_DELAYS = [250, 1000]; // ms — exponential backoff for 5xx
|
|
127
|
-
|
|
140
|
+
const MAX_REDIRECTS = 5;
|
|
141
|
+
/**
|
|
142
|
+
* Fetch with retry on 5xx/network errors AND manual redirect handling. Each
|
|
143
|
+
* redirect hop is re-validated (scheme + host SSRF check + token scoping) so a
|
|
144
|
+
* benign first host cannot redirect to the cloud metadata endpoint or another
|
|
145
|
+
* private target, and a credential meant for host A is never replayed to host
|
|
146
|
+
* B unless B is also allowlisted.
|
|
147
|
+
*/
|
|
148
|
+
async function fetchWithRetry(startUrl, startHeaders, fieldPath, ctx) {
|
|
128
149
|
let lastError;
|
|
129
150
|
for (let attempt = 0; attempt <= RETRY_DELAYS.length; attempt++) {
|
|
130
151
|
try {
|
|
131
|
-
const res = await
|
|
152
|
+
const res = await fetchFollowingRedirects(startUrl, startHeaders, fieldPath, ctx);
|
|
132
153
|
// Don't retry on 4xx (client errors) or 2xx (success)
|
|
133
154
|
if (res.status < 500)
|
|
134
155
|
return res;
|
|
@@ -140,6 +161,9 @@ async function fetchWithRetry(url, headers, fieldPath) {
|
|
|
140
161
|
return res; // final 5xx — let caller handle it
|
|
141
162
|
}
|
|
142
163
|
catch (err) {
|
|
164
|
+
// SSRF rejections are not transient — surface immediately, do not retry.
|
|
165
|
+
if (err instanceof Error && err.message.startsWith("SOURCE_URL_BLOCKED"))
|
|
166
|
+
throw err;
|
|
143
167
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
144
168
|
if (attempt < RETRY_DELAYS.length) {
|
|
145
169
|
await new Promise((r) => setTimeout(r, RETRY_DELAYS[attempt]));
|
|
@@ -147,9 +171,36 @@ async function fetchWithRetry(url, headers, fieldPath) {
|
|
|
147
171
|
}
|
|
148
172
|
}
|
|
149
173
|
}
|
|
150
|
-
throw new Error(`SOURCE_URL_UNREACHABLE: ${
|
|
174
|
+
throw new Error(`SOURCE_URL_UNREACHABLE: ${startUrl} (referenced by ${fieldPath}): ${lastError?.message ?? "unknown error"}. Pass --offline to skip URL sources.`);
|
|
175
|
+
}
|
|
176
|
+
async function fetchFollowingRedirects(startUrl, startHeaders, fieldPath, ctx) {
|
|
177
|
+
let currentUrl = startUrl;
|
|
178
|
+
let headers = startHeaders;
|
|
179
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
180
|
+
const res = await fetch(currentUrl, {
|
|
181
|
+
headers,
|
|
182
|
+
redirect: "manual",
|
|
183
|
+
signal: AbortSignal.timeout(10_000),
|
|
184
|
+
});
|
|
185
|
+
if (!isRedirect(res.status))
|
|
186
|
+
return res;
|
|
187
|
+
const location = res.headers.get("location");
|
|
188
|
+
if (!location)
|
|
189
|
+
return res; // redirect status with no Location — let caller handle
|
|
190
|
+
const nextUrl = new URL(location, currentUrl).toString();
|
|
191
|
+
assertAllowedScheme(nextUrl, fieldPath);
|
|
192
|
+
await assertSafeHost(nextUrl, ctx, fieldPath);
|
|
193
|
+
// Re-scope the credential for the redirect target. A token for host A is
|
|
194
|
+
// dropped on a hop to host B unless B is also explicitly allowlisted.
|
|
195
|
+
headers = await buildFetchHeaders(nextUrl, ctx);
|
|
196
|
+
currentUrl = nextUrl;
|
|
197
|
+
}
|
|
198
|
+
throw new Error(`SOURCE_URL_UNREACHABLE: ${startUrl} (referenced by ${fieldPath}): too many redirects (>${MAX_REDIRECTS}).`);
|
|
199
|
+
}
|
|
200
|
+
function isRedirect(status) {
|
|
201
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
151
202
|
}
|
|
152
|
-
function buildFetchHeaders(url, ctx) {
|
|
203
|
+
async function buildFetchHeaders(url, ctx) {
|
|
153
204
|
const headers = {
|
|
154
205
|
Accept: "text/plain, text/markdown, */*",
|
|
155
206
|
};
|
|
@@ -158,6 +209,12 @@ function buildFetchHeaders(url, ctx) {
|
|
|
158
209
|
headers.Authorization = `Bearer ${token}`;
|
|
159
210
|
return headers;
|
|
160
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* Resolve the bearer token for a URL, but ONLY if the URL's host is on the
|
|
214
|
+
* fetch-token allowlist. There is no blanket `SWENY_FETCH_TOKEN` fallback to
|
|
215
|
+
* arbitrary hosts: a credential is never sent to a host the operator did not
|
|
216
|
+
* explicitly authorize.
|
|
217
|
+
*/
|
|
161
218
|
function resolveFetchToken(url, ctx) {
|
|
162
219
|
let host;
|
|
163
220
|
try {
|
|
@@ -166,9 +223,177 @@ function resolveFetchToken(url, ctx) {
|
|
|
166
223
|
catch {
|
|
167
224
|
return undefined;
|
|
168
225
|
}
|
|
226
|
+
if (!isTokenHostAllowed(host, ctx))
|
|
227
|
+
return undefined;
|
|
169
228
|
const envVar = ctx.authConfig[host];
|
|
170
229
|
if (envVar && ctx.env[envVar])
|
|
171
230
|
return ctx.env[envVar];
|
|
172
231
|
const fallback = ctx.env.SWENY_FETCH_TOKEN;
|
|
173
232
|
return fallback || undefined;
|
|
174
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* A host may receive the fetch token if it is explicitly allowlisted. The
|
|
236
|
+
* allowlist is `fetchTokenHosts` when provided, otherwise the keys of
|
|
237
|
+
* `authConfig` (the hosts the operator configured a per-host mapping for).
|
|
238
|
+
*/
|
|
239
|
+
function isTokenHostAllowed(host, ctx) {
|
|
240
|
+
const allow = ctx.fetchTokenHosts ?? Object.keys(ctx.authConfig);
|
|
241
|
+
return allow.includes(host);
|
|
242
|
+
}
|
|
243
|
+
function assertAllowedScheme(url, fieldPath) {
|
|
244
|
+
let scheme;
|
|
245
|
+
try {
|
|
246
|
+
scheme = new URL(url).protocol;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
throw new Error(`SOURCE_URL_BLOCKED: malformed URL "${url}" (referenced by ${fieldPath}).`);
|
|
250
|
+
}
|
|
251
|
+
if (!ALLOWED_URL_SCHEMES.has(scheme)) {
|
|
252
|
+
throw new Error(`SOURCE_URL_BLOCKED: scheme "${scheme}" not allowed for ${url} (referenced by ${fieldPath}). Only http and https are permitted.`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Reject a URL whose host resolves to a private, loopback, or link-local
|
|
257
|
+
* address (SSRF guard). Literal-IP hosts are checked directly. Hostnames are
|
|
258
|
+
* resolved via the injected DNS lookup and every returned address is checked.
|
|
259
|
+
* A hostname that fails to resolve is allowed through (the subsequent fetch
|
|
260
|
+
* will simply fail) so that this guard does not turn DNS outages or unknown
|
|
261
|
+
* test hosts into hard validation errors.
|
|
262
|
+
*/
|
|
263
|
+
async function assertSafeHost(url, ctx, fieldPath) {
|
|
264
|
+
const hostname = new URL(url).hostname;
|
|
265
|
+
const literal = stripIpv6Brackets(hostname);
|
|
266
|
+
if (isIpAddress(literal)) {
|
|
267
|
+
if (isBlockedIp(literal)) {
|
|
268
|
+
throw new Error(`SOURCE_URL_BLOCKED: ${url} resolves to blocked address ${literal} (referenced by ${fieldPath}). Private, loopback, and link-local hosts are not allowed.`);
|
|
269
|
+
}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const lookup = ctx.dnsLookup ?? defaultDnsLookup;
|
|
273
|
+
let addresses;
|
|
274
|
+
try {
|
|
275
|
+
addresses = await lookup(hostname);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return; // unresolved host — let fetch fail naturally
|
|
279
|
+
}
|
|
280
|
+
for (const addr of addresses) {
|
|
281
|
+
if (isBlockedIp(addr)) {
|
|
282
|
+
throw new Error(`SOURCE_URL_BLOCKED: ${url} resolves to blocked address ${addr} (referenced by ${fieldPath}). Private, loopback, and link-local hosts are not allowed.`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function stripIpv6Brackets(host) {
|
|
287
|
+
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
288
|
+
}
|
|
289
|
+
function isIpAddress(host) {
|
|
290
|
+
return isIpv4(host) || host.includes(":");
|
|
291
|
+
}
|
|
292
|
+
function isIpv4(host) {
|
|
293
|
+
const parts = host.split(".");
|
|
294
|
+
if (parts.length !== 4)
|
|
295
|
+
return false;
|
|
296
|
+
return parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* True if `ip` (v4 or v6 literal) is one of the address ranges that must never
|
|
300
|
+
* be reachable from a source fetch: loopback, private (RFC1918), link-local
|
|
301
|
+
* (including the cloud metadata endpoint 169.254.169.254), CGNAT, unspecified,
|
|
302
|
+
* and the IPv6 equivalents (::1, fe80::/10, fc00::/7, IPv4-mapped forms).
|
|
303
|
+
*/
|
|
304
|
+
function isBlockedIp(ip) {
|
|
305
|
+
const v4 = ipv4Octets(ip);
|
|
306
|
+
if (v4)
|
|
307
|
+
return isBlockedIpv4(v4);
|
|
308
|
+
return isBlockedIpv6(ip);
|
|
309
|
+
}
|
|
310
|
+
function ipv4Octets(ip) {
|
|
311
|
+
if (!isIpv4(ip))
|
|
312
|
+
return null;
|
|
313
|
+
return ip.split(".").map(Number);
|
|
314
|
+
}
|
|
315
|
+
function isBlockedIpv4([a, b]) {
|
|
316
|
+
if (a === 0)
|
|
317
|
+
return true; // 0.0.0.0/8 (incl. unspecified)
|
|
318
|
+
if (a === 10)
|
|
319
|
+
return true; // 10.0.0.0/8 private
|
|
320
|
+
if (a === 127)
|
|
321
|
+
return true; // 127.0.0.0/8 loopback
|
|
322
|
+
if (a === 169 && b === 254)
|
|
323
|
+
return true; // 169.254.0.0/16 link-local (incl. 169.254.169.254 metadata)
|
|
324
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
325
|
+
return true; // 172.16.0.0/12 private
|
|
326
|
+
if (a === 192 && b === 168)
|
|
327
|
+
return true; // 192.168.0.0/16 private
|
|
328
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
329
|
+
return true; // 100.64.0.0/10 CGNAT
|
|
330
|
+
if (a >= 224)
|
|
331
|
+
return true; // 224.0.0.0/4 multicast + 240.0.0.0/4 reserved
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
function isBlockedIpv6(raw) {
|
|
335
|
+
const ip = raw.toLowerCase().split("%")[0]; // drop zone id
|
|
336
|
+
if (ip === "::1" || ip === "::")
|
|
337
|
+
return true; // loopback / unspecified
|
|
338
|
+
const embedded = embeddedIpv4(ip);
|
|
339
|
+
if (embedded)
|
|
340
|
+
return isBlockedIpv4(embedded);
|
|
341
|
+
const firstGroup = ip.split(":")[0];
|
|
342
|
+
const hextet = parseInt(firstGroup || "0", 16);
|
|
343
|
+
if ((hextet & 0xffc0) === 0xfe80)
|
|
344
|
+
return true; // fe80::/10 link-local
|
|
345
|
+
if ((hextet & 0xfe00) === 0xfc00)
|
|
346
|
+
return true; // fc00::/7 unique local
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Extract the embedded IPv4 address from an IPv4-mapped IPv6 literal, handling
|
|
351
|
+
* both the dotted tail form (`::ffff:1.2.3.4`) and the hex form the WHATWG URL
|
|
352
|
+
* parser normalizes to (`::ffff:0102:0304`).
|
|
353
|
+
*
|
|
354
|
+
* Only `::ffff:`-prefixed (IPv4-mapped) addresses are unwrapped. A dotted tail
|
|
355
|
+
* on any other prefix (e.g. `fe80::1.2.3.4`) is NOT treated as an embedded
|
|
356
|
+
* IPv4: unwrapping it would skip the link-local / unique-local hextet checks
|
|
357
|
+
* below and let `fe80::1.2.3.4` masquerade as the public address 1.2.3.4. In
|
|
358
|
+
* practice the WHATWG URL parser already normalizes such inputs to hex form, so
|
|
359
|
+
* this is defense-in-depth, but the predicate must be correct on its own.
|
|
360
|
+
*/
|
|
361
|
+
function embeddedIpv4(ip) {
|
|
362
|
+
if (!ip.includes("::ffff:"))
|
|
363
|
+
return null;
|
|
364
|
+
// Dotted tail form: ::ffff:1.2.3.4
|
|
365
|
+
const lastColon = ip.lastIndexOf(":");
|
|
366
|
+
const tail = lastColon >= 0 ? ip.slice(lastColon + 1) : ip;
|
|
367
|
+
if (tail.includes("."))
|
|
368
|
+
return ipv4Octets(tail);
|
|
369
|
+
// Hex form: an IPv4-mapped address ends in two hextets after ::ffff:.
|
|
370
|
+
const groups = ip.split(":").filter((g) => g.length > 0);
|
|
371
|
+
if (groups.length < 2)
|
|
372
|
+
return null;
|
|
373
|
+
const hi = parseInt(groups[groups.length - 2], 16);
|
|
374
|
+
const lo = parseInt(groups[groups.length - 1], 16);
|
|
375
|
+
if (Number.isNaN(hi) || Number.isNaN(lo))
|
|
376
|
+
return null;
|
|
377
|
+
return [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff];
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Enforce the optional repo-root file sandbox. The sandbox is opt-in: it only
|
|
381
|
+
* applies when `fileRoot` is set (preserving legacy "read anywhere" behavior
|
|
382
|
+
* for callers that don't configure a root). When active, a `file:` source must
|
|
383
|
+
* resolve to a path inside `fileRoot`; `..` traversal and absolute paths that
|
|
384
|
+
* escape the root are rejected. Set `allowFileOutsideRoot` to opt back out even
|
|
385
|
+
* when a root is configured.
|
|
386
|
+
*/
|
|
387
|
+
function assertFileWithinRoot(absolute, ctx, fieldPath) {
|
|
388
|
+
if (ctx.allowFileOutsideRoot)
|
|
389
|
+
return;
|
|
390
|
+
if (!ctx.fileRoot)
|
|
391
|
+
return; // sandbox not configured — legacy behavior
|
|
392
|
+
const root = path.resolve(ctx.fileRoot);
|
|
393
|
+
const normalized = path.resolve(absolute);
|
|
394
|
+
const rel = path.relative(root, normalized);
|
|
395
|
+
const escapes = rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel);
|
|
396
|
+
if (escapes) {
|
|
397
|
+
throw new Error(`SOURCE_FILE_OUTSIDE_ROOT: ${absolute} is outside the allowed root ${root} (referenced by ${fieldPath}). Set allowFileOutsideRoot to permit reads outside the repo.`);
|
|
398
|
+
}
|
|
399
|
+
}
|
package/dist/sources.d.ts
CHANGED
|
@@ -64,10 +64,43 @@ export type SourceResolutionMap = Record<string, ResolvedSource>;
|
|
|
64
64
|
*/
|
|
65
65
|
export declare function classifySource(raw: string): SourceKind;
|
|
66
66
|
export declare function normalizeSource(source: Source): [SourceKind, string];
|
|
67
|
+
/**
|
|
68
|
+
* Result of a DNS lookup: one or more resolved IP addresses for a hostname.
|
|
69
|
+
* Injectable via {@link SourceResolutionContext.dnsLookup} so SSRF host
|
|
70
|
+
* validation is deterministic in tests (no real network).
|
|
71
|
+
*/
|
|
72
|
+
export type DnsLookup = (hostname: string) => Promise<string[]>;
|
|
67
73
|
export type SourceResolutionContext = {
|
|
68
74
|
cwd: string;
|
|
69
75
|
env: NodeJS.ProcessEnv;
|
|
70
76
|
authConfig: Record<string, string>;
|
|
71
77
|
offline: boolean;
|
|
72
78
|
logger: Logger;
|
|
79
|
+
/**
|
|
80
|
+
* Hosts the per-host fetch token (`fetch.auth` mapping) or the
|
|
81
|
+
* `SWENY_FETCH_TOKEN` env var may be sent to. The token is ONLY attached to
|
|
82
|
+
* requests whose host (and every redirect hop's host) appears here. There is
|
|
83
|
+
* deliberately no blanket fallback: a credential is never sent to a host the
|
|
84
|
+
* operator did not explicitly allow. When omitted, the allowlist is derived
|
|
85
|
+
* from the keys of `authConfig`.
|
|
86
|
+
*/
|
|
87
|
+
fetchTokenHosts?: string[];
|
|
88
|
+
/**
|
|
89
|
+
* Restrict `file:` source resolution to this directory tree (the repo root,
|
|
90
|
+
* typically `cwd`). Relative and absolute paths that escape it via `..` or
|
|
91
|
+
* symlinks are rejected. Defaults to `cwd`. Set
|
|
92
|
+
* {@link allowFileOutsideRoot} to opt out.
|
|
93
|
+
*/
|
|
94
|
+
fileRoot?: string;
|
|
95
|
+
/**
|
|
96
|
+
* Opt out of the {@link fileRoot} sandbox, permitting `file:` sources to read
|
|
97
|
+
* anywhere on disk (legacy behavior). Defaults to `false`.
|
|
98
|
+
*/
|
|
99
|
+
allowFileOutsideRoot?: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Injected DNS resolver for SSRF host validation. Defaults to a
|
|
102
|
+
* `node:dns`-backed lookup. Tests inject a stub to assert IP-blocking without
|
|
103
|
+
* touching the network.
|
|
104
|
+
*/
|
|
105
|
+
dnsLookup?: DnsLookup;
|
|
73
106
|
};
|