/** * Trace thinking-block redaction (service-side). Per core [R57]: the agent's reasoning/CoT is emitted on the * live TaskEvent stream but DELIBERATELY kept out of the metadata Tracer because CoT may contain reasoned-about * secrets (and is large / ToS-sensitive for some providers). Persisting it for the trace UI is the deployment's * call — so when we DO persist it, redact unambiguous secret formats first. Conservative on purpose: only clear * key/token shapes are redacted. ⚠️ It is NOT exhaustive: unrecognised secret formats (a free-text password, * a JWT) pass through, and the broad `Bearer <20+ chars>` rule can over-match legitimate CoT (false positives * tolerated — over-redacting trace text is safe). For sensitive deployments, disable thinking capture entirely * via TRACE_THINKING=false. This is a seam — deployments can extend the patterns. * * 🔴 **脱敏 ≠ 出站控制**:**工具结果送模主路径**不经过本模块 —— 发往模型的请求体是工具结果**原文** * (模型作为执行体必须见原文;出站改写等于静默改坏用户的真实数据)。⚠️ 这句边界只管这一条路径,别读成 * 「本模块从不出现在模型输入路径上」——本模块同时被两条**反方向**调用复用:项目记忆片段注入 * (`src/project-memory.ts`,git log/status 与指令文件摘录先脱敏、脱敏结果才拼进注入文本)与 hook * asyncRewake 的唤醒文本(`src/boot/resolve-spec.ts` 的 `wake` 回调,先脱敏再经 `stream.steer` 注入模型)。 * 这两条路径是「先脱敏、脱敏后的结果才给模型看」,与工具结果路径的方向相反,不要混淆。要让内容不出机器, * 核对的是**该 run 实际选中的模型路由**——默认网关(`MODEL_GATEWAY_BASEURL`)只是其中一条:还有备用网关 * (`MODEL_GATEWAY_FALLBACK_URLS`)、`provider:"anthropic"` 的独立直连路由(缺省根 * `https://api.anthropic.com`,除非显式配 `ANTHROPIC_BASEURL`)、以及配置中心可对单个目录模型下发的 * per-model `baseUrl` 覆盖(见 `src/brain.ts` 的路由装配与 `src/config-center/apply-effective.ts:416`)。 * 靠**读侧**敏感路径门与确保**全部**生效路由都指向自己控制的基础设施,不靠本模块、也不是设一根 env 就够。 * * 🔴 **射程自 7.75.1 起含「模型自由文本」全族**(B-106 / S-268):本模块此前在结果面只管诊断/披露文本, * 而**模型自己写的**那一份(`TaskResult.result` / `TaskResult.salvagedOutput` / 账本 `text` 行)整条逐字 * 上 wire —— 工具结果送模路径给的是原文,模型把里面的凭据**逐字引用**进答复,那一份就绕过了本模块。 * 现在三面与 CoT(账本 `reasoning` 行)走**同一只** `redactSecrets`、同一张替换串表:零第二只脱敏器、 * 零 per-face 开关、零体量上限。**装载口只有一只** —— `observability/run-terminal.ts` 的 * `redactModelText`(四座同调:结果两叶经 `redactTerminalResult`,账本两行经 `redactLedgerEventData`); * 它定的是**写回**判据(认出凭据形才改字节、抛则占位),不是第二张规则表。 * * 🔴 **只有一条施加路径:候选池**(S-223 v2 起;此前是「结构段 → 词法关键字段 → 池 → URL 收尾归一化」四步, * 每一步都把**上一步改写过的文本**喂给下一步)。四张臂表({@link STRUCTURAL_SECRET_PATTERNS} / * {@link KEYWORD_SECRET_PATTERNS} / {@link SHAPE_SECRET_PATTERNS} / {@link FRAGMENT_SECRET_PATTERNS}) * 加三条体扫描器全是 {@link armPool} 的成员:全臂看**同一份未改写文本**收候选跨度 → 不动点 → 并集律解重叠 * → **一次**替换,**臂序对输出无语义**(次序只定 `order` = 同起点同长时的稳定裁决)。 * 左边界律因此有**三种**边界:非词内字符 ∨ 转义分隔单元尾 ∨ **一枚已收候选跨度的尾**(= 凭据尾)。 */ /** 本模块替换 token 的**闭形**正则源(`«redacted»` / `«redacted:aws-key»` / `«redacted:gh-token:partial»` …): * URL-userinfo 臂用它把「用户名位上已经是本模块 token」整个当一个用户名单元吃掉。模块加载时对 * {@link REDACTION_TOKENS} 逐个校验闭形(表加了新 token 形而这里没跟 ⇒ 加载即抛,fail-closed)。 */ import { stripFormatCharacters } from "@sema-agent/core"; /** * `redactSecrets` 的**归一化前置**(S-103 ①):把全部 `\p{Cf}`(零宽空格 / 词连接符 / BOM / 软连字符 / ZWJ / ZWNJ …) * 剥掉。实现单一属主是 core 的 `stripFormatCharacters`(`@sema-agent/core`,7.3.1 起导出,正是为「先扫描后渲染」这一序 * 开的口),本模块只**转出**它,不抄第二份 —— 两份实现漂了就等于两个「什么是格式字符」的定义。 * * 🔴 **需要判断「这次脱敏到底有没有认出凭据形」的调用方,比较基准是它、不是原文**: * `redactSecrets(x) !== x` 在本版里对**任何**含 Cf 的文本都为真(输出是剥后文本),拿它当谓词会把一条带 emoji ZWJ * 的普通 `git commit` 判成「含凭据形」;正确的谓词是 `redactSecrets(x) !== normalizeForRedaction(x)`。 * 现役消费方:`src/tool-approval.ts` 的 `durableRuleMaterial`(规则车道素材闸)与语料的 FP=0 格。 */ export declare const normalizeForRedaction: typeof stripFormatCharacters; export declare function armPoolBudgetForTest(): { spanCount: number; coveredPositions: number; headScreenHits: number; fullYCalls: number; armCount: number; boundaryArmCount: number; forensicLevels: number; ssh2ScanBytes: number; }; export declare function resetArmPoolBudgetForTest(): void; /** 测试可见:扫描器 indexOf 实际扫过的字节数(摊还线性 ⇒ ≤ 常数 × 输入长)。 */ export declare function ppkScanBytesForTest(): number; export declare function resetPpkScanBytesForTest(): void; /** Redact unambiguous secret formats from reasoning/CoT before it is persisted to the durable trace log. */ /** S24 (SILENT-FALLBACK P1): optional redaction observer — rewrites previously had zero fingerprint, so * over-redaction (mangling legit content) and under-redaction (a pattern that never fires) were both * invisible. main.ts wires this to `redactions_applied_total{pattern}`. Hot path: the observer only runs * when a pattern actually matched; the label is derived once per pattern at module load. */ /** [ref]:第三参 `meta.arm` = 命中的是哪一组臂。加参而不是改参 —— 只读两参的既有观察者原样可用 * (TS 允许少形参),main.ts 把它铺成 `redactions_applied_total{pattern,arm}` 的第二个维度。 * * 🔴 **词义如实**(codex r1 [medium] 三,采纳):这一维说的是「命中的是**哪一条臂**」,**不是** * 「这段文本真的被上游截过」—— server 收到的是纯字节,core 的 `clip()` 是 `slice(0, 80)` **不留标记** * (亲读 `@sema-agent/core/dist/agents/subagent-steps.js`),截过与天生就短在 wire 上不可区分。 * 所以一个 `arm="fragment"` 计数可能来自残片,也可能来自一个恰好落在阈值之上的 look-alike; * core [ref] 落地后这一支**趋零是证据、不归零不是证明**。别把它当截断事实的度量用。 */ /** * 🔴 **词名 = 表归属,不是语义**(clay 裁 [ref] ①,2026-09-18,关 S-307):`"full"` = 强证据表(`SECRET_PATTERNS` 一族, * 含 7.76.2 起改判进来的 PEM/ppk 体)命中的臂,`"fragment"` = 弱证据表({@link FRAGMENT_SECRET_PATTERNS})命中的臂。 * 7.76.2 起两档语义已是「强证据 / 弱证据」,词名**刻意保留**:它是 Prometheus 标签 `redactions_applied_total{arm}` 的 * 标签值,改名 = 硬 breaking 的标签值漂移(面板与告警按值匹配),而语义只在这一段注释里说全即可。判据是 {@link armOfToken}。 */ export type RedactionArm = "full" | "fragment"; export interface RedactionHitMeta { /** 命中臂的**判别力档位**(闭集两词,S-303):`"fragment"` = {@link FRAGMENT_SECRET_PATTERNS} 那一族 * 「短前缀 + 长度/熵阈值」的弱证据臂;`"full"` = 其余全部(结构锚 / 词法关键字 / 定长裸形)。 * 判据 = 表归属,不是 token 的 `:partial` 后缀 —— 两条轴的分家逐字见 {@link armOfToken}。 */ arm: RedactionArm; } export declare function setRedactionObserver(fn: ((patternLabel: string, count: number, meta: RedactionHitMeta) => void) | undefined): void; export declare function redactSecrets(text: string, opts?: { observe?: boolean; json?: boolean; }): string; /** * 脱敏 + **这一遍命中了哪些档位的臂**({@link RedactionArm} 闭集两词)—— 与 {@link redactSecrets} 同一只 * 脱敏器、同一张表、同一遍扫描的另一个出口。 * * 🔴 **为什么要这个出口**(S-303):下游有一类消费方问的不是「脱完长什么样」而是「这段文本里有没有 * **强证据**的凭据形」——`tool-approval.ts` 的规则素材闸是头一个。旧形只能拿「字节变了没有」当谓词, * 于是弱证据臂(短前缀 + 阈值启发式)的一次多脱就等于一次「素材整只扣下」,用户拿不到常驻放行。 * 出口开在脱敏器**自己**身上是硬约束:全仓只该有一个「什么算凭据」的属主,消费方**不许**按 token 字面 * (`includes(":partial»")`)或另跑一遍正则去反推 —— 那就是第二只脱敏器。 * * `arms` 是**这一遍真命中的档位集合**:空集 = 一条臂都没命中(文本可能仍被归一化剥了 `\p{Cf}`, * 所以「`text !== 入参`」与「`arms` 非空」不是一回事,后者才是凭据形的证据)。 */ export declare function redactSecretsWithArms(text: string, opts?: { observe?: boolean; json?: boolean; }): { text: string; arms: ReadonlySet; }; /** * 🔴 环 + 深度预算(2026-07-25 补)。原实现是纯递归、**既不记已访问节点也不限深度**,而它的调用位置是 * **最不可信的边界**:`toolStart`/`toolEnd` 把工具的 `args`/`output`/`structured` 直接喂进来。后果两条: * · **循环引用** ⇒ 无限递归 ⇒ `RangeError` **抛出构造函数**。在 sync 的 SSE 腿上那个 throw 就落在 * `for await (…) res.write(…)` 的循环里 ⇒ **一个畸形工具输出能打死一条正在跑的流**;在 ledger-sink 腿上 * 则是那一帧 append 直接失败(事件永久不落盘)。 * · 超深嵌套 ⇒ 同样爆栈。 * 同文件的 `redactedPreview` 的文档里早把这条记成"已复审的 MEDIUM"并**绕开** redactDeep —— * 也就是说这个洞是知道的,只是没堵。现在堵在 redactDeep 本体:环与超深都换成**可见的标记**, * 让下游看到"这里有东西被省略了",而不是丢一个空对象或炸掉整条流。 */ export declare function redactDeep(value: unknown): unknown; /** * A REDACTED, length-BOUNDED string preview of a structured value — for surfacing a pending tool call's `args` * in the assistant inbox ("what is this ask about") WITHOUT leaking secrets or dumping an unbounded payload (a * write_file `content` can be megabytes). {@link redactDeep} scrubs secret-shaped strings first; the result is * JSON-stringified and capped (truncation is marked). `null`/`undefined` → `null` (nothing to preview). Defense * in depth: the redaction is conservative (NOT exhaustive — see {@link redactSecrets}); the inbox is already * owner-scoped, so this preview is only ever shown to the ask's own principal (or an operator inspecting it). */ export declare function redactedPreview(value: unknown, cap?: number): string | null; /** * **这一位是不是一个层级式绝对 URL**(有 authority 的那一族)—— 本铸点与**模型路由三键拒启** * (`model-route-url.ts` 第一臂)共用的**同一只**判据:两处曾各判各的,而 `URL.canParse` 单独为真的 * **不透明** URL 恰好是「凭据位看不见」的那一形。 * * ⚠️ **射程如实**(车JC 复审去幻觉轮:此处原文写的是「本仓唯一判据」,亲读证伪)—— 仓里**另有一处**仍用 * 裸 `URL.canParse` 回答同一个问题,且**刻意不在射程内**(改它是一次新的行为面拒启,按三问另开车): * `config.ts` 的 `GIT_API_BASEURL`(`parseGitApiKind`):无协议闸 ⇒ 不透明形今天过门(拒因经本铸点, * 所以不回显凭据;可用性由 `fetch` 那侧决定)。 * ✅ 原先列在这里的第二处(`config-catalog.ts` 的 `stripUrlUserinfo`:不透明形 `href === 原串` ⇒ 原样回显) * 已在 7.90.0 随 core `displayUrlOf` 到货**闭环** —— 那只函数整只退役,url 型行的回显改走本铸点, * 于是这道闸对它自动成立(catalog wire 行为面变更,CHANGELOG 已对外写明并点名 @web-admin)。 * * 🔴 谓词写成 `URL.canParse` 而不是 try/catch:这一臂**不是兜底**(兜底才归静默降级那张表管),它是判据本身 —— * 「不是一个层级式 URL」是一个可判的状态,用异常当控制流只会让它看起来像一次意外。 * 🔴 「解析得动」**不够**:`new URL("alice:hunter2@gw.internal:8000")` 是合法的**不透明** URL(`alice:` 当 scheme, * 与 `mailto:` 同族)—— 它没有 authority,URL 律的锚(`scheme://`)对它不成立,`username`/`password` 恒空, * 直接回原串就是密码明文(红先实测)。闸取「**层级式** URL」:`href` 以 `//` 起头 ⇒ 有 authority * ⇒ 三个凭据位都在律的射程内。 */ export declare function isHierarchicalUrl(raw: string): boolean; /** * 🔴 **配置型 URL 上日志 / 诊断面的唯一 display-safe 铸点**(S-475 件B;`build*` = 返回纯数据的一只纯函数)。 * * 病(7.87.2 亲核):启动日志 `listening` 行把配置里的 URL 写**原值** —— `brain: brainSummary(config)` 里的 * `gateway: config.gatewayBaseUrl` 与同行的 `otel: config.otel.endpoint`,logger 不脱敏。部署若把凭据放在 * 网关 URL 的 userinfo / query 里,密码明文落 `engine-*.log`(日志的读者面比 run 错误文本更宽:它落盘、 * 被采集、进工单)。同类出口不止这两处,类级机器门在 `test/config-url-display-safe.test.ts`。 * * 🔴 **S-483 展示半场(7.90.0 / core 7.24.0):两条正交的律合成一次,名表的射程缩到不可约的那一半**。 * 改前内部只有一条:`redactSecrets(new URL(raw).href)` —— 自由文本那条**名单制**的律,query 的值只在键名 * 落在 `CREDENTIAL_NAME_WORDS`(或值本身像个密钥)时才遮,于是**没人列过的那个键名**就是它的盲点 * (`?sig=…` / `?auth=…` / 部署自定义的网关参数名)。现在前面先跑 core 的 `displayUrlOf`,它是**位置制**的: * · userinfo **落地**(不是遮成记号 —— 它从**解析**组合 `scheme://host + path + query`,userinfo 结构上 * 进不了产物);凭据在 userinfo 这件事本身仍然响亮,只不过是在**声明关口**响 * (`modelRouteUrlRefusal` / `parseA2aServe` / core 的 `assertServableRequestRoot`),不靠展示串暗示; * · query 的**每一个值**成记号、**键名留着**(键名必须留:两条只在 query 上不同的根若都渲染成同一个串, * 「A 配给了 B」这句拒因会写成「targets X … paired with X」); * · 无 `=` 的**裸段**整片成一枚记号、连名字都不给(`URLSearchParams` 把它读成「空值的键」,那个读法 * 正好把 payload 本身放上了屏); * · fragment 丢掉(implicit-OAuth 把 bearer 放那儿),尾斜杠与默认端口按配对键的同一把尺折。 * 位置律跑完**再跑一次形状律**({@link redactSecrets}):它管的是「一段字面量**长得像**凭据」,而位置律 * 对这件事结构性失明 —— `pathname` 被逐字保留,「这个路径段是不是一枚令牌」只能按形认。两条律正交、 * 各有一个属主、在铸点里合成一次;因为位置律先跑,名表在 query 上无事可做,它的射程被**缩小**到 * host + path 这一不可约的半边。 * 换尺的**代价成文、不是意外**:非凭据参数的值(`?model=x`)现在也成记号 —— 排障读不到它了。方向一致: * 一个没人列过的凭据参数名泄出去是不可逆的,少读一个模型名不是。本仓原先**三份**各判各的 URL 脱敏实现 * (本铸点 / catalog 的 `stripUrlUserinfo` / embedder 的 `redactEndpointForLog`)合成这一只,记号闭形仍归 * 本模块({@link REDACTION_TOKENS};委托边界的闭集校验在下面的加载期检查)。 * * 本函数自己只剩两件事,两件都是**配置型 URL 特有**的(core 的渲染器对这两形有自己的答案,而配置面要更严): * ① **有效性闸**:配置里的那一位应当是一个**绝对 URL**。`new URL(raw)` 解析不动 ⇒ 这一位根本不是 URL * (裸 `host:port`、相对路径、手滑的散文),自由文本的 URL 律对它没有定界依据 ⇒ 返回**占位** * {@link DISPLAY_SAFE_URL_TOKEN},**不回落原值**([ref]:安全轴上的兜底必须 fail-closed 或响亮)。 * 成文代价:`MODEL_GATEWAY_BASEURL=127.0.0.1:8000` 这种缺 scheme 的配置在日志上变成 `«redacted:url»` * —— 而它在 `fetch` 那一侧本来就是必然失败的形(WHATWG 要求绝对 URL),日志上响亮一点方向一致。 * ② **空串 = 未设**:里面没有凭据,原样回空串(不铸占位 —— 占位会让「没配」读成「配了但看不见」)。 * ⚠️ **保形只到 host / port / path 这一层**(S-483 起如实改口;`test/config-url-display-safe.test.ts` * 的 S-483-o 是这句话的机器读数,别在散文里再抄一遍):scheme / host / port / path 的字节逐字(模 WHATWG * 的 HTTP 等价归一:默认端口省、host 小写、IDN→punycode、点段折叠,再加**整串末尾**的斜杠去掉 —— * 是**整串末尾**不是「路径末尾」:`…/p/` ⇒ `…/p`,而 `…/p/?k=v` 的 `/p/` 保留),query 只留**键名**。 */ /** 位置律的**形** —— 生产路径恒是 core 的 {@link displayUrlOf}。参数化是**为可测性开的唯一一个缝** * (S-497 的「违反格」要造一个「位置律漏了一段」的世界,而拿上游的现成缺陷当触发器会在它修好那天失效); * 生产调用方**不传**这个参数。 */ export type PositionalUrlLaw = (url: string) => string; /** 测试口(与 `observability/fail-open.ts` 的 `resetFailOpenRecorderForTest` 同姿势):一次性 warn 的闸是 * 模块级的,而「只 warn 一次」本身要被钉,所以给测试一个复位口。生产零调用。 */ export declare function resetDisplaySafeFailClosedWarnForTest(): void; /** * 🔴 **S-497 的不变量 —— 形状律在 query / fragment 区间上有一条凭据跨度 ⇒ 那一整段不出门**。 * * 为什么需要它(差分实测 650 形里 26 个反例,本车亲跑复现):两条律**串起来**的时候,前一条会把后一条 * 赖以认字的**结构**改掉。实测的病形是 `https://h/?next=https://u:&pw@h=` —— 位置律先按 `&` 切段、把 * `next=https://u:` 整段换成记号,于是 `&` 之后那半截密码落到了**下一段的键名位**上被保留,而形状律 * 此刻已经看不到 `://@` 这个结构、无从下手(`redactSecrets(原串)` 是对的,可它拿不到原串)。 * * 🔴 **判据落在「切分之前」,而不是在产物文本里找证据**(这一条是本不变量的全部)。三次尝试都被同一件事 * 击穿:①凭据**整段**不得是产物的子串 —— 位置律把它切开了,整段自然不是子串;②切成**片** + 长度下限 —— * 材料能被切成全是单字符(`&a!b!c!d@`)甚至纯标点(`&!$()*+,;@`),任何下限都漏;③**字符集**包含 —— * 密码的每个字符都能在同一条 URL 的 path / host / 别的键名里另外出现一次,包含关系成立而明文骑在产物上 * (`https://h/abcdefghijklmnopqrstuvwxyz0123456789?ok=1&next=https://u:&secret123=x@h`,S-497-k)。 * 共同的根:**切开之后,「这个字节来自凭据」在文本上不可判**。⇒ 判据搬到切分之前,用形状律自己报的 * **跨度坐标**当出处证明({@link shapeLawCredentialInQueryOrFragment})—— 不需要长度下限、字符类白名单 * 或任何集合运算,**规则比前三版都少**,而且对「将来出现的新形」天然封闭(新形状照样落在跨度里)。 * * 两步(一条规则,不按形状分臂): * ① 形状律在**归一化视图**上取跨度;凡有一条跨度落进 `?`(或 `#`)之后的区间 ⇒ 整段 query + fragment * 换一枚 {@link REDACTION_TOKENS} 内的占位,`scheme://host[:port]/path` 留着并照旧过一遍形状律; * ② 否则照旧合成:位置律 → 形状律。 * **过度遮的射程如实**:一条 query 里**真的**带凭据的配置型 URL,自本版起读不到它的**其它**参数名了 * (改前是逐参数遮)。方向一致,而且干净的根**零影响** —— Azure 式 `?api-version=…&deployment=…` 上 * 形状律一条跨度都没有,结构上走不到这一步(S-497-e 单独钉住)。path 区间的凭据**不**走这条路:位置律 * 逐字保留 `pathname`,形状律在产物上照常认得它(S-497-f 的并集格)。 * 🔴 这是 fail-**closed**(扣下信息),**不**登记 `recordFailOpen` —— 那张表记的是「放行了本该拒的」。 * 只打一条一次性 warn(**不含原文**:原文就是那段凭据)。 */ export declare function buildDisplaySafeUrl(raw: string, positionalLaw?: PositionalUrlLaw): string; /** * {@link buildDisplaySafeUrl} 的**带判据**出口:除了展示串,还说这一次有没有落到 fail-closed * (= 「query / fragment 因为里面有一条凭据跨度而整段被扣下」)。 * * 🔴 **为什么要这个出口**(发前复审):core 的**路由拒因**是一句**自由文本**,里面嵌着 core **已经用 * 位置律改写过**的那条 URL —— 到我们手上时 `://@` 的结构早没了,形状律无从下手,于是那条出口 * 整个在不变量之外(实测:降级拒因把密码写进 `listening` 行)。这里不去猜怎么把上游那句话重新脱敏 * (那会变成第二个写者),而是用**同一条判决**:铸点对这条 URL 都已经守不住了,任何**引用它**的自由文本 * 一律扣下(见 {@link buildDisplaySafeRefusal})。判决属主还是这一只函数。 */ export declare function buildDisplaySafeUrlChecked(raw: string, positionalLaw?: PositionalUrlLaw): { text: string; failedClosed: boolean; }; /** * 🔴 **引用了一条「铸点都守不住」的 URL 的自由文本,一律扣下**(S-497 的第二个出口;codex 第三轮 [high] 二)。 * * 料 = 上游(core)的路由拒因:一句自由文本,里面嵌着 core **已经用位置律改写过**的那条 URL。到本仓手上时 * `://@` 的结构已经没了 ⇒ 形状律认不出那一段 ⇒ 光过一遍 `redactSecrets` 兜不住(实测:降级拒因 * 把密码写进 `listening` 行)。 * * 判据**不新造**:问 {@link buildDisplaySafeUrlChecked} —— 如果铸点对这几条源 URL 里的任何一条都已经落到 * fail-closed,那么**任何引用它的句子**都不许出门,换一句固定文案(零原文)。机器可读的那一半不丢:调用方 * 手里有 `verdict.code`(拒因的码),它本来就在同一个投影的**另一个字段**上。 * 都守得住 ⇒ 照旧只过一遍形状律(上游的位置律已经把 URL 的凭据位处理过,这一层管的是**形状**那一半)。 */ export declare function buildDisplaySafeRefusal(text: string, sourceUrls: readonly string[]): string; //# sourceMappingURL=redact.d.ts.map