agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
241 lines • 11.1 kB
JavaScript
/**
* 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.
*/
import { skillIdFromTarget } from './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`.
*/
export 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 = 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] ?? [])];
}
}
//# sourceMappingURL=PermissionPolicy.js.map