feat(agent): capability registry, Pi AgentRuntimePort, fake model provider and redaction guard (W1-5)
- CapabilityRegistry: descriptor(id/version/schema/scope/risk/idempotent/preview/replay), invoke = scope limit -> input schema -> server-side authorize -> idempotency key -> handler -> output schema -> audit; capability id <-> tool name mapping (download.submit <-> download__submit) - PiAgentRuntime (pi-agent-core AgentHarness 0.87.1): whitelist tools + before_tool re-check, redacted event stream, JSONL session at sessionDir/<runId>.jsonl, resume/abort/compact, tool-call/duration/token/cost budgets -> abort + run_failed(budget) - AgentSettings -> pi-ai createProvider (anthropic / openai-compatible), compaction mapping - FakeModelProvider + createFakeRuntime (scripted, transcript-derived, survives crash/resume) - buildSystemPrompt, redactForModel; 27 tests incl. SIGKILL + resume Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
1262340856
commit
9c881e737f
24 files changed
+2419
-8
No files matched your search
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 能力处理器与注册中心统一的错误类型。
|
||||
* - handler 只应抛 unauthorized / precondition / not_found / conflict / budget
|
||||
* - invalid_input / invalid_output / internal 由注册中心自身产生
|
||||
*/
|
||||
export type CapabilityErrorCode =
|
||||
| "unauthorized"
|
||||
| "precondition"
|
||||
| "not_found"
|
||||
| "conflict"
|
||||
| "budget"
|
||||
| "invalid_input"
|
||||
| "invalid_output"
|
||||
| "internal";
|
||||
|
||||
export class CapabilityError extends Error {
|
||||
readonly code: CapabilityErrorCode;
|
||||
readonly details?: Record<string, unknown>;
|
||||
|
||||
constructor(code: CapabilityErrorCode, message: string, details?: Record<string, unknown>) {
|
||||
super(message);
|
||||
this.name = "CapabilityError";
|
||||
this.code = code;
|
||||
if (details !== undefined) this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export function isCapabilityError(error: unknown): error is CapabilityError {
|
||||
return error instanceof CapabilityError;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { summarizeForLog } from "../redact";
|
||||
import { CapabilityError, isCapabilityError } from "./errors";
|
||||
import { capabilityIdToToolName, isCapabilityId } from "./tool-name";
|
||||
import {
|
||||
type AuditSink,
|
||||
type AuthorizationChecker,
|
||||
type AuthorizationDecision,
|
||||
CAPABILITY_SCOPES,
|
||||
type CapabilityAuditRecord,
|
||||
type CapabilityDescriptor,
|
||||
type CapabilityHandler,
|
||||
type CapabilityScope,
|
||||
type HandlerContext,
|
||||
type IdempotencyKeyGenerator,
|
||||
type InvokeContext,
|
||||
} from "./types";
|
||||
|
||||
export interface RegisteredCapability {
|
||||
descriptor: CapabilityDescriptor;
|
||||
handler: CapabilityHandler;
|
||||
toolName: string;
|
||||
}
|
||||
|
||||
export interface CapabilityRegistryOptions {
|
||||
/**
|
||||
* 授权回调(服务端注入):检查 automation_grant / approval。
|
||||
* 未注入时:read/preview 放行,propose/execute 一律拒绝(安全默认)。
|
||||
*/
|
||||
authorize?: AuthorizationChecker;
|
||||
/** 需要走授权回调的作用域,默认 propose + execute */
|
||||
authorizeScopes?: readonly CapabilityScope[];
|
||||
/** 审计回调;每次调用(含拒绝/校验失败)都会触发一次 */
|
||||
audit?: AuditSink;
|
||||
/** 幂等键生成(服务端基于操作语义生成,不信任模型);默认对 id+version+规范化输入做 sha256 */
|
||||
idempotencyKey?: IdempotencyKeyGenerator;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
/** 稳定序列化:对象键排序,保证同一语义输入得到同一幂等键 */
|
||||
export function stableStringify(value: unknown): string {
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
||||
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
|
||||
}
|
||||
|
||||
export const defaultIdempotencyKey: IdempotencyKeyGenerator = (descriptor, input) =>
|
||||
`${descriptor.id}:v${descriptor.version}:${createHash("sha256").update(stableStringify(input)).digest("hex").slice(0, 32)}`;
|
||||
|
||||
/**
|
||||
* 能力注册中心(REQ §10.3)。Web、Pi tools、CLI、MCP 共用同一个实例与同一条 invoke 路径,互不绕过权限。
|
||||
* invoke 顺序:查找 → 作用域白名单 → 输入 schema → 授权回调 → 幂等键 → handler → 输出 schema → 审计。
|
||||
*/
|
||||
export class CapabilityRegistry {
|
||||
private readonly entries = new Map<string, RegisteredCapability>();
|
||||
private readonly options: CapabilityRegistryOptions;
|
||||
|
||||
constructor(options: CapabilityRegistryOptions = {}) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
register<I extends z.ZodType, O extends z.ZodType>(
|
||||
descriptor: CapabilityDescriptor<I, O>,
|
||||
handler: CapabilityHandler<I, O>,
|
||||
): this {
|
||||
if (!isCapabilityId(descriptor.id)) throw new Error(`能力 ID 必须是 域.动作 形式: ${descriptor.id}`);
|
||||
if (this.entries.has(descriptor.id)) throw new Error(`能力已注册: ${descriptor.id}`);
|
||||
if (!Number.isInteger(descriptor.version) || descriptor.version < 1) {
|
||||
throw new Error(`能力版本必须是正整数: ${descriptor.id}`);
|
||||
}
|
||||
if (!CAPABILITY_SCOPES.includes(descriptor.scope)) throw new Error(`未知作用域: ${descriptor.scope}`);
|
||||
if (!descriptor.description.trim()) throw new Error(`能力缺少给模型看的说明: ${descriptor.id}`);
|
||||
if (descriptor.scope === "execute" && descriptor.replay === "safe" && !descriptor.idempotent) {
|
||||
throw new Error(`有副作用且非幂等的能力不能声明 replay:'safe': ${descriptor.id}`);
|
||||
}
|
||||
const toolName = capabilityIdToToolName(descriptor.id);
|
||||
this.entries.set(descriptor.id, {
|
||||
descriptor: descriptor as unknown as CapabilityDescriptor,
|
||||
handler: handler as unknown as CapabilityHandler,
|
||||
toolName,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
has(id: string): boolean {
|
||||
return this.entries.has(id);
|
||||
}
|
||||
|
||||
get(id: string): RegisteredCapability | undefined {
|
||||
return this.entries.get(id);
|
||||
}
|
||||
|
||||
list(): RegisteredCapability[] {
|
||||
return [...this.entries.values()];
|
||||
}
|
||||
|
||||
/** 解析白名单;未知 ID 直接报错,防止拼写错误悄悄缩小/扩大范围 */
|
||||
resolve(ids: readonly string[]): RegisteredCapability[] {
|
||||
return [...new Set(ids)].map((id) => {
|
||||
const entry = this.entries.get(id);
|
||||
if (!entry) throw new Error(`白名单里的能力未注册: ${id}`);
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
/** 输入 JSON Schema(给模型的工具参数定义) */
|
||||
inputJsonSchema(id: string): Record<string, unknown> {
|
||||
const entry = this.entries.get(id);
|
||||
if (!entry) throw new Error(`能力未注册: ${id}`);
|
||||
const schema = z.toJSONSchema(entry.descriptor.inputSchema, { io: "input", unrepresentable: "any" }) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
delete schema.$schema;
|
||||
return schema;
|
||||
}
|
||||
|
||||
async invoke(id: string, input: unknown, ctx: InvokeContext): Promise<unknown> {
|
||||
const startedAt = this.options.now?.() ?? new Date();
|
||||
const t0 = performance.now();
|
||||
const entry = this.entries.get(id);
|
||||
if (!entry) throw new CapabilityError("not_found", `能力不存在: ${id}`);
|
||||
const { descriptor, handler } = entry;
|
||||
|
||||
const audit = async (patch: Pick<CapabilityAuditRecord, "outcome"> & Partial<CapabilityAuditRecord>) => {
|
||||
if (!this.options.audit) return;
|
||||
const record: CapabilityAuditRecord = {
|
||||
capabilityId: descriptor.id,
|
||||
version: descriptor.version,
|
||||
scope: descriptor.scope,
|
||||
riskLevel: descriptor.riskLevel,
|
||||
actorId: ctx.actorId,
|
||||
taskId: ctx.taskId,
|
||||
inputSummary: summarizeForLog(input),
|
||||
startedAt,
|
||||
durationMs: Math.round(performance.now() - t0),
|
||||
...(ctx.source ? { source: ctx.source } : {}),
|
||||
...(ctx.invocationId ? { invocationId: ctx.invocationId } : {}),
|
||||
...(ctx.agentRunId ? { agentRunId: ctx.agentRunId } : {}),
|
||||
...patch,
|
||||
};
|
||||
try {
|
||||
await this.options.audit(record);
|
||||
} catch {
|
||||
// 审计失败不影响业务结果;由审计实现自行告警
|
||||
}
|
||||
};
|
||||
const fail = async (error: CapabilityError, outcome: CapabilityAuditRecord["outcome"], extra = {}) => {
|
||||
await audit({ outcome, errorCode: error.code, errorMessage: summarizeForLog(error.message, 300), ...extra });
|
||||
return error;
|
||||
};
|
||||
|
||||
if (ctx.signal.aborted) throw await fail(new CapabilityError("precondition", "调用已取消"), "denied");
|
||||
|
||||
// 1. 作用域:调用方给出的上限
|
||||
if (ctx.allowedScopes && !ctx.allowedScopes.includes(descriptor.scope)) {
|
||||
throw await fail(
|
||||
new CapabilityError("unauthorized", `当前任务不允许 ${descriptor.scope} 级能力: ${descriptor.id}`),
|
||||
"denied",
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 输入 schema
|
||||
const parsed = descriptor.inputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw await fail(
|
||||
new CapabilityError(
|
||||
"invalid_input",
|
||||
`输入不符合 ${descriptor.id} 的参数定义: ${z.prettifyError(parsed.error)}`,
|
||||
),
|
||||
"invalid",
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 授权(服务端判定,模型声明无效)
|
||||
const authorizeScopes = this.options.authorizeScopes ?? ["propose", "execute"];
|
||||
let decision: AuthorizationDecision | undefined;
|
||||
if (authorizeScopes.includes(descriptor.scope)) {
|
||||
if (!this.options.authorize) {
|
||||
throw await fail(new CapabilityError("unauthorized", `未配置授权校验,拒绝 ${descriptor.id}`), "denied");
|
||||
}
|
||||
try {
|
||||
decision = await this.options.authorize({ descriptor, input: parsed.data, ctx });
|
||||
} catch (error) {
|
||||
throw await fail(new CapabilityError("unauthorized", `授权校验失败: ${errorMessage(error)}`), "denied");
|
||||
}
|
||||
if (!decision.allowed) {
|
||||
throw await fail(new CapabilityError("unauthorized", decision.reason), "denied");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 幂等键(只对有副作用的作用域生成)
|
||||
let idempotencyKey: string | undefined;
|
||||
if (descriptor.scope === "propose" || descriptor.scope === "execute") {
|
||||
idempotencyKey = (this.options.idempotencyKey ?? defaultIdempotencyKey)(descriptor, parsed.data, ctx);
|
||||
}
|
||||
|
||||
const grantPatch = {
|
||||
...(decision?.allowed && decision.grantId ? { grantId: decision.grantId } : {}),
|
||||
...(decision?.allowed && decision.approvalId ? { approvalId: decision.approvalId } : {}),
|
||||
...(idempotencyKey ? { idempotencyKey } : {}),
|
||||
};
|
||||
const handlerCtx: HandlerContext = {
|
||||
...ctx,
|
||||
...(idempotencyKey ? { idempotencyKey } : {}),
|
||||
...(decision?.allowed ? { authorization: decision } : {}),
|
||||
};
|
||||
|
||||
// 5. 执行
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await handler(parsed.data, handlerCtx);
|
||||
} catch (error) {
|
||||
const capErr = isCapabilityError(error) ? error : new CapabilityError("internal", errorMessage(error));
|
||||
throw await fail(capErr, "error", grantPatch);
|
||||
}
|
||||
|
||||
// 6. 输出 schema
|
||||
const out = descriptor.outputSchema.safeParse(raw);
|
||||
if (!out.success) {
|
||||
throw await fail(
|
||||
new CapabilityError("invalid_output", `${descriptor.id} 返回值不符合输出定义: ${z.prettifyError(out.error)}`),
|
||||
"error",
|
||||
grantPatch,
|
||||
);
|
||||
}
|
||||
await audit({ outcome: "ok", ...grantPatch });
|
||||
return out.data;
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { CapabilityId } from "@umt/contracts";
|
||||
|
||||
/**
|
||||
* 能力 ID ↔ 模型工具名 的唯一映射。
|
||||
* 上游(Anthropic/OpenAI)工具名只允许 ^[a-zA-Z0-9_-]{1,64}$,不能带点,
|
||||
* 所以 `download.submit` 对 Pi 暴露为 `download__submit`。
|
||||
* 域名部分不含下划线,反向映射按第一个 `__` 切分即可无歧义。
|
||||
*/
|
||||
const TOOL_NAME_SEPARATOR = "__";
|
||||
const TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
|
||||
|
||||
export function isCapabilityId(id: string): boolean {
|
||||
return CapabilityId.safeParse(id).success;
|
||||
}
|
||||
|
||||
export function capabilityIdToToolName(id: string): string {
|
||||
if (!isCapabilityId(id)) throw new Error(`非法能力 ID: ${id}`);
|
||||
const dot = id.indexOf(".");
|
||||
const name = `${id.slice(0, dot)}${TOOL_NAME_SEPARATOR}${id.slice(dot + 1)}`;
|
||||
if (!TOOL_NAME_PATTERN.test(name)) throw new Error(`能力 ID 映射后的工具名超长或非法: ${name}`);
|
||||
return name;
|
||||
}
|
||||
|
||||
/** 工具名不是合法映射时返回 undefined(例如模型编造的 `bash`)。 */
|
||||
export function toolNameToCapabilityId(name: string): string | undefined {
|
||||
const at = name.indexOf(TOOL_NAME_SEPARATOR);
|
||||
if (at <= 0) return undefined;
|
||||
const id = `${name.slice(0, at)}.${name.slice(at + TOOL_NAME_SEPARATOR.length)}`;
|
||||
return isCapabilityId(id) ? id : undefined;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { CapabilityContext } from "@umt/contracts";
|
||||
import type { z } from "zod";
|
||||
|
||||
/**
|
||||
* 能力作用域(由弱到强):
|
||||
* - read:只读查询
|
||||
* - preview:生成预览/方案,不落任何变更
|
||||
* - propose:生成并保存变更计划(MutationPlan),等待授权
|
||||
* - execute:产生外部副作用(下载提交、Copy/Move、策略发布…)
|
||||
*/
|
||||
export const CAPABILITY_SCOPES = ["read", "preview", "propose", "execute"] as const;
|
||||
export type CapabilityScope = (typeof CAPABILITY_SCOPES)[number];
|
||||
|
||||
export const CAPABILITY_RISK_LEVELS = ["low", "medium", "high", "critical"] as const;
|
||||
export type CapabilityRiskLevel = (typeof CAPABILITY_RISK_LEVELS)[number];
|
||||
|
||||
/**
|
||||
* 能力描述。description 会原样给模型看,用中文写清用途、边界与返回含义。
|
||||
* replay:崩溃恢复时 harness 是否可以用持久化参数重跑;有副作用的一律 'never'。
|
||||
*/
|
||||
export interface CapabilityDescriptor<I extends z.ZodType = z.ZodType, O extends z.ZodType = z.ZodType> {
|
||||
id: string;
|
||||
version: number;
|
||||
title: string;
|
||||
description: string;
|
||||
inputSchema: I;
|
||||
outputSchema: O;
|
||||
scope: CapabilityScope;
|
||||
riskLevel: CapabilityRiskLevel;
|
||||
idempotent: boolean;
|
||||
supportsPreview: boolean;
|
||||
replay: "safe" | "never";
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用上下文:contracts 的 CapabilityContext(actorId/taskId/scopeIds/signal)加上本包需要的字段。
|
||||
* allowedScopes:本次调用方允许触达的最高作用域集合(例如"先给方案"模式只给 read/preview/propose)。
|
||||
*/
|
||||
export interface InvokeContext extends CapabilityContext {
|
||||
allowedScopes?: readonly CapabilityScope[];
|
||||
/** 调用来源,仅用于审计 */
|
||||
source?: "agent" | "web" | "cli" | "mcp" | "task";
|
||||
/** agent 调用时的 tool call id / invocation id,用于审计对账 */
|
||||
invocationId?: string;
|
||||
agentRunId?: string;
|
||||
}
|
||||
|
||||
/** handler 实际收到的上下文:注册中心补上服务端生成的幂等键与授权结果 */
|
||||
export interface HandlerContext extends InvokeContext {
|
||||
idempotencyKey?: string;
|
||||
authorization?: AuthorizationDecision & { allowed: true };
|
||||
}
|
||||
|
||||
export type CapabilityHandler<I extends z.ZodType = z.ZodType, O extends z.ZodType = z.ZodType> = (
|
||||
input: z.infer<I>,
|
||||
ctx: HandlerContext,
|
||||
) => Promise<z.input<O>> | z.input<O>;
|
||||
|
||||
export interface AuthorizationRequest {
|
||||
descriptor: CapabilityDescriptor;
|
||||
input: unknown;
|
||||
ctx: InvokeContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* 授权结论。由服务端基于 automation_grant / approval 记录判定;
|
||||
* 模型参数里的"用户已同意"之类声明一律无效,checker 不应读取。
|
||||
*/
|
||||
export type AuthorizationDecision =
|
||||
| { allowed: true; grantId?: string; approvalId?: string }
|
||||
| { allowed: false; reason: string };
|
||||
|
||||
export type AuthorizationChecker = (
|
||||
request: AuthorizationRequest,
|
||||
) => Promise<AuthorizationDecision> | AuthorizationDecision;
|
||||
|
||||
export type IdempotencyKeyGenerator = (descriptor: CapabilityDescriptor, input: unknown, ctx: InvokeContext) => string;
|
||||
|
||||
export type AuditOutcome = "ok" | "denied" | "invalid" | "error";
|
||||
|
||||
export interface CapabilityAuditRecord {
|
||||
capabilityId: string;
|
||||
version: number;
|
||||
scope: CapabilityScope;
|
||||
riskLevel: CapabilityRiskLevel;
|
||||
actorId: string;
|
||||
taskId: string;
|
||||
source?: InvokeContext["source"];
|
||||
invocationId?: string;
|
||||
agentRunId?: string;
|
||||
outcome: AuditOutcome;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
idempotencyKey?: string;
|
||||
grantId?: string;
|
||||
approvalId?: string;
|
||||
/** 已脱敏的输入摘要 */
|
||||
inputSummary: string;
|
||||
startedAt: Date;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export type AuditSink = (record: CapabilityAuditRecord) => void | Promise<void>;
|
||||
Reference in new issue
Block a user