import { type Hooks } from "@sema-agent/core"; import type { Logger } from "./logger.js"; import type { Metrics } from "./metrics.js"; /** * ALWAYS-ON gate-deny meter (goal B4, 2026-07-08): `permission_denied_total{deniedBy}` — how often the * adjudicate chain DENIES a tool call, by the LAYER whose verdict is the deny. Deliberately SEPARATE from * {@link createToolTracer}: the tracer is a default-OFF diagnostic (TOOL_TRACE=true), but deny frequency is an * operational/security signal that must reach /metrics on every deployment — a spike in `deniedBy="policy"` * means a task keeps asking for something the operator forbids; `deniedBy="org"` is the organization layer * refusing, the one class an operator most wants to see on its own. main.ts composes this UNDER the tracer via * composeHooks (both are void observers on the permissionDenied slot, which composeHooks double-runs * deployment-first). * * 🔴 BREAKING(server 7.64.0,core 7.6.0 提货):标签名 `source` → `deniedBy`,词表从七词 * (`policy/hook/safety/shellGate/planMode/classifier/org`)换成八词 * (`policy/hook/org/classifier/plan_mode/compliance/write_protection/ask_resolution`)。仪表盘/告警规则要改。 * * 🔴 **第二个计数器 `auto_mode_classifier_deny_cause_total{cause}`(server 7.70.0 生为 * `auto_mode_classifier_unavailable_total`,core 7.12.0 [ref];**7.72.0 / core 7.14.0 [ref] 改名、换座、换词**,见本段末)** * —— 分类器**停摆**的常开信号。7.12.0 之前这件事有两条可观测路:熔断跳闸时的 * `auto_mode_breaker_open_total`,以及「`unavailable` 的 ask 会走到人面前」(审批 inbox 里看得见)。 * 该版把熔断族整族退役、又把 `unavailable` 改成**当场 deny** ⇒ 两条路同时没了,而 * `permission_denied_total{deniedBy}` 对它是**瞎的**:分类器停摆与分类器正常拦下一次危险调用, * 两者都记 `deniedBy:"classifier"`。一次持续停摆于是在默认监控上与「今天安全拦截多了点」不可区分 * (`TOOL_TRACE` 默认关,拿不到那半边)。core 同批把事实加在了 deny 上 * (`PermissionDeniedPayload.classifierUnavailable`),本仓就在**这同一只常开观察者**上消费它。 * 两个计数刻意**不合并成一个标签**:它们答两个问题(「谁拒的」vs「这次拒是不是因为分类器停摆、哪一类」), * 而运维对停摆要立刻动手(改配置 / 等重试 / 看后端),对安全拦截不要。 * ⛔ 纯计量:不改任何裁决、不新开观察面、事实缺席时**不铸**(缺席 ≠ 「分类器好着呢」,只是这次拒不是它)。 * * 🔴 **7.72.0(core 7.14.0 [ref] C-a)—— 同一只计数器换了名、换了座、也换了词表,运维面 BREAKING**: * · **名**(合并复审 R2):`auto_mode_classifier_unavailable_total` ⇒ `auto_mode_classifier_deny_cause_total`, * **无别名** —— 一只也数 `parse_error`(分类器跑了、答得出格)的计数器再叫 unavailable 就是名不副实; * 与下面两条是同一次运维 BREAKING,只痛一次。 * · **座**:`payload.classifierUnavailable.cause`(整族退役)⇒ `payload.gate.disposition.cause`。同一条 * 门记录的三张脸(`tool_end.gate` / 本观察者的 `payload.gate` / 行的 `resolvedOutcome`)core 一次铸、 * 三处投,所以这一位与 `deniedBy` 现在**读自同一只对象**——两个标签不可能再各说各话。 * · **词表**:`AUTO_MODE_UNAVAILABLE_CAUSES`(`error` / `timeout` = 分类器**为什么**没跑成) * ⇒ `CLASSIFIER_DENY_CAUSES`(`unavailable` / `parse_error` = **这次 deny 是什么形**)。 * ⚠️ 两代不是同一个问题的两组词:`unavailable` 覆盖了旧的 error+timeout 两格(「为什么」降级成散文, * 只在 deny 句与 `auto_mode.classified` 诊断帧上),而 `parse_error`(分类器跑了、但答得出格 ⇒ 为安全 * 起见拦下)是**旧计数器根本数不到**的一格。看板/告警的 `cause=` 过滤要改,按旧词 by-cause 分组的 * 历史序列与新序列**不可直接拼**。 * · **计数总量方向**:新座只在「分类器那一轮没有裁决」时在场(`yes` 的真裁决恒缺席),与旧座的 * 「分类器停摆」同向;多出来的那一格是 `parse_error`,它也确实是「分类器没正常工作」的一种。 */ export declare function createPermissionDeniedMeter(metrics: Pick): Hooks; /** * prove-before-scale stopgap ([ref]): a no-op observer hook that logs every tool call (pre + post) * to the worker log — INCLUDING resumed legs that `GET /v1/runs/:id/events` does NOT surface (the * streaming-resume observability gap, service[15] API request #2). Gated by `TOOL_TRACE=true`; OFF by * default (no prod noise). * * Why: a durable-HITL-gated coding task that keeps re-suspending is ambiguous from /events alone — * could be a model restart-on-resume loop (the SAME tool re-issued each resume) or slow legit iteration. * The pre-hook logs `{tool, id, arg:{path,contentLen,…}}` per call, so grepping `tool_trace` reconstructs * the full sequence: a repeated `write_file path=X contentLen=Y` across resumes ⇒ restart-loop; * write X → write Y → bash ⇒ iteration. * * Pure observation: every hook returns `undefined`, so it never rewrites args, never gates, never alters * the result (and per core's hooks invariant a hook's `allow` can't bypass the policy gate anyway). * * 🔴 core 5.20.0 起这条「纯观察」性质必须**声明**出来(`preToolUseObservational`),不能只写在注释里: * core 5.19.0 的委派折叠按「PreToolUse 席位在不在」把父的 screening 面折进子代的祖先约束链——它分不出 * 诊断 tracer 与真裁决者。后果是**这个默认关的诊断旋钮**一开,该部署所有 hook-wired 父的 parked 子代 * checkpoint 就记 `parentConstraintCount=2`,而跨副本赎回腿(`boot/parked-revive-gate.ts`)恒供 1 ⇒ * core pre-CAS 响亮拒、行永久 pending(层数 park 时写死,事后关旋钮/重批/回滚都不改)。 * 声明之后该面不铸链条目,count 回到 1,病路消灭。 * ⛔ 打这个标的前提是**真的一条裁决都不出**:core 对声明了却返回判词的面是「拒绝采纳 + onError 告警」 * (classification `observational-hook-verdict-ignored`),即判词被静默丢弃——把标打到会裁决的面上比原 * 缺陷更坏。本文件三个 pre/post 回调恒返 `undefined`,`permissionDenied` 本就是 void 观察槽。 * 📎 连带的**可观测**变化(不是回归):core 的「可写工具面无 effect-aware 门」启动告警此前把本 tracer * 算作一道门而被抑制;声明之后它不再算,于是「没配任何 policy 层 + 开着 TOOL_TRACE」的部署升级后会 * 多出那行告警。方向正确——tracer 从来没有门住任何东西,原先的抑制本身就是一次静默 fail-open。 */ export declare function createToolTracer(logger: Logger): Hooks; //# sourceMappingURL=tool-trace.d.ts.map