UNPKG

agentfootprint

Version:

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

337 lines • 17.2 kB
"use strict"; /** * 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(); * ``` */ Object.defineProperty(exports, "__esModule", { value: true }); exports.vaultCredentials = void 0; const kinds_js_1 = require("../../identity/kinds.js"); const linearText_js_1 = require("../../lib/linearText.js"); /** Auth methods a caller may ask for, and the option that would carry each one * when it is built. Naming them is the teaching: a refusal that says "not * supported" ends the conversation; one that says what it would take starts a * field report. */ const UNBUILT_AUTH_METHODS = { approle: '`roleId` + `secretId` (Vault `auth/approle/login`)', kubernetes: '`role` + the projected service-account token path (`auth/kubernetes/login`)', jwt: '`role` + a signed JWT/OIDC assertion (`auth/jwt/login`)', oidc: '`role` + a signed JWT/OIDC assertion (`auth/jwt/login`)', aws: '`role` + a signed STS identity document (`auth/aws/login`)', cert: 'a client certificate + key, which is a TLS-agent decision, not a header', userpass: '`username` + `password` (`auth/userpass/login`)', ldap: '`username` + `password` (`auth/ldap/login`)', }; // ─── Public factory ────────────────────────────────────────────────── /** * 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. */ function vaultCredentials(options) { const id = options.id ?? 'vault'; if (!options.address || typeof options.address !== 'string' || options.address.trim() === '') { throw new TypeError(`${id}: \`address\` is required — the Vault base URL, e.g. ` + `'https://vault.internal:8200'. It is not read from VAULT_ADDR on purpose: ` + `an agent that picks its vault out of the environment reads a different vault ` + `when the environment changes under it.`); } const address = (0, linearText_js_1.trimTrailing)(options.address, linearText_js_1.isSlash); if (!/^https:\/\//i.test(address)) { if (!/^http:\/\//i.test(address)) { throw new TypeError(`${id}: \`address\` must be an http(s) URL (got '${address}').`); } if (!options.allowHttp) { throw new TypeError(`${id}: refusing a plain-http address ('${address}'). The Vault token travels in ` + `the \`X-Vault-Token\` request header, so over plaintext HTTP anyone on the path ` + `reads a token that can usually read every secret it can reach — and a leaked ` + `read token is not revoked by rotating one secret. Use https, or set ` + `\`allowHttp: true\` deliberately for a loopback dev server.`); } } if (options.auth !== undefined && options.auth !== 'token') { const asked = String(options.auth).toLowerCase(); const wouldTake = UNBUILT_AUTH_METHODS[asked]; throw new TypeError(`${id}: \`auth: '${String(options.auth)}'\` is not built. V1 authenticates with a ` + `TOKEN only — \`token\`, or the \`VAULT_TOKEN\` environment variable.` + (wouldTake ? ` ${asked} login would arrive on ${wouldTake}, plus a re-login when the ` + `returned lease expires.` : ` A login method would need its own credentials and a re-login on lease expiry.`) + ` This is field-gated rather than guessed: tell us your auth shape (which method, ` + `which mount path, which lease behaviour) and it gets built against a real cluster ` + `instead of an API document. Until then, exchange it yourself and pass the ` + `resulting token as \`token\`.`); } if (options.paths && options.resolve) { throw new TypeError(`${id}: pass \`paths\` OR \`resolve\`, not both — two spellings of one rule can ` + `disagree, and the one that loses would do so silently. Use \`paths\` for a fixed ` + `map, \`resolve\` when the path is computed from the service id.`); } const token = options.token ?? readEnv('VAULT_TOKEN'); if (!token) { throw new TypeError(`${id}: no Vault token. Pass \`token\`, or set the VAULT_TOKEN environment variable. ` + `(V1 authenticates with a token only — see \`auth\`.)`); } const mount = trimSlashes(options.mount ?? 'secret'); const timeoutMs = options.timeoutMs ?? 5000; const apiKeyHeader = options.apiKeyHeader ?? 'x-api-key'; const doFetch = options._fetch ?? ((...args) => fetch(...args)); /** service → path inside the mount, by whichever arm was configured. */ function pathFor(service) { if (options.paths) { const mapped = options.paths[service]; if (mapped === undefined) { throw new Error(`${id}: no secret path configured for service '${service}'. ` + `Known services: ${Object.keys(options.paths).join(', ') || '(none)'}.`); } return trimSlashes(mapped); } if (options.resolve) { const mapped = options.resolve(service); if (!mapped) { throw new Error(`${id}: \`resolve('${service}')\` returned no path, so this service has no secret ` + `to read. Return a path inside the '${mount}' mount, or configure the service ` + `elsewhere.`); } return trimSlashes(mapped); } // No mapping configured: the service id IS the path. return trimSlashes(service); } return { id, async getCredential(req) { const secretPath = pathFor(req.service); // KV v2's read shape. `/data/` is the v2 API segment, not part of your // path — `secret/ci/github` in the UI is `secret/data/ci/github` here. const url = `${address}/v1/${mount}/data/${secretPath}`; const where = `'${mount}/${secretPath}'`; const secret = await readSecret({ url, where, id, service: req.service, token, namespace: options.namespace, timeoutMs, doFetch, }); const custom = options.toCredential?.(secret, req.service); if (custom) return { status: 'issued', credential: custom }; const credential = mapFieldsToCredential(secret, apiKeyHeader); if (!credential) { throw new Error(`${id}: the secret at ${where} has none of the field shapes this adapter reads — ` + `\`token\`, \`api_key\`/\`apiKey\`/\`key\`, \`username\`+\`password\`, or ` + `\`headers\`. (The fields it DOES have are deliberately not named here: an ` + `error message reaches the model and the credential.failed event.) Rewrite the ` + `secret in one of those shapes, or pass \`toCredential\` to map your own.`); } // No `expiresAt`: a KV v2 secret has no lease, so there is no expiry to // report and inventing one would be worse than absence. V1 re-reads on // every call, which is the library's model since 9.7.0. return { status: 'issued', credential }; }, }; } exports.vaultCredentials = vaultCredentials; /** * GET one KV v2 secret and return its inner `data.data` object. * * Every throw below names the provider id, the service, the mount path and (for * an HTTP failure) the status. None of them can name the token, a response body * or a field of the secret — that restraint IS the contract, because this * message becomes a tool result the model reads and a `credential.failed` * payload every observer receives. */ async function readSecret(args) { const { id, where, service } = args; let res; try { res = await args.doFetch(args.url, { method: 'GET', headers: { 'X-Vault-Token': args.token, accept: 'application/json', ...(args.namespace && { 'X-Vault-Namespace': args.namespace }), }, signal: AbortSignal.timeout(args.timeoutMs), }); } catch (err) { // Transport failure: DNS, TLS, refused connection, timeout. The cause is // safe to name — it describes the socket, never the payload — but it is // re-wrapped rather than rethrown so no fetch implementation can smuggle // request headers into the text. throw new Error(`${id}: could not reach Vault for service '${service}' (${where}): ` + `${transportReason(err)}.`); } if (!res.ok) { throw new Error(`${id}: Vault returned ${res.status} reading ${where} for service '${service}'` + `${statusHint(res.status)}`); } let body; try { body = await res.json(); } catch { throw new Error(`${id}: Vault's response for ${where} was not JSON (status ${res.status}). ` + `Check that \`address\` points at Vault's API and not at a proxy or login page.`); } const outer = body?.data; if (!isRecord(outer)) { throw new Error(`${id}: no secret data at ${where} (status ${res.status}). ` + `Check the path exists in the KV v2 mount.`); } const inner = outer.data; if (!isRecord(inner)) { // A v1 mount answers with the fields directly under `data`, with no inner // envelope. That is the one cheap, unambiguous v1 tell, so it is named // rather than guessed at. throw new Error(`${id}: the response for ${where} is not KV v2 shaped (no \`data.data\` envelope). ` + `This adapter reads KV **v2** only, which is why the URL carries the \`/data/\` ` + `segment. If '${where.split('/')[0]?.replace(/'/g, '') || 'that mount'}' is a KV v1 ` + `mount, upgrade it (\`vault kv enable-versioning\`) or mount v2 — or tell us, and ` + `\`kvVersion\` is the option a v1 reader would arrive on.`); } return inner; } /** A short, payload-free reason for a transport failure. */ function transportReason(err) { if (err instanceof Error) { if (err.name === 'TimeoutError' || err.name === 'AbortError') return 'the request timed out'; return err.name || 'network error'; } return 'network error'; } /** What a status usually means here. Static text keyed by number — it cannot * echo a response, because it never reads one. */ function statusHint(status) { if (status === 403) { return '. The token is valid but its policy does not allow reading that path.'; } if (status === 401) return '. The token is missing, expired or revoked.'; if (status === 404) { return ('. Either the path does not exist, or the mount is not a KV v2 mount ' + '(the read URL carries the v2 `/data/` segment).'); } if (status === 503) return '. Vault is sealed or standby.'; return '.'; } // ─── Field → kind ──────────────────────────────────────────────────── /** * The built-in field table, first match wins. Deliberately small: four shapes * cover what secrets actually hold, and `toCredential` covers the rest without * this function growing an option per spelling. */ function mapFieldsToCredential(secret, defaultApiKeyHeader) { const token = str(secret.token); if (token) return (0, kinds_js_1.bearer)(token); const key = str(secret.api_key) ?? str(secret.apiKey) ?? str(secret.key); if (key) return (0, kinds_js_1.apiKey)(key, str(secret.header) ?? defaultApiKeyHeader); const username = str(secret.username); const password = str(secret.password); if (username && password) return (0, kinds_js_1.basic)(username, password); const map = secret.headers; if (isRecord(map)) { const flat = {}; for (const [k, v] of Object.entries(map)) { const s = str(v); if (s) flat[k] = s; } if (Object.keys(flat).length > 0) return (0, kinds_js_1.headers)(flat); } return undefined; } // ─── Small helpers ─────────────────────────────────────────────────── function isRecord(v) { return typeof v === 'object' && v !== null && !Array.isArray(v); } /** A non-empty string, or undefined. Numbers are NOT coerced: a secret field * that is not a string is not a credential this adapter knows how to apply. */ function str(v) { return typeof v === 'string' && v !== '' ? v : undefined; } function trimSlashes(s) { return (0, linearText_js_1.trimBoth)(s, linearText_js_1.isSlash); } /** Read one environment variable, in any runtime. Absent `process` (a browser, * a worker) simply means no fallback. */ function readEnv(name) { const p = globalThis.process; return p?.env?.[name]; } //# sourceMappingURL=vault.js.map