import { IRTool } from '@archstone/compiler'; import { AuditSink, RateLimitCounter } from '@archstone/emitter-support'; interface InvokeResult { ok: boolean; status: number; data?: unknown; error?: string; } type FetchLike = typeof globalThis.fetch; /** * A fact about ONE invocation — never about the compiled artifact (ADD-32 D-1). The IR is * reused across many invocations by many different end users; a caller credential lives only * in invoke-context types (here, and threaded through `agent`'s `ExecuteOptions` / `runtime`'s * `serveStdio`/`createHttpHandler`), never in `IRTool`/`IR`. */ interface CallerContext { /** The end user's bearer token, supplied by a host that has already authenticated them * (Archstone does not host an OIDC broker). Undefined means "no caller supplied" — the * fail-closed gate below distinguishes that from an explicit `""`, which is treated as * present (ADD-32 §3/R-6, mirrors this file's existing env-var precedent). */ accessToken?: string; /** Reserved for `tenant-scoped` policy enforcement — NOT enforced by ADD-32 (D-4/R-5), and * deliberately still not enforced by #43 (BR-39: tenant scoping is a third axis, distinct * from credential-presence and identity, and is refused rather than absorbed as a side * effect of a policy increment). The shape carries this now so a future increment doesn't * need a second breaking change to `CallerContext`; nothing reads this field yet. */ tenantId?: string; /** * WHO this invocation acts on behalf of — the caller's identity, as opposed to `accessToken`, * which is a credential to act WITH (ADD-42 D-2/D-3; two fields, never merged: #44's audit * record must always carry the principal and must never carry the credential). * * **Asserted by the host, never verified by Archstone.** Archstone does not parse, decode, * split, normalize, or validate this value at any entry point, ever (ADD-42 D-1) — it is an * opaque, deployer-chosen string, matched byte-for-byte against a policy's `allow`/`deny` * entries and nothing more. Its trustworthiness is exactly the trustworthiness of the host's * own authentication and no more: if the host reads a JWT's `sub` without verifying the * signature against the issuer's JWKS, Archstone will faithfully authorize on an * attacker-controlled string and #44 will faithfully record it. Archstone cannot detect this. * * Absent means ANONYMOUS, not denied (ADD-42 D-4) — there is no sentinel value. An absent * principal simply satisfies no `allow` entry, so a capability that must not be invoked * anonymously says so by declaring one. Supplying a principal does NOT satisfy * `policies: [authenticated]`, which still requires `accessToken` (D-7). * * Usable in `${caller.principal}` interpolation with zero new mechanism, via the existing * `resolveCaller()` pass below (ADD-42 D-10) — the common enterprise shape where a backend is * reached with a service account that accepts a trusted identity header. */ principal?: string; } interface InvokeOptions { env?: Record; fetchImpl?: FetchLike; /** ADD-32: the end user this specific invocation acts on behalf of. `invokeRest` uses it for * `${caller.NAME}` placeholder substitution ONLY — it makes no authorization decision from * it, so an absent caller changes nothing here beyond leaving those placeholders unresolved * (which fails closed on its own, as a missing value rather than a refusal). * * Whether a caller is REQUIRED — `policies: [authenticated]` — is decided upstream at the * one evaluation point (#43), not in this file. Do not look for that gate here. */ caller?: CallerContext; /** * Security-hardening follow-up to ADD-32: a **deployer-level policy**, static for the whole * process/deployment — set once at construction time, like `bearerToken` elsewhere in this * codebase (`runtime/src/http.ts`'s `CreateHttpHandlerOptions.bearerToken`), NOT per-request/ * per-invocation like `caller` above. Only relevant when a binding's `rest.baseUrl` contains a * `${caller.NAME}` placeholder (per-tenant routing) — see the guard in `invokeRest` for why * that specific case, unlike headers/query/body, needs an allowlist at all. * * Each entry is either an exact hostname (`"api.example.com"`) or a `"*."`-prefixed wildcard * matching any subdomain (`"*.core.example.com"` matches `tenant-a.core.example.com` but NOT * `core.example.com` itself — list that separately if it must also be allowed). * * Undefined/empty is the secure default: a baseUrl whose *original template* referenced * `${caller.…}` fails closed unless the resolved host explicitly matches an entry here. */ allowedHosts?: string[]; /** * Issue #39 / ADD-31: a fire-and-forget observation hook for the RAW, unmapped backend * response of a completed HTTP round-trip (any status, 2xx or non-2xx). It exists so a * developer whose bound capability's own backend happens to bill per call/token (most * concretely, a capability whose connector calls a paid LLM completions API) can inspect * whatever usage/cost/audit fields that backend's response happens to contain — data a * `response:` mapping would otherwise silently discard before either caller (`callTool`, * `executeCapability`) ever sees it. * * Fires exactly once, synchronously, immediately after the response body is parsed — * BEFORE any response-mapping/OK-DEGRADED-VIOLATION classification runs in the caller * (BR-1/BR-3), including on a contract VIOLATION, where the caller's own D-6 rule withholds * this same raw body from the MCP client (BR-5 — a deliberate divergence: this hook runs * inside the binding author's own trusted process, not on the MCP boundary). * * It MUST NOT fire when `invokeRest` returns before any HTTP round-trip completes — no REST * connector, missing env/caller placeholder(s), a caller-influenced-baseUrl allowlist * rejection, a missing required path parameter, or a `doFetch` exception/timeout (BR-4) — * none of those ever produced a response to observe. The same guarantee holds for a policy * refusal (#43), which short-circuits in the CALLER before `invokeRest` is entered at all, so * this function is never reached and the hook cannot fire. * * `capabilityId` is `tool.id` — the unsanitized CDL id, never any MCP-sanitized advertised * tool name (BR-8). `data` is the exact same value that ends up in `InvokeResult.data`: * parsed JSON, the raw text if unparseable, or `undefined` for an empty body. * * BR-16 / ADD-31 Architectural Challenge: Archstone will NEVER parse or normalize a * provider-specific usage/token/cost shape out of this body. Three real LLM APIs already * disagree on the field name for the same concept — OpenAI `usage.prompt_tokens`, Anthropic * `usage.input_tokens`, Gemini `usageMetadata.promptTokenCount` — and baking any one of them * into this hook would tie this repo's release cycle to a third party's API changes on its * own timeline. The hook exists specifically so Archstone never has to pick one: the binding * author already knows their own backend's shape (they wrote the connector for it) and can * extract whatever fields matter themselves from the raw body. * * Fire-and-forget by design (OQ-1): `invokeRest` never awaits it, and a returned Promise's * rejection is swallowed — a slow or hanging hook can never add latency to, or affect the * result of, the business call it merely observes (BR-6/BR-7). A throwing or rejecting hook * is logged as a single line to stderr (this codebase's existing "stdout is the MCP channel, * human output goes to stderr" convention — see `serveStdio`) and never rethrown into * `InvokeResult`/`ExecuteResult`/the MCP `CallResult`. * * Deliberately NOT exposed as a CLI flag on any command, ever (BR-13/OQ-3) — a callback * function cannot be expressed as a CLI argument. This is a programmatic-API-only surface, * reachable only by code that imports `@archstone/provider-rest`/`@archstone/agent`/ * `@archstone/runtime` directly and constructs its own options object — a deliberate, * structural boundary, not an oversight. */ onResponse?: (info: { capabilityId: string; status: number; data: unknown; durationMs: number; }) => void | Promise; /** * Issue #44 / ADD-44: the `Execution` audit sink — one record per invocation ATTEMPT. * * **`invokeRest` never reads, calls, or branches on this field.** It rides this bag so that a * deployer wires one options object (the same one they already pass `env`/`caller` in) and so * that every shipped pass-through — `ExecuteOptions`, `serveStdio`'s `invoke`, * `createHttpHandler`'s/`mcpHandler`'s `invoke` — forwards it with zero new plumbing. The * record is built and emitted by the two AUDITED CONSUMERS, `@archstone/runtime`'s `callTool` * and `@archstone/agent`'s `executeCapability` — the same two sites that call the policy * evaluator, so a path that skips the gate also skips the record instead of emitting one that * falsely implies a gate ran. Do not look for the emission here, and do not add one: * `verifyTool` forwards this identical bag into `invokeRest`, so a sink read in this file * would make "the contract prober emits nothing" unimplementable without a special case. * (This file now does the same for `authenticated`, whose gate #43 moved out of it.) * * See `AuditSink` in `@archstone/emitter-support` for the fire-and-forget contract and for * the statement that the trail is best-effort and lossy. */ auditSink?: AuditSink; /** * #44: correlation ids, **passed through to the audit record exactly as the host supplied * them** — never synthesized, defaulted, or derived, and never read by `invokeRest`. Absent * means the key is absent from the record. * * **Scope trap, and it differs from `caller`'s.** On `serveStdio` a value set here is * per-process and architecturally correct — one child process per conversation. On * `createHttpHandler`/`mcpHandler` the handler rebuilds this bag per request as * `{...invoke, caller: resolveCaller?.(request)}`: `caller` is overwritten and therefore * fails loudly, but these two **survive the spread** and silently stamp every concurrent * request with one session. There is no per-request correlation seam on the HTTP surface * today and this increment does not invent one. */ sessionId?: string; workflowId?: string; /** * #48: set by `@archstone/runtime`'s `createHttpHandler` for ONE request, when that * request's `resolveCaller` (ADD-32) threw instead of returning. `invokeRest` never reads * this — like `auditSink`/`sessionId` above, it rides the shared options bag purely so the * one caller that needs it (`callTool`, ADD-43's policy evaluation point) can see it without * a second, parallel options type. * * A throwing resolver is strictly less trustworthy than one that returned `undefined` * (identity extraction itself failed, not merely "no credential offered"), so this is * deliberately NOT the same as an absent `caller`: an absent caller still lets an * unauthenticated capability proceed and only fails closed via `policies:[authenticated]` * (`authenticated_no_credential`). This flag instead short-circuits `callTool`'s policy step * straight to a `policy_unevaluatable` denial for EVERY capability in the request, matching * ADD-42 R-11 — an identity-extraction failure must resolve to fail-closed, never to treating * the caller as merely anonymous. */ callerResolutionFailed?: boolean; /** * #45 / ADD-45: TYPE-ONLY, exactly like `auditSink` above. `invokeRest` never reads, calls, or * branches on this field — it rides the shared options bag so a deployer wires ONE options * object and every shipped pass-through forwards it with zero new plumbing. The state-owning * counter/store implementation itself must never live in this package (layer purity, ADD-45) * — this is only the deployer-supplied hook, threaded through by the two consumers that call * the rate-limit evaluation step (`@archstone/runtime`'s `callTool`, `@archstone/agent`'s * `executeCapability` — the same two sites that call the policy evaluator). `verifyTool` * deliberately does NOT read this field: rate-limiting `archstone verify` probes is * out-of-scope for #45. * * No-store default: a capability declaring `spec.rateLimit` with this field absent DENIES * (fails closed) rather than silently proceeding unlimited — see `evaluateRateLimit` in * `@archstone/emitter-support` for the full reasoning. */ rateLimitCounter?: RateLimitCounter; } declare function hostMatchesPattern(host: string, pattern: string): boolean; /** * Invoke a compiled capability against its REST backend. * * **This function performs NO AUTHORIZATION.** It is mechanical: connector presence → env/caller * placeholder resolution → caller-influenced-`baseUrl` allowlist → required path params → fetch. * It reads no `tool.policies`, branches on no `caller.principal`, and contains no allow/deny * logic of any kind. * * That is deliberate and was previously otherwise: the `policies: [authenticated]` gate used to * live here and was **moved** — not copied — to the one shared evaluation point in * `@archstone/emitter-support` (#43 / ADD-43 D-4, ADD-42 D-8). Two enforcement sites is exactly * the "one answer to where a policy is decided" this project set out to establish, and a check * inside an HTTP adapter is invisible to every non-REST connector added later. Callers * (`callTool`, `executeCapability`, `verifyTool`) evaluate policy BEFORE reaching this function. * * Consequence, named rather than hidden: a third party calling this exported function directly * against an `authenticated` capability with no caller now **proceeds** to the backend where it * previously failed closed. If you are re-adding a gate here because it looks missing — it is * not missing, it moved. Add your check at your own call site, or call one of the three * consumers above. */ declare function invokeRest(tool: IRTool, input: Record, opts?: InvokeOptions): Promise; export { type CallerContext, type FetchLike, type InvokeOptions, type InvokeResult, hostMatchesPattern, invokeRest };