UNPKG

agentfootprint

Version:

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

554 lines • 23.4 kB
/** * hostFilled — tool arguments the HOST fills, from the run's credential or * identity. The model never sees them and cannot set them. * * ```ts * import { hostFilled, fromCredential, fromIdentity } from 'agentfootprint'; * * // An MCP server whose every tool takes a `token` argument: * const tools = hostFilled(await crm.tools(), { * token: fromCredential({ service: 'crm' }), // ctx.credential, resolved per call * tenantId: fromIdentity('tenant'), // ctx.identity.tenant * }); * ``` * * ── What it does ──────────────────────────────────────────────────────────── * 1. **Removes the field from the schema the model sees** (`properties` and * `required`). The model is never told the argument exists. * 2. **Refuses a model-supplied value by name.** A call that sends the field * anyway — guessed, injected by a document, copied from another tool — is * refused with a result naming the argument, and the tool does not run. * 3. **Fills it at the last moment**, inside `execute`, from `ctx.credential` * (the tool's declared `needs`, which `fromCredential({ service })` sets * when the tool has none) or `ctx.identity`. A missing value refuses the * call by name — never an empty or default value. * * ── Why a wrapper around `execute` ────────────────────────────────────────── * The filled value exists only inside the call: the arguments every dispatch * door records (`tool_start`, history, the commit log, a consent pause's * checkpoint) are the MODEL's arguments, which never held it. Wrapping * `execute` makes that true at every door — the agent loop, a resumed call, * an inner `ctx.tools` call and `mcpServe` — without each door knowing. * * A credential value is also SCRUBBED on the way out: every occurrence in the * tool's result is replaced by `[host-filled]`, and a thrown error that * carries it (an SDK that echoes its request) is replaced by one that does * not. That is defence in depth for a server that echoes; it cannot cover a * value the tool transforms (base64, hashing) or sends elsewhere itself. * Identity values are not secrets and are not scrubbed. * * Pattern: Decorator over `Tool` (declaration + execute). Role: tools, argument law. */ import { inheritSearch } from './searchOf.js'; import { inheritReadOnly } from './tools.js'; /** * Fill the argument from the call's credential. With `service`, the tool's * `needs` is declared for you (a tool that already declares one must name the * same service, or omit `service`). */ export function fromCredential(options = {}) { return { from: 'credential', ...options }; } /** Fill the argument from the identity the run's caller named. */ export function fromIdentity(field) { return { from: 'identity', field }; } /** * A model sent a value for an argument the host fills. The call is refused * and the tool does not run. Names the arguments, never a value. */ export class HostFilledArgumentError extends Error { toolName; fields; code = 'ERR_HOST_FILLED_ARGUMENT'; constructor(toolName, fields) { super(`tool '${toolName}': ${fields.map((f) => `'${f}'`).join(', ')} ` + `${fields.length === 1 ? 'is' : 'are'} filled by the host and cannot be set by the ` + 'caller — call the tool again without it.'); this.toolName = toolName; this.fields = fields; this.name = 'HostFilledArgumentError'; } } /** * A host-filled argument had no value for this call — no credential was * resolved, the credential has no single value to read, or the run's caller * named no such identity field. The call is refused; nothing is guessed. */ export class HostFilledValueMissingError extends Error { toolName; field; code = 'ERR_HOST_FILLED_VALUE_MISSING'; constructor(toolName, field, why) { super(`tool '${toolName}': argument '${field}' is filled by the host, and ${why}.`); this.toolName = toolName; this.field = field; this.name = 'HostFilledValueMissingError'; } } /** * The wrapper's own declaration, keyed by the tool `hostFilled` returned — * read through {@link hostFillsOf}, never by probing a tool's fields. */ const DECLARED_FILLS = new WeakMap(); /** * Which arguments `hostFilled` fills on `tool`, by where their value comes * from — the names only, never a value. `undefined` for a tool no * `hostFilled` returned. * * The filled values exist only inside `execute`, so the arguments every door * records never hold them; a reader that composes ground from a call's * arguments (`Tool.search` composition) learns the filled set here: an * identity-filled argument is a filter in effect, a credential-filled one * names no ground. * * @example * ```ts * hostFillsOf(hostFilled(tool, { tenantId: fromIdentity('tenant') })); * // { identity: { tenantId: 'tenant' }, credential: [] } * ``` */ export function hostFillsOf(tool) { const fills = DECLARED_FILLS.get(tool); if (fills === undefined) return undefined; const identity = {}; const credential = []; for (const [field, fill] of Object.entries(fills)) { if (fill.from === 'identity') identity[field] = fill.field; else credential.push(field); } return { identity, credential }; } /** Below this length a credential's OTHER values (header values) are not scrubbed. */ const MIN_SCRUB_LENGTH = 8; /** What replaces a credential value in a result or an error. */ export const HOST_FILLED_PLACEHOLDER = '[host-filled]'; export function hostFilled(toolOrTools, fills) { const checked = checkFills(fills); if (!Array.isArray(toolOrTools)) return wrapTool(toolOrTools, checked, true); const tools = toolOrTools; const unused = new Set(Object.keys(checked)); const out = tools.map((tool) => { const own = Object.fromEntries( // The tools that take the field as an argument of the call. One that // declares it only through a root combinator (`allOf`/`anyOf`/`oneOf`, // `if`/`then`, `$defs`) takes it too, and cannot have it removed — it is // refused, never left model-settable. One that names it only inside a // nested object (a `cursor.token` pagination field) has a different // argument and is left alone. Object.entries(checked).filter(([field]) => { if (Object.hasOwn(propertiesOf(tool), field)) return true; if (declaresAtRoot(tool.schema?.inputSchema, field)) refuseNested(tool, field); return false; })); for (const field of Object.keys(own)) unused.delete(field); return Object.keys(own).length === 0 ? tool : wrapTool(tool, own, false); }); if (unused.size > 0) { throw new TypeError(`hostFilled: no tool in the list declares ${[...unused].map((f) => `'${f}'`).join(', ')}.`); } return out; } // ─── Build-time checks ────────────────────────────────────────────── function checkFills(fills) { if (fills === null || typeof fills !== 'object' || Array.isArray(fills)) { throw new TypeError('hostFilled: `fills` must be an object of argument → fill.'); } const names = Object.keys(fills); if (names.length === 0) throw new TypeError('hostFilled: `fills` names no argument.'); for (const name of names) { const fill = fills[name]; const ok = (fill?.from === 'credential' && (fill.read === undefined || typeof fill.read === 'function')) || (fill?.from === 'identity' && ['principal', 'tenant', 'conversationId'].includes(fill.field)); if (!ok) { throw new TypeError(`hostFilled: argument '${name}' needs fromCredential(…) or fromIdentity('principal' | ` + "'tenant' | 'conversationId')."); } } return fills; } function propertiesOf(tool) { const props = tool.schema?.inputSchema ?.properties; return props !== null && typeof props === 'object' ? props : {}; } /** The one service a tool's credential fills read, checked against its own `needs`. */ function needsFor(tool, fills) { const name = tool.schema.name; const credentialFills = Object.entries(fills).filter((e) => e[1].from === 'credential'); if (credentialFills.length === 0) return tool.needs; const named = new Set(credentialFills.map(([, f]) => f.service).filter((s) => s !== undefined)); if (named.size > 1) { throw new TypeError(`hostFilled: tool '${name}' takes ONE credential (its \`needs\`), and the fills name ` + `${[...named].map((s) => `'${s}'`).join(' and ')}.`); } const service = [...named][0]; if (tool.needs !== undefined) { if (service !== undefined && service !== tool.needs.credential) { throw new TypeError(`hostFilled: tool '${name}' already needs credential '${tool.needs.credential}', and a ` + `fill names '${service}'.`); } return tool.needs; } if (service === undefined) { throw new TypeError(`hostFilled: tool '${name}' declares no \`needs\` — say which credential fills ` + `${credentialFills.map(([f]) => `'${f}'`).join(', ')}: fromCredential({ service }).`); } const declared = credentialFills.find(([, f]) => f.service === service)?.[1]; return { credential: service, ...(declared?.scopes !== undefined && { scopes: declared.scopes }), ...(declared?.mode !== undefined && { mode: declared.mode }), }; } function wrapTool(tool, fills, strict) { const name = tool.schema.name; const props = propertiesOf(tool); for (const field of Object.keys(fills)) { if (strict && !Object.hasOwn(props, field)) { if (declaresAtRoot(tool.schema.inputSchema, field)) refuseNested(tool, field); throw new TypeError(`hostFilled: tool '${name}' has no argument '${field}'.`); } // Also named in a combinator or a nested schema: it cannot be removed from // everything the model reads, so the tool is refused rather than left // half-hidden. if (mentionsElsewhere(tool.schema.inputSchema, field)) refuseNested(tool, field); if (tool.wants !== undefined && Object.hasOwn(tool.wants, field)) { throw new TypeError(`hostFilled: tool '${name}' declares '${field}' under \`wants\` — an argument is filled ` + 'by the host or resolved from a ref, not both.'); } if (tool.askOrAssume !== undefined && Object.hasOwn(tool.askOrAssume, field)) { throw new TypeError(`hostFilled: tool '${name}' declares '${field}' under \`askOrAssume\` — an argument is ` + 'filled by the host or asked of a person, not both.'); } } const needs = needsFor(tool, fills); const inputSchema = withoutFields(tool.schema.inputSchema, Object.keys(fills)); const fieldNames = Object.keys(fills); const inner = tool.execute.bind(tool); const wrapped = { ...tool, schema: { ...tool.schema, inputSchema }, ...(needs !== undefined && { needs }), async execute(args, ctx) { const given = isRecord(args) ? fieldNames.filter((f) => Object.hasOwn(args, f)) : []; if (given.length > 0) throw new HostFilledArgumentError(name, given); const filled = {}; const secrets = []; for (const field of fieldNames) { const fill = fills[field]; const value = valueOf(name, field, fill, ctx); filled[field] = value; if (fill.from === 'credential') secrets.push(value, ...credentialValues(ctx.credential)); } const base = isRecord(args) ? args : {}; try { const result = await inner({ ...base, ...filled }, ctx); return secrets.length === 0 ? result : scrubValue(result, secrets); } catch (err) { throw secrets.length === 0 || !carries(err, secrets, 0, new Set()) ? err : scrubbedError(err, secrets); } }, }; // The declaration a reader asks of the wrapped tool — a wrap of a wrap keeps both. DECLARED_FILLS.set(wrapped, { ...DECLARED_FILLS.get(tool), ...fills }); // The search was judged against the schema BEFORE the filled arguments left it. if (tool.search !== undefined) inheritSearch(tool, wrapped); inheritReadOnly(tool, wrapped); return wrapped; } /** * Whether `field` is named anywhere in the schema OTHER than the root's own * `properties` / `required` — a combinator (`allOf`/`anyOf`/`oneOf`, `if`/ * `then`/`else`, `not`), `$defs`, `dependentRequired`/`dependencies`, or a * nested object's `properties`/`required`. The root's own property schema is * not walked. */ function mentionsElsewhere(schema, field) { if (schema === null || typeof schema !== 'object') return false; const seen = new Set(); const walk = (node, root) => { if (node === null || typeof node !== 'object' || seen.has(node)) return false; seen.add(node); if (Array.isArray(node)) return node.some((n) => walk(n, false)); const obj = node; if (!root) { const props = obj.properties; if (props !== null && typeof props === 'object' && Object.hasOwn(props, field)) return true; if (Array.isArray(obj.required) && obj.required.includes(field)) return true; } for (const key of ['dependentRequired', 'dependencies', 'dependentSchemas']) { const dep = obj[key]; if (dep !== null && typeof dep === 'object') { if (Object.hasOwn(dep, field)) return true; for (const v of Object.values(dep)) { if (Array.isArray(v) && v.includes(field)) return true; } } } for (const [key, value] of Object.entries(obj)) { if (root && key === 'required') continue; if (root && key === 'properties' && value !== null && typeof value === 'object') { for (const [prop, sub] of Object.entries(value)) { if (prop !== field && walk(sub, false)) return true; } continue; } if (walk(value, false)) return true; } return false; }; return walk(schema, true); } /** * Whether the ROOT object's shape names `field` through a combinator or a * definition — anything that makes it an argument of the call without being * one of the root's own `properties`. Nested objects' properties are not * walked. */ function declaresAtRoot(schema, field) { const SHAPE_KEYS = [ 'allOf', 'anyOf', 'oneOf', 'not', 'if', 'then', 'else', '$defs', 'definitions', ]; const seen = new Set(); const walk = (node, root) => { if (node === null || typeof node !== 'object' || seen.has(node)) return false; seen.add(node); if (Array.isArray(node)) return node.some((n) => walk(n, false)); const obj = node; if (!root) { const props = obj.properties; if (props !== null && typeof props === 'object' && Object.hasOwn(props, field)) return true; if (Array.isArray(obj.required) && obj.required.includes(field)) return true; } for (const key of ['dependentRequired', 'dependencies', 'dependentSchemas']) { const dep = obj[key]; if (dep !== null && typeof dep === 'object' && Object.hasOwn(dep, field)) return true; } for (const key of SHAPE_KEYS) { const value = obj[key]; if (value === null || typeof value !== 'object') continue; // `$defs` / `definitions` are maps of schemas; the others are schemas or lists. const children = key === '$defs' || key === 'definitions' ? Object.values(value) : [value]; if (children.some((c) => walk(c, false))) return true; } return false; }; return walk(schema, true); } function refuseNested(tool, field) { throw new TypeError(`hostFilled: tool '${tool.schema.name}' names '${field}' inside a nested schema or a ` + 'combinator (allOf/anyOf/oneOf, $defs, dependentRequired …), where it cannot be removed ' + 'from what the model reads — declare it as a top-level property, or wrap the tool yourself.'); } function withoutFields(schema, fields) { const props = { ...(schema.properties ?? {}) }; for (const f of fields) delete props[f]; const out = { ...schema, properties: props }; if (Array.isArray(schema.required)) { const required = schema.required.filter((r) => typeof r !== 'string' || !fields.includes(r)); if (required.length > 0) out.required = required; else delete out.required; } return out; } // ─── Dispatch-time fill ───────────────────────────────────────────── function valueOf(tool, field, fill, ctx) { if (fill.from === 'identity') { const value = ctx?.identity?.[fill.field]; if (typeof value !== 'string' || value === '') { throw new HostFilledValueMissingError(tool, field, `the run's caller named no identity ${fill.field}`); } return value; } const credential = ctx?.credential; if (credential === undefined) { throw new HostFilledValueMissingError(tool, field, 'no credential was resolved for the call'); } let value; try { value = fill.read !== undefined ? fill.read(credential) : defaultRead(credential); } catch { value = undefined; } if (typeof value !== 'string' || value === '') { throw new HostFilledValueMissingError(tool, field, `its '${credential.kind}' credential has no single value to fill it with` + (fill.read === undefined ? ' — pass fromCredential({ read })' : '')); } return value; } /** * Every value of the credential itself, beside the filled one — its raw * token or key and its header values — so an echo of the bare token is * scrubbed even when `read` filled a decorated form (`Bearer …`). */ function credentialValues(credential) { if (credential === undefined) return []; const out = [defaultRead(credential)]; try { for (const v of Object.values(credential.toHeaders())) { out.push(v); if (typeof v === 'string' && /^(?:Bearer|Basic) /.test(v)) out.push(v.slice(v.indexOf(' ') + 1)); } } catch { // A credential that cannot produce headers adds nothing. } // A short value (a header like `json`) would mangle ordinary text; only a // value long enough to be a credential is scrubbed. The filled value itself // is always scrubbed. return out.filter((v) => typeof v === 'string' && v.length >= MIN_SCRUB_LENGTH); } function defaultRead(credential) { const c = credential; if (c.kind === 'bearer') return c.token; if (c.kind === 'apiKey') return c.key; return undefined; } // ─── Scrub on the way out ─────────────────────────────────────────── function isRecord(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); } function isPlainContainer(v) { if (Array.isArray(v)) return true; if (v === null || typeof v !== 'object') return false; const proto = Object.getPrototypeOf(v); return proto === Object.prototype || proto === null; } function scrubText(text, secrets) { let out = text; // Longest first, so `Bearer x` is replaced whole before `x`. for (const s of [...new Set(secrets)].sort((a, b) => b.length - a.length)) { out = out.split(s).join(HOST_FILLED_PLACEHOLDER); } return out; } /** The value with every occurrence of a secret replaced; the SAME value when none. */ function scrubValue(value, secrets, seen = new Map()) { if (typeof value === 'string') return scrubText(value, secrets); if (!isPlainContainer(value)) return value; const prior = seen.get(value); if (prior !== undefined) return prior; if (Array.isArray(value)) { const out = []; seen.set(value, out); let changed = false; for (const item of value) { const next = scrubValue(item, secrets, seen); if (next !== item) changed = true; out.push(next); } if (!changed) seen.set(value, value); return changed ? out : value; } const out = {}; seen.set(value, out); let changed = false; for (const key of Object.keys(value)) { const item = value[key]; const next = scrubValue(item, secrets, seen); if (next !== item) changed = true; out[key] = next; } if (!changed) seen.set(value, value); return changed ? out : value; } /** Whether a thrown value carries a secret anywhere a reader could find it. */ function carries(value, secrets, depth, seen) { if (typeof value === 'string') return secrets.some((s) => value.includes(s)); if (value === null || typeof value !== 'object' || depth > 8 || seen.has(value)) return false; seen.add(value); // An error's own fields are mostly non-enumerable (`message`, `stack`, // `cause`, `AggregateError.errors`), so every own name is read. const keys = value instanceof Error ? Object.getOwnPropertyNames(value) : Object.keys(value); for (const key of keys) { let item; try { item = value[key]; } catch { continue; } if (carries(item, secrets, depth + 1, seen)) return true; } return false; } /** A replacement error: the name and the scrubbed message, nothing else. */ function scrubbedError(err, secrets) { let message; try { message = err instanceof Error ? String(err.message) : String(err); } catch { message = 'the tool failed'; } const out = new Error(scrubText(message, secrets)); out.name = err instanceof Error ? scrubText(err.name, secrets) : 'Error'; return out; } //# sourceMappingURL=hostFilled.js.map