跳至正文

机器翻译。英文版本为权威来源,母语审校尚未完成。

Claude Code

在钩子内部的 PEP:在 Claude Code 内部实施拒绝关闭策略

作者 Olivares AI 5 分钟阅读

一个平台团队在其工程组织中启用了Claude Code。为了在工具调用上执行策略,他们连接了一个hook服务器——这是一个本地进程,在stdin上接收hook事件,并在stdout上返回JSON决策。第一周看起来一切正常。PreToolUse在每次工具调用前触发,hook在任何涉及生产的操作上返回deny,团队认为执行有效。

然后Claude Code发布了一个新版本。PermissionRequest开始与PreToolUse一同触发。hook服务器返回相同的permissionDecision JSON,这与之前返回的一样。Claude Code接受响应,解析它,发现没有它能识别的字段对应该事件,并继续操作,就好像没有给出任何决策一样。拒绝动作被悄悄忽略。团队的执行点现在成了一面墙,墙上有一个gap,但日志中没有任何信息显示这一点。

这是一个受控的PEP必须解决的问题:Claude Code的hook生命周期不是一个具有单一线格式的事件。它大约包含30个事件,每个事件都有不同的输出模式,运行时会遵循这些模式,而模式中的错误无法与无响应区分。

线机制就是安全合约

单个permissionDecision字段无法在所有地方工作的原因是Claude Code的hook事件是独立演变的。用于控制工具调用的输出形状并不是控制提示提交的形状,也不是停止任务的形状。

有六种不同的线机制:

机制输出形状事件
permissionDecisionhookSpecificOutput.permissionDecision(允许/deny/ask/defer)使用前工具
permissionBehaviorhookSpecificOutput.decision.behavior(允许/deny)权限请求
topLevelDecision顶级decision: "block" + reason用户提示提交、用户提示扩展、紧缩前、配置更改、工具批处理后
continueFalsecontinue: false + stopReason任务创建、任务完成、队友空闲
postToolUsedecision: "block"(阻止进一步处理;工具已运行)使用后工具
neutral无可强制执行的阻止所有 context/observe 事件,以及像 Stop 这样的反转事件

如果一个 PEP 在 PermissionRequest 事件上返回 permissionDecision: "deny",Claude Code 会忽略它——该事件期望 decision.behavior,而不是 permissionDecision。JSON 是有效的,HTTP 响应是 200,并且 deny 不存在。这不是理论上的边缘情况;这是记录在案的传输协议,并已针对 code.claude.com/docs/en/hooks 进行验证。

三分类:gating、context、observe

并非每个钩子事件都能控制一个动作。试图拒绝 SessionEndNotification 是没有意义的——这些事件是信息性的,不带有决策控制。试图拒绝 Stop 事件比没有意义还要糟糕:decision: "block"Stop 上保持代理运行,这正好与安全停止相反。

PEP 将所有已识别的挂钩事件分类为三类之一:

Gating(门控) 事件包含一个决策,可以允许、拒绝或阻止一个操作。它们是执行的表面。但并非所有的门控事件都同样可执行:StopSubagentStop 在 Claude Code 自身的分类法中被归类为门控,但它们的阻止语义是反转的(阻止 = 继续运行),因此 PEP 将它们视为中立——它从不发出会让代理在操作员意愿下继续存活的阻止指令。类似地,ElicitationElicitationResult 也具有门控分类,但在当前版本中缺乏有线动作机制,因此 PEP 将它们默认设置为中立,而不是假装存在无法执行的强制机制。

上下文 事件允许 PEP 注入 additionalContext 或重写输出,但无法真正阻止该操作。PostToolUse 是 partial 异常——它可以阻止对已标记输出的进一步处理,但工具已经执行完毕。其余的(PermissionDeniedMessageDisplaySessionStartSetupSubagentStartPostCompactInstructionsLoadedPostToolUseFailure)是带上下文的观察:有助于丰富模型的视图,而不是用来阻止它。

观察 事件(NotificationSessionEndStopFailureCwdChangedFileChangedWorktreeCreateWorktreeRemove)完全没有决策控制。PEP 会将它们记录用于遥测路径和 SIEM 库存,中性地回答,并继续处理。

此分类不是建议性的。它决定组合根决策器是应用策略规则(控制 + 可执行)、注入上下文(上下文),还是仅仅观察(观察)。分类错误意味着要么是错误执行(返回被忽略的拒绝),要么是执行遗漏(将控制事件当作观察处理)。

hookSpecs 映射:唯一真实来源

分类、传递机制和可执行性标志都存放在一个映射中。这是连接器实际使用的代码 — HTTP 响应渲染器和组合根决策器都从中读取:

type hookSpec struct {
    class       string   // "gating" | "context" | "observe"
    mech        hookMech
    enforceable bool
}

var hookSpecs = map[string]hookSpec{
    // GATING — hook 返回值可对操作执行 allow/deny/block。
    "PreToolUse":          {"gating", mechPermissionDecision, true},
    "PermissionRequest":   {"gating", mechPermissionBehavior, true},
    "UserPromptSubmit":    {"gating", mechTopLevelDecision, true},
    "UserPromptExpansion": {"gating", mechTopLevelDecision, true},
    "PreCompact":          {"gating", mechTopLevelDecision, true},
    "ConfigChange":        {"gating", mechTopLevelDecision, true},
    "PostToolBatch":       {"gating", mechTopLevelDecision, true},
    "TaskCreated":         {"gating", mechContinueFalse, true},
    "TaskCompleted":       {"gating", mechContinueFalse, true},
    "TeammateIdle":        {"gating", mechContinueFalse, true},
    "Stop":                {"gating", mechNeutral, false}, // 已反转
    "SubagentStop":        {"gating", mechNeutral, false}, // 已反转
    "Elicitation":         {"gating", mechNeutral, false}, // v1 中未接入
    "ElicitationResult":   {"gating", mechNeutral, false},
    // CONTEXT — additionalContext / 输出重写,不会真正阻止操作。
    "PostToolUse":        {"context", mechPostToolUse, true},
    "PostToolUseFailure": {"context", mechNeutral, false},
    "PermissionDenied":   {"context", mechNeutral, false},
    "MessageDisplay":     {"context", mechNeutral, false},
    "SessionStart":       {"context", mechNeutral, false},
    "Setup":              {"context", mechNeutral, false},
    "SubagentStart":      {"context", mechNeutral, false},
    "PostCompact":        {"context", mechNeutral, false},
    "InstructionsLoaded": {"context", mechNeutral, false},
    // OBSERVE — 无决策控制。
    "Notification":   {"observe", mechNeutral, false},
    "SessionEnd":     {"observe", mechNeutral, false},
    "StopFailure":    {"observe", mechNeutral, false},
    "CwdChanged":     {"observe", mechNeutral, false},
    "FileChanged":    {"observe", mechNeutral, false},
    "WorktreeCreate": {"observe", mechNeutral, false},
    "WorktreeRemove": {"observe", mechNeutral, false},
}

三十个事件,每个事件都有一个分类、一个线机制和一个可执行性裁决。渲染器会查阅 hookMechFor(event) 来决定生成哪种 JSON 结构。决策器会查阅 HookEnforcementFor(event) 来决定是否应用策略规则、注入上下文或进行观察。两者都读取同一张映射表,因此不可能产生分歧。

拒绝关闭的钩子 PEP:事件分类和线机制流

拒绝关闭的默认值

文件中最重要的函数只有四行长:

func hookSpecFor(event string) hookSpec {
    if s, ok := hookSpecs[event]; ok {
        return s
    }
    return hookSpec{class: "unknown", mech: mechPermissionDecision, enforceable: true}
}

一个不在地图上的事件——因为 Claude Code 发送了一个新的钩子事件,连接器尚未对其进行分类——会被视为 unknown,分配 mechPermissionDecision 的线机制,并标记为可执行。这是默认的拒绝关闭策略:一个未识别的事件是一个权限门,而不是默默通过。如果没有策略规则匹配,将应用操作员配置的默认态度,在受管控的部署中这是拒绝。

另一种选择——默认设为中立或观察——意味着每个 Claude Code 引入的新钩子事件在被注意并添加到地图之前都是不受控制的。在拒绝关闭模型中,新事件从触发的那一刻起就受到管理,即使分类是谨慎的。对新事件的错误拒绝是可见且可修复的;而默默允许则是不可见的,可能会持续数月。

HookEnforcement 结构导出此分类,以便决策器能够区分三类门控事件:

  • 经典门PreToolUsePermissionRequestPostToolUse,以及任何未知事件):当没有受管规则匹配时,操作员的策略默认值适用(拒绝-关闭)。
  • 生命周期门(其他可强制执行的门控事件,如 UserPromptSubmitTaskCreated):决策器对每个事件应用安全默认值——对 UX/lifecycle 事件为中性,对状态变更事件为拒绝。
  • 不可强制门StopSubagentStopElicitation):决策器始终返回中性。发出被 Claude Code 解释为“继续运行”的拒绝信号,将与安全原则相悖。

分发:从分类到整个机群

正确地分类事件只是其中一半。将 PEP 部署到每个 Claude Code 实例上才是另一半。托管设置连接器会将钩子配置呈现为 Claude Code 所期望的形态,并将其作为服务器管理的设置文件分发——这是控制平面推送到每个托管主机的不可覆盖配置。

PEP 钩子以空匹配器分发(匹配所有工具——没有工具能逃过执行点),并将其与 allowManagedHooksOnly 配对,以防开发者的本地钩子削弱托管的 PEP。如果没有该标志,用户级钩子可能会覆盖托管钩子,使得执行看似存在但实际上可以被绕过。创作时的验证会捕捉到这一点:如果策略发布了没有 allowManagedHooksOnly 的 PreToolUse PEP 钩子,控制台会发出防篡改警告。

遥测路径是独立的且无计划门控的:Claude Code 的 OpenTelemetry 导出通过托管环境变量(CLAUDE_CODE_ENABLE_TELEMETRYOTEL_* 导出器密钥)启用,因此控制平面可以在不代理推理或触碰订阅凭证的情况下观察订阅使用情况。内容捕获(OTEL_LOG_USER_PROMPTSOTEL_LOG_TOOL_CONTENT)默认为关闭;开启它是一个经过深思熟虑、标记的选择,因为它会将提示和工具内容从开发者的机器发送出去,从而产生控制平面必须承担的居留和编辑责任。

对于受管理的部署意味着什么

使用受管理 PEP 运行 Claude Code 的团队将获得一些重要属性:

  1. 不允许静默通过。 每个钩子事件都是分类的,任何未识别的事件默认拒绝。新的 Claude Code 版本不能引入不受控制的生命周期事件。
  2. 每个事件的正确线缆形态。 PEP 不会返回通用 JSON 对象并期望 Claude Code 遵从。它返回特定事件所期望的精确输出模式,因为在错误模式下拒绝并不是真正的拒绝。
  3. 诚实的不强制执行。 无法强制执行的事件(观察事件、反向门控事件)不会被假装强制执行。PEP 会记录这些事件,返回中性状态,并且不会给操作员虚假的控制感。
  4. 分发时防篡改。 管理设置层确保 PEP 钩子不可被覆盖,并标记可能被削弱的配置。

PEP 是更大治理操作模型的执行部分。访问映射、审计账本以及围绕它的策略即代码层在 产品概述钩子文档 中有所涵盖。如果你想了解遥测路径和权限门如何组合,架构概述 会对两者进行讲解。

相关文章

常见问题

为什么 PEP 默认将未知的钩子事件设置为拒绝而不是允许?

在未识别事件上静默允许意味着未来版本中任何新的 Claude Code 钩子都会绕过执行,直到有人手动将其添加到分类映射中。这与受管理部署所需的安全姿态相反。拒绝关闭的默认设置将未知事件视为权限门:它会被观察到,但不会被丢弃,如果没有策略规则匹配它,则会被拒绝。这确保了新生命周期事件从出现的那一刻起就受到管理,而不是从有人发现它们未被管理的那一刻起。

如果 PEP 对钩子响应发出错误的线形会发生什么?

Claude Code 默默地忽略它。每个钩子事件遵循特定的输出模式:PreToolUse 期望 hookSpecificOutput.permissionDecision,PermissionRequest 期望 hookSpecificOutput.decision.behavior,其他门控事件期望顶层的 decision 或 continue 字段。如果 PEP 返回了一个有效的 JSON 对象,但其模式不符合该事件,Claude Code 会将其视为没有决策并继续操作。这就是为什么 wire 机制是安全合同的一部分,而不是装饰性的原因:一个被 Claude Code 忽略的拒绝并不算是拒绝。

查看您的智能体能触及哪些资源

Olivares AI 是面向您 AI 资产体系的开放式自托管平台。将其部署在您自己的基础设施上,即可获得安全与平台团队一直期待的访问关系图。