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:
-
Static allowlist in operator YAML
(
LOOMCYCLE_HTTP_HOST_ALLOWLIST): every host the agent might ever need, declared at boot time. -
Per-run narrowing via the caller's
allowed_hostson 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:
-
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.
localhostis still unreachable unless the operator separately opted in viaHTTPPrivateHostAllowlist. -
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. -
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.