@sweny-ai/core 0.1.101 → 0.1.103

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.
@@ -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
- mcp = {
126
- type: m.type ?? (m.command ? "stdio" : "http"),
127
- ...(m.command ? { command: m.command } : {}),
128
- ...(m.args ? { args: m.args } : {}),
129
- ...(m.url ? { url: m.url } : {}),
130
- ...(m.headers ? { headers: m.headers } : {}),
131
- ...(m.env ? { env: m.env } : {}),
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,7 +245,7 @@ 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]));
@@ -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
  },
@@ -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 a webhook URL",
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: { type: "string", description: "Webhook URL (overrides NOTIFICATION_WEBHOOK_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 ?? ctx.config.NOTIFICATION_WEBHOOK_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" },
@@ -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
- const headers = buildFetchHeaders(url, ctx);
82
- const res = await fetchWithRetry(url, headers, fieldPath);
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
- async function fetchWithRetry(url, headers, fieldPath) {
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 fetch(url, { headers, signal: AbortSignal.timeout(10_000) });
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: ${url} (referenced by ${fieldPath}): ${lastError?.message ?? "unknown error"}. Pass --offline to skip URL sources.`);
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
  };