agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
147 lines (146 loc) • 8.13 kB
TypeScript
/**
* vaultCredentials — a {@link CredentialProvider} over a HashiCorp-Vault-compatible
* KV v2 secret store, spoken as plain HTTP.
*
* import { vaultCredentials } from 'agentfootprint/security';
*
* const credentials = vaultCredentials({
* address: 'https://vault.internal:8200', // https, or say `allowHttp` out loud
* mount: 'secret', // KV v2 mount, default 'secret'
* paths: { github: 'ci/github' }, // service → path INSIDE the mount
* }); // token: VAULT_TOKEN, or `token`
*
* Zero dependencies and no SDK: one `GET` per resolution through the runtime's
* own `fetch`. Vault's HTTP API is small, stable and the thing every
* Vault-compatible store (OpenBao, and the Vault-API modes of several managed
* stores) implements — so the adapter that speaks HTTP works against more
* backends than the adapter that imports one vendor's client.
*
* ## V1 is deliberately one shape, and says so by name
*
* | Axis | V1 | Anything else |
* |---|---|---|
* | Auth | a **token** (`token` option, else `VAULT_TOKEN`) | AppRole / Kubernetes / JWT / AWS IAM login are **refused by name**, naming the option that would carry them |
* | Secret engine | **KV v2** (`<mount>/data/<path>`, the `data.data` envelope) | a KV v1 mount is refused by name once the response shape gives it away |
* | Leases / renewal | **none** — every `getCredential` re-reads the secret | a lease-aware provider is a different object, and the library's model since 9.7.0 is re-resolve-per-call |
*
* That is not modesty, it is the honest edge: an auth method the author cannot
* exercise against a real cluster would be a guess wearing an adapter's clothes.
* Each refusal names the option it would arrive on, so "tell us your auth shape"
* is a field report rather than an issue title.
*
* ## Field → credential kind
*
* A KV v2 read returns `{ data: { data: { …your fields… }, metadata: {…} } }`.
* The inner object is mapped to a {@link Credential} by the FIRST rule that
* matches, so a secret written the ordinary way needs no configuration:
*
* | Fields present | Becomes | Header it applies |
* |---|---|---|
* | `token` | `bearer(token)` | `authorization: Bearer …` |
* | `api_key` \| `apiKey` \| `key` | `apiKey(value, header ?? 'x-api-key')` | that header |
* | `username` + `password` | `basic(username, password)` | `authorization: Basic …` |
* | `headers` (an object of strings) | `headers(map)` | all of them |
*
* A secret matching none of them is refused — naming the PATH and the four
* shapes, never the secret. `toCredential` is the seam for a shop whose fields
* are named otherwise; it sees the secret and returns a `Credential`, and
* returning `undefined` falls back to the table above.
*
* ## Secrecy (the 8.6.0 two-clause law, applied here)
*
* A thrown message reaches the model as a tool result AND rides
* `agentfootprint.credential.failed`. So every error this adapter raises names
* **the service, the path and the HTTP status, and nothing from the response
* body or the token**. Nothing here logs, and no secret value, no `X-Vault-Token`
* header and no field name from the payload appears in any message it can throw
* — pinned by a grep-shaped test over every failure path. The credential it
* returns hides its own secret fields (non-enumerable) and carries `toHeaders`,
* so `structuredClone` rejects it and it cannot enter tracked scope by accident.
*
* @example Dev → prod is the same two lines
* ```ts
* // dev
* const credentials = staticTokens({ github: 'ghp_dev_xxx' });
* // prod — the tool code does not change
* const credentials = vaultCredentials({ address: process.env.VAULT_ADDR! });
* Agent.create({ provider, model, credentials }).build();
* ```
*/
import type { Credential, CredentialProvider } from '../../identity/types.js';
/** The base options every form shares. */
interface VaultCredentialsBase {
/** Vault's base URL, e.g. `https://vault.internal:8200` — **required**, and
* **https** unless {@link VaultCredentialsBase.allowHttp} says otherwise. No
* `VAULT_ADDR` fallback: an agent that silently picks up an address from the
* environment is an agent that reads a different vault when the environment
* changes under it. Name it. */
readonly address: string;
/** The Vault token. Falls back to `VAULT_TOKEN` (the variable every Vault
* tool already sets). This is a secret: it is sent as `X-Vault-Token` and
* appears in no message this adapter can throw. */
readonly token?: string;
/** Auth method. **`'token'` is the only one V1 implements.** Anything else is
* refused at construction, by name, with what it would take — see
* {@link vaultCredentials}. */
readonly auth?: 'token';
/** KV v2 mount point. Default `'secret'` (Vault's own default for the KV v2
* engine). The read URL is `<address>/v1/<mount>/data/<path>`. */
readonly mount?: string;
/** Vault Enterprise / HCP namespace, sent as `X-Vault-Namespace`. Omit for
* open-source Vault and OpenBao, which have no namespaces. */
readonly namespace?: string;
/** Map the secret's fields to a {@link Credential} yourself. Returns
* `undefined` to fall back to the built-in table (`token` / `api_key` /
* `username`+`password` / `headers`). The seam for a shop whose field names
* are its own — and the reason this adapter does not need an option per
* spelling. **Never log or return the fields from here**; they are the
* secret. */
readonly toCredential?: (secret: Readonly<Record<string, unknown>>, service: string) => Credential | undefined;
/** Header name for the `api_key` shape when the secret does not carry its own
* `header` field. Default `'x-api-key'`. */
readonly apiKeyHeader?: string;
/** Request timeout in ms. Default 5000 — a credential resolution sits in
* front of a tool call, so a hung vault must fail rather than hang a run. */
readonly timeoutMs?: number;
/** Allow a plain-`http://` address. **Refused unless you set this**, because
* the Vault token travels in a request header: over plaintext HTTP, anyone
* on the path reads a token that can usually read every secret it can reach.
* Set it only for a loopback dev server (`http://127.0.0.1:8200`). */
readonly allowHttp?: boolean;
/** Stable provider id (default `'vault'`). Shows up in "which provider vended
* this". */
readonly id?: string;
/** Test seam — inject `fetch`. Bypasses the network entirely. */
readonly _fetch?: typeof fetch;
}
/**
* How a `service` becomes a path inside the mount. Three arms, and they
* EXCLUDE each other — two spellings of one rule can disagree, so the type
* refuses the pair and so does the constructor.
*/
type VaultPathMapping = {
/** `service → path inside the mount`, the {@link staticTokens} shape one
* level up: the same literal map, holding a path instead of a token. An
* unknown service is refused by name, listing the known ones. */
readonly paths: Readonly<Record<string, string>>;
readonly resolve?: never;
} | {
/** `service → path`, computed. Return `undefined` to refuse a service.
* For the convention-driven shop: ``(s) => `agents/${s}` ``. */
readonly resolve: (service: string) => string | undefined;
readonly paths?: never;
} | {
/** Neither: the **service id IS the path** under the mount, so
* `service: 'github'` reads `<mount>/data/github`. */
readonly paths?: undefined;
readonly resolve?: undefined;
};
export type VaultCredentialsOptions = VaultCredentialsBase & VaultPathMapping;
/**
* Build a {@link CredentialProvider} that reads KV v2 secrets from a
* Vault-compatible store. See {@link VaultCredentialsOptions} for the
* per-option contract and this module's docstring for the V1 boundary.
*/
export declare function vaultCredentials(options: VaultCredentialsOptions): CredentialProvider;
export {};