santifer/career-ops · error · Error
plugin egress to "${u.hostname}" is not in allowedHosts [${[
Error message
plugin egress to "${u.hostname}" is not in allowedHosts [${[...allow].join(', ')}] What it means
Thrown by the guardedFetch host check when the manifest declares a non-empty allowedHosts allowlist and the request's hostname is not in it. The allowlist pins each plugin to exactly the hosts it needs (least privilege); the error lists the allowed set so the gap is obvious.
Source
Thrown at plugins/_engine.mjs:377
* the allowlist + per-hop re-validation + cross-host credential strip bound it.
*
* ADVISORY only: this binds a plugin that routes through ctx.fetch*, not one
* that calls global fetch directly (see the trust note in README.md).
*
* @param {string[]} allowedHosts
*/
function makeGuardedFetch(allowedHosts, { allowsLocalhost = false } = {}) {
const allow = new Set(allowedHosts);
const isLoopbackHost = (h) => /^(localhost|127\.\d+\.\d+\.\d+|\[?::1\]?)$/i.test(h);
const hostOk = (u) => {
if (u.protocol !== 'https:') {
// Plain HTTP is allowed ONLY for an opted-in loopback host (local-AI
// providers like Ollama/LM Studio serve http://localhost:11434).
if (!(allowsLocalhost && u.protocol === 'http:' && isLoopbackHost(u.hostname))) {
throw new Error(`plugin egress must use HTTPS: ${u.href}`);
}
}
if (allow.size > 0 && !allow.has(u.hostname)) throw new Error(`plugin egress to "${u.hostname}" is not in allowedHosts [${[...allow].join(', ')}]`);
};
return async function guardedFetch(url, opts = {}) {
const { timeoutMs = 10_000, headers = {}, method = 'GET', body = null } = opts;
let current = new URL(url);
hostOk(current);
// SSRF: reject a host that resolves to a private/loopback/metadata address
// (re-checked on every redirect hop). Loopback allowed only when opted in.
await resolveAndValidate(current.hostname, { allowsLocalhost });
let reqHeaders = { ...headers };
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
let res;
try {
res = await fetch(current.href, {
method, headers: reqHeaders, body, redirect: 'manual', signal: controller.signal,
});
} finally {View on GitHub (pinned to 9b17a8ac97)
Solutions
- Add the missing hostname to the plugin manifest's allowedHosts (if the call is legitimate).
- Fix the plugin code to call the already-allowed host instead.
- Check redirects — the guard re-validates each hop, so a redirect target must also be allowlisted.
- Re-audit the plugin's network surface before widening the allowlist.
Example fix
// before: allowedHosts = ['api.example.com'], code hits cdn.example.com
await ctx.fetch('https://cdn.example.com/asset');
// after: add cdn.example.com to manifest allowedHosts, or use the api host Defensive patterns
Strategy: validation
Validate before calling
function isAllowedHost(s, allowedHosts) {
const allow = new Set(allowedHosts);
if (allow.size === 0) return true;
return allow.has(new URL(s).hostname);
} Type guard
/** Narrows a URL to one whose hostname is in the plugin's allowlist. */
function isAllowedHost(s, allowedHosts) {
if (typeof s !== 'string') return false;
const allow = new Set(allowedHosts);
if (allow.size === 0) return true;
let u;
try { u = new URL(s); } catch { return false; }
return allow.has(u.hostname);
} Prevention
- Enumerate every host a plugin calls in its manifest allowedHosts.
- Include CDN/asset hosts and any redirect targets.
- Re-audit the plugin's network surface before widening the allowlist.
When it happens
Trigger: A plugin requests a host not declared in its manifest's allowedHosts; a redirect hops to an allowlisted-external host; the manifest lists api.example.com but the code hits cdn.example.com.
Common situations: Manifest allowlist out of date vs the plugin's actual calls; plugin hits a CDN or sibling subdomain not enumerated; a redirect leaves the allowed host set; typo in the allowlist entry.
Related errors
- plugin egress must use HTTPS: ${u.href}
- refusing non-GitHub/unsafe repo URL: ${arg} (expected https:
- a 40-hex commit --sha is required (got ${JSON.stringify(sha)
- too many redirects (>${MAX_REDIRECTS}) for ${url}
- plugin egress to ${hostname} is blocked (private/loopback/me
AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13).
Data as JSON: /api/errors/89fd92abe7275459.
Report an issue: GitHub.