fix(mode): select the backend explicitly and refuse non-loopback egress under test - #43
Conversation
…ss under test
The presence of HASNA_KNOWLEDGE_API_URL + HASNA_KNOWLEDGE_API_KEY selected the
cloud backend. Both are exported in a login shell on this machine and the tmux
server carries them, so every pane inherited them: `bun test` reported 99
failures instead of 1 (64 of them the same "cloud API flip is active" refusal)
while the rest of the suite operated against the live store believing it was
isolated. The symptom named neither the cause nor the store, and every agent
working the repo had to rediscover it and prefix runs with `env -u`.
Two layers were inferring cloud from presence, and both are closed:
- src/cloud-store.ts withInferredCloudMode(), deleted; and
- resolveClientTransport in @hasna/contracts, which independently maps
"url and key are both set" to cloud. Callers now hand it a mode-PINNED env,
in both directions, so it can no longer pick a backend behind this package.
src/knowledge-mode.ts is the single mode resolver: the first mode key carrying a
value wins and returns without reading the pointers, so KNOWLEDGE_MODE=local is
authoritative on a machine that exports a URL and a key; with no mode key the
answer is local. Key lists come from @hasna/contracts rather than being restated,
so a key we ignore cannot still flip the transport.
src/net-guard.ts is the primary control, at the egress rather than on the
environment. Clearing the selectors cannot be primary: a test file assigning them
at module scope runs after any one-shot clear, one `bun test` process shares one
preload so a single file's leak reaches every later file, and a bunfig.toml
preload resolves from the cwd. Each produced a green run with live writes in the
sibling mementos fix. Loopback stays allowed so hermetic transport tests remain
real rather than stubbed.
`knowledge mode` reports the resolved backend, what selected it, pointers present
but ignored, and whether the guard is armed — from the environment alone, with no
store open, no config read and no request, and by env var NAME never value.
BREAKING for the fleet flip: a flip writing only the two pointer vars now leaves
the CLI local. It must also write HASNA_KNOWLEDGE_STORAGE_MODE=cloud.
Evidence, ambient env deliberately left as-is:
- bun test: 306 pass / 2 skip / 1 fail across 309 tests in 42 files (was 99
failures under the same env). The one red is the pre-existing
tests/cli.test.ts:3044 context-pack case, unrelated.
- strace against the real configured endpoint, explicit cloud mode,
NODE_ENV=test: 0 connect() syscalls, 0 AF_INET/AF_INET6 sockets, 0 connects
to :443. Positive control with the guard disarmed: 4 connects to :443, so the
measurement can detect what it claims to.
- `knowledge mode` x6 in an isolated HOME: byte-identical file trees before and
after.
Refs: todos f22a263f
… state its scope Self-review found the guard's own header claiming it covered "every knowledge-owned outbound HTTP request" while src/source-ingest.ts readWebText called global fetch directly. Under NODE_ENV=test a `web://` or `https://` source ref would still have reached the network. Nothing in the suite ingests a web ref, which is exactly why it was easy to leave open — the cloud transport was the visible hole and this one was not. readWebText now uses guardedFetch, with a test that a non-loopback web source-ref ingest is refused and that the refusal does not name the host. The header now states the scope precisely instead of overclaiming: covered are the cloud item transport and web source ingestion; NOT covered are third-party SDK transports with their own HTTP stacks (@aws-sdk/client-s3, the `ai` provider clients), which are credential-gated and driven with --fake in tests. A guard people believe covers more than it does is worse than a narrow one. bun test, ambient selectors left as-is: 307 pass / 2 skip / 1 fail across 310 tests in 42 files. The one red is the pre-existing tests/cli.test.ts:3044 context-pack case. Refs: todos f22a263f
|
Second commit The guard's header claimed it covered "every knowledge-owned outbound HTTP request" while
Updated numbers, ambient selectors still left exactly as they are: |
…at still describe presence-selection Adversarial review of #43. Three findings, all fixed here. 1. THE LOOPBACK ALLOWANCE WAS A SPRINGBOARD. guardedFetch checked only the first target and then handed the request to `fetch`, which follows a 3xx internally — and an internal hop never comes back through a `fetchImpl`. A hermetic 127.0.0.1 server answering 302 with an off-box `Location` therefore left the machine through a request the guard had already approved. Measured with strace before the fix: 4 connect() calls to :443, 9 non-loopback INET connects and a real HTTP 200 from a public host. While armed, the guard now follows redirects itself and asserts each hop, refusing an off-box hop with the same host-withholding error as a first-hop refusal (bounded to 5 hops, with `fetch`'s own 301/302/303 method downgrade). Same probe after: 0 connects. Unarmed, this is plain `fetch` with plain `fetch` redirect handling, so the production path is untouched. Two tests added; the off-box-hop one is red without the fix. 2. THE MESSAGE AN OPERATOR ACTUALLY HITS STILL DESCRIBED THE DELETED RULE. assertLocalCatalogMode said the refusal was because "the cloud API flip is active (HASNA_KNOWLEDGE_API_URL + HASNA_KNOWLEDGE_API_KEY set)" and told them to "unset the API env". After this PR that advice is wrong in both halves: the pointers are not why the CLI is in cloud mode, and unsetting them does not restore local — with the mode var still set it produces a misconfiguration error instead. It now names the mode var (sourced from KNOWLEDGE_MODE_ENV_KEYS rather than restated) and points at `knowledge mode`. Same correction to the header comment there and to the item-Store comment in cli.ts. 3. CHANGELOG suite count was stale: it claimed 306 / 2 / 1 across 309, measured at a4c1460 and not updated when 691cb7c added a test. Now states the count at this tip, and separates it from the CI number it is being compared against. Suite, ambient selector vars left exactly as they are (no `env -u`), which is the condition the PR exists to fix: 309 pass / 2 skip / 1 fail across 312 tests in 42 files. The one red is the pre-existing `context pack and proposal context commands` case, red on main at 95c853d too.
Adversarial review — APPROVE WITH FIXES (fixes pushed as
|
| condition | connect() total | connects to :443 | AF_INET/INET6 sockets | non-loopback INET connects | outcome |
|---|---|---|---|---|---|
| armed, real endpoint, explicit cloud | 0 | 0 | 0 | 0 | KnowledgeNetworkGuardError |
| positive control: same command disarmed, public endpoint | 13 | 4 | 7 | 9 | real HasnaHttpError |
actual suite condition: pointers set, no mode var, NODE_ENV=test |
0 | 0 | 0 | 0 | resolved local, no transport built |
The control reproduces the reported 4/9/7 to the digit, so the zeros are the guard working and not my filter being wrong.
4. FINDING (fixed in 43a61dc): the loopback allowance was a springboard
The brief asked whether the loopback allowance can be abused to reach a non-loopback address. It could. guardedFetch asserted only the first target and then handed the request to fetch, which follows a 3xx internally — and an internal hop never comes back through a fetchImpl. A hermetic 127.0.0.1 server answering 302 with an off-box Location therefore left the machine through a request the guard had already approved.
Measured before the fix, strace on a loopback server redirecting to a public host: 4 connects to :443, 9 non-loopback INET connects, HTTP 200 returned from off-box. The module header's claim that a non-loopback target "is refused before a socket is opened" was not true for redirect targets.
Fixed: while armed, the guard follows redirects itself and asserts every hop, refusing an off-box hop with the same host-withholding error (bounded to 5 hops, with fetch's own 301/302/303 method downgrade). Unarmed it is plain fetch with plain fetch redirect handling, so the production path is untouched. Two tests added; the off-box-hop one is red without the fix (verified red first), and the same strace probe now makes 0 connects.
Also checked, and not a defect: *.localhost is allowed by name rather than by address, so a resolver mapping x.localhost to a routable address would slip through. On this machine getent hosts evil.localhost returns ::1. Left as-is — address-based checking would mean the guard doing its own DNS.
5. FINDING (fixed): the message an operator actually hits still described the deleted rule
src/knowledge-db.ts assertLocalCatalogMode still said the refusal was because "the cloud API flip is active (HASNA_KNOWLEDGE_API_URL + HASNA_KNOWLEDGE_API_KEY set)" and instructed "Unset the API env". After this PR that is wrong in both halves — the pointers are not why the process is in cloud mode, and unsetting them does not restore local; with the mode var still set it produces a misconfiguration error instead. This is the loudest user-facing message in the repo and it was left telling operators to edit the wrong variable, which is precisely the confusion the PR set out to end. The README was updated thoroughly; this message and the item-Store comment at src/cli.ts:936 were missed. Both corrected, with the mode key sourced from KNOWLEDGE_MODE_ENV_KEYS rather than restated. No test asserted on the old text.
6. FINDING (fixed): stale count in the CHANGELOG
The CHANGELOG claimed 306 / 2 / 1 across 309 — the a4c1460 measurement, not updated when 691cb7c added a test. Corrected to the count at this tip and separated from the CI figure it is compared against.
7. Plants re-run — both confirm, one is stronger than reported
Both planted after committing, both reverted, tree clean at the same HEAD each time.
- Plant A (presence-inference restored in the resolver): 78 red. Exactly 6 are the mode contract — the two
REGRESSION:cases, the invertedstays local when ONLY API url+key are set,resolveItemStore returns the LOCAL transport under a pointer-only env,isKnowledgeApiMode and resolveKnowledgeCloudStore agree, and the mode report. That 6 matches the reported figure. The other 72 are the ambient-env artefact flood the PR exists to remove — 66 carrying theopening the local knowledge.dbrefusal, i.e. the same signature as the original 64. Re-planting the defect reproduces the flood; that is the strongest single piece of evidence in this PR and it was under-reported. - Plant B (
isNetworkGuardActiveforcedfalse): 10 red, of which 9 are plant-caused (the tenth is the pre-existing context-pack case) — reported as 7. Both transport-boundary egress cases are among them, plus the source-ingest case and, usefully, thethis very suite runs with the guard armedtripwire.
Two count corrections to the report: there are two REGRESSION:-labelled cases, not three (tests/knowledge-mode.test.ts:59 and :74); and Plant B is 9, not 7. Both errors are in the conservative direction.
8. CI — the traps are real, and the report understated the coverage
gh pr checks shows 7 failed/pending; the job conclusions at 691cb7c are 2 failure + 5 cancelled (fail-fast). Confirmed.
Reading the run log rather than the summary: four jobs reached and completed the test step, not three — test (ubuntu, bun), test (macos, bun), test-matrix (ubuntu, bun), test-matrix (macos, bun) — all four 307 pass / 1 fail / 310 tests across 42 files, with the same sole failure. test (ubuntu, bun) finished its suite before being cancelled at a later step, which is why it was missed.
Verify generated artifacts appears 0 times in the full run log, not just the failed log — HC-00201 confirmed. Run locally instead: bun run verify:generated → rc=0, 6 bundles rebuild byte-identically, both at 691cb7c and again at 43a61dc after rebuilding. Still aarch64, so the x64 caveat on the byte gate stands unchanged. Windows remains cancelled, therefore unverified — and so do both node jobs.
9. auth whoami — a real defect, and it belongs in its own task
Confirmed at src/auth.ts:101-118: authenticated: Boolean(key.apiKey). Authentication is reported from the presence of a key, with no session verified — the same presence-means-truth shape as the defect this PR fixes, one layer up. The finding is correct and the childEnv() remedy here is the right scope: it stops the suite measuring the developer's shell, which is a test-isolation fix.
Ruling: out of scope for this PR, needs its own task. Making whoami truthful means either verifying the session (a network call, which the guard would then refuse under test — a design question in itself) or renaming the field and deprecating it. api_key_present already carries the honest signal, so authenticated is a misleading duplicate rather than a missing capability. That is a contract change to a shipped command with its own consumers, and bundling it here would put an unrelated behavioural change behind a mode-selection fix.
10. Two deliberate omissions — both correct calls
- Not reaching the live store, and not reproducing the defect the obvious way. Reproducing through the pure resolver is sound and I re-ran it: at
691cb7cunder ambient pointers,resolveKnowledgeCloudStore→null,isKnowledgeApiMode()→false,resolveItemStore→LocalItemStore, with and withoutNODE_ENV=test. Plant A then re-creates the failure flood from the same environment without any live traffic. Refusing to re-run the pre-fix suite under ambient env was right: not all 99 failures were local throws, and the count was already recorded elsewhere. The.invalidreserved TLD for every fake pointer is the correct choice. - The
@hasnaCLI presence-selection audit is not done and the task stays open. Agreed — that is a separate sweep with its own control requirements, not a condition of landing this fix.
11. The breaking change stands — I did not soften it
No compatibility fallback added. A fleet flip writing only the two pointer vars now leaves the CLI local, and it must also write HASNA_KNOWLEDGE_STORAGE_MODE=cloud; that writer lives in @hasna/machines, not here. The [BREAKING] notice is real — I read #announcements 603451 back by id rather than through a digest. conversations blockers reports no blocking message.
Merging is safe ahead of the @hasna/machines change because merging to main flips no machine — the behaviour only reaches a box when the package is published and installed. This machine is a live example: it carries the pointers with no mode var, so its installed CLI is on cloud today and would become local on upgrade. Publish is the gate, not merge. I have not published and would not without the machines-side change landing first.
Unverified, stated plainly
- Windows and both node CI jobs: cancelled by fail-fast, so unverified. No path handling is involved in the guard or the resolver, and the node jobs run
bun testregardless, so the residual risk is low — but it is not measured. - The byte gate on x64: still only checked on aarch64.
- The original "99 failures, 64 sharing one error": not re-measured, by design — it would require running the pre-fix suite against the live store. Plant A's 78 red / 66 same-signature is the closest independent analogue.
- The
@hasna/machinesflip writer: not inspected. The localopen-machinescheckout on this box has no working tree.
What
HASNA_KNOWLEDGE_API_URL+HASNA_KNOWLEDGE_API_KEYselected the cloud backend by mere presence. Both are exported in a login shell on the machine this was found on, and the tmux server carries them, so every pane inherited them:bun testreported 99 failures instead of 1 — 64 of them the identical "cloud API flip is active" refusal — while the rest of the suite operated against the live store believing it was isolated. The symptom named neither the cause nor the store, so each agent working this repo rediscovered it independently and prefixed runs withenv -u.Third confirmed instance of one defect shape (todos
f22a263f); themementosinstance is closed (7c9ea749, PR #17), andemailsis being handled separately by other agents. This PR is theknowledgehalf only.Three changes
1. Request-boundary guard —
src/net-guard.ts(the primary control). WhileNODE_ENV=test, an outbound request whose target is not loopback is refused before a socket is opened. Refusals never name the target host, matching the existing repo precedent of refusing cloud config "without leaking the URL".Environment clearing is not the primary control and could not be. The
mementosfix measured all three escape routes: a test file assigning the selectors at module scope runs after any one-shot clear (green run, writes over HTTP, rc=0);bun testruns many files in one process with one preload, so a single file's leak reaches every later file; and abunfig.tomlpreload resolves from the cwd and is simply absent when the suite is invoked from a subdirectory. At the egress it no longer matters which layer decided to make the request or when the variable was set. This repo has nobunfig.tomlat all, so a preload was never even available here.Loopback stays allowed on purpose — tests that stand up a
Bun.serveon 127.0.0.1 and drive the real transport are the good case, and refusing them would push the suite toward stubbing the transport, which is how an egress bug hides. There is no opt-out: every in-repo script that legitimately talks to a real endpoint (scripts/smoke-*.mjs) runs outsidebun test.2. Explicit mode selection —
src/knowledge-mode.ts. Modelled onemails'resolveEmailsModeSelection: the first mode key carrying a value wins and returns, without reading the pointer keys, soKNOWLEDGE_MODE=localis authoritative on a machine that exports a URL and a key. With no mode key the answer islocal.Two layers were inferring cloud from presence, and both had to be closed:
src/cloud-store.tswithInferredCloudMode()— deleted.resolveClientTransportin@hasna/contracts(dist/client/storage.js:6206-6209,else if (urlHit && keyHit) { mode = "cloud" }) — still infers independently. Deleting only the first would have changed nothing. Callers now hand it a mode-pinned env viapinnedTransportEnv(), in both directions, so its inference cannot fire and the two layers cannot disagree about which store is authoritative.Env key lists come from
clientTransportEnvKeys()in@hasna/contractsrather than being restated locally (the old code restated them with a comment admitting it was a mirror) — a key we ignored could otherwise still flip the transport underneath us. Mode values normalize through the vendoredstorage-kitnormalizeStorageMode, soself_hostedkeeps working as a deprecated spelling ofcloud.3.
knowledge mode— a side-effect-free reporter. Prints the resolved backend, the env var that selected it, pointer vars present-but-ignored, and whether the guard is armed. Env var names only, never values. It reads the environment alone: no store opened, no config read, no request, so it answers on a machine with no config and no network.Specifically checked for the trap the
mementosequivalent hit — there, the mode command created a 720 KB database through a globalpreActionhook callinggetDatabase()before every command.knowledgehas nopreActionhook, butrun()did construct the service before command dispatch, so the handler is placed above that, ahead of every store, config and db touch.Breaking, and who has to act
A fleet flip that writes only the two pointer vars now leaves the CLI local. It must also write
HASNA_KNOWLEDGE_STORAGE_MODE=cloud. The flip writer is not in this repo (it lives in@hasna/machines/fleet tooling), so this needs a coordinated update before any machine is relied on to be in cloud mode.tests/cloud-store.test.ts's "fleet-flip writes no STORAGE_MODE" case is inverted in this PR, deliberately and with the reasoning recorded at the test.Evidence
All test numbers below are with the ambient selector variables left exactly as they are — no
env -u— because that is the condition that was broken.bun testThe single remaining red is the pre-existing
context pack and proposal context commands return bounded agent JSON(tests/cli.test.ts:3044), which also fails onmainat95c853d. Baseline onmainwith the vars neutralised: 276 pass / 2 skip / 1 fail across 279 tests in 40 files. No timeout-shaped failure names appeared, so theb87fd095hardcoded-timeout artefact did not fire at current load. (FORCE_COLOR=3is set here:grep -c '(fail)'returned 0 on runs that really had failures, while✗returned the true count — counts above come from the✗marker and the summary line.)Socket count,
strace -f -e trace=socket,connect, against the real configured endpoint under explicit cloud mode +NODE_ENV=test:Positive control — the identical command with the guard disarmed, pointed at a public harmless endpoint, so the measurement is shown able to detect what it claims: 4 connects to :443, 9 non-loopback INET connects, 7 INET sockets, and a real HTTP 404 came back. Without that control, "0 connects" would only have proven the strace filter was wrong.
The actual test-suite condition (
NODE_ENV=test, pointers present, no mode var, nothing else changed): 0 connects, 0 sockets, andlistreturned rows from the on-box store.Reporter side effects: six
knowledge modeinvocations in an isolatedHOMEand cwd produced byte-identical file trees before and after (excluding bun's own transpile cache, which is the runtime's, not this CLI's).Tests proven red against the planted old behaviour, run after committing (so
git checkout -- <file>restores the fix rather than deleting it):REGRESSION:cases plusresolveItemStore returns the LOCAL transport under a pointer-only env, theisKnowledgeApiMode/resolveKnowledgeCloudStoreagreement case, and the report's pointer-name case.isNetworkGuardActiveforced tofalse: 7 red, including both transport-boundary egress cases.bun run verify:generated→ rc=0, "6 generated bundles rebuild byte-identically and carry no stale generated code". Rebuilt with bun 1.3.14, matching theci.ymlpin, since the byte gate is bundler-version-sensitive. Note that CI cannot be relied on to catch a bundle mistake here:Verify generated artifactsis skipped in every job that defines it (HC-00201) because the precedingRun testsstep fails and it has noif: always().Also fixed, same family
tests/cli.test.tshanded the ambient pointer vars to every spawned CLI child.auth whoamireportsauthenticated: truefrom the mere presence of an API key (src/auth.ts), so the hosted-auth contract test was measuring the developer's shell instead of the temp auth dir it had just created — a second red under ambient env, independent of the mode fix (verified: it passes on this branch withenv -u, fails without). Spawned children now go through onechildEnv()helper that strips those keys, with the strip list imported from the contract rather than restated. Labelled in-code as defence in depth, explicitly not the control.Not done / assumed
@hasnaCLI sweep thatf22a263falso asks for. Out of scope here;emailsuntouched by request.@hasna/contracts' own presence-inference. It is neutralised forknowledgeby pinning, but every other consumer of that package still has it. Worth its own task.knowledge mode. The reporter is exported from the SDK (knowledgeModeReport) so the MCP surface can pick it up, but no tool was added.0.0.0.0and[::]are deliberately not treated as loopback — they are bind-any addresses, and accepting a wildcard as safe is how a guard gets talked past.Refs: todos
f22a263f. Not self-merged — ready for an adversarial review pass.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.