santifer/career-ops · error · Error

plugin egress to " " is not in allowedHosts [ ]

Error message

plugin egress to "${u.hostname}" is not in allowedHosts [${[...allow].join(', ')}]

What it means

hostOk also enforces an allowlist: when the plugin declares allowedHosts (non-empty), any fetch whose URL hostname is not in that set throws before the request leaves the process. This is the least-privilege egress policy — plugins can only talk to hosts they explicitly declared.

Solutions

  1. Add the target hostname to the plugin's allowedHosts in its manifest/config (exact hostname match, no scheme or path)
  2. Verify the hostname spelling matches the URL exactly (subdomains must be listed explicitly)
  3. If the fetch is unexpected, audit the plugin code — a request you didn't anticipate may be exfiltrating data

Example fix

// config: allowedHosts: ['api.github.com']
// before
await ctx.fetch('https://raw.githubusercontent.com/user/repo/main/x.json');
// Error: plugin egress to "raw.githubusercontent.com" is not in allowedHosts [api.github.com]
// after: allowedHosts: ['api.github.com', 'raw.githubusercontent.com']
await ctx.fetch('https://raw.githubusercontent.com/user/repo/main/x.json');
Defensive patterns

Strategy: validation

Validate before calling

const u = new URL(target);
const allowed = pluginConfig.allowedHosts ?? [];
if (allowed.length > 0 && !allowed.includes(u.hostname)) {
  throw new Error(`host ${u.hostname} not in allowedHosts — add it to the plugin config first`);
}

Type guard

const hostIsAllowed = (url, allow) => allow.length === 0 || allow.includes(new URL(url).hostname);

Try / catch

try { return await ctx.fetch(url); } catch (e) { const m = e.message.match(/plugin egress to "([^"]+)" is not in allowedHosts/); if (m) { throw new Error(`${e.message} — add "${m[1]}" to allowedHosts or remove this call`); } throw e; }

Prevention

When it happens

Trigger: A plugin calling ctx.fetch() against a hostname not listed in its allowedHosts config; a provider plugin redirected/updated to a new API domain that was never added to the allowlist.

Common situations: A plugin fetching a CDN, webhook, or secondary API domain beyond its declared hosts; typos in allowedHosts entries; a plugin upgrade adding new endpoints while the config still lists old domains.

Understand the failure class

Background: Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership — this error's family across 18 libraries.

Related errors


AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16). Data as JSON: /api/errors/89fd92abe7275459. Report an issue: GitHub.

Appendix: source

Thrown at plugins/_engine.mjs:394

 * 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 aac998c7ed)