UNPKG

agentfootprint

Version:

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

245 lines • 11.3 kB
"use strict"; /** * PermissionPolicy — data-driven role-based authorization for tool dispatch. * * Closes host-app gap #2 (of 8). Permissions are CROSS-CUTTING — they're not * context engineering, they're a guard ON context-engineering operations * (tool dispatch, skill activation, memory writes, output emission). * That's why this lives in `agentfootprint/security`, parallel to the * provider subpaths. * * Two surfaces, one primitive: * 1. `PermissionPolicy.fromRoles({...}, activeRole)` — declarative, * data-driven, auditable. Production governance. * 2. The PermissionPolicy instance satisfies BOTH: * - `PermissionChecker` interface (async check; consumed by Agent * constructor's `permissionChecker` field) * - sync `isAllowed(toolId)` method (consumed by `gatedTools(...)` * from `agentfootprint/providers`) * * **Status: contract-shaped and tested — independently reproduced against a * local harness, 2026-08-13.** A tool declaring `memory_write` and `user_data` * was put in front of two roles: the read-only role passed the tool-NAME * allowlist, failed the `memory_write` capability check, and the tool executed * ZERO times; the admin role cleared `tool_call`, `memory_write` and * `user_data` and executed once. A separate checker that threw `simulated * authorizer outage` failed CLOSED — a denial carrying the operator's error in * its rationale, the model free to continue without the tool, zero executions * again. The reviewer's own wording: *"the permission checker is a real * execution guard, not merely tool hiding"*, which holds exactly as far as the * policy inputs and the role binding are trustworthy — a fact about a * deployment, not about this class. * * What was exercised: the guard, end to end, by somebody who is not this * library's author — on a DETERMINISTIC LOCAL run whose model was a mock and * whose "authorizer outage" was a thrown `Error` (those tests *"were local and * deterministic, so they consumed no GCP credit"*). What was NOT: **no external * authorization service has answered a `check()` from this repository**, so * this is not the field-validated rung — a real IAM/OPA/entitlements backend, * with its own latency, partial outages and stale caches, remains unexercised. * Runnable: `examples/features/03-permissions.ts` (all four shapes). * * Pattern: Strategy (GoF) for the role-allowlist policy + Adapter * (matches `PermissionChecker` interface so it composes with * existing v2.4 Agent constructor). * * Role: Layer-3 cross-cutting guard. Not Injection. Not provider. * Lives in its own subpath (`agentfootprint/security`). * * @example Read-only role for a support agent * const policy = PermissionPolicy.fromRoles( * { * readonly: ['lookup_order', 'get_status', 'list_skills', 'read_skill'], * support: ['lookup_order', 'get_status', 'process_refund', 'list_skills', 'read_skill'], * }, * 'readonly', * ); * * policy.isAllowed('lookup_order'); // → true * policy.isAllowed('process_refund'); // → false (not in readonly role) * * // As a tool-dispatch gate (composes with gatedTools) * const provider = gatedTools(staticTools(allTools), (name) => policy.isAllowed(name)); * * // As an Agent permissionChecker (the v2.4 surface) * const agent = Agent.create({ provider, model, permissionChecker: policy }).build(); * * @example Per-identity role switching at runtime * const policy = PermissionPolicy.fromRoles({ * readonly: [...], * admin: [...], * }, 'readonly'); * * const adminPolicy = policy.withActiveRole('admin'); * // Same allowlist data; different active role. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.PermissionPolicy = void 0; const skillTarget_js_1 = require("./skillTarget.js"); /** The four capabilities a tool can declare — the set a capability rule may * list. `'tool_call'` is the allowlist's own job and `'skill_read'` has its * own rule map, so neither belongs here. */ const TOOL_CAPABILITIES = [ 'memory_read', 'memory_write', 'external_net', 'user_data', ]; /** * Data-driven role-based permission policy. Satisfies the v2.4 * `PermissionChecker` interface AND exposes a sync `isAllowed` method * for use with `gatedTools` from `agentfootprint/providers`. */ class PermissionPolicy { opts; name = 'PermissionPolicy'; /** * Which capabilities this policy asks to be consulted about beyond * `'tool_call'` (9.11.0) — DERIVED from what was configured, never a * separate switch that could disagree with the rules. * * Absent when no rules were given, which is what makes every policy built * the old way byte-identical: the framework asks it nothing new. */ governs; constructor(opts) { this.opts = opts; if (!opts.roles[opts.activeRole]) { throw new Error(`PermissionPolicy: activeRole '${opts.activeRole}' is not defined in roles. Available: ${Object.keys(opts.roles).join(', ') || '(none)'}`); } const governs = []; // Configuring capability rules for ANY role means "ask me about all four". // Governing only the ones some role happened to list would let an // unlisted capability pass unasked — a fail-OPEN gap in a fail-closed // primitive. if (opts.rules?.capabilities !== undefined) governs.push(...TOOL_CAPABILITIES); if (opts.rules?.skills !== undefined) governs.push('skill_read'); if (governs.length > 0) this.governs = Object.freeze(governs); } /** * Factory: build a role-based policy from a role → tool-ids map and * the role active for this instance. * * Throws if `activeRole` isn't a key in `roles` — fail loud at * config time, not at first denied call. */ static fromRoles(roles, activeRole, rules) { return new PermissionPolicy({ roles, activeRole, ...(rules !== undefined && { rules }) }); } /** * Sync allowlist check. Use as a predicate with `gatedTools`: * * gatedTools(staticTools(allTools), (toolId) => policy.isAllowed(toolId)) * * Returns true iff `toolId` is in the active role's allowlist. * Closes-fail by design: missing role membership = denied. */ isAllowed(toolId) { return (this.opts.roles[this.opts.activeRole] ?? []).includes(toolId); } /** * Async check matching the `PermissionChecker` interface — consumed * by `Agent.create({ permissionChecker })`. Wraps `isAllowed` with * the structured `PermissionDecision` envelope (allow / deny + a * `policyRuleId` so observability can trace which role decided). * * ## What each capability is judged by (9.11.0) * * - `'tool_call'` — the tool allowlist. Unchanged since v2.4. * - `'skill_read'` — the active role's `rules.skills` list, when it has one. * Without one, allowed: a policy that never mentioned skills has not * denied any, and silence is not a refusal. * - a {@link ToolCapability} — the active role's `rules.capabilities` list, * when it has one. Without one, it falls through to the tool allowlist * below, which the tool already passed on its `'tool_call'` check — so a * role with no capability rule behaves exactly as it did before. * * The framework only ever sends the last two to a policy that declared * {@link PermissionPolicy.governs}, which this class derives from the rules * themselves — so "unconfigured" and "never asked" cannot drift apart. */ async check(request) { if (request.capability === 'skill_read') return this.checkSkill(request); if (request.capability !== 'tool_call') { const rule = this.opts.rules?.capabilities?.[this.opts.activeRole]; if (rule !== undefined && !rule.includes(request.capability)) { return { result: 'deny', policyRuleId: `${this.opts.activeRole}.capabilities.miss`, rationale: `The '${this.opts.activeRole}' role may not exercise '${request.capability}'` + `${request.target ? `, which tool '${request.target}' declares` : ''}.`, }; } } const toolId = request.target ?? request.capability; if (this.isAllowed(toolId)) { return { result: 'allow', policyRuleId: `${this.opts.activeRole}.allowlist`, }; } return { result: 'deny', policyRuleId: `${this.opts.activeRole}.allowlist.miss`, rationale: `Tool '${toolId}' is not in the '${this.opts.activeRole}' role allowlist.`, }; } /** * Skill activation, judged on its own map (9.11.0). * * Deliberately NOT routed through the tool allowlist: a `skill:<id>` target * is not a tool name, so `isAllowed` would deny every skill for every role * the moment a policy was attached — hiding a whole catalogue nobody asked * to hide. A role with no skill rule sees every skill; a role WITH one sees * exactly what it lists. */ checkSkill(request) { const rule = this.opts.rules?.skills?.[this.opts.activeRole]; if (rule === undefined) { return { result: 'allow', policyRuleId: `${this.opts.activeRole}.skills.unruled` }; } const skillId = (0, skillTarget_js_1.skillIdFromTarget)(request.target ?? ''); if (rule.includes(skillId)) { return { result: 'allow', policyRuleId: `${this.opts.activeRole}.skills` }; } return { result: 'deny', policyRuleId: `${this.opts.activeRole}.skills.miss`, rationale: `Skill '${skillId}' is not available to the '${this.opts.activeRole}' role.`, }; } /** * Derive a sibling policy with a different active role. Same role * map; different active role. Useful for per-identity routing * (one policy instance per request, varying active role per caller). * * Returns a NEW PermissionPolicy — original is unchanged. */ withActiveRole(activeRole) { return new PermissionPolicy({ roles: this.opts.roles, activeRole, // The rules travel with the policy — a per-caller role swap that dropped // them would silently widen what the new role may do. ...(this.opts.rules !== undefined && { rules: this.opts.rules }), }); } /** The role name currently active. Useful for observability. */ get activeRole() { return this.opts.activeRole; } /** All defined role names. Stable order = registration order. */ get roles() { return Object.keys(this.opts.roles); } /** All tool ids allowed under the current active role. */ allowedToolIds() { return [...(this.opts.roles[this.opts.activeRole] ?? [])]; } } exports.PermissionPolicy = PermissionPolicy; //# sourceMappingURL=PermissionPolicy.js.map