Skip to main content
loomcycle
§ architecture note

Who decides which URLs an agent can visit? It's not the runtime.

Sunday afternoon. Final round of agent tests before the next production deploy. The job-search pipeline runs through Brave Search, gets back a list of position URLs, and the next agent tries to fetch one of them to extract the actual posting. It doesn't. The agent reports a tool error, retries the way agents retry — calling the tool again with slightly different framing — gets the same error, gives up.

I'd assumed the failure was upstream of the URL — model hallucination, malformed URL, transient network — and the symptom would resolve on retry. It didn't. By the third run I stopped guessing and opened loomcycle's UI to read the actual tool traces. The diagnosis took thirty seconds. The fix took most of the rest of the day and produced a small but architecturally meaningful feature I want to write down before I forget the shape of the argument.

What loomcycle's UI showed

The agent had completed the Brave Search call cleanly. The search results came back: a list of URLs from job-board domains, each pointing at a specific position posting. The agent then called WebFetch on the first URL — and the runtime refused with host not in allowlist.

That refusal was technically correct. Loomcycle's URL ACL has two layers:

  1. Static allowlist in operator YAML (LOOMCYCLE_HTTP_HOST_ALLOWLIST): every host the agent might ever need, declared at boot time.
  2. Per-run narrowing via the caller's allowed_hosts on the run request: the consumer service can shrink the static allowlist for a single run, but never widen it.

Both layers require the consumer service (here, our jobs-search-web app) to pre-enumerate every hostname the agent might reach. For curated REST endpoints — the same few backends called over and over — pre-enumeration is the right shape and has been working fine since v0.3.x.

But WebSearch is fundamentally a discovery primitive. It produces URLs nobody pre-enumerated, by definition. Job postings live on whichever domain the employer's ATS happens to use this week. There is no list of hostnames that covers them — there is only "the URL Brave just returned." Pre-enumeration is not just inconvenient here; it's structurally the wrong tool.

Whose decision is this, actually?

The question I had to answer before writing any code: who is supposed to decide whether the agent can fetch this URL?

It's not the runtime's job. Loomcycle doesn't know who the user is in any meaningful sense — it knows a user_id, but not the user's preferences, the per-tenant access rules, the reputation history of the proposed hostname, the business-logic constraints. It's not the runtime's job to know any of that. The runtime is a substrate. It holds the mechanism — bearer tokens, default-deny posture, ctx propagation, audit events. It does not hold the context.

The consumer service does. Jobs-search-web knows the user, the tenant, the user's per-account preferences, the per-domain reputation data it's collected over time, the business rules that say "this user can query LinkedIn but not Indeed." All of that lives in the consumer's database and policy layer, not in loomcycle's config.

So the right shape was always: loomcycle owns the mechanism, the consumer owns the policy. Pre-enumerated allowlists conflated those two — they made the consumer encode its policy as a list of strings in loomcycle's config. That works for static surfaces. It does not work for dynamic discovery.

We needed to push the policy decision out of loomcycle, into a place the consumer service controls, while keeping the security boundary intact. That was the architectural ask.

Hooks were already the right shape

Loomcycle has had a tool-use hooks system since v0.7.x. The original use case was different: external services register HTTP webhooks against (agent, tool, phase) selectors, and loomcycle invokes them around the dispatcher. Pre-hooks can rewrite tool inputs or short-circuit with a synthetic denial. Post-hooks can rewrite the result — the canonical case being wrapping untrusted web content in trust-boundary markers so a downstream LLM treats payloads as data rather than instructions.

Rereading the hook design, it was structurally already almost what we needed. A Pre-hook already runs at the right point in the pipeline (after the agent emits a tool_use, before the dispatcher executes it). It already has access to the agent context (user_id, agent_id, tool_call). It already returns a structured decision: rewrite the input, deny with a synthetic result, or let it through.

We extended the Pre-hook response shape with one more field:

{
  "input": {...},                            // existing: rewrite tool input
  "deny": {"is_error": true, "text": "..."}, // existing: short-circuit
  "allow_hosts": ["acme.com", ".trusted-cdn.com"]  // NEW (v0.8.17)
}

When a Pre-hook returns allow_hosts, the dispatcher adds those hostnames to the effective allowlist for exactly one Execute() call. The grant is never cached server-side, never inherited by sub-agents, and only honoured if the hook's owner is in the operator's explicit opt-in list (hooks.permit_host_widen.owners). Without that operator opt-in, any hook's allow_hosts is silently dropped at the dispatcher and a counter increments — the runtime tells the operator that a hook tried to widen, but the operator never told the runtime it was allowed to.

Why this preserves the security boundary

Three properties of the design that mattered to me:

  1. The operator yaml is still the floor. The runtime's default-deny posture stays the architectural floor. Hooks can extend it per-call; they can never bypass it. The dial-time private-IP block stays in place regardless of what a hook approves. localhost is still unreachable unless the operator separately opted in via HTTPPrivateHostAllowlist.
  2. The grant scope is one tool call. Not one run, not one session, not one user — one Execute(). A hook approves a hostname for the WebFetch the agent is asking about right now. The next WebFetch — if the model proposes a different URL — triggers a fresh hook call. The hook decides each time. No cached state that ages out of validity.
  3. Sub-agents do not inherit the widening. The v0.4.0 sub-agent inheritance carries the parent's caller-side host policy into children, but per-call widening evaporates the moment the parent's Execute() returns. A sub-agent that needs the same widening triggers its own hook call with its own context. The composition story stays clean: each call is its own policy decision.

The hazard worth flagging

One thing I spent more time thinking about than I expected: a naive Pre-hook implementation can let the model widen its own allowlist.

The Pre-hook's input includes tool_call.input.url — the URL the model is proposing to fetch. If the hook just echoes that hostname back as allow_hosts, it has created a feature that approves whatever the model asks for. The model writes the policy. That's a confused-deputy pattern in textbook form.

The fix is in how the hook is implemented, not in the runtime — but the runtime can help operators detect the pattern after the fact. Every successful widening emits a typed EventHostWidened event with the requested URL's host and the allow_hosts the hook granted. If those are always identical for one hook owner, the hook is probably echoing model input without independent validation, and the operator should fix the hook. The dispatcher also exposes counters: HostWidenPermitted and HostWidenDenied. Both visible via the /v1/_metrics/* API the v0.8.11 process-resource sampler already lit up.

The hook the consumer service writes should validate the URL independently — against the user's per-account preferences, a per-tenant allowlist, a domain-reputation service, the business rules. Never trust the URL the model is asking about as authority for whether the URL should be approved. The runtime's audit surface is designed assuming the consumer might write a buggy hook; the operator can catch the bug from the metrics even if the hook never tells them directly.

The architectural lesson

When a substrate hits a "we don't have enough context to make this decision" wall, the right move is usually to push the decision out — to the consumer service that does have the context — and design the interface so the consumer's mistake can't compromise the substrate's invariants. The runtime keeps the mechanism (per-call grant, scope rules, audit surface). The consumer keeps the policy logic (which URLs are OK for which users in which contexts). The boundary between them is the typed Pre-hook response.

This is the same shape we used for per-run bearer auth in v0.8.14 (see the MCP-auth post — same theme: the runtime owns substitution, the consumer owns identity resolution). Each time we've hit a version of "loomcycle doesn't know enough to make this call," the answer has been to factor the decision out to the consumer and tighten the interface so the consumer can't accidentally compromise the security boundary. It's the recurring architectural shape of the project, and I expect we'll hit it again.

The job-search pipeline ran clean by Sunday evening. Brave Search returned URLs; the agent proposed one; the dispatcher called the hook; jobs-search-web validated against the user's preferences and a small per-tenant reputation cache; the hook returned allow_hosts: ["found-domain.example"]; the WebFetch went through. The agent saw a successful fetch, not a tool error, and the conversation continued. Production deploy went out Monday morning.