Files
my-pi/pi-permission-system/src/index.ts
T
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 7e6a8faae1 feat(pi-permission-system): AutoReview 改为跟随主模型的对话式工具调用审查
AutoReview:

- 审查器回到 Codex Guardian 风格的对话式审查,默认跟随会话当前主模型
  (每次审查实时读取 ctx.model);provider/model 必须同时设置才固定审查
  模型,只设其一视为配置错误并转人工。
- 结论只通过 approve / reject 两个工具给出,参数仅 reason;不强制
  toolChoice,正文文本(即使是合法 JSON)一律忽略。
- approve 自动放行;reject 转人工并在弹窗显示理由(标注模型生成)。
  超时、Provider 错误、截断、未调用工具、多次调用、参数无效等异常同样
  转人工,并附固定的系统说明;用户按 Esc 中止时直接拒绝。
- 模型调用改为 modelRegistry.streamSimple(...).result():Pi 1.3.0 面向
  扩展的 ModelRegistry 没有 completeSimple,原写法在真实环境中每次都会
  转人工。
- 对话记录保留可信度标注;工具结果在有更短的 pi-compaction 摘要时替换
  为摘要;token 估算改为非 ASCII 字符按 1 token 计;预算可配置。
- 同一条用户消息内完全相同的请求只缓存 approve 结论。
- 默认 reasoning low、总超时 30 秒、最多 2 次尝试、maxOutputTokens 4000。

删除:

- 未发布过的 TypeSafe Jev 分类器实现、DenialCircuitBreaker。
- 整个 annotator 机制(model-explanation、注册表、registerAnnotator、
  provider-timeout);annotators 配置键保留为已弃用并忽略,避免旧配置
  被拒绝。

测试:

- 新增 reviewer / transcript / 弹窗说明 / 默认配置测试;清理重复、恒真
  或只测实现细节的旧测试。
- 使用 deepseek-v4.1-flash 实测 5 个场景各 5 次,25 次结论全部符合预期。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 01:54:17 -07:00

331 lines
13 KiB
TypeScript

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import { warmBashParser } from "./access-intent/bash/parser";
import { buildResolvedIntentFromMatchValues } from "./access-intent/input-normalizer";
import { AuthorizerRegistry } from "./authority/authorizer-registry";
import { AuthorizerSelection } from "./authority/authorizer-selection";
import {
ForwardedRequestServer,
type ServingPolicy,
} from "./authority/forwarded-request-server";
import {
ForwardingLivenessJudge,
ServingHeartbeatStore,
} from "./authority/forwarding-liveness";
import { ForwardingManager } from "./authority/forwarding-manager";
import { PERMISSION_FORWARDING_TIMEOUT_MS } from "./authority/permission-forwarding";
import { requestPermissionDecision } from "./authority/permission-prompt-component";
import { PermissionPrompter } from "./authority/permission-prompter";
import {
composeServingAnnouncers,
getServingSessionRegistry,
} from "./authority/serving-registry";
import { SubagentDetection } from "./authority/subagent-detection";
import { subscribeSubagentLifecycle } from "./authority/subagent-lifecycle-events";
import { getSubagentSessionRegistry } from "./authority/subagent-registry";
import { registerBuiltinToolInputFormatters } from "./builtin-tool-input-formatters";
import { registerPermissionSystemCommand } from "./config-modal";
import { getGlobalConfigPath } from "./config-paths";
import { ConfigStore } from "./config-store";
import { DecisionAudit } from "./decision-audit";
import { GateDecisionReporter } from "./decision-reporter";
import { isYoloModeEnabled } from "./extension-config";
import { computeExtensionPaths } from "./extension-paths";
import {
AgentPrepHandler,
PermissionGateHandler,
SessionLifecycleHandler,
} from "./handlers";
import { GateRunner } from "./handlers/gates/runner";
import { SkillInputGatePipeline } from "./handlers/gates/skill-input-gate-pipeline";
import { ToolCallGatePipeline } from "./handlers/gates/tool-call-gate-pipeline";
import { installBuiltinAutoReview } from "./auto-review/runtime";
import { createFailClosedToolCall } from "./handlers/tool-call-boundary";
import { pathFlavorForPlatform } from "./path/path-flavor";
import { PermissionManager } from "./permission-manager";
import { HardGuardPolicyLoader } from "./hard-guard-loader";
import { getHardGuardConfigPath } from "./config-paths";
import { PermissionResolver } from "./permission-resolver";
import { PermissionSession } from "./permission-session";
import { LocalPermissionsService } from "./permissions-service";
import { resolveRenderBudget } from "./presentation/dialog-renderer";
import { PermissionServiceLifecycle } from "./service-lifecycle";
import { PermissionSessionLogger } from "./session-logger";
import { SessionRules } from "./session-rules";
import { ToolAccessExtractorRegistry } from "./tool-access-extractor-registry";
import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
const agentDir = getAgentDir();
const paths = computeExtensionPaths(agentDir);
// The single process.platform read for the whole extension, resolved once
// into the path-language flavor that every consumer shares (the session's
// PathNormalizer, rule evaluation, and subagent detection). Interior modules
// must not read process.platform (enforced by the eslint guard scoped to
// src/) and never re-derive the win32 flavor — they receive this product.
const hostFlavor = pathFlavorForPlatform(process.platform);
const sessionRules = new SessionRules();
const subagentRegistry = getSubagentSessionRegistry();
// Process-global, like subagentRegistry: an in-process child reads it from a
// separate jiti instance to learn whether its parent is draining its inbox.
const servingRegistry = getServingSessionRegistry();
// Single owner of subagent detection, shared across every consumer instead of
// threading the (subagentSessionsDir, platform, registry) triple into each.
const subagentDetection = new SubagentDetection({
subagentSessionsDir: paths.subagentSessionsDir,
flavor: hostFlavor,
registry: subagentRegistry,
});
const formatterRegistry = new ToolInputFormatterRegistry();
registerBuiltinToolInputFormatters(formatterRegistry);
const accessExtractorRegistry = new ToolAccessExtractorRegistry();
// One registry instance backs both the registerAuthorizer service surface and
// AuthorizerSelection's chain resolution, so a registration is visible to
// composition.
const authorizerRegistry = new AuthorizerRegistry();
// Both `configStore` and `session` are forward-declared so the logger's
// lazy thunks can close over them without a cast or null-init holder.
// TypeScript exempts closure captures from definite-assignment analysis;
// all synchronous reads occur after the assignments below.
// eslint-disable-next-line prefer-const -- forward-declared let; `const` requires an initializer
let configStore: ConfigStore;
// eslint-disable-next-line prefer-const -- forward-declared let; `const` requires an initializer
let session: PermissionSession;
// Declared after the `configStore` forward declaration so the reader can
// close over it; every call runs after configStore is assigned below. yolo is
// a composition-stage ask→allow rewrite (#526) that the gate runner extends
// to asks synthesized after resolution (#712), so both share this reader.
const isYoloEnabled = (): boolean => isYoloModeEnabled(configStore.current());
const permissionManager = new PermissionManager({
agentDir,
flavor: hostFlavor,
isYoloEnabled,
});
const logger = new PermissionSessionLogger({
globalLogsDir: paths.globalLogsDir,
getConfig: () => configStore.current(),
notify: (message) => session.notify(message),
});
configStore = new ConfigStore({
agentDir,
policyPaths: permissionManager,
logger,
});
const prompter = new PermissionPrompter({ logger });
// The filesystem half of the serving announcement. `servingRegistry` reaches
// an in-process child through `globalThis`; a child in its own process shares
// nothing but this directory, so the served session publishes a heartbeat
// there too (#721).
const servingHeartbeats = new ServingHeartbeatStore({
forwardingDir: paths.forwardingDir,
logger,
});
// The read side of both channels, routed by how the target was resolved.
const servingLiveness = new ForwardingLivenessJudge({
registry: servingRegistry,
heartbeats: servingHeartbeats,
});
const authorizerSelection = new AuthorizerSelection({
detection: subagentDetection,
events: pi.events,
getPromptPreferences: () => ({
doublePressToConfirm: configStore.current().doublePressToConfirm,
budget: resolveRenderBudget(configStore.current()),
}),
requestPermissionDecision,
forwardingDir: paths.forwardingDir,
registry: subagentRegistry,
serving: servingLiveness,
getForwardingTimeoutMs: () =>
configStore.current().forwardingTimeoutMs ??
PERMISSION_FORWARDING_TIMEOUT_MS,
logger,
prompter,
// The published service is the narrow, session-scoped PermissionQuery a
// chain link is handed (it routes bash/path at gate parity against the live
// session cwd). A thunk because `permissionsService` is constructed below;
// it resolves at session_start (activate), well after assignment.
getPermissionQuery: () => permissionsService,
// Same registry instance the registerAuthorizer service surface writes to,
// resolved in config order at activation.
authorizerRegistry,
getAuthorizerChain: () => configStore.current().authorizerChain ?? [],
});
// Resolver composes the manager + session ruleset and owns the
// access-path → path-values unwrap. Constructed here (before `session`) so
// the forwarded-request server's ServingPolicy can resolve against it; the
// service and gates below share this one instance.
const hardGuardManager = new PermissionManager({
policyLoader: new HardGuardPolicyLoader(getHardGuardConfigPath(agentDir)),
flavor: hostFlavor,
});
const resolver = new PermissionResolver(
permissionManager,
sessionRules,
hardGuardManager,
);
// Serving a forwarded request is resolution: resolve the child-fixed
// ForwardedAccessIntent (ADR 0008) directly against the serving node's
// composed ruleset, agent-scoped to the requester (§3) — the match values
// are used as fixed by the child, never re-derived through this session's
// PathNormalizer/cwd (#597).
const servingPolicy: ServingPolicy = {
resolve: (intent) =>
resolver.resolve(
buildResolvedIntentFromMatchValues(
intent.surface,
intent.matchValues,
intent.principal.agentName,
),
),
};
const requestServer = new ForwardedRequestServer({
forwardingDir: paths.forwardingDir,
logger,
policy: servingPolicy,
escalator: authorizerSelection,
// Records a whole-session grant into the same SessionRules the resolver and
// gate runner read, so a serving-scope grant governs the parent and future
// forwarded resolutions.
recorder: sessionRules,
registry: subagentRegistry,
});
session = new PermissionSession(
paths,
new ForwardingManager({
detection: subagentDetection,
forwarder: requestServer,
serving: composeServingAnnouncers(servingRegistry, servingHeartbeats),
logger,
}),
permissionManager,
sessionRules,
configStore,
authorizerSelection,
hostFlavor,
);
// refresh() must run after `session` is assigned: a debug-write IO failure
// triggers the logger's notify sink — `session.notify(m)` — which no-ops
// on the null context but requires `session` to be bound.
// No ctx/trust decision exists at factory init, so withhold the project
// scope (fail closed); session_start reloads with the real trust decision.
configStore.refresh(undefined, false);
const configPath = getGlobalConfigPath(agentDir);
registerPermissionSystemCommand(pi, {
config: configStore,
configPath,
getActiveAgentConfigRules: () =>
permissionManager.getComposedConfigRules(
session.lastKnownActiveAgentName ?? undefined,
),
});
const permissionsService = new LocalPermissionsService(
resolver,
session,
formatterRegistry,
accessExtractorRegistry,
authorizerRegistry,
);
const autoReview = installBuiltinAutoReview({
service: permissionsService,
config: configStore,
});
// Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
// sessions register/unregister without the core calling us (ADR 0002).
const unsubSubagentLifecycle = subscribeSubagentLifecycle(
pi.events,
subagentRegistry,
);
// PermissionServiceLifecycle owns the process-global service publication:
// activate() publishes (skipped for registered subagent children — see #302)
// and emits ready; teardown() unsubscribes all session listeners and
// unpublishes. Deferred to session_start because identifying a child
// requires the session id from ctx, unavailable at factory-init time.
const serviceLifecycle = new PermissionServiceLifecycle(
permissionsService,
subagentDetection,
pi.events,
[unsubSubagentLifecycle],
);
const toolRegistry = {
getAll: () => pi.getAllTools(),
};
const audit = new DecisionAudit();
const lifecycle = new SessionLifecycleHandler(
session,
resolver,
serviceLifecycle,
logger,
audit,
);
const agentPrep = new AgentPrepHandler(session, resolver, () => {
void warmBashParser();
});
const reporter = new GateDecisionReporter(logger, pi.events);
const gateRunner = new GateRunner(
resolver,
sessionRules,
authorizerSelection,
reporter,
isYoloEnabled,
);
const toolCallGatePipeline = new ToolCallGatePipeline(
resolver,
session,
formatterRegistry,
accessExtractorRegistry,
);
const skillInputGatePipeline = new SkillInputGatePipeline(resolver);
const gates = new PermissionGateHandler(
session,
toolRegistry,
toolCallGatePipeline,
skillInputGatePipeline,
gateRunner,
);
pi.on("session_start", async (event, ctx) => {
await lifecycle.handleSessionStart(event, ctx);
autoReview.handleSessionStart(ctx);
});
pi.on("resources_discover", (event, ctx) =>
lifecycle.handleResourcesDiscover(event, ctx),
);
pi.on("session_shutdown", async () => {
autoReview.handleSessionShutdown();
await lifecycle.handleSessionShutdown();
});
pi.on("before_agent_start", (event, ctx) => agentPrep.handle(event, ctx));
pi.on("input", (event, ctx) => gates.handleInput(event, ctx));
pi.on(
"tool_call",
createFailClosedToolCall(
(event, ctx) => gates.handleToolCall(event, ctx),
reporter,
audit,
logger,
),
);
}