FishTea
Documentation

Access control

Short answer: on the call, after classification. Never on needle.

The classifier is a router. It decides what you probably meant. It must never decide what you are allowed to do, and FishTea already puts the gate in the right place: packages/core/src/engine.ts, where both paths converge.

Why needle cannot be the guard#

Three reasons, and any one of them is sufficient.

The chip path does not go through it. The client can post {shape, args} directly, which is what a next chip is, and what a forged chip would be. Nothing is classified. If the only thing keeping a stranger out of checkout were its absence from a tool list, a single curl would walk straight past it.

The text is the attacker's. Everything needle sees is what somebody typed. Prompt injection against a small tool-calling model is not exotic; it is the expected case. A router that can be talked into choosing a different tool is a router, not a guard, and treating it as one means an authorisation bug that arrives as a jailbreak.

The classifier can now be a plugin. Since the second plugin pass, an application can supply its own Classifier. That is somebody else's code returning shape names on the request path. It is safe precisely because nothing downstream trusts what it returns.

What tool filtering is actually for#

The engine still filters the tool list by persona before classifying, via allowedShapes(spec, persona), and that remains worth doing, for reasons that are not security:

The layers, as they stand today#

Every one of these is in the code now.

# layer where what it stops
1 persona derived from identity persona.ts, from the visitor's customer_id and the trail none
2 tool list filtered engine.ts before classify the model suggesting what you cannot have
3 shape gate engine.ts, !allowed.includes(shapeName) a classified or posted shape this persona may not ask
4 operator shapes refused same place, shapeName.startsWith("op.") the operator surface being reached from the visitor surface
5 argument validation zod, derived from the shape's declared input malformed or extra arguments
6 parameterised SQL $1..$n, never string-built SQL injection
7 row scoping the call's own SQL: WHERE … AND visitor_id = $2 reading somebody else's row with a guessed id
8 confirm-write the engine, always, before any write a write nobody agreed to

Layer 3 is the important one and it is one line. Both the classified path and the posted-shape path reach it, which is the whole design: there is one gate, and it is downstream of everything that can be influenced.

Layer 7 is the one a business has to get right itself, per call. The archetype does:

-- $1 orderId, $2 visitorId — scoped to the visitor; someone else's order id yields no row

Shape-level permission answers may you ask this kind of question. Only the SQL can answer is this row yours, and no amount of classification or gating substitutes for it.

The real problem is not authorisation. It is authentication.#

The permission model above is sound. What it rests on is not, and this is the finding worth acting on.

visitorOf in http.ts accepts the visitor id from the x-fishtea-visitor header or the cookie, and ensureVisitor hands back that visitor's customer_id, hence their persona, their cart, their orders. The visitor id is a bearer credential. Whoever presents one is that visitor.

Until this was written down, that credential was minted as Date.now() plus six characters of Math.random(). The time half is public. The random half is neither long enough nor unpredictable: Math.random() is a PRNG whose internal state can be recovered from a small number of its own outputs, and an attacker can produce outputs on demand simply by asking for new visitors.

Fixed: ids now take 96 bits from the platform CSPRNG. Existing ids keep working; only new ones change. packages/core/test/core.test.ts asserts the length, because the regression that matters is an id short enough to guess.

What is still open#

If you add roles#

The persona expression language reads identity and trail facts. Roles would be a column on the customer entity surfaced through Engine.identity(), then verifier: { when: "identity.roles has 'verifier'" }, which needs a has operator the language does not have yet (already noted under the verification archetype).

The thing to resist is putting roles anywhere the classifier can see them. A role is an input to allowedShapes, which runs before and after classification. It is not something a shape asks for and never something a model is told about.

The rule#

The classifier proposes. The gate disposes. The database has the last word.

Filtering what needle sees is worth doing and proves nothing. The shape gate is the boundary. Row ownership is the SQL's job and only the SQL's. And a write is confirmed regardless of how confidently anything upstream decided it was wanted.