FORAGER — KNOWLEDGE · v0.4.2.14

Knowledge security

Source: modules/anthill/docs/KNOWLEDGE_SECURITY.md, a path from the root of the product's repository — versioned with the code, rendered here at every release.

The knowledge subsystem's threat model, the fences, and the things it deliberately cannot do.


1. What is being protected

Two properties, and everything below serves one of them:

  1. Knowledge does not cross a tenant boundary. Project A's knowledge never reaches a mission, agent, cache entry or console response belonging to project B.
  2. The colony cannot be talked into reading the filesystem. An agent acting on instructions it read in a document must not be able to point the ingestion pipeline at /etc, ~/.ssh, or anything outside its workspace.

The second is not hypothetical. A knowledge base is made of documents the colony was told to read, and a document is untrusted input. Ingestion is the one place where content the colony ingested can influence what the colony ingests next.


2. FORAGER authenticates its API, and ANTHILL must hold a credential

Corrected 2026-09-11 (W3-04). This section previously read "FORAGER has no authentication" and was false from FORAGER's 007_operator_access (2026-09-08) onward. It is stated plainly now for the same reason it was stated plainly then: the design is built around it rather than around a hope.

FORAGER authenticates every /api route — app.use('/api', authenticate(...)) at src/server/app.ts:44, mounted ahead of every router, with /health, /ready, /openapi.json and /session the only public paths. It still binds 127.0.0.1 and still expects to be the only tenant of its own machine, and its documentation still says to put a reverse proxy in front before exposing it. Consequences:

  • ANTHILL is the authenticated edge. Every /knowledge/* route requires read_knowledge or manage_knowledge through the existing RequireAuth gate. The console never talks to FORAGER directly.
  • Non-loopback endpoints are refused by default. knowledge_forager_allow_remote is false, and the check runs on the parsed address (so all of 127/8 and ::1 work) at configuration time and again per call — the second check catches a path that somehow carried an absolute URL past the first.
  • A required bearer token (knowledge_forager_token, ConfigSecurity.Secret). It is an fgr_ integration token the FORAGER operator mints, carrying scopes, an optional project limit, an expiry and immediate revocation; ANTHILL holds it and cannot mint it. Scope it minimally — read and ingest are what this integration uses — and limit it to the mapped projects, so a leaked colony credential cannot read a knowledge base the colony was never mapped to. It is never rendered in config.example.json, never returned by any route, and never published in a module-registration event — RegistrationPublishesNoSecret asserts it.
  • A refused credential is reported, not swallowed. ForagerKnowledgeProvider.ProbeAsync treats a 401 on /api/capabilities as an unusable service with a stated reason. Before W3-04 it collapsed that 401 into "declared nothing" and reported Reachable=true, Compatible=true while every query failed.

If you run FORAGER on another host, you are choosing to put an unauthenticated knowledge base on a network. The flag exists so that is a decision somebody made, not one a copied config made for them.


3. Tenant isolation

The scope is not a parameter

ITool.Run receives arguments and nothing else, so a knowledge tool learns its scope from an argument or from ambient state. An argument is not an option: tool arguments are chosen by a model, so a project_id parameter would make retrieval scope a model's choice — and Rule 12 would be enforced by the model's discretion, which is not enforcement.

Scope arrives through KnowledgeScopeContext, an AsyncLocal entered by the core at mission intake. It only narrows. The default is Unresolved, which retrieves nothing. NoKnowledgeTool_TakesAProjectArgument is the test that stops one being added by helpfulness.

The map is the boundary

Console callers name an ANTHILL project; knowledge_project_map translates it to a FORAGER project. That indirection is the containment: no request can reach a knowledge base the operator has not deliberately mapped, whatever it puts in the query string. An unmapped project resolves to Unresolved — a refusal, never a fallback to the default base.

The upstream is not project-scoped, so we check the response

Verified against a running FORAGER: GET /api/knowledge/{id} takes no project and happily returns another project's row with HTTP 200. So the provider compares project_id on the response and answers NotFound on a mismatch. The same check guards GET /api/jobs/{id}.

NotFound rather than a denial, deliberately: confirming that an id exists in a project the caller cannot see is itself a disclosure.

The cache is partitioned

Every entry is keyed on KnowledgeScope.CacheKey, and there is no read path that does not name a scope. A shared cache is the classic way an isolation rule gets broken by accident — one query warms an entry, a differently-scoped query hits it, and nothing in the call path looks wrong. Partitioning by construction is cheaper than auditing for it.

Tenant vs general

FORAGER separates customer-identifying knowledge (scope: tenant) from generalized operational learning (scope: general). ANTHILL honours it:

  • A global scope may read general material only.
  • Anything narrower may read both.
  • tenant material never enters shared or global colony memory, and never becomes a pheromone trail keyed on customer-identifying content.

The rule lives in one method, KnowledgeScope.Allows. It was written wrong first — as an ordering comparison on the enum, which silently admitted tenant material into a global scope because Tenant is 1 and General is 2 and the numbers mean nothing. Any ordering-based check on that enum is a bug waiting for a reader who assumes otherwise.


4. The ingestion fence

Two independent fences, and neither is trusted to be the only one.

Near side — ANTHILL

Every requested path is resolved through IWorkspacePathGuard before anything is sent:

  • ResolveSafePath canonicalizes component by component, following symlinks and junctions, and throws on an escape. Throwing rather than returning a bool is deliberate — a bool a caller forgets to check is a path traversal that succeeds.
  • Containment requires a separator boundary, so an allowed root of /srv/project does not admit /srv/project-secret.
  • IsBlockedPath refuses .git, data, virtualenvs and caches.

A path outside the workspace is 403 permission_denied and no request leaves the process.

Far side — FORAGER

FORAGER_ALLOWED_INPUT_ROOTS names the directories FORAGER may scan.

This integration fixed a real defect here. The check read if (roots.length && !roots.some(...)) — so an unset FORAGER_ALLOWED_INPUT_ROOTS, which is the shipped default, skipped containment entirely and any absolute path on the machine could be scanned through an unauthenticated API. Confirmed live: POST /sources/directory with /etc reached readdir and returned HTTP 500 from an EACCES rather than refusing.

Now: no configured roots means directory import is refused with 403. Containment is checked on the resolved real path, so a symlink planted inside an allowed root cannot escape it, and an unreadable subdirectory is skipped and reported rather than escaping as a 500. FORAGER_ALLOW_ANY_INPUT_ROOT=true restores the old behaviour for an operator who wants it, and logs a warning every time it is used. tests/integration/input-roots.test.ts pins all of it.

Uploads are unaffected — only server-side directory scanning is fenced.

The FORAGER the colony runs is given its roots by the colony (K2). With no roots it refuses every directory import, and allowing one from FORAGER's own interface takes its operator key — so the colony's import by path always failed on its own engine. knowledge_forager_input_roots (empty by default) is handed to that engine as FORAGER_ALLOWED_INPUT_ROOTS at its start, and the far fence is held inside the near one: every listed folder is resolved through the same WorkspacePathGuard as a requested path and refused when it leaves the workspace, sits under a blocked path part, or has a comma the variable cannot carry — at the console's write (POST /settings names the folder and the reason) and again at every start, because the workspace can move. A list that names folders replaces the colony's environment even when every folder in it was refused, so a refusal never quietly hands the engine the environment's roots instead. The workspace itself is deliberately not the default: FORAGER's deny-by-default stands until a person allows a folder, the workspace is where agents write, and agent_workspace_dir is editable, so a default derived from it would let one edit move two fences.


5. Mutation is gated

Retrieval is read-only. The one tool that writes anything writes a proposal:

agent proposes  ->  operator reviews  ->  FORAGER applies  ->  history retained

knowledge_review records intent into ANTHILL's approval lane and never calls FORAGER. It requires a rationale of substance — an unexplained proposal cannot be reviewed, so accepting one would produce approval requests an operator has no basis to decide. Its success message states that the knowledge base has not changed, so a model cannot proceed as though it had.

No role has knowledge_review in its contract by default. Granting it is a deliberate edit.

Provenance is never destroyed: FORAGER's review model supersedes rather than deletes, keeps merge history, and re-applies reviewer decisions on every subsequent run.

What a person decides, through the console (K2)

The console carries FORAGER's own curation: deciding a disagreement or reopening one, merging a possible duplicate or keeping the pair apart, undoing a merge, and marking a statement reviewed, rejected or unreviewed. Each is a person's act, never a model's and never the colony's own — no agent tool reaches these routes, the colony never applies FORAGER's suggestion or picks a side, and the signed-in user's name travels with each act as FORAGER's actor, so FORAGER's audit trail says who. Four fences, in order: manage_knowledge; the commercial gate (forager.knowledge.curate, Forager's paid capability — refused before FORAGER is asked anything); the project resolved at the operator's rank (W1-03 §8.3, project.knowledge.review: never a viewer's); and the record named by id read first and held to that project's knowledge base, because FORAGER's record routes are not project-scoped — another knowledge base's disagreement, candidate, person, statement or export is NotFound in the words a nonexistent id gets, and nothing is sent to change it. A decision that does not fit the record (archiving "duplicates" of two contradicting documents, a winner that is not a side) or would overwrite one already on record is refused before FORAGER is asked, where FORAGER's own API would accept it.

The colony's event log records every act — who, which knowledge base and project, which record, what was chosen — and never the person's note, which goes to FORAGER's audit trail and nowhere else.

An export leaves only as the archive it was recorded as (K2)

Downloading an export is project.export: the named project's operator or administrator (a GET would otherwise resolve as a read, which any role on the project or ownership of its organization passes, so the route asks for the operator's role itself). The colony takes the whole archive from FORAGER into a temporary file of its own — opened delete-on-close, so every way out removes it — hashing it as it arrives, and hands it on only when its SHA-256 is the digest the export recorded, the digest FORAGER sent beside the bytes agrees, and its size is the recorded size. Anything else is 502 upstream_mismatch and nothing leaves. The file name a browser saves is FORAGER's reduced to letters, digits, -, _ and .; the answer is no-store; and the digest is on the answer (X-Export-Sha256) and in the colony's record of the download, so what left can be proved later. Making and downloading an export is knowledge.export, never gated: a lapsed subscription still takes away what it has.


6. Role permissions

Knowledge tools are dispatchable only by roles whose contract lists them. Today that is researcher alone, and the restraint is intentional.

The obvious next candidates are verifier (to check a claim against evidence) and archivist (to avoid re-learning what is already known). Both were left out because verifier declares AllowedTools: S() and archivist declares AllowsModelCalls: false — neither handler can dispatch anything today, so granting them would declare reach that does not exist. That is the drift this repository warns about in AntExecution.cs: a role whose declared surface stops matching its real one, looking like a working feature while being inert.

web is excluded for a different reason: its lane is the public internet, and keeping what the world says separate from what the organization knows is the same distinction ToolEvidence draws between its retrieval and inspection lanes.


7. Logging

Structured, and deliberately thin.

Logged: request id, mission id, project scope, knowledge ids, source ids, duration, result count, failure reason, FORAGER's upstream request_id (the field that turns a colony-side failure into a findable line in FORAGER's log).

Never logged: the configured token, excerpt bodies, whole documents, or a malformed response payload. That last one is easy to miss — the natural instinct on a parse failure is to log what came back, and what came back can be confidential source text. The client logs the parser's message and not the body.


8. Model independence

No provider is named anywhere in the knowledge model. FORAGER runs deterministically with no LLM configured — verified: the live instance reports model_provider: "none (deterministic mode)" and produces entity resolution, conflict detection, evidence and provenance without one. Where semantic processing is wanted, FORAGER uses its own adapter interface and its output is schema-validated and quote-located before anything is persisted.

Customer knowledge does not become training data, shared colony memory, or a global pheromone trail by being retrieved. Scope is carried on the record, not inferred at the point of use.


9. Failure posture

Every failure is a typed refusal, never an invention.

Condition Result
Disabled Disabled — tools stay registered and refuse at call time
Unreachable / timeout / upstream 5xx typed, retryable, mission continues
Malformed response Malformed, not retryable, nothing partial persisted
Scope unresolvable refusal naming the config key — never a widened query
Path outside workspace AuthorizationFailure before any network call
Unauthorized upstream Unauthorized, not retryable without operator action

The unavailable text ends with an instruction, and it is load-bearing rather than decorative:

Do NOT substitute recalled, assumed, or generally-known facts for it, and do not present anything from this attempt as sourced.

A model told only that retrieval failed will commonly answer from training as though it had retrieved something. That is the failure this subsystem exists to prevent, arriving through the error path instead of the success path.


10. Not implemented, on purpose

  • Cross-project retrieval. Not expressible in KnowledgeScopeKind at all, so it cannot be reached by a bug, a misconfiguration, or a model that asks nicely. If it is ever wanted it needs its own permission and its own audit lane.
  • Agent-authored knowledge. Agents propose review actions; they do not create canonical knowledge.
  • Automatic promotion of learning into knowledge. Designed, gated, not built.
  • ANTHILL reading FORAGER's database. Not read-only, not for the console. HTTP only.