UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

316 lines • 14.8 kB
"use strict"; /** * hosting/ingressRecord — what the door decided, for the requests that never * became a run. * * await standingAgent({ * agent, sessions, host, * identity: { verify }, * admission: turnsPerHour({ limit: 60 }), * onIngressDecision: (record) => myAuditSink.write(record), * }); * * ── The gap this closes, named exactly ─────────────────────────────────────── * `auditExport()` is a record of RUNS. A 401 out of `identity.verify` and a 429 * out of `admission.decide` both happen before a run exists — no agent has been * built, no observer is attached, no typed event is emitted — so neither is in * the bundle. Two independent review rounds wrote that down as the same finding, * and the honest consequence is the sharp one: **an empty bundle is not evidence * that nobody was turned away.** It is evidence that nobody ran. * * This is the seam where the turned-away go. One record per request the composer * decided, handed to a sink you own, at the moment the reply reaches its * terminal. * * ── What it is NOT, said before anything else ──────────────────────────────── * **It does not join the audit hash chain.** Nothing here is hashed, chained, * sequenced against `auditExport()`'s records, or verifiable by * `verifyAuditBundle`. It is a STREAM you chain into your own sink. Saying * otherwise would make this fix the exact failure it exists to close — a * mechanism that looks like evidence and is not. If you need ingress decisions * inside a tamper-evident chain, write them into the same store your audit * bundle lands in and chain them there, where the sequence is yours to define. * * ── Secrets never travel ───────────────────────────────────────────────────── * A record carries the CLASS of what happened and the identifiers the caller * already knew. Never the bearer token, never a header, never a claim set, * never an error's message — the `sdkFailure` law, applied to the front door. * `errorName` and `errorCode` are this package's own vocabulary; a message may * carry an SDK's text, an operator's sentence or a store's detail, so no message * is copied. The one identity field is `userId`, and it is the id the token * PROVED — never one a request merely claimed. * * ── What the composer can decide, and what it never sees ───────────────────── * Every ingress exit of `standingAgent` funnels through one reply terminal, so * all of these land here — including the ones a consumer's own `verify` and * `decide` never observe: * * - a request with no `Authorization: Bearer …` at all (`verify` is never * called, so nothing you wrote could have logged it); * - a verifier that could not answer (503) as distinct from a bad credential; * - an admission refusal, and also an admission ALLOW — the census is what * makes an absence readable; * - a turn or a transcript naming somebody else's session (the 404 that is * deliberately indistinguishable from "no such session"); * - a session op at a door with no verifier, a store with no owner index, a * concurrent-run refusal, an artifact ref that did not resolve; * - and a request that was SERVED. * * The HOST's own decisions land here too, though no handler ever saw them: a * request its door guard turned away (a content type any page can send, a * foreign Origin, a Host it was not configured for — `'cross-site-refused'`) * and a session id over the bound (`'refused'`), at the request door and the * conversation door alike. They arrive through the host's `onRefusal`, which * the composer subscribes to whenever a sink is set, and they carry the same * classes and nothing more — door `'request'` or `'conversation'`, because a * forged request is refused before its body says which of the three doors it * wanted. * * What it does not carry, stated rather than implied: a body the TRANSPORT * refused as MALFORMED — unparseable JSON, a body over `maxBodyBytes`, or an * `op` this host's wire grammar does not speak (`InvalidWireOpError`, answered * 400 by `httpHost` inside its own request reader). That is a broken request * rather than a decision about a caller, and the composer does not claim it. * Your HTTP access log has it. * * Pattern: Observer at a composition boundary. Zero-cost when unset — with no * sink configured nothing is built, nothing is wrapped, and the reply object the * handler uses is the host's own, to the byte. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.beginIngress = exports.recordHostRefusal = void 0; const identityVerification_js_1 = require("./identityVerification.js"); const errors_js_1 = require("./errors.js"); const artifactWire_js_1 = require("./artifactWire.js"); const sessionWire_js_1 = require("./sessionWire.js"); /** * Classify one refusal into the record's vocabulary. * * The order is deliberate: the classes this package authored are matched by * `instanceof` first, so a door refusal never reads as a generic failure, and * anything else keeps only its class NAME. No message is read, from any error, * ever. */ function classify(err) { if (err instanceof errors_js_1.IdentityNotVerifiedError) { return { outcome: 'identity-refused', errorCode: err.code, errorName: err.name, identityFailure: err.failure, claimedUser: err.claimedUser, }; } if (err instanceof errors_js_1.VerifierUnavailableError) { return { outcome: 'verifier-unavailable', errorCode: err.code, errorName: err.name }; } if (err instanceof errors_js_1.AdmissionRefusedError) { return { outcome: 'admission-refused', errorCode: err.code, errorName: err.name }; } if (err instanceof errors_js_1.SessionNotFoundError) { return { outcome: 'session-refused', errorCode: err.code, errorName: err.name }; } if (err instanceof errors_js_1.UnsupportedMediaTypeError || err instanceof errors_js_1.OriginNotAllowedError || err instanceof errors_js_1.HostNotAllowedError) { return { outcome: 'cross-site-refused', errorCode: err.code, errorName: err.name }; } const code = err?.code; const name = err?.name; // A hosting refusal names itself with an `ERR_…` code. Anything else is a // failure rather than a decision — and either way only the class travels. const hasHostingCode = typeof code === 'string' && code.startsWith('ERR_'); return { outcome: hasHostingCode ? 'refused' : 'failed', ...(typeof code === 'string' && { errorCode: code }), ...(typeof name === 'string' && { errorName: name }), }; } /** Which op name a request named, in the spelling a body uses. */ function opOf(request) { if (request.session !== undefined) { return request.session.op === 'list' ? sessionWire_js_1.SESSION_LIST_OP : request.session.op === 'pending' ? sessionWire_js_1.SESSION_PENDING_OP : sessionWire_js_1.SESSION_TRANSCRIPT_OP; } if (request.artifact !== undefined) { return (0, artifactWire_js_1.artifactOpWireName)(request.artifact.op); } return undefined; } /** Which door a request knocked at — the same discriminants the handler uses. */ function doorOf(request) { if (request.session !== undefined) return 'session-op'; if (request.artifact !== undefined) return 'artifact'; return 'turn'; } /** * Which conversation this request named. A `session-transcript` names it inside * the op rather than beside it, and a record that missed that would be blind to * exactly the refusal the ownership rule produces. */ function sessionOf(request) { if (request.session !== undefined && (request.session.op === 'transcript' || request.session.op === 'pending')) { return request.session.sessionId; } return request.sessionId; } let warnedOnce = false; /** Report a throwing sink once. A broken record is a fault worth naming; a * fault reported on every request is a second outage. */ function warnSink(err) { if (warnedOnce) return; warnedOnce = true; // eslint-disable-next-line no-console console.warn(`[hosting] the onIngressDecision sink threw and the record was dropped. The request was ` + `unaffected — a broken log must not turn a served request into a failed one — but this ` + `door's ingress decisions are not being recorded. Reported once per process. Cause: ` + `${err?.name ?? 'unknown'}`); } /** * File the record for a request the HOST refused before the composer saw it — * what `standingAgent` hands to the host's `onRefusal` when a sink is set. * * Built from the refusal's class and the fact of a credential, exactly as a * composer refusal is: no message, no header, no session id (a session id the * bound refused is the one thing this record must never carry), no user — * nothing was verified, because nothing got that far. * * @internal — the option is public, this bookkeeping is not. */ function recordHostRefusal(refusal, sink) { const detail = classify(refusal.error); const record = { at: Date.now(), door: refusal.door, outcome: detail.outcome, ...(detail.errorCode !== undefined && { errorCode: detail.errorCode }), ...(detail.errorName !== undefined && { errorName: detail.errorName }), ...(detail.identityFailure !== undefined && { identityFailure: detail.identityFailure }), bearerPresent: refusal.bearerPresent, }; try { sink(record); } catch (sinkErr) { warnSink(sinkErr); } } exports.recordHostRefusal = recordHostRefusal; /** * Begin one request's ingress record. * * @internal — the option is public, this bookkeeping is not. */ function beginIngress(request, sink) { const door = doorOf(request); const op = opOf(request); const sessionId = sessionOf(request); // Presence, never the value: the whole point of the class vocabulary above is // that an operator can act without a single character of the credential. const bearerPresent = (0, identityVerification_js_1.bearerToken)(request.headers) !== undefined; let userId; let admission; let hookFailure; let filed = false; function file(outcome, err) { if (filed) return; filed = true; const detail = err === undefined ? undefined : classify(err); const record = { at: Date.now(), door, ...(op !== undefined && { op }), outcome: detail?.outcome ?? outcome, ...(detail?.errorCode !== undefined && { errorCode: detail.errorCode }), ...(detail?.errorName !== undefined && { errorName: detail.errorName }), ...(detail?.identityFailure !== undefined && { identityFailure: detail.identityFailure }), ...(detail?.claimedUser !== undefined && { claimedUser: detail.claimedUser }), ...(userId !== undefined && { userId }), ...(sessionId !== undefined && { sessionId }), bearerPresent, ...(admission !== undefined && { admission }), ...(hookFailure !== undefined && { turnArtifactsFailure: hookFailure }), }; try { sink(record); } catch (sinkErr) { warnSink(sinkErr); } } return { watch(reply) { // Every optional terminal is forwarded ONLY when the host has it: the // composer feature-detects `awaiting`/`artifact`/`sessions`, so a wrapper // that defined them unconditionally would tell it the host can describe a // pause it cannot, and the named refusal for that case would never fire. return { complete(output) { file('served'); reply.complete(output); }, fail(error) { file('failed', error); reply.fail(error); }, ...(reply.awaiting && { awaiting: (pending) => { file('served'); reply.awaiting?.(pending); }, }), ...(reply.artifact && { artifact: (result) => { file('served'); reply.artifact?.(result); }, }), ...(reply.sessions && { sessions: (result) => { file('served'); reply.sessions?.(result); }, }), // Not a terminal: a chunk is part of an answer still being written. ...(reply.emit && { emit: (chunk) => reply.emit?.(chunk) }), // Not a terminal either, and forwarded by presence for the same reason // the terminals are: the composer builds a binding only for a host // that asked, so defining it here unconditionally would build one per // turn that nobody holds. The host's promise is returned as-is — the // composer awaits it (bounded), and records a failure through // `turnArtifactsFailed` below. ...(reply.turnArtifacts && { turnArtifacts: (turn) => reply.turnArtifacts?.(turn), }), }; }, identified(verified) { if (verified !== undefined) userId = verified.userId; }, admitted(verdict) { admission = verdict; }, turnArtifactsFailed(failure) { // The first failure is the census entry; a later one of the same hand-over // (the bound firing after an operation failed, say) is on the stream. hookFailure ??= failure; }, settle() { // Only reachable if a request left the door without ending its reply, // which the composer never does. Recorded as a failure rather than // silently dropped: a census with holes is the thing this replaces. file('failed'); }, }; } exports.beginIngress = beginIngress; //# sourceMappingURL=ingressRecord.js.map