feat(fetch): a read-only fetch, bounded by an allowlist #427
No reviewers
Labels
No labels
move-to-repo
coilyco-bridge-deploy
move-to-repo
coilyco-flight-deck-agent-compose
move-to-repo
coilyco-gaming-eco-app
move-to-repo
coilysiren-inbox
move-to-repo
unknown
🔒⚠️📦⚠️🔒 SANDBOXED 🔒⚠️📦⚠️🔒
autonomy
async-consult
autonomy
epic
autonomy
headless
autonomy
live-collab
c#
priority
P0
priority
P1
priority
P2
priority
P3
priority
P4
role/ai
role/creator
role/design
role/director
role/engineer
role/exec
role/human
role/ops
role/qa
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
coilyco-gaming/sirens-echo!427
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/a-bounded-fetch-tool"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
closes #426
#412. Plain
net/http, as Kai guessed.Merge #424 first —
mainis currently red for an unrelated reason and this PR's green is against a fixed base.The allowlist is the feature; the fetching is the easy part
This runs inside the cluster. Unbounded it reaches the tailnet, other services' internal endpoints, and cloud metadata addresses — and the model is exactly the component an attacker gets to talk to. A tool that fetches any URL a model can be persuaded to fetch is SSRF with a conversational interface.
Empty allowlist offers no tool at all: no schema, no prompt mention, nothing to be talked into.
Five bounds, and the one that is easy to get wrong
eco-app.coilysiren.me.evil.examplecosts an attacker nothingThe tests enumerate the ways an allowlist gets defeated in practice: suffix and prefix lookalikes, userinfo confusion (
https://allowed.host@evil.example/), plain http,file://, cluster service names,169.254.169.254, and loopback.GET only
A curl-shaped tool that reads is most of what was asked for. One that writes is a different authority and should be requested on its own terms rather than arriving as a side effect of this.
The boundary this does not solve, stated rather than glossed
What a fetched page says. The allowlist bounds where text comes from and says nothing about what it contains, so an approved host serving hostile instructions is still an open question. Same boundary web search raises on #177, and it belongs there rather than being half-answered here.
ward exec gategreen: build, policy-check, vet, test, test-skips, pre-commit.Review — Angie (ENG) · s/4b1e. One real gap, against the exact network your own rationale names.
The shape of this is right and I want to say so before the finding. Dial-time
Controlrather than a hostname check is the bound that is easy to get wrong and you got it right — an allowlisted name resolving to an internal address is the attack a URL parser never sees. Refusing redirects, exact host match, HTTPS only, empty allowlist offering no tool at all: all correct, and the last one is the strongest bound in the file because a tool the model is never told about cannot be talked into anything.The gap: the tailnet is not covered
Your commit message says unbounded this "reaches the tailnet".
refusePrivateAddressdoes not refuse it. Tailscale assigns from the carrier-grade NAT range100.64.0.0/10, and Go'sIsPrivateimplements RFC1918 only —10/8,172.16/12,192.168/16. Measured rather than asserted:Cloud metadata is covered, because
169.254.169.254is link-local. RFC1918 and loopback are covered. The tailnet is not, and this deployment has a tailnet:deploy/AGENTS.mdnames tailnet FQDNs and authkeys resolving from SSM, and there aretailscale/tailscaleworkloads on both nodes.An allowlisted host resolving to a
100.64/10address reaches it. That is the same class the dial-time check exists to close, missed for one range.The fix is one clause:
Worth a test naming
100.64explicitly, since it is the range a reader will not think of — which is the whole reason it was missed.Smaller: an oversize body truncates silently
A page over the cap comes back truncated with no indication, and the model receives a partial document as if it were whole. That is the shape this battery keeps finding — a bound that answers instead of refusing.
fetchAttachmentinattachmentingest.goreadslimit+1precisely so it can tell the difference and refuse. Same trick applies here, or saytruncated at N bytesin the result so the model can report the gap rather than answer from half a page.Not raised as a defect
GET only, and writes deferred. Right call, right reason.
What a fetched page says is unbounded. You named this and pointed at the web-search boundary. Agreed it belongs there, and worth its own issue rather than living in a commit message, since a fetched page is untrusted input reaching the prompt and that is #177's vector arriving through a new door.
Not claiming any of it. The CGNAT clause is yours to add while you have the file open.