feat(ui-secret-mask): mask credentials in sent user text
This commit is contained in:
1 parent
9cac4beb05
commit
a93958c7d0
46 files changed
+2368
-3
No files matched your search
@@ -0,0 +1,67 @@
|
||||
# Agent Note: 用户文本凭据识别的线性匹配与性能门禁
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-11-user-text-credential-matching.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[ui-secret-mask](../../../../packages/client/ui-secret-mask/README.zh.md) 在每条已发送用户文本上识别凭据。UserText 调度器在每个被采纳的节点之后再查询一次匹配器,一段文本的成本是它全部查询之和。大部分文本不含凭据,所以首要目标是让不含凭据的文本尽量少付成本;其次是含凭据文本的成本只与凭据个数和长度成正比;对抗输入必须保持线性。约束:正则不回溯;按前缀剪枝;一段文本的全部查询合计线性;返回节点之前不分配内存。
|
||||
|
||||
## 决定
|
||||
|
||||
### 算法:每部分的做法与复杂度
|
||||
|
||||
| 部分 | 做法 | 一段文本全部查询的代价 |
|
||||
|---|---|---|
|
||||
| 找格式起点 | 所有起始字面量(`sk-`、`sk_live_` 等、`AIza`、`ghp_` 等、`github_pat_`、`glpat-`、`npm_`、`hf_`、`AKIA`/`ASIA`、`LTAI`、`AKID`、`xox?-`、`whsec_`、`eyJ`、`-----BEGIN `、`://`)按共同前缀分组成一个只含字面量的交替正则,用 `test` 扫描 | Θ(n) 次原生比较,没有量词因而不回溯;相邻两次查询至多重读一个字面量 |
|
||||
| 重叠的字面量 | 字面量的后缀若是另一个字面量的开头(`sk-`、`glpat-`、`xox?-` 的 `-` 与 `-----BEGIN `),扫描从该后缀处继续;续扫位置在模块加载时由字面量表算出 | 每个命中至多回退一个字符 |
|
||||
| 识别命中的字面量 | 命中末尾两个码元查表;只有共享结尾的三组(`t_`、`e_`、`IA`)再用 `startsWith` 区分 | 每个命中 O(1) |
|
||||
| 格式校验 | 单词边界、长度、数字位置、`.eyJ` 分隔用数值比较;私钥标签至多读 52 个字符,协议名至多回看 16 个字符;字符段用粘性 `[字符类]*` 原生测量,模式在第一个不属于该类的字符处停止,后面没有内容,不回溯 | 每个命中 O(1) 加字符段测量 |
|
||||
| 字符段、下一个数字、下一行 `END` | 每个匹配器实例用一个 `Int32Array` 按文本记忆:`[A-Za-z0-9_-]` 四个槽(JWT 同时用三段),其余字符类各一个槽,另记下一个数字和下一行有效 `END` 的位置;这些是文本本身的事实,跨查询复用,查询换成另一段文本时清空 | 每类字符每个字符至多测量一次 |
|
||||
| 地址密码 | `://` 在查询起点之前、密码在起点之后时,先确认起点后面紧跟 `:`、非空密码和 `@`,才回看用户名找 `://`;起点落在 `://` 中间时直接检查 | 回看至多 258 个字符,且只在 `:密码@` 紧跟起点时发生 |
|
||||
| 合并候选 | 候选按起点顺序处理;地址密码在其 `://` 之后开始,延后到扫描越过它的起点,此前不会出现另一个 `://`(用户名和密码不含 `/`);重叠的候选并入最早的候选并使用其遮挡方式;范围到文本末尾即停止扫描 | 每个候选 O(1),不建列表 |
|
||||
| 遮挡串 | 只由前缀格式组成的范围全是 ASCII 且没有换行,遮挡串是前 8 个字符的切片加一个共享星号串的切片;私钥正文用原生模式跳过普通字符,每行取一个"星号加换行"共享串的切片;地址密码和其他混合范围逐字符计数码点 | Θ(遮挡字符数 + 行数),与输出同阶 |
|
||||
|
||||
### 内存
|
||||
|
||||
- 返回节点之前不分配:`test` 不产生匹配数组,记忆槽是预先分配的整数数组,遍历用下标而不是迭代器,候选范围存在局部数值里。
|
||||
- 每个凭据只分配结果:节点、`data` 对象和遮挡串。单行凭据的遮挡串是两个切片的拼接;多行遮挡每行再加一个切片和一次拼接。
|
||||
- 每个实例保留最近一次查询的文本引用,直到查询另一段文本。
|
||||
|
||||
### 取舍
|
||||
|
||||
- **扫描交给原生正则,判断交给 JavaScript**:V8 中逐字符的 JavaScript 循环比原生字面量扫描和原生字符段测量慢;凡是与输入长度成正比的工作(找起点、量字符段、跳过私钥正文)都用不回溯的原生模式,JavaScript 只做每个命中常数次的判断。
|
||||
- **只依赖格式本身的剪枝**:起点由格式字面量决定,不按字符在自然文本中的频率挑锚点,因此任何字符分布的文本都付同样的线性成本。
|
||||
- **记忆按文本而不按查询**:一次查询内的记忆不能阻止后续查询重新测量同一段字符;以文本为键、由实例持有,换文本即清空,代价只是保留一个字符串引用。
|
||||
- **遮挡串用共享串切片**:比先写入缓冲区再整体解码更快;多行遮挡的分配与行数成正比,仍属于输出本身。
|
||||
|
||||
### 性能门禁
|
||||
|
||||
[benchmarks/user-text](../../../../benchmarks/user-text/user-text.bench.client.ts) 在 `node 24 / benchmarks` 必跑 lane 中运行。编译后的 worker 使用生产的 UserText 注册表和全部已发布匹配器(会话、文件、文件夹、skill、命令、空白、凭据),在纯 Node 下分两个进程运行:计时与分配。
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 负载 | 无凭据:普通消息,以及散文、代码、base64、Markdown、日志、JSON、shell 长文本。含凭据:每条消息含一种凭据(模型密钥、GitHub 令牌、云密钥 ID、JWT、地址密码、私钥块),以及成批的 `.env` 密钥。对抗:针对每种格式的前缀、长度与分隔规则构造的重复输入,两种长度。全部由常量生成,凭据样本运行时拼接 |
|
||||
| 计时 | 每个样本新建注册表、文档和匹配器实例,只计求值;每个负载分别测有无凭据匹配器,取多个样本中最快的一个,并记录同一区间的进程 CPU 时间 |
|
||||
| 分配 | 单独进程,`--expose-gc`,semi-space 固定为大于任何一次求值的大小;预热后 GC,再单独驱动匹配器回答全部查询一遍,取新生代增量;用 GC 事件的时间戳确认区间内没有 GC |
|
||||
| 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 |
|
||||
| 排除 | React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 |
|
||||
|
||||
预期值来自 x64 开发机。CI 时间预算按共享比例与余量换算;耗时比、翻倍比和分配预算不随机器缩放。负对照:同一 benchmark 指向正则实现或"每次查询新建记忆"的实现时都会失败。等价性测试经生产调度器逐节点比较本实现与正则实现([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)),并对每个查询起点单独比较。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。
|
||||
- **把全部格式合成一个带量词的大正则**:一次扫描能找到最左匹配,但各分支的量词失败时仍回溯。
|
||||
- **先找稀有字符再回看前缀**:普通文本扫描更快,但成本取决于字符分布,`-`、`_` 密集的文本明显变慢。
|
||||
- **纯 JavaScript 的 Aho–Corasick 自动机**:可以零分配,但逐字符查表有状态依赖,比原生字面量扫描慢,最常见的无凭据文本反而变慢。
|
||||
- **每次查询新建记忆**:被接受的 token 之间夹着无数字的 `sk-` 时,每次查询的数字查找都扫到文末,平方增长。
|
||||
- **用 `TextDecoder` 从缓冲区一次生成私钥遮挡串**:每块只分配一个字符串,但每次解码的固定开销高于共享串切片。
|
||||
- **用 `exec` 取命中、用 `for…of` 遍历记忆槽**:分别分配匹配数组和迭代器结果。
|
||||
|
||||
## 影响
|
||||
|
||||
- 无凭据文本的额外成本是一遍原生扫描加字面量处的校验;不依赖字符分布就无法再降。
|
||||
- 新增格式要在格式表里加字面量和只向前的校验;续扫位置与命中识别表自动生成。字面量正则的单测拒绝量词,benchmark 拒绝超线性扫描和多余分配。
|
||||
- 匹配器实例由插件在 `apply` 中创建并持有;测试和 benchmark 每个样本新建实例,不共享记忆。
|
||||
@@ -0,0 +1,74 @@
|
||||
/** A cold-seeded user message that carries a credential draws it masked behind its reveal control. */
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium, type Browser, type Page } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/user-text-secret-mask', import.meta.url))
|
||||
// Borrowed read-only: this scenario needs any settled user message, not a new recording.
|
||||
const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const RECORDED_PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
|
||||
// Assembled at run time so the repository holds no literal in a credential format.
|
||||
const KEY = ['sk', '-', '0123456789abcdef0123456789abcdef'].join('')
|
||||
const PROMPT = `${KEY} is the key for this task.`
|
||||
|
||||
describe('web e2e: credential mask in user text', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
const raw = (await readFile(SEED, 'utf8')).replace(RECORDED_PROMPT, PROMPT)
|
||||
expect(fixtureUserPrompts(raw), 'adapted seed must carry the credential prompt').toContain(PROMPT)
|
||||
await seedSession(scaffold, raw, 'user-text-secret-mask')
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.locator('[role="treeitem"]').first().click()
|
||||
await page.locator('[role="treeitem"]').nth(1).click()
|
||||
await page.getByRole('button', { name: 'Show secret', exact: true }).waitFor()
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('masks the credential in the bubble and reveals it from its own control', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-user-text-secret-mask'))
|
||||
const text = page.locator('[class*="bubble"]', { has: page.locator('[data-secret]') })
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'bubble.expected.md'), await text.ariaSnapshot(), MODE)
|
||||
expect(await text.textContent()).not.toContain(KEY)
|
||||
const run = page.locator('[data-secret]')
|
||||
// The mask marks and the control share one vertical center.
|
||||
const centers = await run.evaluate((box) => {
|
||||
const middle = (element: Element): number => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.top + rect.height / 2
|
||||
}
|
||||
const [value, control] = [...box.children]
|
||||
return { mask: middle(value!.lastElementChild!), control: middle(control!) }
|
||||
})
|
||||
expect(Math.abs(centers.control - centers.mask)).toBeLessThanOrEqual(1)
|
||||
const masked = { run: await run.boundingBox(), bubble: await text.boundingBox() }
|
||||
await page.getByRole('button', { name: 'Show secret', exact: true }).click()
|
||||
await expect.poll(() => text.textContent()).toBe(PROMPT)
|
||||
// The monospace run keeps one mask character per original character, so revealing does not move the line.
|
||||
expect({ run: await run.boundingBox(), bubble: await text.boundingBox() }).toEqual(masked)
|
||||
await page.getByRole('button', { name: 'Hide secret', exact: true }).click()
|
||||
await expect.poll(() => text.textContent()).not.toContain(KEY)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['bubble.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -147,6 +147,7 @@
|
||||
"tests/message-feedback.e2e.ts",
|
||||
"tests/markdown-images.e2e.ts",
|
||||
"tests/markdown-file-links.e2e.ts",
|
||||
"tests/user-text-secret-mask.e2e.ts",
|
||||
"tests/reference-composer.e2e.ts",
|
||||
"tests/markdown-wide-table.e2e.ts",
|
||||
"tests/thinking-markdown.e2e.ts",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@deepseek-ai/dsh-brand": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-chat": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:*",
|
||||
"@deepseek-ai/dsh-deque": "workspace:*",
|
||||
"@deepseek-ai/dsh-lazy-require": "workspace:*",
|
||||
"@deepseek-ai/dsh-llm": "workspace:*",
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface BuiltBenchmarkWorkerOptions {
|
||||
readonly timeoutMs: number
|
||||
readonly exposeGc?: boolean
|
||||
readonly heapLimitMb?: number
|
||||
/** Fixed semi-space size, so an allocation interval smaller than it runs without a scavenge. */
|
||||
readonly semiSpaceMb?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,6 +42,9 @@ export function runBuiltBenchmarkWorker<Report>(
|
||||
...options.heapLimitMb === undefined
|
||||
? []
|
||||
: [`--max-old-space-size=${String(options.heapLimitMb)}`],
|
||||
...options.semiSpaceMb === undefined
|
||||
? []
|
||||
: [`--min-semi-space-size=${String(options.semiSpaceMb)}`, `--max-semi-space-size=${String(options.semiSpaceMb)}`],
|
||||
options.worker,
|
||||
...options.args ?? [],
|
||||
], {
|
||||
|
||||
@@ -66,4 +66,11 @@ export default defineConfig([
|
||||
clean: true,
|
||||
tsconfig: 'tsconfig.client.json',
|
||||
},
|
||||
{
|
||||
...shared,
|
||||
entry: { 'user-text.worker': 'user-text/user-text.worker.client.ts' },
|
||||
outDir: '.dsh-build/user-text',
|
||||
clean: true,
|
||||
tsconfig: 'tsconfig.client.json',
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,161 @@
|
||||
/** Required time and allocation budgets for sent-user-text evaluation with the credential matcher. */
|
||||
|
||||
import { join } from 'node:path'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
import { runBuiltBenchmarkWorker } from '../support/built-worker.ts'
|
||||
import { ciTimeBudget, PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts'
|
||||
import { recordTimings, type BenchmarkCase } from '../support/scaling-report.ts'
|
||||
import type {
|
||||
UserTextAllocationReport, UserTextTimeReport, UserTextWorkloadAllocation, UserTextWorkloadTiming,
|
||||
} from './user-text.worker.client.ts'
|
||||
|
||||
/** Fresh registries and documents per sample in one compiled worker; the fastest sample removes scheduler delay. */
|
||||
const ATTEMPTS = 5
|
||||
/** A stuck worker is reaped before the outer benchmark deadline. */
|
||||
const WORKER_TIMEOUT_MS = 180_000
|
||||
/** Larger than every workload's allocation, so the allocation interval runs without a scavenge. */
|
||||
const ALLOCATION_SEMI_SPACE_MB = 256
|
||||
const WORKER = join(import.meta.dirname, '..', '.dsh-build', 'user-text', 'user-text.worker.js')
|
||||
|
||||
/**
|
||||
* Documents without credentials: reference-machine milliseconds with the matcher, and the
|
||||
* ratio to the shipped rules without it. Ratios do not depend on machine speed. The matcher
|
||||
* adds one native literal scan, about 2 ns per character, plus format checks where a literal
|
||||
* occurs (`://`, `sk-` in identifiers); 5,000 messages also pay the scheduler's cost for one
|
||||
* more matcher. Running sixteen format patterns from every query offset measures 1.6–4.9×.
|
||||
*/
|
||||
const UNMATCHED_EXPECTED = {
|
||||
'typical-messages': { ms: 28, ratio: 1.25 },
|
||||
'prose-256k': { ms: 1.25, ratio: 1.8 },
|
||||
'code-256k': { ms: 1.7, ratio: 2.3 },
|
||||
'base64-256k': { ms: 1.4, ratio: 2.05 },
|
||||
'markdown-256k': { ms: 1.6, ratio: 2.15 },
|
||||
'log-256k': { ms: 1.75, ratio: 2.05 },
|
||||
'json-256k': { ms: 2.05, ratio: 2.75 },
|
||||
'shell-256k': { ms: 3.8, ratio: 1.3 },
|
||||
} as const
|
||||
/** Reference-machine milliseconds with the matcher for documents whose credentials it masks. */
|
||||
const MATCHED_EXPECTED_MS = {
|
||||
'messages-with-model-key': 28,
|
||||
'messages-with-github-token': 28,
|
||||
'messages-with-cloud-key-id': 28,
|
||||
'messages-with-json-web-token': 29,
|
||||
'messages-with-address-password': 31,
|
||||
'messages-with-private-key-block': 45,
|
||||
'env-keys-2000': 2.1,
|
||||
} as const
|
||||
/**
|
||||
* 128 KiB shaped against one format's prefix, length, or separator rules. Format patterns
|
||||
* that backtrack, or a per-query memo whose digit search reaches the end of the text from
|
||||
* every query, measure 60–720 ms and up to 4× per doubling.
|
||||
*/
|
||||
const ADVERSARIAL_EXPECTED_MS = 3.25
|
||||
/** Doubling the input doubles a linear scan. */
|
||||
const MAX_DOUBLING_SCALING = 2 * PERFORMANCE_BUDGET_HEADROOM
|
||||
|
||||
/** Measurement floor: heap statistics objects created while reading the young generation. */
|
||||
const ALLOCATION_FLOOR_BYTES = 16 * 1024
|
||||
/**
|
||||
* One credential node: the node and data objects, the visible-prefix slice, and the
|
||||
* concatenation with a slice of a shared mark string; measured at about 180 bytes.
|
||||
*/
|
||||
const ALLOCATION_PER_CREDENTIAL_BYTES = 256
|
||||
/** A multi-line mask adds one slice of a shared mark string and one concatenation per line; measured at about 60 bytes. */
|
||||
const ALLOCATION_PER_MASKED_LINE_BREAK_BYTES = 64
|
||||
/** A slice shorter than a sliced string copies its characters: at most two bytes per masked character. */
|
||||
const ALLOCATION_PER_MASKED_CHARACTER_BYTES = 2
|
||||
|
||||
const ADVERSARIAL = [
|
||||
'digitless-sk', 'jwt-header-runs', 'jwt-two-segments', 'unterminated-key-blocks', 'address-without-at',
|
||||
'colon-run', 'long-scheme-user', 'short-token-runs', 'tokens-between-digitless-keys',
|
||||
] as const
|
||||
|
||||
function benchmarkCase(id: string, measures: string): BenchmarkCase {
|
||||
return { id: `user-text/${id}`, measures, affects: 'Rendering sent user messages, queued messages, and goal input in the Web GUI.' }
|
||||
}
|
||||
|
||||
async function worker<Report>(args: readonly string[], semiSpaceMb?: number): Promise<Report> {
|
||||
const run = await runBuiltBenchmarkWorker<Report>({
|
||||
worker: WORKER, args, timeoutMs: WORKER_TIMEOUT_MS, exposeGc: true, ...semiSpaceMb === undefined ? {} : { semiSpaceMb },
|
||||
})
|
||||
if (run.report === undefined) {
|
||||
throw new Error(`user-text worker failed: exit=${String(run.exitCode)}, signal=${String(run.signal)}, `
|
||||
+ `timedOut=${String(run.timedOut)}\n${run.stderr.trim().split('\n').slice(-10).join('\n')}`)
|
||||
}
|
||||
return run.report
|
||||
}
|
||||
|
||||
describe('sent user text with the credential matcher', () => {
|
||||
let timings: ReadonlyMap<string, UserTextWorkloadTiming>
|
||||
let allocations: ReadonlyMap<string, UserTextWorkloadAllocation>
|
||||
|
||||
beforeAll(async () => {
|
||||
const time = await worker<UserTextTimeReport>(['time', String(ATTEMPTS)])
|
||||
const allocation = await worker<UserTextAllocationReport>(['allocation'], ALLOCATION_SEMI_SPACE_MB)
|
||||
console.log(JSON.stringify({ benchmark: 'user-text', ...time, allocation: allocation.workloads }))
|
||||
timings = new Map(time.workloads.map(workload => [workload.id, workload]))
|
||||
allocations = new Map(allocation.workloads.map(workload => [workload.id, workload]))
|
||||
}, 2 * WORKER_TIMEOUT_MS)
|
||||
|
||||
const timing = (id: string): UserTextWorkloadTiming => {
|
||||
const found = timings.get(id)
|
||||
if (found === undefined) throw new Error(`user-text worker reported no ${id} workload`)
|
||||
return found
|
||||
}
|
||||
const record = (benchmark: BenchmarkCase, measured: UserTextWorkloadTiming, budgetMs: number): void => {
|
||||
recordTimings(benchmark, { withSecretMs: { ms: measured.withSecretMs, cpuMs: measured.withSecretCpuMs } }, { withSecretMs: budgetMs })
|
||||
}
|
||||
|
||||
it.each(Object.entries(UNMATCHED_EXPECTED))('evaluates %s without credentials within its budget and ratio', (id, expected) => {
|
||||
const measured = timing(id)
|
||||
const budgetMs = ciTimeBudget(expected.ms)
|
||||
record(benchmarkCase(id, `Fresh ${id} documents without credentials through every shipped matcher.`), measured, budgetMs)
|
||||
expect(measured.group).toBe('unmatched')
|
||||
expect(measured.secrets).toBe(0)
|
||||
expect(measured.withSecretMs).toBeLessThanOrEqual(budgetMs)
|
||||
expect(measured.ratio).toBeLessThanOrEqual(expected.ratio * PERFORMANCE_BUDGET_HEADROOM)
|
||||
})
|
||||
|
||||
it.each(Object.entries(MATCHED_EXPECTED_MS))('masks every credential of %s within its budget', (id, expectedMs) => {
|
||||
const measured = timing(id)
|
||||
const budgetMs = ciTimeBudget(expectedMs)
|
||||
record(benchmarkCase(id, `Fresh ${id} documents, each credential masked, through every shipped matcher.`), measured, budgetMs)
|
||||
expect(measured.group).toBe('matched')
|
||||
expect(measured.secrets).toBe(id === 'env-keys-2000' ? 2_000 : 5_000)
|
||||
expect(measured.withSecretMs).toBeLessThanOrEqual(budgetMs)
|
||||
})
|
||||
|
||||
it('masks 2,000 pasted keys in time linear in their number', () => {
|
||||
const half = timing('env-keys-1000')
|
||||
const full = timing('env-keys-2000')
|
||||
expect([half.secrets, full.secrets]).toEqual([1_000, 2_000])
|
||||
expect(full.withSecretMs / half.withSecretMs).toBeLessThanOrEqual(MAX_DOUBLING_SCALING)
|
||||
})
|
||||
|
||||
it.each(ADVERSARIAL)('scans adversarial %s input linearly without backtracking', (name) => {
|
||||
const half = timing(`adversarial-${name}-64k`)
|
||||
const full = timing(`adversarial-${name}-128k`)
|
||||
const budgetMs = ciTimeBudget(ADVERSARIAL_EXPECTED_MS)
|
||||
record(benchmarkCase(`adversarial-${name}`, `One 128 KiB document repeating a ${name} unit.`), full, budgetMs)
|
||||
expect(full.withSecretMs).toBeLessThanOrEqual(budgetMs)
|
||||
expect(full.withSecretMs / half.withSecretMs).toBeLessThanOrEqual(MAX_DOUBLING_SCALING)
|
||||
})
|
||||
|
||||
it.each(workloadIds())('allocates only result nodes and masks for %s', (id) => {
|
||||
const allocation = allocations.get(id)
|
||||
if (allocation === undefined) throw new Error(`user-text worker reported no ${id} allocation`)
|
||||
expect(allocation.secrets).toBe(timing(id).secrets)
|
||||
const budgetBytes = ALLOCATION_FLOOR_BYTES + ALLOCATION_PER_CREDENTIAL_BYTES * allocation.secrets
|
||||
+ ALLOCATION_PER_MASKED_LINE_BREAK_BYTES * allocation.maskedLineBreaks
|
||||
+ ALLOCATION_PER_MASKED_CHARACTER_BYTES * allocation.maskedCharacters
|
||||
expect(allocation.ruleAllocatedBytes).toBeLessThanOrEqual(budgetBytes)
|
||||
})
|
||||
})
|
||||
|
||||
/** Every workload id, in the worker's order, so each has an allocation case. */
|
||||
function workloadIds(): readonly string[] {
|
||||
return [
|
||||
...Object.keys(UNMATCHED_EXPECTED), ...Object.keys(MATCHED_EXPECTED_MS), 'env-keys-1000',
|
||||
...ADVERSARIAL.flatMap(name => [`adversarial-${name}-64k`, `adversarial-${name}-128k`]),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
/** Compiled worker for sent-user-text evaluation cost and allocation with and without the credential matcher. */
|
||||
|
||||
import { PerformanceObserver, performance } from 'node:perf_hooks'
|
||||
import { getHeapSpaceStatistics } from 'node:v8'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { UserTextFacts, UserTextRule, UserTextTransformContext } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// These Client-only UserText modules have no plain-Node package export and are compiled into this worker.
|
||||
import { UserTextRegistry } from '../../packages/client/ui-conversation/src/client/conversation/user-text-registry.ts'
|
||||
import { sessionReferenceRule } from '../../packages/client/ui-conversation/src/client/user-text/session-reference-rule.ts'
|
||||
import { collapseWhitespaceRule, trimWhitespaceRule } from '../../packages/client/ui-conversation/src/client/user-text/whitespace-rules.ts'
|
||||
import { fileReferenceRule, folderReferenceRule } from '../../packages/client/ui-reference/src/client/user-text-rules.ts'
|
||||
import { skillReferenceRule } from '../../packages/client/ui-skill/src/client/user-text-rules.ts'
|
||||
import { goalCommandReferenceRule } from '../../packages/client/ui-goal/src/client/user-text-rules.ts'
|
||||
import { createSecretRule } from '../../packages/client/ui-secret-mask/src/client/secret-rule.ts'
|
||||
import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts'
|
||||
|
||||
/** What a workload contains: no credential, real credentials, or input shaped against one format. */
|
||||
export type UserTextWorkloadGroup = 'unmatched' | 'matched' | 'adversarial'
|
||||
|
||||
/** Timing of one workload evaluated by the shipped rule set with and without the credential matcher. */
|
||||
export interface UserTextWorkloadTiming {
|
||||
readonly id: string
|
||||
readonly group: UserTextWorkloadGroup
|
||||
readonly documents: number
|
||||
readonly characters: number
|
||||
readonly secrets: number
|
||||
/** Characters inside credential ranges, whose masks the matcher builds. */
|
||||
readonly maskedCharacters: number
|
||||
readonly baseMs: number
|
||||
readonly withSecretMs: number
|
||||
/** Process CPU time over the fastest with-credential sample. */
|
||||
readonly withSecretCpuMs: number
|
||||
readonly ratio: number
|
||||
}
|
||||
|
||||
/** Heap allocation of the credential matcher over one workload, with the size of what it returns. */
|
||||
export interface UserTextWorkloadAllocation {
|
||||
readonly id: string
|
||||
/** Young-generation bytes the matcher allocates answering every query of every document, nodes included; no GC ran meanwhile. */
|
||||
readonly ruleAllocatedBytes: number
|
||||
readonly secrets: number
|
||||
/** Characters inside credential ranges. */
|
||||
readonly maskedCharacters: number
|
||||
/** Line breaks inside credential ranges, each of which ends one piece of a multi-line mask. */
|
||||
readonly maskedLineBreaks: number
|
||||
}
|
||||
|
||||
/** Result of `time <attempts>`: the fastest of `attempts` fresh samples per workload. */
|
||||
export interface UserTextTimeReport {
|
||||
readonly attempts: number
|
||||
readonly workloads: readonly UserTextWorkloadTiming[]
|
||||
}
|
||||
|
||||
/** Result of `allocation`, which needs `--expose-gc` and a young generation larger than one evaluation. */
|
||||
export interface UserTextAllocationReport {
|
||||
readonly workloads: readonly UserTextWorkloadAllocation[]
|
||||
}
|
||||
|
||||
/** Matchers the Web composition registers besides the credential matcher. */
|
||||
const BASE_RULES: readonly UserTextRule[] = [
|
||||
sessionReferenceRule, fileReferenceRule, folderReferenceRule, skillReferenceRule,
|
||||
goalCommandReferenceRule, collapseWhitespaceRule, trimWhitespaceRule,
|
||||
]
|
||||
/** Facts a sent message bubble supplies. */
|
||||
const FACTS: Readonly<Partial<UserTextFacts>> = { sessionLabels: ['会话一'], skillNames: ['plan', 'review'] }
|
||||
|
||||
const KIB = 1024
|
||||
const PROSE = '帮我看看这个函数为什么慢,顺便把 README 也更新一下。The quick brown fox jumps over the lazy dog, 1234 times. '
|
||||
const CODE = 'const value = await fetch(`https://api.example.com/v1/items?id=${id}`); if (!value.ok) throw new Error("bad: " + value.status);\n'
|
||||
const BASE64 = 'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODkrLw'
|
||||
const MARKDOWN = [
|
||||
'## 安装与配置', '', '- 运行 `pnpm install`,然后执行 `pnpm run build`。',
|
||||
'- 详见 [文档](https://example.com/docs/setup?ref=readme#install)。', '',
|
||||
'| 选项 | 默认值 | 说明 |', '|---|---|---|', '| `timeout` | 30s | 请求超时 |', '| `retries` | 3 | 失败重试次数 |', '',
|
||||
'```ts', "const client = createClient({ baseUrl: 'https://api.example.com/v1', timeout: 30_000 })", '```', '', '',
|
||||
].join('\n')
|
||||
const LOG = '2026-10-11T12:00:00.123Z INFO [worker-3] GET https://api.example.com/v1/items?id=1234&page=2 200 12ms '
|
||||
+ 'trace=4bf92f3577b34da6a3ce929d0e0e4736 [email protected]\n'
|
||||
const JSON_LINES = '{"id":"123e4567-e89b-12d3-a456-426614174000","url":"https://cdn.example.com/assets/img_0001.png",'
|
||||
+ '"name":"task-runner","tags":["sk-button","api_key_name"],"created_at":"2026-10-11T12:00:00Z","size":10240},\n'
|
||||
const SHELL = 'pnpm --filter @deepseek-ai/dsh-client-ui-secret-mask run test -- --coverage --reporter=verbose'
|
||||
+ ' && git push origin feat/user-text-secret-mask\n'
|
||||
/** Ordinary message fragments: prose, references, a slash skill, a URL without credentials, and code. */
|
||||
const MESSAGE_PARTS = [
|
||||
PROSE, '参照 @src/app/main.ts 修复一下', ' /plan 先列计划', ' 见 https://example.com/docs/a?b=c#d ',
|
||||
'`const total = items.reduce((a, b) => a + b, 0)`', ' 然后跑 pnpm run test -- --watch=false。',
|
||||
] as const
|
||||
|
||||
const fill = (unit: string, characters: number): string => unit.repeat(Math.ceil(characters / unit.length)).slice(0, characters)
|
||||
const MIXED = 'a1B2c3D4e5F6g7H8i9J0'
|
||||
// Samples are assembled at run time so the repository holds no literal in a credential format.
|
||||
const key = (index: number): string => ['sk', '-', `${index.toString(16).padStart(8, '0')}0123456789abcdef01234567`].join('')
|
||||
const CREDENTIALS = {
|
||||
'model-key': key(7),
|
||||
'github-token': ['gh', 'p_', fill(MIXED, 36)].join(''),
|
||||
'cloud-key-id': ['AK', 'IA', 'ABCDEFGHIJ234567'].join(''),
|
||||
'json-web-token': ['ey', 'J', fill(MIXED, 17), '.', 'ey', 'J', fill(MIXED, 30), '.', fill(MIXED, 43)].join(''),
|
||||
'address-password': ['postgres://admin:', 'p4ss-W0rd', '@db.internal:5432/app'].join(''),
|
||||
'private-key-block': [
|
||||
['-----BEGIN', ' RSA PRIVATE KEY-----'].join(''), ...Array.from({ length: 10 }, () => fill(BASE64, 64)),
|
||||
['-----END', ' RSA PRIVATE KEY-----'].join(''),
|
||||
].join('\n'),
|
||||
} as const
|
||||
|
||||
function messages(count: number): string[] {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const parts = Array.from({ length: 4 }, (_unused, part) => MESSAGE_PARTS[(index + part * 2) % MESSAGE_PARTS.length]!)
|
||||
return `${String(index)} ${parts.join('')}`.slice(0, 200)
|
||||
})
|
||||
}
|
||||
|
||||
/** Ordinary 200-character messages, each carrying one credential whole. */
|
||||
function credentialMessages(count: number, credential: string): string[] {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const head = `${String(index)} 帮我看看这个配置能不能用:`
|
||||
const tail = `,${MESSAGE_PARTS[index % MESSAGE_PARTS.length]!}${PROSE}`
|
||||
return head + credential + tail.slice(0, Math.max(0, 200 - head.length - credential.length))
|
||||
})
|
||||
}
|
||||
|
||||
const envFile = (count: number): string =>
|
||||
Array.from({ length: count }, (_, index) => `SERVICE_${String(index)}_API_KEY=${key(index)}`).join('\n')
|
||||
|
||||
/** Inputs shaped against each credential format's prefix, length, and separator rules. */
|
||||
const ADVERSARIAL_UNITS = {
|
||||
'digitless-sk': 'sk-',
|
||||
'jwt-header-runs': 'eyJabcdefgh-',
|
||||
'jwt-two-segments': 'eyJabcdefgh.eyJabcdefgh-',
|
||||
'unterminated-key-blocks': ['-----BEGIN', ' RSA PRIVATE KEY-----\nabc\n'].join(''),
|
||||
'address-without-at': 'a://u:',
|
||||
'colon-run': ':',
|
||||
'long-scheme-user': `abcdefghijklmnop://${'u'.repeat(250)}:`,
|
||||
'short-token-runs': `ghp_${fill('a1B2c3', 35)} `,
|
||||
// Each query starts after an accepted digit-free token and meets a digit-free `sk-` whose digit search reaches the end.
|
||||
'tokens-between-digitless-keys': ['-', 'sk', '-', 'gh', 'p_', fill('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 36)].join(''),
|
||||
} as const
|
||||
|
||||
interface Workload {
|
||||
readonly id: string
|
||||
readonly group: UserTextWorkloadGroup
|
||||
readonly texts: readonly string[]
|
||||
}
|
||||
|
||||
/** Named fixed workloads; adversarial inputs appear at two sizes so their scaling is observable. */
|
||||
function workloads(): readonly Workload[] {
|
||||
return [
|
||||
{ id: 'typical-messages', group: 'unmatched', texts: messages(5_000) },
|
||||
...Object.entries({ prose: PROSE, code: CODE, base64: BASE64, markdown: MARKDOWN, log: LOG, json: JSON_LINES, shell: SHELL })
|
||||
.map(([name, unit]): Workload => ({ id: `${name}-256k`, group: 'unmatched', texts: [fill(unit, 256 * KIB)] })),
|
||||
...Object.entries(CREDENTIALS).map(([name, credential]): Workload => ({
|
||||
id: `messages-with-${name}`, group: 'matched', texts: credentialMessages(5_000, credential),
|
||||
})),
|
||||
{ id: 'env-keys-1000', group: 'matched', texts: [envFile(1_000)] },
|
||||
{ id: 'env-keys-2000', group: 'matched', texts: [envFile(2_000)] },
|
||||
...Object.entries(ADVERSARIAL_UNITS).flatMap(([name, unit]): Workload[] => [
|
||||
{ id: `adversarial-${name}-64k`, group: 'adversarial', texts: [fill(unit, 64 * KIB)] },
|
||||
{ id: `adversarial-${name}-128k`, group: 'adversarial', texts: [fill(unit, 128 * KIB)] },
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
interface Sample {
|
||||
readonly ms: number
|
||||
readonly cpuMs: number
|
||||
readonly secrets: number
|
||||
readonly maskedCharacters: number
|
||||
}
|
||||
|
||||
/** Rules for one sample; a fresh credential matcher per sample carries no state from an earlier sample. */
|
||||
type RuleSet = () => readonly UserTextRule[]
|
||||
const withoutSecret: RuleSet = () => BASE_RULES
|
||||
const withSecret: RuleSet = () => [...BASE_RULES, createSecretRule()]
|
||||
|
||||
function registryWith(rules: RuleSet): { readonly registry: UserTextRegistry; readonly scope: Context } {
|
||||
const scope = new Context()
|
||||
const registry = new UserTextRegistry(scope)
|
||||
for (const rule of rules()) registry.register(rule)
|
||||
return { registry, scope }
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate every text once as a fresh document, as a newly rendered message does.
|
||||
* @returns elapsed and CPU milliseconds, credential nodes produced, and their characters.
|
||||
*/
|
||||
function evaluateAll(rules: RuleSet, texts: readonly string[]): Sample {
|
||||
const { registry, scope } = registryWith(rules)
|
||||
let secrets = 0
|
||||
let maskedCharacters = 0
|
||||
const cpuStart = process.cpuUsage()
|
||||
const start = performance.now()
|
||||
for (const text of texts) {
|
||||
for (const node of registry.createEvaluator()({ text, facts: FACTS })) {
|
||||
if (node.id !== 'secret') continue
|
||||
secrets++
|
||||
maskedCharacters += node.end - node.start
|
||||
}
|
||||
}
|
||||
const ms = performance.now() - start
|
||||
const cpu = process.cpuUsage(cpuStart)
|
||||
void scope.fiber.dispose()
|
||||
return { ms, cpuMs: (cpu.user + cpu.system) / 1_000, secrets, maskedCharacters }
|
||||
}
|
||||
|
||||
function fastest(rules: RuleSet, texts: readonly string[], attempts: number): Sample {
|
||||
let best = evaluateAll(rules, texts)
|
||||
for (let attempt = 1; attempt < attempts; attempt++) {
|
||||
const sample = evaluateAll(rules, texts)
|
||||
if (sample.ms < best.ms) best = sample
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
declare const gc: (() => void) | undefined
|
||||
|
||||
/**
|
||||
* Young-generation bytes in use. Code and feedback that compilation allocates during a
|
||||
* measurement live in other spaces, so they do not count as allocation by the evaluation.
|
||||
*/
|
||||
function youngBytes(): number {
|
||||
let total = 0
|
||||
for (const space of getHeapSpaceStatistics()) {
|
||||
if (space.space_name === 'new_space' || space.space_name === 'new_large_object_space') total += space.space_used_size
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/** The credential matcher reads no Facts. */
|
||||
const NO_FACTS: UserTextTransformContext = { read: () => undefined }
|
||||
|
||||
/**
|
||||
* Query the credential matcher over every document as the scheduler does when no other
|
||||
* matcher produces a node; an index loop, so the loop itself allocates nothing.
|
||||
*/
|
||||
function queryAll(rule: UserTextRule, texts: readonly string[]): void {
|
||||
for (let index = 0; index < texts.length; index++) {
|
||||
const text = texts[index]!
|
||||
for (let from = 0; ;) {
|
||||
const node = rule.match(text, from, NO_FACTS)
|
||||
if (node === undefined) break
|
||||
from = node.end
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Credential nodes the matcher returns over every document, counted outside any measured interval. */
|
||||
function returned(texts: readonly string[]): Omit<UserTextWorkloadAllocation, 'id' | 'ruleAllocatedBytes'> {
|
||||
const rule = createSecretRule()
|
||||
let secrets = 0
|
||||
let maskedCharacters = 0
|
||||
let maskedLineBreaks = 0
|
||||
for (const text of texts) {
|
||||
for (let node = rule.match(text, 0, NO_FACTS); node !== undefined; node = rule.match(text, node.end, NO_FACTS)) {
|
||||
secrets++
|
||||
maskedCharacters += node.end - node.start
|
||||
for (let index = node.start; index < node.end; index++) {
|
||||
const code = text.charCodeAt(index)
|
||||
if (code === 10 || code === 13) maskedLineBreaks++
|
||||
}
|
||||
}
|
||||
}
|
||||
return { secrets, maskedCharacters, maskedLineBreaks }
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes one warmed pass of every query allocates, measured across an interval without GC.
|
||||
* @returns young-generation growth over the interval.
|
||||
*/
|
||||
async function allocatedBytes(texts: readonly string[], collect: () => void): Promise<number> {
|
||||
const collections: number[] = []
|
||||
const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) collections.push(entry.startTime) })
|
||||
observer.observe({ entryTypes: ['gc'] })
|
||||
try {
|
||||
const rule = createSecretRule()
|
||||
for (let warm = 0; warm < 10; warm++) queryAll(rule, texts)
|
||||
collect()
|
||||
collect()
|
||||
const start = performance.now()
|
||||
const used = youngBytes()
|
||||
queryAll(rule, texts)
|
||||
const grown = youngBytes() - used
|
||||
const end = performance.now()
|
||||
// GC entries arrive on a later turn.
|
||||
await new Promise(resolve => { setTimeout(resolve, 20) })
|
||||
const inside = collections.filter(time => time >= start && time <= end).length
|
||||
if (inside > 0) throw new Error(`${String(inside)} garbage collections ran inside the allocation interval; enlarge the young generation`)
|
||||
return grown
|
||||
} finally {
|
||||
observer.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
function positiveInteger(value: string | undefined, name: string): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new Error(`${name} must be a positive integer`)
|
||||
return parsed
|
||||
}
|
||||
|
||||
async function allocations(): Promise<UserTextAllocationReport> {
|
||||
if (gc === undefined) throw new Error('user-text allocation mode needs --expose-gc')
|
||||
const collect = gc
|
||||
const results: UserTextWorkloadAllocation[] = []
|
||||
for (const { id, texts } of workloads()) {
|
||||
results.push({ id, ruleAllocatedBytes: await allocatedBytes(texts, collect), ...returned(texts) })
|
||||
}
|
||||
return { workloads: results }
|
||||
}
|
||||
|
||||
function timings(attempts: number): UserTextTimeReport {
|
||||
return {
|
||||
attempts,
|
||||
workloads: workloads().map(({ id, group, texts }) => {
|
||||
const base = fastest(withoutSecret, texts, attempts)
|
||||
const secret = fastest(withSecret, texts, attempts)
|
||||
return {
|
||||
id,
|
||||
group,
|
||||
documents: texts.length,
|
||||
characters: texts.reduce((sum, text) => sum + text.length, 0),
|
||||
secrets: secret.secrets,
|
||||
maskedCharacters: secret.maskedCharacters,
|
||||
baseMs: base.ms,
|
||||
withSecretMs: secret.ms,
|
||||
withSecretCpuMs: secret.cpuMs,
|
||||
ratio: secret.ms / base.ms,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
assertBuiltBenchmarkRuntime(import.meta.url, {
|
||||
'@deepseek-ai/cordis': import.meta.resolve('@deepseek-ai/cordis'),
|
||||
'@deepseek-ai/dsh-client-store': import.meta.resolve('@deepseek-ai/dsh-client-store'),
|
||||
})
|
||||
const [mode, count] = process.argv.slice(2)
|
||||
if (mode !== 'time' && mode !== 'allocation') throw new Error('usage: user-text.worker.js time <attempts> | allocation')
|
||||
console.log(JSON.stringify(mode === 'time' ? timings(positiveInteger(count, 'attempts')) : await allocations()))
|
||||
@@ -4532,6 +4532,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
| `@deepseek-ai/dsh-client-ui-reference` | — | [`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-renderer` | — | [`packages/client/ui-renderer/src/index.ts`](../packages/client/ui-renderer/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-schedule` | — | [`packages/client/ui-schedule/src/index.ts`](../packages/client/ui-schedule/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-secret-mask` | — | [`packages/client/ui-secret-mask/src/index.ts`](../packages/client/ui-secret-mask/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-session` | — | [`packages/client/ui-session/src/index.ts`](../packages/client/ui-session/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-settings` | — | [`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-settings-agent-loop` | — | [`packages/client/ui-settings-agent-loop/src/index.ts`](../packages/client/ui-settings-agent-loop/src/index.ts) |
|
||||
|
||||
@@ -4534,6 +4534,7 @@ export interface Config {
|
||||
| `@deepseek-ai/dsh-client-ui-reference` | — | [`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-renderer` | — | [`packages/client/ui-renderer/src/index.ts`](../packages/client/ui-renderer/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-schedule` | — | [`packages/client/ui-schedule/src/index.ts`](../packages/client/ui-schedule/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-secret-mask` | — | [`packages/client/ui-secret-mask/src/index.ts`](../packages/client/ui-secret-mask/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-session` | — | [`packages/client/ui-session/src/index.ts`](../packages/client/ui-session/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-settings` | — | [`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-settings-agent-loop` | — | [`packages/client/ui-settings-agent-loop/src/index.ts`](../packages/client/ui-settings-agent-loop/src/index.ts) |
|
||||
|
||||
@@ -175,6 +175,7 @@ flowchart TD
|
||||
pkg_client_ui_reference["client-ui-reference"]
|
||||
pkg_client_ui_renderer["client-ui-renderer"]
|
||||
pkg_client_ui_schedule["client-ui-schedule"]
|
||||
pkg_client_ui_secret_mask["client-ui-secret-mask"]
|
||||
pkg_client_ui_session["client-ui-session"]
|
||||
pkg_client_ui_settings["client-ui-settings"]
|
||||
pkg_client_ui_settings_account["client-ui-settings-account"]
|
||||
@@ -1494,6 +1495,7 @@ flowchart TD
|
||||
| [`client-ui-reference`](../packages/client/ui-reference) | `client` | — |
|
||||
| [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | — |
|
||||
| [`client-ui-schedule`](../packages/client/ui-schedule) | `client` | — |
|
||||
| [`client-ui-secret-mask`](../packages/client/ui-secret-mask) | `client` | — |
|
||||
| [`client-ui-session`](../packages/client/ui-session) | `client` | — |
|
||||
| [`client-ui-settings`](../packages/client/ui-settings) | `client` | — |
|
||||
| [`client-ui-settings-account`](../packages/client/ui-settings-account) | `client` | — |
|
||||
|
||||
@@ -177,6 +177,7 @@ flowchart TD
|
||||
pkg_client_ui_reference["client-ui-reference"]
|
||||
pkg_client_ui_renderer["client-ui-renderer"]
|
||||
pkg_client_ui_schedule["client-ui-schedule"]
|
||||
pkg_client_ui_secret_mask["client-ui-secret-mask"]
|
||||
pkg_client_ui_session["client-ui-session"]
|
||||
pkg_client_ui_settings["client-ui-settings"]
|
||||
pkg_client_ui_settings_account["client-ui-settings-account"]
|
||||
@@ -1496,6 +1497,7 @@ flowchart TD
|
||||
| [`client-ui-reference`](../packages/client/ui-reference) | `client` | — |
|
||||
| [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | — |
|
||||
| [`client-ui-schedule`](../packages/client/ui-schedule) | `client` | — |
|
||||
| [`client-ui-secret-mask`](../packages/client/ui-secret-mask) | `client` | — |
|
||||
| [`client-ui-session`](../packages/client/ui-session) | `client` | — |
|
||||
| [`client-ui-settings`](../packages/client/ui-settings) | `client` | — |
|
||||
| [`client-ui-settings-account`](../packages/client/ui-settings-account) | `client` | — |
|
||||
|
||||
@@ -378,6 +378,10 @@
|
||||
- id: ui-reference
|
||||
name: '@deepseek-ai/dsh-client-ui-reference'
|
||||
|
||||
# Display masking for recognized credentials in sent user text; remove
|
||||
# this entry to draw credentials as written.
|
||||
- id: ui-secret-mask
|
||||
name: '@deepseek-ai/dsh-client-ui-secret-mask'
|
||||
|
||||
# Background jobs: the session-header roster with on-demand streaming
|
||||
# record panels over the session job-output service.
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-reference": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-secret-mask": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-session": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-schedule": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:*",
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 1eb35b015a6c423f
|
||||
zh: a8a3dceee2c9e943
|
||||
/client-web-gui-browser-half/packages:
|
||||
en: 1aa93b8088fb1962
|
||||
zh: 10feaed78bc6408c
|
||||
en: e0bdfa58440be810
|
||||
zh: a006bb2e73f3f264
|
||||
/client-web-gui-browser-half/related-documentation:
|
||||
en: d1d506e68fab7333
|
||||
zh: 1a90fe6f3f8f24a9
|
||||
|
||||
@@ -63,6 +63,7 @@ The kernel packages boot and serve the page; the UI feature packages present it.
|
||||
| [`ui-input-trigger/`](ui-input-trigger/README.md) | Coordinates inline command and reference suggestions | — |
|
||||
| [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions | — |
|
||||
| [`ui-reference/`](ui-reference/README.md) | Unified Web `@file` / `@session` reference source | — |
|
||||
| [`ui-secret-mask/`](ui-secret-mask/README.md) | Masks recognized credentials in sent user text | — |
|
||||
| [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references | — |
|
||||
| [`ui-jobs/`](ui-jobs/README.md) | Lists this session's background jobs with on-demand streaming record panels | — |
|
||||
| [`ui-schedule/`](ui-schedule/README.md) | Lists the current Session's active reminders in a read-only header catalog | — |
|
||||
|
||||
@@ -63,6 +63,7 @@ kind: "package-group"
|
||||
| [`ui-input-trigger/`](ui-input-trigger/README.zh.md) | 协调内联命令与引用建议 | — |
|
||||
| [`ui-skill/`](ui-skill/README.zh.md) | 向内联建议添加 skill(技能)引用 | — |
|
||||
| [`ui-reference/`](ui-reference/README.zh.md) | 统一的 Web `@file` / `@session` 引用 source | — |
|
||||
| [`ui-secret-mask/`](ui-secret-mask/README.zh.md) | 遮挡已发送用户文本中可识别的凭据 | — |
|
||||
| [`ui-subagent/`](ui-subagent/README.zh.md) | 提供 subagent 导航、子级 transcript(文本记录)状态与内联引用 | — |
|
||||
| [`ui-jobs/`](ui-jobs/README.zh.md) | 列出本会话的后台任务,并提供按需展开的流式输出面板 | — |
|
||||
| [`ui-schedule/`](ui-schedule/README.zh.md) | 在只读标题栏目录中列出当前会话中生效的提醒 | — |
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-secret-mask/README.md
|
||||
/:
|
||||
en: 1dc6c25906eed342
|
||||
zh: 6d2502e37189d576
|
||||
/deepseek-ai-dsh-client-ui-secret-mask:
|
||||
en: f26ae7ca26826032
|
||||
zh: b74b0e6322c0b56c
|
||||
/deepseek-ai-dsh-client-ui-secret-mask/summary:
|
||||
en: 2a509a8ec34675b3
|
||||
zh: e0afbb185bde0c57
|
||||
/deepseek-ai-dsh-client-ui-secret-mask/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-secret-mask/use-this-package:
|
||||
en: b0381650c2b15970
|
||||
zh: a30982f630ea71f1
|
||||
/deepseek-ai-dsh-client-ui-secret-mask/use-this-package/what-is-masked:
|
||||
en: 445a9c788955600d
|
||||
zh: b36ebee0735f5c48
|
||||
/deepseek-ai-dsh-client-ui-secret-mask/understand-the-implementation:
|
||||
en: e3759e0f0a595dfe
|
||||
zh: 66fbd1a601733154
|
||||
/deepseek-ai-dsh-client-ui-secret-mask/further-exploration:
|
||||
en: 5c806601580c92fe
|
||||
zh: 42d6ee505af39e9c
|
||||
/deepseek-ai-dsh-client-ui-secret-mask/model-experience:
|
||||
en: 9a362b8fa90e0d5a
|
||||
zh: 65c22dcf8f352782
|
||||
/deepseek-ai-dsh-client-ui-secret-mask/model-experience/kv-cache-effect:
|
||||
en: ca75c51c89c2c9b0
|
||||
zh: c2241315dc0c89c9
|
||||
/deepseek-ai-dsh-client-ui-secret-mask/known-limitations-and-deferred-work:
|
||||
en: 14c6e5817a106972
|
||||
zh: 13082b68474b5a6f
|
||||
/deepseek-ai-dsh-client-ui-secret-mask/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: e88aeb617ffb74ea
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
description: "Web display masking for recognized credentials in sent user text, with a per-value reveal control in interactive messages."
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-client-ui-secret-mask
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Use `dsh-client-ui-secret-mask` when Web users paste API keys, tokens, or passwords into messages that others may see on a shared screen or in a screenshot. Sent user text draws each recognized credential masked: a key or token keeps its first eight characters, and each remaining character is drawn as `*`. Masking changes only what is drawn; the message text, the copy action, and the model input keep the original characters.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Use this package](#use-this-package)
|
||||
- [Understand the implementation](#understand-the-implementation)
|
||||
- [Further Exploration](#further-exploration)
|
||||
- [Model Experience](#model-experience)
|
||||
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
||||
- [Dev Note](#dev-note)
|
||||
|
||||
-----
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Mount the package beside `dsh-client-ui-conversation`. The Web app bundle mounts it by default; removing its row draws credentials as written. Every caller of the [UserText Factory](../ui-conversation/README.md#sent-user-text) masks: message bubbles, steering messages, pending echoes, queued messages, and `/goal` input bubbles.
|
||||
|
||||
Each masked value is drawn in a monospace font, so revealing it does not move the surrounding text. Interactive text, which is a sent message or an admitted steering message, adds an eye control at the end of each run. The control reveals or hides that one value, and every mount starts masked. Other callers draw the masked value without a control.
|
||||
|
||||
<a id="what-is-masked"></a>
|
||||
### What is masked
|
||||
|
||||
| Value | Examples | Display |
|
||||
| --- | --- | --- |
|
||||
| Model-service and Google API keys | `sk-…` with at least one digit, `AIza…` | First eight characters kept |
|
||||
| Code-hosting and package-registry tokens | `ghp_…`, `github_pat_…`, `glpat-…`, `npm_…`, `hf_…` | First eight characters kept |
|
||||
| Cloud access key IDs | `AKIA…`, `ASIA…`, `LTAI…`, `AKID…` | First eight characters kept |
|
||||
| Collaboration and payment tokens | `xoxb-…`, `sk_live_…`, `rk_test_…`, `whsec_…` | First eight characters kept |
|
||||
| JSON Web Tokens | `eyJ….eyJ….…` | First eight characters kept |
|
||||
| Private key blocks | `-----BEGIN … PRIVATE KEY-----` through its `END` line | `BEGIN` and `END` lines and line breaks kept |
|
||||
| Address passwords | `postgres://user:password@host/db` | Password masked completely, including a token used as one |
|
||||
|
||||
Named assignments such as `password=…` or `API_KEY=…`, passwords written in a sentence, verification codes, and personal data have no published format and stay as written.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
The plugin registers one UserText matcher, `secret`, and its keyed `conversation.user.text.node` renderer; the `/client` export is the plugin body (`apply`/`inject`) only. The matcher reads no Facts, so Facts updates never query it again. Each query returns the nearest credential at or after the requested offset and allocates nothing before it returns a node. A literal-only alternation finds where a format can begin; where a literal's suffix can begin another literal, the scan resumes at that suffix. Format checks are bounded character comparisons and sticky `[class]*` patterns, which stop at the first character outside the class and leave nothing to backtrack into. The matcher instance that `apply` creates memoizes run ends, the next digit, and the next private-key `END` line per text across queries, so all queries of one text cost linear time together; the instance keeps the last queried text until a query for another text. At an equal start, the private-key, address-password, and prefix formats win in that order, and every overlapping candidate joins the earliest under its mask. When an address's `://` precedes the requested offset, the matcher walks back over the user name only if `:`, a password, and `@` follow the offset, so a query from anywhere inside an address finds the same password. The [user-text benchmark](../../../benchmarks/user-text/user-text.bench.client.ts) bounds evaluation time for text with and without credentials, linear scaling, and the matcher's allocation ([rationale](../../../.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md)).
|
||||
|
||||
The node payload carries only the masked display text. The renderer draws each run of `*` as centered marks while keeping the characters for copying and assistive technology, because a font's own asterisk sits near the cap height. It switches between that text and the node's original text, and it remembers the revealed original value instead of a flag, so a different credential at the same position starts masked.
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## Further Exploration
|
||||
|
||||
- [ui-conversation sent user text](../ui-conversation/README.md#sent-user-text) — the matcher registry, the scheduler, and the node renderer Slot.
|
||||
- [User text candidate scheduling](../../../.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.md) — the forward-query and cache rules this matcher follows.
|
||||
- [Credential matching performance](../../../.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md) — prefix pruning, the benchmark workloads, and the measured comparison.
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
None, as masking changes only what the browser draws; the message text and the model input keep the original characters.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
These limits define when a credential is drawn as written; they are current package constraints.
|
||||
|
||||
- **Recognition requires a published format** — a credential without one of the [recognized formats](#what-is-masked) is drawn as written. Named assignments stay plain because the same names also label ordinary values, such as token usage.
|
||||
- **Only UserText callers mask** — session titles, turn previews, search excerpts, the goal bar, tool cards, assistant replies, and the unsent composer draw credentials as written.
|
||||
- **An earlier reference draws its own text** — a credential inside a reference that starts before it, such as the file mention `@config/<key>.txt`, is drawn by that reference.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
<details>
|
||||
<summary>Working context for maintainers — click to expand</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
description: "Web 端已发送用户文本中可识别凭据的显示遮挡,交互消息中每个值带显示控件。"
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-client-ui-secret-mask
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
## 概述
|
||||
|
||||
当 Web 用户把 API 密钥、令牌或密码粘贴进消息,而消息可能在共享屏幕或截图中被他人看到时,使用 `dsh-client-ui-secret-mask`。已发送的用户文本会遮挡每个可识别的凭据:密钥或令牌保留前八个字符,其余每个字符显示为 `*`。遮挡只改变显示内容;消息原文、复制操作和模型输入都保留原始字符。
|
||||
|
||||
## 目录
|
||||
|
||||
- [使用本包](#use-this-package)
|
||||
- [理解实现](#understand-the-implementation)
|
||||
- [进一步探索](#further-exploration)
|
||||
- [模型体验](#model-experience)
|
||||
- [已知限制与延期工作](#known-limitations-and-deferred-work)
|
||||
- [开发备注](#dev-note)
|
||||
|
||||
-----
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
把本包与 `dsh-client-ui-conversation` 一起挂载。Web 应用 bundle 默认挂载本包;移除该行后凭据按原样显示。[UserText Factory](../ui-conversation/README.zh.md#sent-user-text) 的每个调用方都会遮挡:消息气泡、插话消息、发送中回显、排队消息以及 `/goal` 输入气泡。
|
||||
|
||||
每个被遮挡的值以等宽字体显示,因此展开时不会挪动周围文字。交互文本(即已发送消息或已接纳的插话消息)会在每段末尾加一个眼睛控件。该控件显示或隐藏这一个值,每次挂载都从遮挡状态开始。其他调用方只显示遮挡后的值,不带控件。
|
||||
|
||||
<a id="what-is-masked"></a>
|
||||
### 遮挡范围
|
||||
|
||||
| 值 | 示例 | 显示 |
|
||||
| --- | --- | --- |
|
||||
| 模型服务与 Google API 密钥 | 至少含一个数字的 `sk-…`、`AIza…` | 保留前八个字符 |
|
||||
| 代码托管与包仓库令牌 | `ghp_…`、`github_pat_…`、`glpat-…`、`npm_…`、`hf_…` | 保留前八个字符 |
|
||||
| 云访问密钥 ID | `AKIA…`、`ASIA…`、`LTAI…`、`AKID…` | 保留前八个字符 |
|
||||
| 协作与支付令牌 | `xoxb-…`、`sk_live_…`、`rk_test_…`、`whsec_…` | 保留前八个字符 |
|
||||
| JSON Web Token | `eyJ….eyJ….…` | 保留前八个字符 |
|
||||
| 私钥块 | 从 `-----BEGIN … PRIVATE KEY-----` 到对应 `END` 行 | 保留 `BEGIN`、`END` 两行及换行 |
|
||||
| 地址中的密码 | `postgres://user:password@host/db` | 密码全部遮挡,用作密码的令牌也一样 |
|
||||
|
||||
`password=…`、`API_KEY=…` 这类带名字的赋值、写在句子里的密码、验证码以及个人信息都没有公开格式,保持原样。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
本插件注册一个 UserText 匹配器 `secret` 及其 keyed `conversation.user.text.node` renderer;`/client` 导出只有插件主体(`apply`/`inject`)。匹配器不读取 Facts,因此 Facts 更新不会再次查询它。每次查询返回请求偏移处或之后最近的凭据,返回节点之前不分配内存。一个只含字面量的交替正则找到格式可能开始的位置;字面量的后缀可能是另一个字面量的开头时,扫描从该后缀处继续。格式检查是有界的字符比较和粘性 `[字符类]*` 模式,这类模式在第一个不属于该类的字符处停止,没有可回溯的部分。`apply` 创建的匹配器实例按文本跨查询记忆字符段结尾、下一个数字和下一行私钥 `END`,因此一段文本的全部查询合计线性;实例保留最近一次查询的文本,直到查询另一段文本。同一起点上,私钥、地址密码和前缀格式依次优先;所有重叠的候选都并入最早的候选,并使用其遮挡方式。地址的 `://` 在请求偏移之前时,只有偏移后紧跟 `:`、密码和 `@` 才回看用户名,因此从地址内任何位置查询都得到同一个密码。[user-text benchmark](../../../benchmarks/user-text/user-text.bench.client.ts) 约束无凭据与含凭据文本的求值耗时、线性增长和匹配器的内存分配([理由](../../../.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md))。
|
||||
|
||||
节点 payload 只携带遮挡后的显示文本。renderer 把每段 `*` 绘制为垂直居中的标记,同时保留字符本身供复制与辅助技术读取,因为字体自带的星号靠近大写字母顶部。renderer 在该文本与节点原文之间切换,并记住已显示的原文值而不是一个开关,因此同一位置换成另一个凭据时会从遮挡状态开始。
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## 进一步探索
|
||||
|
||||
- [ui-conversation 已发送用户文本](../ui-conversation/README.zh.md#sent-user-text)——匹配器注册表、调度器与节点 renderer Slot。
|
||||
- [用户文本候选调度](../../../.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.zh.md)——本匹配器遵循的向前查询与缓存规则。
|
||||
- [凭据识别的性能](../../../.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md)——前缀剪枝、benchmark 负载与实测对比。
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
无。遮挡只改变浏览器的显示内容;消息原文与模型输入都保留原始字符。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;本包从不组装或发送提供方请求。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
以下限制说明凭据何时按原样显示;它们是本包当前的约束。
|
||||
|
||||
- **识别依赖公开格式**——不具备[可识别格式](#what-is-masked)的凭据按原样显示。带名字的赋值保持原样,因为同样的名字也用于 token 用量等普通值。
|
||||
- **只有 UserText 调用方会遮挡**——会话标题、轮次预览、搜索摘要、目标栏、工具卡片、助手回复以及未发送的输入框都按原样显示凭据。
|
||||
- **更早开始的引用显示自身文本**——位于更早开始的引用之内的凭据,例如文件提及 `@config/<key>.txt`,由该引用显示。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
<details>
|
||||
<summary>维护者工作上下文——点击展开</summary>
|
||||
|
||||
无。
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "ui-secret-mask",
|
||||
"description": "Masks recognized keys, tokens, and passwords in sent messages."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "凭据遮挡",
|
||||
"description": "遮挡已发送消息中可识别的密钥、令牌与密码。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-secret-mask",
|
||||
"description": "Web display masking for recognized credentials in sent user text",
|
||||
"version": "0.2.1-alpha.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/ui-secret-mask"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json",
|
||||
"./locale/*.json": "./locale/*.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-renderer"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:*",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"locale/*.json",
|
||||
"lib/index.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/* A monospace box: the value and the control are flex items centered on one
|
||||
axis, and the box takes the surrounding text's baseline from the value.
|
||||
Each mask character keeps its original character's advance, so revealing
|
||||
never reflows the line. */
|
||||
.secret {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
margin: 0 2px;
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.value {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* The characters stay for copying but are not painted; an asterisk tile per
|
||||
character advance replaces them, because a font's own asterisk sits near
|
||||
the cap height. */
|
||||
.mask {
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-color: currentColor;
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 20'%3E%3Cpath d='M6 5.6v8.8M2.19 7.8l7.62 4.4M2.19 12.2l7.62-4.4' stroke='%23000' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E") left center / 1ch 100% repeat-x;
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 20'%3E%3Cpath d='M6 5.6v8.8M2.19 7.8l7.62 4.4M2.19 12.2l7.62-4.4' stroke='%23000' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E") left center / 1ch 100% repeat-x;
|
||||
}
|
||||
|
||||
/* The line-box center sits below the middle of the surrounding CJK and Latin
|
||||
glyphs; both marks and the control rise together without moving layout. */
|
||||
.mask,
|
||||
.toggle {
|
||||
position: relative;
|
||||
top: -0.125em;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.toggle:focus-visible {
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 1px;
|
||||
border-radius: var(--dsw-radius-xs);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/** Masked credential presentation with a per-value reveal control in interactive text. */
|
||||
import { useState } from 'react'
|
||||
import type { UserTextNodeProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { NS } from './locales.ts'
|
||||
import { MASK } from './secret-rule.ts'
|
||||
import css from './SecretText.module.css'
|
||||
|
||||
const EYE_CONTOUR = 'M1.5 8C2.9 5.25 5.25 3.5 8 3.5C10.75 3.5 13.1 5.25 14.5 8C13.1 10.75 10.75 12.5 8 12.5C5.25 12.5 2.9 10.75 1.5 8Z'
|
||||
const EYE_PATH = `${EYE_CONTOUR}M10 8C10 9.1 9.1 10 8 10C6.9 10 6 9.1 6 8C6 6.9 6.9 6 8 6C9.1 6 10 6.9 10 8Z`
|
||||
/** The struck-through glyph drops the pupil so it stays distinct at text size. */
|
||||
const EYE_OFF_PATH = `${EYE_CONTOUR}M2.5 2.5L13.5 13.5`
|
||||
const MASK_RUNS = new RegExp(`(\\${MASK}+)`, 'u')
|
||||
|
||||
function EyeGlyph({ path }: { readonly path: string }) {
|
||||
return (
|
||||
<svg className={css.icon} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.3} strokeLinecap="round" aria-hidden="true">
|
||||
<path d={path} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the masked form into kept text and mask runs. Mask runs keep their
|
||||
* characters for copying and assistive technology but draw centered marks,
|
||||
* because a font's own asterisk sits near the cap height.
|
||||
*/
|
||||
function MaskedText({ masked }: { readonly masked: string }) {
|
||||
let offset = 0
|
||||
return masked.split(MASK_RUNS).map((part) => {
|
||||
const key = offset
|
||||
offset += part.length
|
||||
if (part === '') return null
|
||||
return part.startsWith(MASK)
|
||||
? <span key={key} className={css.mask}>{part}</span>
|
||||
: <span key={key}>{part}</span>
|
||||
})
|
||||
}
|
||||
|
||||
type SecretTextProps = UserTextNodeProps<'secret'> & PropsLocale<typeof NS>
|
||||
|
||||
/**
|
||||
* Draw one credential masked; interactive text adds a control that reveals or hides this value.
|
||||
* Every mount, and every different original value at the same position, starts masked.
|
||||
* @param props - masked node, original spelling, interaction mode, and locale seat.
|
||||
* @returns an inline masked run with an optional reveal control.
|
||||
*/
|
||||
export function SecretText({ node, raw, interactive, t }: SecretTextProps) {
|
||||
// Holding the revealed value instead of a flag re-masks a different credential that reuses this element.
|
||||
const [revealedRaw, setRevealedRaw] = useState<string | undefined>()
|
||||
const revealed = interactive && revealedRaw === raw
|
||||
return (
|
||||
<span className={css.secret} data-secret="">
|
||||
<span className={css.value}>{revealed ? raw : <MaskedText masked={node.data.masked} />}</span>
|
||||
{interactive && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.toggle}
|
||||
aria-pressed={revealed}
|
||||
aria-label={t(revealed ? 'secret.hide' : 'secret.reveal')}
|
||||
onClick={() => { setRevealedRaw(revealed ? undefined : raw) }}
|
||||
>
|
||||
<EyeGlyph path={revealed ? EYE_OFF_PATH : EYE_PATH} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Credential display masking for sent Web user text: one UserText matcher and
|
||||
* its keyed `secret` node renderer. Masking changes only the drawn text; the
|
||||
* message text, copy actions, and model input keep the original characters.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-client-ui-secret-mask/client
|
||||
*/
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: pulls the SlotRegistry service merge (ctx.slots).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
// Type-only: pulls the UiConversation service merge (ctx.uiConversation).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { SecretText } from './SecretText.tsx'
|
||||
import { createSecretRule } from './secret-rule.ts'
|
||||
export type {} from './secret-rule.ts'
|
||||
import { en, NS, zh } from './locales.ts'
|
||||
|
||||
/** Required services: the copy, the node renderer registry, and the UserText rule registry. */
|
||||
export const inject = ['locale', 'slots', 'uiConversation']
|
||||
|
||||
/**
|
||||
* Register the dictionaries, the credential matcher, and its node renderer.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-secret-mask: dictionaries')
|
||||
ctx.uiConversation.userText.register(createSecretRule())
|
||||
ctx.slots.inject('conversation.user.text.node', () => ctx.slots.register({
|
||||
name: 'conversation.user.text.node', key: 'secret', priority: 100, locale: NS,
|
||||
}, SecretText))
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/** `secretMask` dictionaries for the per-credential reveal control. */
|
||||
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
export const NS = 'secretMask'
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'secret.reveal': '显示密钥',
|
||||
'secret.hide': '隐藏密钥',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The secretMask namespace key union. */
|
||||
export type SecretMaskKey = keyof typeof zh
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Accessible names of the control that reveals one masked credential. */
|
||||
secretMask: SecretMaskKey
|
||||
}
|
||||
}
|
||||
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'secret.reveal': 'Show secret',
|
||||
'secret.hide': 'Hide secret',
|
||||
} satisfies Record<SecretMaskKey, string>
|
||||
@@ -0,0 +1,535 @@
|
||||
/**
|
||||
* Original-text credential candidates. A value is recognized by its published
|
||||
* format alone: provider key and token prefixes, JSON Web Tokens, private key
|
||||
* blocks, and the password of an address that carries credentials. Free-form
|
||||
* passwords and `name=value` assignments have no such format and stay plain.
|
||||
*
|
||||
* Matching is prefix-pruned and linear and allocates nothing until it returns a
|
||||
* node. One literal-only alternation finds each place a format can begin, and
|
||||
* resumes inside a literal that can overlap the next one. Format checks are
|
||||
* bounded character comparisons and sticky `[class]*` runs, which end at the
|
||||
* first character outside the class and leave nothing to backtrack into. Run
|
||||
* ends, the next digit, and the next key-block `END` line are memoized per text
|
||||
* across queries, so each is measured once per text.
|
||||
*/
|
||||
import type { UserTextRule } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface UserTextDataMap {
|
||||
/** One recognized credential range. */
|
||||
secret: {
|
||||
/** Display form of the original range; line breaks stay in place. */
|
||||
readonly masked: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Character that replaces each hidden credential character in `masked`. */
|
||||
export const MASK = '*'
|
||||
/** Leading characters of a prefixed key left readable: the provider prefix and little else. */
|
||||
const VISIBLE_PREFIX = 8
|
||||
const MASKS = MASK.repeat(4096)
|
||||
const MASKS_LF = `${MASKS}\n`
|
||||
const MASKS_CR = `${MASKS}\r`
|
||||
/** `count` marks: up to the shared string's length, one slice of it. */
|
||||
const marks = (count: number): string => count <= MASKS.length ? MASKS.slice(0, count) : MASK.repeat(count)
|
||||
/** `count` marks and then the line break `code`, 10 or 13: one slice where `marks` would be one. */
|
||||
const marksThen = (count: number, code: number): string => count <= MASKS.length
|
||||
? (code === 10 ? MASKS_LF : MASKS_CR).slice(MASKS.length - count)
|
||||
: MASK.repeat(count) + (code === 10 ? '\n' : '\r')
|
||||
|
||||
// Classes of one ASCII code unit, as bits of CLASSES.
|
||||
const KEY = 1 // [A-Za-z0-9_-]
|
||||
const ALNUM = 2 // [A-Za-z0-9]
|
||||
const WORD = 4 // \w
|
||||
const ALNUM_DASH = 8 // [A-Za-z0-9-]
|
||||
const UPPER_ALNUM = 16 // [A-Z0-9]
|
||||
const LABEL = 32 // [A-Z0-9 ]
|
||||
const SCHEME = 64 // [a-z0-9+.-]
|
||||
const LOWER = 128 // [a-z]
|
||||
const USER = 256 // [^\s/:@?#]
|
||||
const PASSWORD = 512 // [^\s/@?#]
|
||||
|
||||
const CLASSES = new Uint16Array(128)
|
||||
for (let code = 0; code < 128; code++) {
|
||||
const upper = code >= 65 && code <= 90
|
||||
const lower = code >= 97 && code <= 122
|
||||
const digit = code >= 48 && code <= 57
|
||||
const alnum = upper || lower || digit
|
||||
const authority = !((code >= 9 && code <= 13) || code === 32 || code === 47 || code === 64 || code === 63 || code === 35)
|
||||
CLASSES[code] = (alnum || code === 95 || code === 45 ? KEY : 0) | (alnum ? ALNUM : 0) | (alnum || code === 95 ? WORD : 0)
|
||||
| (alnum || code === 45 ? ALNUM_DASH : 0) | (upper || digit ? UPPER_ALNUM : 0) | (upper || digit || code === 32 ? LABEL : 0)
|
||||
| (lower || digit || code === 43 || code === 46 || code === 45 ? SCHEME : 0) | (lower ? LOWER : 0)
|
||||
| (authority && code !== 58 ? USER : 0) | (authority ? PASSWORD : 0)
|
||||
}
|
||||
|
||||
/** `\s` above ASCII. */
|
||||
function isWideSpace(code: number): boolean {
|
||||
return code === 0xa0 || code === 0x1680 || (code >= 0x2000 && code <= 0x200a) || code === 0x2028 || code === 0x2029
|
||||
|| code === 0x202f || code === 0x205f || code === 0x3000 || code === 0xfeff
|
||||
}
|
||||
|
||||
/** `\w` at `index`; out of range is not a word character. */
|
||||
function isWordAt(text: string, index: number): boolean {
|
||||
const code = text.charCodeAt(index)
|
||||
return code < 128 && (CLASSES[code]! & WORD) !== 0
|
||||
}
|
||||
|
||||
/** `[^\s/:@?#]` for one UTF-16 code unit; NaN, read out of range, is not one. */
|
||||
function isUserCode(code: number): boolean {
|
||||
return code < 128 ? (CLASSES[code]! & USER) !== 0 : code === code && !isWideSpace(code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sticky run patterns: nothing follows the starred class, so nothing backtracks,
|
||||
* and `test` allocates nothing. Every use sets `lastIndex` first; matching is
|
||||
* synchronous and never re-enters, so no two runs share a cursor.
|
||||
*/
|
||||
const KEY_RUN = /[A-Za-z0-9_-]*/y
|
||||
const ALNUM_RUN = /[A-Za-z0-9]*/y
|
||||
const WORD_RUN = /\w*/y
|
||||
const ALNUM_DASH_RUN = /[A-Za-z0-9-]*/y
|
||||
const UPPER_ALNUM_RUN = /[A-Z0-9]*/y
|
||||
const USER_RUN = /[^\s/:@?#]*/y
|
||||
const PASSWORD_RUN = /[^\s/@?#]*/y
|
||||
const NON_DIGITS = /\D*/y
|
||||
/** Code units that `maskLines` counts without inspection: neither line breaks nor surrogates. */
|
||||
const ORDINARY = /[^\r\n\ud800-\udfff]*/y
|
||||
|
||||
/** Offset after the run that `pattern` matches at `start`. */
|
||||
function runEnd(pattern: RegExp, text: string, start: number): number {
|
||||
pattern.lastIndex = start
|
||||
pattern.test(text)
|
||||
return pattern.lastIndex
|
||||
}
|
||||
|
||||
// Literal kinds; same-start candidates resolve by ascending kind, so a token used as an address password is masked completely.
|
||||
const KEY_BLOCK = 0
|
||||
const ADDRESS = 1
|
||||
const PREFIXED = 2
|
||||
// Prefixed shapes.
|
||||
const RUN = 0 // the whole run, at least `min`
|
||||
const BOUNDED = 1 // `min`..`max`, then `\b`
|
||||
const SK = 2 // at least `min`, with a digit among the first 256
|
||||
const GOOGLE = 3 // exactly `min`
|
||||
const JWT = 4 // three runs joined by `.eyJ` and `.`
|
||||
|
||||
interface Format {
|
||||
readonly literal: string
|
||||
readonly kind: number
|
||||
readonly shape: number
|
||||
readonly run: number
|
||||
readonly min: number
|
||||
readonly max: number
|
||||
}
|
||||
|
||||
const format = (literal: string, kind: number, shape = RUN, run = 0, min = 0, max = 0): Format => ({ literal, kind, shape, run, min, max })
|
||||
|
||||
/** Every prefixed format matches more than `VISIBLE_PREFIX` characters. */
|
||||
const FORMATS: readonly Format[] = [
|
||||
format('-----BEGIN ', KEY_BLOCK),
|
||||
format('://', ADDRESS),
|
||||
// Model services: `sk-` keys carry a digit, which hyphenated identifiers rarely do.
|
||||
format('sk-', PREFIXED, SK, KEY, 20),
|
||||
format('AIza', PREFIXED, GOOGLE, KEY, 35),
|
||||
// Code hosting and package registries.
|
||||
...['ghp_', 'gho_', 'ghu_', 'ghs_', 'ghr_'].map(literal => format(literal, PREFIXED, RUN, ALNUM, 36)),
|
||||
format('github_pat_', PREFIXED, RUN, WORD, 22),
|
||||
format('glpat-', PREFIXED, RUN, KEY, 20),
|
||||
format('npm_', PREFIXED, RUN, ALNUM, 36),
|
||||
format('hf_', PREFIXED, RUN, ALNUM, 30),
|
||||
// Cloud access key IDs.
|
||||
format('AKIA', PREFIXED, BOUNDED, UPPER_ALNUM, 16, 16),
|
||||
format('ASIA', PREFIXED, BOUNDED, UPPER_ALNUM, 16, 16),
|
||||
format('LTAI', PREFIXED, BOUNDED, ALNUM, 12, 20),
|
||||
format('AKID', PREFIXED, BOUNDED, ALNUM, 28, 36),
|
||||
// Collaboration and payment services.
|
||||
...['xoxa-', 'xoxb-', 'xoxp-', 'xoxr-', 'xoxs-'].map(literal => format(literal, PREFIXED, RUN, ALNUM_DASH, 10)),
|
||||
...['sk_live_', 'sk_test_', 'rk_live_', 'rk_test_'].map(literal => format(literal, PREFIXED, RUN, ALNUM, 16)),
|
||||
format('whsec_', PREFIXED, RUN, ALNUM, 24),
|
||||
format('eyJ', PREFIXED, JWT, KEY, 8, 2048),
|
||||
]
|
||||
const LITERAL_LENGTH = Uint8Array.from(FORMATS, f => f.literal.length)
|
||||
const LITERAL_KIND = Uint8Array.from(FORMATS, f => f.kind)
|
||||
const LITERAL_SHAPE = Uint8Array.from(FORMATS, f => f.shape)
|
||||
const LITERAL_MIN = Int32Array.from(FORMATS, f => f.min)
|
||||
const LITERAL_MAX = Int32Array.from(FORMATS, f => f.max)
|
||||
const LITERAL_RUN: readonly RegExp[] = FORMATS.map(f => f.run === ALNUM ? ALNUM_RUN : f.run === WORD ? WORD_RUN
|
||||
: f.run === ALNUM_DASH ? ALNUM_DASH_RUN : f.run === UPPER_ALNUM ? UPPER_ALNUM_RUN : KEY_RUN)
|
||||
/**
|
||||
* Length of the longest proper suffix of each literal that begins a literal. The
|
||||
* scan resumes there, so a key block whose first dash ends `sk-` is still seen.
|
||||
*/
|
||||
const LITERAL_OVERLAP = Uint8Array.from(FORMATS, (f) => {
|
||||
for (let size = f.literal.length - 1; size > 0; size--) {
|
||||
const suffix = f.literal.slice(-size)
|
||||
if (FORMATS.some(other => other.literal.startsWith(suffix))) return size
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
const escape = (literal: string): string => literal.replace(/[.*+?^${}()|[\]\\/]/gu, '\\$&')
|
||||
|
||||
/** Literal alternatives grouped by shared leading characters; groups only factor literals and add no quantifier. */
|
||||
function alternation(literals: readonly string[]): string {
|
||||
const groups = new Map<string, string[]>()
|
||||
for (const literal of literals) groups.set(literal.charAt(0), [...groups.get(literal.charAt(0)) ?? [], literal.slice(1)])
|
||||
return [...groups].map(([head, tails]) => tails.length === 1
|
||||
? escape(head + tails.join(''))
|
||||
: `${escape(head)}(?:${alternation(tails)})`).join('|')
|
||||
}
|
||||
|
||||
/** Literal-only alternation of every place a format can begin; no literal is a suffix of another. */
|
||||
export const PREFIX_SOURCE = alternation(FORMATS.map(f => f.literal))
|
||||
/** Compiled once; every query sets `lastIndex` before its first test. */
|
||||
const PREFIXES = new RegExp(PREFIX_SOURCE, 'g')
|
||||
/** Literal ending in two ASCII code units: its index plus one, or -1 where several literals share the ending. */
|
||||
const ENDING = new Int8Array(128 * 128)
|
||||
/** Literals that share an ending, by the same key. */
|
||||
const SHARED_ENDINGS: number[][] = []
|
||||
FORMATS.forEach((f, index) => {
|
||||
const key = f.literal.charCodeAt(f.literal.length - 2) * 128 + f.literal.charCodeAt(f.literal.length - 1)
|
||||
if (ENDING[key] === 0) {
|
||||
ENDING[key] = index + 1
|
||||
return
|
||||
}
|
||||
if (ENDING[key]! > 0) {
|
||||
SHARED_ENDINGS[key] = [ENDING[key]! - 1]
|
||||
ENDING[key] = -1
|
||||
}
|
||||
SHARED_ENDINGS[key]!.push(index)
|
||||
})
|
||||
|
||||
/** Index of the literal that PREFIXES matched ending at `end`; one of the literals sharing its ending matches. */
|
||||
function literalEndingAt(text: string, end: number): number {
|
||||
const key = text.charCodeAt(end - 2) * 128 + text.charCodeAt(end - 1)
|
||||
const literal = ENDING[key]!
|
||||
if (literal > 0) return literal - 1
|
||||
const shared = SHARED_ENDINGS[key]!
|
||||
let index = 0
|
||||
while (!text.startsWith(FORMATS[shared[index]!]!.literal, end - LITERAL_LENGTH[shared[index]!]!)) index++
|
||||
return shared[index]!
|
||||
}
|
||||
|
||||
// Memo layout: [start, end] pairs. A lookup at `offset` hits when start <= offset <= end; -1 pairs hold nothing.
|
||||
const KEY_SLOTS = 0 // four pairs: a JSON Web Token measures three runs
|
||||
const KEY_NEXT = 8
|
||||
const ALNUM_SLOT = 9
|
||||
const WORD_SLOT = 11
|
||||
const ALNUM_DASH_SLOT = 13
|
||||
const UPPER_ALNUM_SLOT = 15
|
||||
const USER_SLOT = 17
|
||||
const PASSWORD_SLOT = 19
|
||||
const DIGIT_FROM = 21
|
||||
const DIGIT_AT = 22
|
||||
const BLOCK_FROM = 23
|
||||
const BLOCK_END_START = 24
|
||||
const BLOCK_END = 25
|
||||
/** Start of the password that the last address check accepted. */
|
||||
const CANDIDATE_START = 26
|
||||
const MEMO_SIZE = 27
|
||||
const LITERAL_SLOT = Uint8Array.from(FORMATS, f => f.run === ALNUM ? ALNUM_SLOT : f.run === WORD ? WORD_SLOT
|
||||
: f.run === ALNUM_DASH ? ALNUM_DASH_SLOT : f.run === UPPER_ALNUM ? UPPER_ALNUM_SLOT : 0)
|
||||
|
||||
/** End of the `[A-Za-z0-9_-]*` run at `start`, through four memo pairs replaced in turn. */
|
||||
function keyRunEnd(text: string, start: number, memo: Int32Array): number {
|
||||
for (let slot = KEY_SLOTS; slot < KEY_NEXT; slot += 2) {
|
||||
if (memo[slot]! <= start && start <= memo[slot + 1]!) return memo[slot + 1]!
|
||||
}
|
||||
const end = runEnd(KEY_RUN, text, start)
|
||||
const next = (memo[KEY_NEXT]! + 1) & 3
|
||||
memo[KEY_NEXT] = next
|
||||
memo[KEY_SLOTS + 2 * next] = start
|
||||
memo[KEY_SLOTS + 2 * next + 1] = end
|
||||
return end
|
||||
}
|
||||
|
||||
/** End of the run `pattern` matches at `start`, through the memo pair at `slot`. */
|
||||
function memoRunEnd(pattern: RegExp, text: string, start: number, slot: number, memo: Int32Array): number {
|
||||
if (memo[slot]! <= start && start <= memo[slot + 1]!) return memo[slot + 1]!
|
||||
const end = runEnd(pattern, text, start)
|
||||
memo[slot] = start
|
||||
memo[slot + 1] = end
|
||||
return end
|
||||
}
|
||||
|
||||
/** Offset of the first digit at or after `start`, or the text length. */
|
||||
function nextDigit(text: string, start: number, memo: Int32Array): number {
|
||||
if (memo[DIGIT_FROM]! <= start && start <= memo[DIGIT_AT]!) return memo[DIGIT_AT]!
|
||||
memo[DIGIT_FROM] = start
|
||||
memo[DIGIT_AT] = runEnd(NON_DIGITS, text, start)
|
||||
return memo[DIGIT_AT]!
|
||||
}
|
||||
|
||||
/** Offset after `[A-Z0-9 ]{0,40}PRIVATE KEY-----` at `start`, or -1; reads at most 52 characters. */
|
||||
function labelEnd(text: string, start: number): number {
|
||||
const limit = Math.min(text.length, start + 52)
|
||||
let index = start
|
||||
while (index < limit) {
|
||||
const code = text.charCodeAt(index)
|
||||
if (code > 127 || (CLASSES[code]! & LABEL) === 0) break
|
||||
index++
|
||||
}
|
||||
// "PRIVATE KEY" is label characters and the `-` after it is not, so the run ends right after it.
|
||||
const tail = index - 'PRIVATE KEY'.length
|
||||
return index < start + 52 && tail >= start && text.startsWith('PRIVATE KEY-----', tail) ? tail + 'PRIVATE KEY-----'.length : -1
|
||||
}
|
||||
|
||||
/** Offset after the first complete `END` line at or after `start`, or the text length. */
|
||||
function keyBlockEnd(text: string, start: number, memo: Int32Array): number {
|
||||
if (memo[BLOCK_FROM]! <= start && start <= memo[BLOCK_END_START]!) return memo[BLOCK_END]!
|
||||
memo[BLOCK_FROM] = start
|
||||
memo[BLOCK_END_START] = text.length
|
||||
memo[BLOCK_END] = text.length
|
||||
for (let at = text.indexOf('-----END ', start); at >= 0; at = text.indexOf('-----END ', at + 1)) {
|
||||
const end = labelEnd(text, at + '-----END '.length)
|
||||
if (end < 0) continue
|
||||
memo[BLOCK_END_START] = at
|
||||
memo[BLOCK_END] = end
|
||||
break
|
||||
}
|
||||
return memo[BLOCK_END]!
|
||||
}
|
||||
|
||||
/** End of the `-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----` block at `at` through its first `END` line or the end of the text, or -1. */
|
||||
function keyBlockCandidate(text: string, at: number, memo: Int32Array): number {
|
||||
const header = labelEnd(text, at + '-----BEGIN '.length)
|
||||
return header < 0 ? -1 : keyBlockEnd(text, header, memo)
|
||||
}
|
||||
|
||||
/** Whether `\b[a-z][a-z0-9+.-]{1,15}` ends at `at`; reads at most 17 characters back. */
|
||||
function schemeBefore(text: string, at: number): boolean {
|
||||
for (let start = at - 1; start >= 0 && start >= at - 16; start--) {
|
||||
const code = text.charCodeAt(start)
|
||||
const bits = code < 128 ? CLASSES[code]! : 0
|
||||
if ((bits & SCHEME) === 0) return false
|
||||
if ((bits & LOWER) !== 0 && at - start >= 2 && !isWordAt(text, start - 1)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* The password in `\b[a-z][a-z0-9+.-]{1,15}://[^\s/:@?#]{0,256}:[^\s/@?#]+@` for the `://` at
|
||||
* `at`; neither part crosses `?` or `#`, where the authority ends. Callers pass a `://` at or after
|
||||
* the query offset, or one whose user name reaches it, so the password never starts before it.
|
||||
* @returns the password end with its start in `memo[CANDIDATE_START]`, or -1.
|
||||
*/
|
||||
function addressPassword(text: string, at: number, memo: Int32Array): number {
|
||||
if (!schemeBefore(text, at)) return -1
|
||||
const userStart = at + '://'.length
|
||||
const userEnd = memoRunEnd(USER_RUN, text, userStart, USER_SLOT, memo)
|
||||
if (userEnd - userStart > 256 || text.charCodeAt(userEnd) !== 58) return -1
|
||||
const start = userEnd + 1
|
||||
const end = memoRunEnd(PASSWORD_RUN, text, start, PASSWORD_SLOT, memo)
|
||||
if (end === start || text.charCodeAt(end) !== 64) return -1
|
||||
memo[CANDIDATE_START] = start
|
||||
return end
|
||||
}
|
||||
|
||||
/**
|
||||
* The address password at or after `from` whose `://` starts before `from`, as `addressPassword`
|
||||
* returns it. The walk back over the user name runs only when a `:`, a password, and `@` follow.
|
||||
*/
|
||||
function addressBefore(text: string, from: number, memo: Int32Array): number {
|
||||
if (from < 3) return -1
|
||||
// `from` inside the `://`.
|
||||
if (text.startsWith('://', from - 1)) return addressPassword(text, from - 1, memo)
|
||||
if (text.startsWith('://', from - 2)) return addressPassword(text, from - 2, memo)
|
||||
let index = from - 1
|
||||
const before = text.charCodeAt(index)
|
||||
let passwordStart: number
|
||||
if (before === 58) {
|
||||
// The password starts at `from`, right after the user name's `:`.
|
||||
passwordStart = from
|
||||
index--
|
||||
} else if (isUserCode(before)) {
|
||||
const userEnd = memoRunEnd(USER_RUN, text, from, USER_SLOT, memo)
|
||||
if (text.charCodeAt(userEnd) !== 58) return -1
|
||||
passwordStart = userEnd + 1
|
||||
} else {
|
||||
return text.startsWith('://', from - 3) ? addressPassword(text, from - 3, memo) : -1
|
||||
}
|
||||
const passwordEnd = memoRunEnd(PASSWORD_RUN, text, passwordStart, PASSWORD_SLOT, memo)
|
||||
if (passwordEnd === passwordStart || text.charCodeAt(passwordEnd) !== 64) return -1
|
||||
// A user name is at most 256 characters, followed by `:`.
|
||||
const lowest = Math.max(0, passwordStart - 258)
|
||||
while (index >= lowest && isUserCode(text.charCodeAt(index))) index--
|
||||
return index >= 2 && text.startsWith('://', index - 2) ? addressPassword(text, index - 2, memo) : -1
|
||||
}
|
||||
|
||||
/** End of the prefixed credential whose literal starts at `at`, or -1. */
|
||||
function prefixedEnd(text: string, literal: number, at: number, memo: Int32Array): number {
|
||||
if (isWordAt(text, at - 1)) return -1
|
||||
const start = at + LITERAL_LENGTH[literal]!
|
||||
const shape = LITERAL_SHAPE[literal]!
|
||||
const min = LITERAL_MIN[literal]!
|
||||
if (shape === SK) {
|
||||
// `(?=[A-Za-z0-9_-]{0,255}\d)[A-Za-z0-9_-]{20,}`: the memoized next digit rejects a digit-free `sk-` before any run is measured.
|
||||
const digit = nextDigit(text, start, memo)
|
||||
if (digit - start > 255) return -1
|
||||
const end = keyRunEnd(text, start, memo)
|
||||
return end - start >= min && digit < end ? end : -1
|
||||
}
|
||||
const slot = LITERAL_SLOT[literal]!
|
||||
const end = slot === 0 ? keyRunEnd(text, start, memo) : memoRunEnd(LITERAL_RUN[literal]!, text, start, slot, memo)
|
||||
const length = end - start
|
||||
if (length < min) return -1
|
||||
if (shape === RUN) return end
|
||||
if (shape === GOOGLE) return start + min
|
||||
if (shape === BOUNDED) return length <= LITERAL_MAX[literal]! && !isWordAt(text, end) ? end : -1
|
||||
// `[A-Za-z0-9_-]{8,2048}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}` after `eyJ`.
|
||||
if (length > LITERAL_MAX[literal]! || !text.startsWith('.eyJ', end)) return -1
|
||||
const payloadStart = end + '.eyJ'.length
|
||||
const payloadEnd = keyRunEnd(text, payloadStart, memo)
|
||||
if (payloadEnd - payloadStart < min || text.charCodeAt(payloadEnd) !== 46) return -1
|
||||
const signatureEnd = keyRunEnd(text, payloadEnd + 1, memo)
|
||||
return signatureEnd - (payloadEnd + 1) >= min ? signatureEnd : -1
|
||||
}
|
||||
|
||||
/**
|
||||
* `text[start, end)` with every character except line breaks replaced; a
|
||||
* surrogate pair is one character, as `[^\r\n]` with the `u` flag counts it.
|
||||
* A character loop, because a native run could read far past `end`.
|
||||
*/
|
||||
function maskRange(text: string, start: number, end: number): string {
|
||||
let masked = ''
|
||||
let count = 0
|
||||
for (let index = start; index < end; index++) {
|
||||
const code = text.charCodeAt(index)
|
||||
if (code === 10 || code === 13) {
|
||||
masked += marksThen(count, code)
|
||||
count = 0
|
||||
continue
|
||||
}
|
||||
if (code >= 0xd800 && code <= 0xdbff && index + 1 < end) {
|
||||
const next = text.charCodeAt(index + 1)
|
||||
if (next >= 0xdc00 && next <= 0xdfff) index++
|
||||
}
|
||||
count++
|
||||
}
|
||||
return masked + marks(count)
|
||||
}
|
||||
|
||||
/**
|
||||
* `maskRange` for a key-block body, which ends at a line break or the end of the
|
||||
* text, so native runs stop at `end`. One slice of a shared string per line.
|
||||
*/
|
||||
function maskLines(text: string, start: number, end: number): string {
|
||||
let masked = ''
|
||||
let count = 0
|
||||
let index = start
|
||||
while (index < end) {
|
||||
const stop = Math.min(runEnd(ORDINARY, text, index), end)
|
||||
count += stop - index
|
||||
if (stop === end) break
|
||||
const code = text.charCodeAt(stop)
|
||||
index = stop + 1
|
||||
if (code === 10 || code === 13) {
|
||||
masked += marksThen(count, code)
|
||||
count = 0
|
||||
continue
|
||||
}
|
||||
// A high surrogate followed by a low one is one character.
|
||||
if (code <= 0xdbff && index < end) {
|
||||
const next = text.charCodeAt(index)
|
||||
if (next >= 0xdc00 && next <= 0xdfff) index++
|
||||
}
|
||||
count++
|
||||
}
|
||||
return masked + marks(count)
|
||||
}
|
||||
|
||||
/** The display form of a range whose earliest candidate has `kind`; `plain` ranges hold only prefixed-format characters. */
|
||||
function masked(text: string, kind: number, plain: boolean, start: number, end: number): string {
|
||||
if (kind === ADDRESS) return maskRange(text, start, end)
|
||||
if (kind === PREFIXED) {
|
||||
const prefix = text.slice(start, start + VISIBLE_PREFIX)
|
||||
return prefix + (plain ? marks(end - start - VISIBLE_PREFIX) : maskRange(text, start + VISIBLE_PREFIX, end))
|
||||
}
|
||||
// A key block keeps its `BEGIN` line, its `END` line when present, and every line break; one written on one line is masked whole.
|
||||
const firstBreak = text.indexOf('\n', start)
|
||||
const bodyStart = firstBreak < 0 || firstBreak >= end ? start : firstBreak + 1
|
||||
const endLine = text.lastIndexOf('\n-----END', end - 1)
|
||||
const bodyEnd = endLine < bodyStart ? end : endLine
|
||||
return text.slice(start, bodyStart) + maskLines(text, bodyStart, bodyEnd) + text.slice(bodyEnd, end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the credential matcher. Each instance owns a memo of the last queried
|
||||
* text, which it keeps until a query for another text.
|
||||
* @returns the `secret` rule; it reads no Facts, so Facts changes never invalidate its queries.
|
||||
*/
|
||||
export function createSecretRule(): UserTextRule<'secret'> {
|
||||
const memo = new Int32Array(MEMO_SIZE).fill(-1)
|
||||
let memoText: string | undefined
|
||||
|
||||
return {
|
||||
id: 'secret',
|
||||
// Same-start ties with other rules draw the credential masked.
|
||||
order: 0,
|
||||
/**
|
||||
* Return the nearest credential at or after `from`. Overlapping candidates of any format join
|
||||
* the earliest one and take its mask, so no credential is drawn half masked. Candidates arrive
|
||||
* in start order; an address password waits until the scan passes its start, and no other
|
||||
* `://` can occur before then, because a user name and a password exclude `/`. A password that
|
||||
* starts inside a range also ends inside it: the range holds the `:` before the password, so it
|
||||
* is a key block, whose `END` line has spaces, or one that runs to the end of the text.
|
||||
*/
|
||||
match(text, from) {
|
||||
if (text !== memoText) {
|
||||
memo.fill(-1)
|
||||
memoText = text
|
||||
}
|
||||
const length = text.length
|
||||
let start = -1
|
||||
let end = -1
|
||||
let kind = PREFIXED
|
||||
let plain = true
|
||||
let deferredEnd = addressBefore(text, from, memo)
|
||||
let deferredStart = deferredEnd < 0 ? -1 : memo[CANDIDATE_START]!
|
||||
PREFIXES.lastIndex = from
|
||||
while (PREFIXES.test(text)) {
|
||||
const literalEnd = PREFIXES.lastIndex
|
||||
const literal = literalEndingAt(text, literalEnd)
|
||||
const at = literalEnd - LITERAL_LENGTH[literal]!
|
||||
PREFIXES.lastIndex = literalEnd - LITERAL_OVERLAP[literal]!
|
||||
if (deferredStart >= 0 && deferredStart <= at) {
|
||||
if (start < 0) {
|
||||
start = deferredStart
|
||||
end = deferredEnd
|
||||
kind = ADDRESS
|
||||
} else if (deferredStart >= end) {
|
||||
break
|
||||
}
|
||||
deferredStart = -1
|
||||
}
|
||||
// A candidate starts at or after its literal, so none past the range reaches it; nothing extends a range to the end.
|
||||
if (start >= 0 && (at >= end || end === length)) break
|
||||
const literalKind = LITERAL_KIND[literal]!
|
||||
if (literalKind === ADDRESS) {
|
||||
deferredEnd = addressPassword(text, at, memo)
|
||||
deferredStart = deferredEnd < 0 ? -1 : memo[CANDIDATE_START]!
|
||||
continue
|
||||
}
|
||||
const candidateEnd = literalKind === KEY_BLOCK ? keyBlockCandidate(text, at, memo) : prefixedEnd(text, literal, at, memo)
|
||||
if (candidateEnd < 0) continue
|
||||
if (start < 0) {
|
||||
start = at
|
||||
end = candidateEnd
|
||||
kind = literalKind
|
||||
plain = literalKind === PREFIXED
|
||||
} else if (candidateEnd > end) {
|
||||
end = candidateEnd
|
||||
if (literalKind !== PREFIXED) plain = false
|
||||
}
|
||||
}
|
||||
if (start < 0 && deferredStart >= 0) {
|
||||
start = deferredStart
|
||||
end = deferredEnd
|
||||
kind = ADDRESS
|
||||
}
|
||||
if (start < 0) return undefined
|
||||
return { id: 'secret', start, end, data: { masked: masked(text, kind, plain, start, end) } }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Credential display masking plugin, node half. Pure UI plugin: the empty
|
||||
* apply exists so the plugin appears in the host cordis.yml / Loader; the
|
||||
* browser half ships via exports["./client"], discovered through the
|
||||
* package.json `dsh.client` declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this display plugin. */
|
||||
export function apply(): void {}
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Plugin registration: the credential matcher and its keyed node renderer leave with the plugin fiber. */
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { TestSessions } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation/assembly.ts'
|
||||
import { SecretText } from '../src/client/SecretText.tsx'
|
||||
import { NS } from '../src/client/locales.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { apply as nodeApply } from '../src/index.ts'
|
||||
|
||||
function TextSlotDeclaration(_props: PropsRenderSlots<'conversation.user.text.node'>): null {
|
||||
return null
|
||||
}
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares its services and releases the matcher and renderer on disposal', async () => {
|
||||
expect(inject).toEqual(['locale', 'slots', 'uiConversation'])
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
ctx.provide('locale', new LocaleRuntime(ctx))
|
||||
const sessions = new TestSessions(async (action) => { await action() }, ctx)
|
||||
onTestFinished(() => sessions.disposeScopes())
|
||||
ctx.provide('sessions', sessions)
|
||||
new UiConversation(ctx, sessions)
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: { 'conversation.user.text.node': { kind: 'keyed', scope: 'session' } },
|
||||
}, TextSlotDeclaration)
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(ctx.uiConversation.userText.entries().map(rule => rule.id)).toEqual(['secret'])
|
||||
expect(ctx.slots.entries('conversation.user.text.node')).toMatchObject([
|
||||
{ options: { key: 'secret' }, component: SecretText },
|
||||
])
|
||||
expect(ctx.locale.bind(NS)('secret.reveal')).toBe('Show secret')
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.uiConversation.userText.entries()).toHaveLength(0)
|
||||
expect(ctx.slots.entries('conversation.user.text.node')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('the node half applies without host-side behavior', () => {
|
||||
expect(() => { nodeApply() }).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* The backtracking regular-expression formats this package replaced, kept as
|
||||
* an equivalence oracle for the prefix-pruned matcher. Not product code. It
|
||||
* joins every overlapping match, including later matches of the same format.
|
||||
*/
|
||||
import type { UserTextRule } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '../src/client/secret-rule.ts'
|
||||
|
||||
const MASK = '*'
|
||||
/** Leading characters of a prefixed key left readable: the provider prefix and little else. */
|
||||
const VISIBLE_PREFIX = 8
|
||||
|
||||
const maskRun = (value: string): string => value.replace(/[^\r\n]/gu, MASK)
|
||||
const maskAfterPrefix = (value: string): string =>
|
||||
value.slice(0, VISIBLE_PREFIX) + maskRun(value.slice(VISIBLE_PREFIX))
|
||||
/**
|
||||
* Keeps the `BEGIN` line, the `END` line when present, and every line break.
|
||||
* A block written on one line has no line to keep and is masked whole.
|
||||
*/
|
||||
const maskKeyBlock = (value: string): string => {
|
||||
const bodyStart = value.indexOf('\n') + 1
|
||||
const end = value.lastIndexOf('\n-----END')
|
||||
const bodyEnd = end < bodyStart ? value.length : end
|
||||
return value.slice(0, bodyStart) + maskRun(value.slice(bodyStart, bodyEnd)) + value.slice(bodyEnd)
|
||||
}
|
||||
|
||||
interface Format {
|
||||
/** Global pattern whose whole match is the credential. */
|
||||
readonly pattern: RegExp
|
||||
readonly mask: (value: string) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Credentials whose published format starts with a fixed prefix. Every pattern
|
||||
* matches more than `VISIBLE_PREFIX` characters, which `maskAfterPrefix` relies on.
|
||||
*/
|
||||
const PREFIXED: readonly RegExp[] = [
|
||||
// Model services: `sk-` keys carry at least one digit, which hyphenated identifiers rarely do.
|
||||
// The bounded lookahead keeps a long run of digit-free `sk-` repeats linear.
|
||||
/\bsk-(?=[A-Za-z0-9_-]{0,255}\d)[A-Za-z0-9_-]{20,}/gu,
|
||||
/\bAIza[A-Za-z0-9_-]{35}/gu,
|
||||
// Code hosting and package registries.
|
||||
/\bgh[pousr]_[A-Za-z0-9]{36,}/gu,
|
||||
/\bgithub_pat_[A-Za-z0-9_]{22,}/gu,
|
||||
/\bglpat-[A-Za-z0-9_-]{20,}/gu,
|
||||
/\bnpm_[A-Za-z0-9]{36,}/gu,
|
||||
/\bhf_[A-Za-z0-9]{30,}/gu,
|
||||
// Cloud access key IDs.
|
||||
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/gu,
|
||||
/\bLTAI[A-Za-z0-9]{12,20}\b/gu,
|
||||
/\bAKID[A-Za-z0-9]{28,36}\b/gu,
|
||||
// Collaboration and payment services.
|
||||
/\bxox[abprs]-[A-Za-z0-9-]{10,}/gu,
|
||||
/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/gu,
|
||||
/\bwhsec_[A-Za-z0-9]{24,}/gu,
|
||||
// JSON Web Tokens. The header bound keeps a long run of `eyJ` repeats linear.
|
||||
/\beyJ[A-Za-z0-9_-]{8,2048}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/gu,
|
||||
]
|
||||
|
||||
/** Same-start matches resolve in this order, so a token used as an address password is masked completely. */
|
||||
const FORMATS: readonly Format[] = [
|
||||
{
|
||||
pattern: /-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]{0,40}PRIVATE KEY-----|$)/gu,
|
||||
mask: maskKeyBlock,
|
||||
},
|
||||
// The lookbehind starts the match at the password, so a query from inside the address finds the same candidate.
|
||||
// The user name may be empty, and neither part crosses `?` or `#`, where the authority ends.
|
||||
{ pattern: /(?<=\b[a-z][a-z0-9+.-]{1,15}:\/\/[^\s/:@?#]{0,256}:)[^\s/@?#]+(?=@)/gu, mask: maskRun },
|
||||
...PREFIXED.map(pattern => ({ pattern, mask: maskAfterPrefix })),
|
||||
]
|
||||
|
||||
interface Hit {
|
||||
readonly start: number
|
||||
readonly end: number
|
||||
}
|
||||
|
||||
function find(regex: RegExp, text: string, from: number): Hit | undefined {
|
||||
regex.lastIndex = from
|
||||
const match = regex.exec(text)
|
||||
return match === null ? undefined : { start: match.index, end: match.index + match[0].length }
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the nearest credential at or after `from`. Overlapping matches of any
|
||||
* format join the earliest one and take its mask, so no credential is drawn half masked.
|
||||
* Reads no Facts, so Facts changes never invalidate its queries.
|
||||
*/
|
||||
export const regexSecretRule: UserTextRule<'secret'> = {
|
||||
id: 'secret',
|
||||
order: 0,
|
||||
match(text, from) {
|
||||
const scans = FORMATS.map((format) => {
|
||||
const regex = new RegExp(format.pattern)
|
||||
return { format, regex, hit: find(regex, text, from) }
|
||||
})
|
||||
let first: { readonly format: Format; readonly hit: Hit } | undefined
|
||||
for (const { format, hit } of scans) {
|
||||
// The strict comparison keeps the earlier format at an equal start.
|
||||
if (hit !== undefined && (first === undefined || hit.start < first.hit.start)) first = { format, hit }
|
||||
}
|
||||
if (first === undefined) return undefined
|
||||
const { start } = first.hit
|
||||
let end = first.hit.end
|
||||
for (let grown = true; grown;) {
|
||||
grown = false
|
||||
for (const scan of scans) {
|
||||
while (scan.hit !== undefined && scan.hit.start < end) {
|
||||
if (scan.hit.end > end) {
|
||||
end = scan.hit.end
|
||||
grown = true
|
||||
}
|
||||
// Overlapping matches of one format join as well, so a key that starts inside another is masked whole.
|
||||
scan.hit = find(scan.regex, text, scan.hit.start + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { id: 'secret', start, end, data: { masked: first.format.mask(text.slice(start, end)) } }
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* The prefix-pruned matcher produces the same nodes as the regular-expression
|
||||
* matcher it replaced, through the production evaluator, on fixed and generated inputs.
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
import type { UserTextRule, UserTextTransformContext } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { UserTextRegistry } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation/user-text-registry.ts'
|
||||
import { collapseWhitespaceRule, trimWhitespaceRule } from '@deepseek-ai/dsh-client-ui-conversation/src/client/user-text/whitespace-rules.ts'
|
||||
import { createSecretRule } from '../src/client/secret-rule.ts'
|
||||
import { regexSecretRule } from './regex-oracle.client.ts'
|
||||
|
||||
function evaluator(rule: UserTextRule) {
|
||||
const ctx = new Context()
|
||||
onTestFinished(async () => { await ctx.fiber.dispose() })
|
||||
const registry = new UserTextRegistry(ctx)
|
||||
for (const each of [rule, collapseWhitespaceRule, trimWhitespaceRule]) registry.register(each)
|
||||
return registry.createEvaluator()
|
||||
}
|
||||
|
||||
const join = (...parts: string[]): string => parts.join('')
|
||||
/** Fragments that begin, continue, break, or end each recognized format. */
|
||||
const FRAGMENTS = [
|
||||
join('s', 'k-'), join('s', 'k_live_'), join('r', 'k_test_'), join('AI', 'za'), join('gh', 'p_'), join('gh', 's_'),
|
||||
join('github', '_pat_'), join('gl', 'pat-'), join('np', 'm_'), join('h', 'f_'), join('AK', 'IA'), join('AS', 'IA'),
|
||||
join('LT', 'AI'), join('AK', 'ID'), join('xo', 'xb-'), join('wh', 'sec_'), join('ey', 'J'), '.', join('.ey', 'J'),
|
||||
join('-----BEGIN', ' '), join('-----END', ' '), ' PRIVATE KEY-----', 'PRIVATE KEY-----', 'RSA ',
|
||||
'://', 'https', 'postgres', ':', '@', '/', '?', '#', ' ', '\n', '\r\n', '\t', '-', '_', '+',
|
||||
'a', 'Z', '0', '7', 'abcdefgh', 'ABCDEFGH', '01234567', 'a1B2c3D4e5F6g7H8i9J0', '用', ':', '🙂',
|
||||
] as const
|
||||
|
||||
/** Deterministic generator so every run checks the same inputs. */
|
||||
function generator(seed: number): () => number {
|
||||
let state = seed
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) | 0
|
||||
let mixed = Math.imul(state ^ (state >>> 15), 1 | state)
|
||||
mixed = (mixed + Math.imul(mixed ^ (mixed >>> 7), 61 | mixed)) ^ mixed
|
||||
return ((mixed ^ (mixed >>> 14)) >>> 0) / 4_294_967_296
|
||||
}
|
||||
}
|
||||
|
||||
function generated(count: number, seed: number, maxFragments = 60): string[] {
|
||||
const random = generator(seed)
|
||||
return Array.from({ length: count }, () => {
|
||||
const length = 1 + Math.floor(random() * maxFragments)
|
||||
return Array.from({ length }, () => {
|
||||
const fragment = FRAGMENTS[Math.floor(random() * FRAGMENTS.length)]!
|
||||
// Repeat short fragments so length thresholds are crossed on both sides.
|
||||
return fragment.length <= 2 ? fragment.repeat(1 + Math.floor(random() * 40)) : fragment
|
||||
}).join('')
|
||||
})
|
||||
}
|
||||
|
||||
const fill = (unit: string, characters: number): string => unit.repeat(Math.ceil(characters / unit.length)).slice(0, characters)
|
||||
const FIXED = [
|
||||
'',
|
||||
join('用 ', 'sk', '-', '0123456789abcdef0123456789abcdef', ' 试试'),
|
||||
join('postgres://admin:', 'p4ss-W0rd', '@db.internal:5432/app'),
|
||||
join('git clone https://x-access-token:', 'gh', 'p_', fill('a1B2c3', 36), '@github.com/o/r.git'),
|
||||
join('redis://:p4ss@cache https://example.com:8080/a@b https://example.com:8443?e=a@b'),
|
||||
join('https://', 'sk', '-', '0123456789abcdef0123456789abcdef', ':[email protected]/v1'),
|
||||
[join('-----BEGIN', ' OPENSSH PRIVATE KEY-----'), 'b3BlbnNzaC1rZXk', join('-----END', ' OPENSSH PRIVATE KEY-----')].join('\n'),
|
||||
[join('-----BEGIN', ' PRIVATE KEY-----'), 'MIIEvQIBADANBgkq', join('-----END', ' PRIVATE KEY-----')].join('\r\n'),
|
||||
join('-----BEGIN', ' PRIVATE KEY-----', 'MIIE', '-----END', ' PRIVATE KEY-----'),
|
||||
join('ey', 'J', fill('a1B2', 17), '.', 'ey', 'J', fill('c3D4', 30), '.', fill('e5F6', 43)),
|
||||
join('xo', 'xb-1234567890-', 'sk', '-abc_', fill('a1B2', 20)),
|
||||
join('AK', 'IA', 'ABCDEFGHIJ234567 ', 'AK', 'IA', 'ABCDEFGHIJ2345678 ', 'LT', 'AI', fill('a1', 20), ' ', 'AK', 'ID', fill('a1', 40)),
|
||||
join('x', 's', 'k-----BEGIN', ' RSA PRIVATE KEY-----\nabc\n'),
|
||||
join('gl', 'pat-----BEGIN', ' RSA PRIVATE KEY-----\nabc\n-----END', ' RSA PRIVATE KEY-----'),
|
||||
join('LT', 'AIza', 'a1B2c3D4e5F6 '),
|
||||
fill(join('-', 's', 'k-', 'gh', 'p_', 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJ'), 2_000),
|
||||
fill(join('s', 'k-'), 2_000),
|
||||
fill(join('ey', 'Jabcdefgh-'), 2_000),
|
||||
fill(join('-----BEGIN', ' RSA PRIVATE KEY-----\nabc\n'), 2_000),
|
||||
fill('a://u:', 2_000),
|
||||
fill(`abcdefghijklmnop://${'u'.repeat(250)}:`, 2_000),
|
||||
fill(join('gh', 'p_', fill('a1B2c3', 35), ' '), 2_000),
|
||||
]
|
||||
|
||||
const context: UserTextTransformContext = { read: () => undefined }
|
||||
|
||||
describe('prefix-pruned credential matching', () => {
|
||||
it.each([['fixed', FIXED], ['generated', generated(5_000, 5824)]] as const)('matches the regular-expression nodes on %s inputs', (_name, texts) => {
|
||||
const pruned = evaluator(createSecretRule())
|
||||
const oracle = evaluator(regexSecretRule)
|
||||
for (const text of texts) {
|
||||
for (const facts of [{}, { collapseWhitespace: true, trimWhitespace: true }]) {
|
||||
expect(pruned({ text, facts }), JSON.stringify(text)).toEqual(oracle({ text, facts }))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the regular-expression candidate from every query offset', () => {
|
||||
const rule = createSecretRule()
|
||||
for (const text of [...FIXED.filter(text => text.length <= 600), ...generated(400, 77, 25)]) {
|
||||
for (let from = 0; from <= text.length; from++) {
|
||||
expect(rule.match(text, from, context), `${JSON.stringify(text)} from ${String(from)}`).toEqual(regexSecretRule.match(text, from, context))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Credential recognition, masking forms, and forward-query behavior through the
|
||||
* production UserText evaluator.
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { UserTextNode, UserTextRule, UserTextTransformContext } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { UserTextRegistry } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation/user-text-registry.ts'
|
||||
import { collapseWhitespaceRule, trimWhitespaceRule } from '@deepseek-ai/dsh-client-ui-conversation/src/client/user-text/whitespace-rules.ts'
|
||||
import { createSecretRule, PREFIX_SOURCE } from '../src/client/secret-rule.ts'
|
||||
|
||||
// Samples are assembled at run time so the repository holds no literal in a credential format.
|
||||
const hex = (length: number): string => '0123456789abcdef'.repeat(8).slice(0, length)
|
||||
const mixed = (length: number): string => 'a1B2c3D4e5F6g7H8i9J0'.repeat(8).slice(0, length)
|
||||
const stars = (length: number): string => '*'.repeat(length)
|
||||
const join = (...parts: string[]): string => parts.join('')
|
||||
|
||||
function setup(rules: readonly UserTextRule[] = [createSecretRule()]) {
|
||||
const ctx = new Context()
|
||||
onTestFinished(async () => { await ctx.fiber.dispose() })
|
||||
const registry = new UserTextRegistry(ctx)
|
||||
for (const rule of rules) registry.register(rule)
|
||||
return registry.createEvaluator()
|
||||
}
|
||||
|
||||
const context: UserTextTransformContext = { read: () => undefined }
|
||||
const isSecret = (node: UserTextNode): node is UserTextNode<'secret'> => node.id === 'secret'
|
||||
const secrets = (text: string): UserTextNode<'secret'>[] => setup()({ text, facts: {} }).filter(isSecret)
|
||||
const found = (text: string): string[] => secrets(text).map(node => text.slice(node.start, node.end))
|
||||
const masks = (text: string): string[] => secrets(text).map(node => node.data.masked)
|
||||
|
||||
describe('secretRule recognition', () => {
|
||||
it.each([
|
||||
['a model-service key', join('sk', '-', hex(32))],
|
||||
['a project-scoped model-service key', join('sk', '-proj-', mixed(48))],
|
||||
['a Google API key', join('AI', 'za', mixed(35))],
|
||||
['a GitHub classic token', join('gh', 'p_', mixed(36))],
|
||||
['a GitHub fine-grained token', join('github', '_pat_', mixed(22), '_', mixed(59))],
|
||||
['a GitLab token', join('gl', 'pat-', mixed(20))],
|
||||
['an npm token', join('np', 'm_', mixed(36))],
|
||||
['a Hugging Face token', join('h', 'f_', mixed(34))],
|
||||
['an AWS access key ID', join('AK', 'IA', 'ABCDEFGHIJ234567')],
|
||||
['a temporary AWS access key ID', join('AS', 'IA', 'ABCDEFGHIJ234567')],
|
||||
['an Alibaba Cloud AccessKey ID', join('LT', 'AI', mixed(20))],
|
||||
['a Tencent Cloud SecretId', join('AK', 'ID', mixed(32))],
|
||||
['a Slack token', join('xo', 'xb-', '1234567890-', mixed(24))],
|
||||
['a Stripe secret key', join('sk', '_live_', mixed(24))],
|
||||
['a Stripe restricted key', join('rk', '_test_', mixed(24))],
|
||||
['a Stripe webhook secret', join('wh', 'sec_', mixed(32))],
|
||||
['a JSON Web Token', join('ey', 'J', mixed(17), '.', 'ey', 'J', mixed(30), '.', mixed(43))],
|
||||
])('recognizes %s inside a sentence', (_name, sample) => {
|
||||
expect(found(`用 ${sample} 试试`)).toEqual([sample])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['ordinary prose', '把密码改一下,token 用量也看看,password=hunter2'],
|
||||
['a hyphenated identifier without digits', 'sk-button-primary-large-rounded'],
|
||||
['a key prefix that is too short', 'sk-12345'],
|
||||
['a key prefix inside a longer word', join('task', '-', hex(32))],
|
||||
['a commit hash', hex(40)],
|
||||
['a UUID', '123e4567-e89b-12d3-a456-426614174000'],
|
||||
])('leaves %s alone', (_name, text) => {
|
||||
expect(found(text)).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves addresses without a password alone', () => {
|
||||
expect(found('https://example.com:8080/a@b https://[email protected]/x [email protected]:o/r.git')).toEqual([])
|
||||
expect(found('https://example.com:[email protected] https://example.com:8443#[email protected]')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('secretRule masking', () => {
|
||||
it('keeps the first eight characters of a key and masks every other character', () => {
|
||||
const key = join('sk', '-', hex(32))
|
||||
expect(masks(`${key}用这个`)).toEqual([key.slice(0, 8) + stars(27)])
|
||||
})
|
||||
|
||||
it('masks only the password of an address that carries credentials', () => {
|
||||
const text = join('postgres://admin:', 'p4ss-W0rd', '@db.internal:5432/app')
|
||||
expect(found(text)).toEqual(['p4ss-W0rd'])
|
||||
expect(masks(text)).toEqual([stars(9)])
|
||||
})
|
||||
|
||||
it('masks a key used as an address user name and the password after it separately', () => {
|
||||
const key = join('sk', '-', hex(32))
|
||||
const text = `https://${key}:[email protected]/v1`
|
||||
expect(found(text)).toEqual([key, 'p4ss-W0rd'])
|
||||
expect(masks(text)).toEqual([key.slice(0, 8) + stars(27), stars(9)])
|
||||
})
|
||||
|
||||
it('masks one character per code point, so an emoji or a lone surrogate in a password is one mark', () => {
|
||||
expect(masks('postgres://u:p\u{1F642}\ud83dx@db')).toEqual([stars(4)])
|
||||
})
|
||||
|
||||
it('ends a key in an address user name before the password, which a later query finds', () => {
|
||||
const key = join('sk', '-', hex(32))
|
||||
const token = join('gh', 'p_', mixed(36))
|
||||
const text = `https://${key}:[email protected] ${token}`
|
||||
expect(found(text)).toEqual([key, 'p4ss-W0rd', token])
|
||||
})
|
||||
|
||||
it('masks an address password that has no user name', () => {
|
||||
expect(found('redis://:p4ss-W0rd@cache:6379/0')).toEqual(['p4ss-W0rd'])
|
||||
})
|
||||
|
||||
it('masks a token used as an address password completely', () => {
|
||||
const token = join('gh', 'p_', mixed(36))
|
||||
const text = `git clone https://x-access-token:${token}@github.com/o/r.git`
|
||||
expect(found(text)).toEqual([token])
|
||||
expect(masks(text)).toEqual([stars(token.length)])
|
||||
})
|
||||
|
||||
it('joins an overlapping later match to the earliest one and keeps its mask', () => {
|
||||
const slack = join('xo', 'xb-', '1234567890-')
|
||||
const key = join('sk', '-abc_', mixed(20))
|
||||
const text = `${slack}${key} 完`
|
||||
expect(found(text)).toEqual([slack + key])
|
||||
expect(masks(text)).toEqual([(slack + key).slice(0, 8) + stars(slack.length + key.length - 8)])
|
||||
})
|
||||
|
||||
it('masks the body of a private key block and keeps its first and last lines', () => {
|
||||
const begin = join('-----BEGIN', ' OPENSSH PRIVATE KEY-----')
|
||||
const end = join('-----END', ' OPENSSH PRIVATE KEY-----')
|
||||
const block = [begin, 'b3BlbnNzaC1rZXk', 'AAAAB3NzaC1yc2E=', end].join('\n')
|
||||
const text = `私钥在这\n${block}\n帮我配一下`
|
||||
expect(found(text)).toEqual([block])
|
||||
expect(masks(text)).toEqual([[begin, stars(15), stars(16), end].join('\n')])
|
||||
})
|
||||
|
||||
it('keeps carriage returns of a private key block with CRLF line endings', () => {
|
||||
const begin = join('-----BEGIN', ' PRIVATE KEY-----')
|
||||
const end = join('-----END', ' PRIVATE KEY-----')
|
||||
const text = [begin, 'MIIEvQIBADANBgkq', end].join('\r\n')
|
||||
expect(masks(text)).toEqual([[begin, stars(16), end].join('\r\n')])
|
||||
})
|
||||
|
||||
it('masks a private key block that is cut off before its last line', () => {
|
||||
const begin = join('-----BEGIN', ' RSA PRIVATE KEY-----')
|
||||
const text = `${begin}\nMIIEowIBAAKCAQEA`
|
||||
expect(found(text)).toEqual([text])
|
||||
expect(masks(text)).toEqual([`${begin}\n${stars(16)}`])
|
||||
})
|
||||
|
||||
it.each([join('s', 'k-'), join('gl', 'pat-'), join('xo', 'xb-')])('finds a private key block whose first dash ends the key prefix %s', (prefix) => {
|
||||
const block = [join('-----BEGIN', ' RSA PRIVATE KEY-----'), 'MIIEowIBAAKCAQEA', join('-----END', ' RSA PRIVATE KEY-----')].join('\n')
|
||||
const text = `x${prefix.slice(0, -1)}${block}`
|
||||
expect(found(text)).toEqual([block])
|
||||
})
|
||||
|
||||
it('masks a key longer than four thousand characters one mark per character', () => {
|
||||
const key = join('sk', '-', hex(16), 'a'.repeat(5_000))
|
||||
expect(masks(`${key} 完`)).toEqual([key.slice(0, 8) + stars(key.length - 8)])
|
||||
})
|
||||
|
||||
it('joins a private key block that starts inside a key and keeps the line breaks of both', () => {
|
||||
const key = join('sk', '-', hex(20))
|
||||
const block = [join('-----BEGIN', ' RSA PRIVATE KEY-----'), 'MIIE', join('-----END', ' RSA PRIVATE KEY-----')].join('\r\n')
|
||||
const text = `${key}${block}`
|
||||
expect(found(text)).toEqual([text])
|
||||
const tail = text.slice(8).split('\r\n').map(line => stars(line.length)).join('\r\n')
|
||||
expect(masks(text)).toEqual([text.slice(0, 8) + tail])
|
||||
})
|
||||
|
||||
it('masks a private key body one mark per code point, including lone and cut surrogates', () => {
|
||||
const begin = join('-----BEGIN', ' PRIVATE KEY-----')
|
||||
const end = join('-----END', ' PRIVATE KEY-----')
|
||||
const text = [begin, 'a\u{1F642}b\udc00c\ud83dx\ud83d', end].join('\n')
|
||||
expect(masks(text)).toEqual([[begin, stars(8), end].join('\n')])
|
||||
})
|
||||
|
||||
it('masks private key body lines longer than four thousand characters, before either line break', () => {
|
||||
const begin = join('-----BEGIN', ' PRIVATE KEY-----')
|
||||
const end = join('-----END', ' PRIVATE KEY-----')
|
||||
const text = `${begin}\n${'A'.repeat(5_000)}\r\n${'B'.repeat(5_000)}\nC\n${end}`
|
||||
expect(masks(text)).toEqual([`${begin}\n${stars(5_000)}\r\n${stars(5_000)}\n${stars(1)}\n${end}`])
|
||||
})
|
||||
|
||||
it('masks an address inside a private key block as part of the block', () => {
|
||||
const begin = join('-----BEGIN', ' PRIVATE KEY-----')
|
||||
const end = join('-----END', ' PRIVATE KEY-----')
|
||||
const key = join('sk', '-', hex(32))
|
||||
const block = [begin, 'https://u:p4ss@host', key, end].join('\n')
|
||||
expect(found(`${block}\n用这个`)).toEqual([block])
|
||||
})
|
||||
|
||||
it('masks a private key block written on one line whole', () => {
|
||||
const text = join('-----BEGIN', ' PRIVATE KEY-----', 'MIIEvQIBADANBgkq', '-----END', ' PRIVATE KEY-----')
|
||||
expect(masks(text)).toEqual([stars(text.length)])
|
||||
})
|
||||
})
|
||||
|
||||
describe('secretRule scheduling', () => {
|
||||
it('finds format starts through grouped literals only, so scanning cannot backtrack', () => {
|
||||
const literals = PREFIX_SOURCE.replace(/\\./gu, '').replaceAll('(?:', '').replaceAll(')', '')
|
||||
expect(literals.split('|').length).toBeGreaterThan(20)
|
||||
expect(literals).not.toMatch(/[*+?{}()[\].^$]/u)
|
||||
})
|
||||
|
||||
it('reads no Facts, queries forward once per credential, and reuses the result across Facts updates', () => {
|
||||
const read = vi.fn()
|
||||
const rule = createSecretRule()
|
||||
const match = vi.fn<UserTextRule<'secret'>['match']>((text, from, context) => rule.match(text, from, {
|
||||
read: (key) => { read(key); return context.read(key) },
|
||||
}))
|
||||
const evaluate = setup([{ ...rule, match }])
|
||||
const first = join('gh', 'p_', mixed(36))
|
||||
const second = join('sk', '-', hex(32))
|
||||
const text = `${first} 和 ${second}`
|
||||
const before = evaluate({ text, facts: {} })
|
||||
expect(before.filter(isSecret).map(node => text.slice(node.start, node.end))).toEqual([first, second])
|
||||
expect(before.map(node => [node.start, node.end])).toEqual([
|
||||
[0, first.length], [first.length, first.length + 3], [first.length + 3, text.length],
|
||||
])
|
||||
expect(match.mock.calls.map(([, from]) => from)).toEqual([0, first.length, text.length])
|
||||
expect(evaluate({ text, facts: { collapseWhitespace: true } })).toBe(before)
|
||||
expect(match).toHaveBeenCalledTimes(3)
|
||||
expect(read).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['postgres://admin:p4ss@db', 'redis://:p4ss@cache'])('finds the same address password in %s from any cursor before it', (text) => {
|
||||
const start = text.indexOf('p4ss')
|
||||
for (let from = 0; from <= start; from++) {
|
||||
// A fresh instance per cursor: no query earlier in the text warms its memo.
|
||||
expect(createSecretRule().match(text, from, context), `from ${String(from)}`).toEqual({ id: 'secret', start, end: start + 4, data: { masked: stars(4) } })
|
||||
}
|
||||
expect(createSecretRule().match(text, start + 1, context)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps line breaks of a private key block when queue whitespace transforms run beside it', () => {
|
||||
const begin = join('-----BEGIN', ' PRIVATE KEY-----')
|
||||
const end = join('-----END', ' PRIVATE KEY-----')
|
||||
const block = [begin, 'MIIEvQIBADANBgkq', end].join('\n')
|
||||
const evaluate = setup([createSecretRule(), collapseWhitespaceRule, trimWhitespaceRule])
|
||||
const nodes = evaluate({ text: ` 私钥:\n${block}\n `, facts: { collapseWhitespace: true, trimWhitespace: true } })
|
||||
const display = (node: UserTextNode): string =>
|
||||
isSecret(node) ? node.data.masked : node.id === 'text' ? node.data.display ?? node.data.text : ''
|
||||
expect(nodes.map(display).join('')).toBe(`私钥: ${[begin, stars(16), end].join('\n')}`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment jsdom
|
||||
/** The masked credential renderer: reveal control, accessible names, and reset to masked. */
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SecretText } from '../src/client/SecretText.tsx'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
type Props = ComponentProps<typeof SecretText>
|
||||
|
||||
// Assembled at run time so the repository holds no literal in a credential format.
|
||||
const KEY = ['sk', '-', '0123456789abcdef0123456789abcdef'].join('')
|
||||
const OTHER = ['sk', '-', 'fedcba9876543210fedcba9876543210'].join('')
|
||||
const masked = (value: string): string => value.slice(0, 8) + '*'.repeat(value.length - 8)
|
||||
|
||||
function props(raw: string, interactive: boolean, dictionary: Record<string, string> = zh): Props {
|
||||
// The component reads only these owner and locale seats.
|
||||
return {
|
||||
node: { id: 'secret', start: 0, end: raw.length, data: { masked: masked(raw) } },
|
||||
raw, interactive, t: makeTranslate(dictionary),
|
||||
} as Props
|
||||
}
|
||||
|
||||
describe('SecretText', () => {
|
||||
it('draws only the masked form without a control in non-interactive text', () => {
|
||||
const view = render(<div data-host><SecretText {...props(KEY, false)} /></div>)
|
||||
const run = view.container.querySelector('[data-secret]')!
|
||||
expect(run.textContent).toBe(masked(KEY))
|
||||
expect(view.container.querySelector('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('reveals and hides the value from its own control', () => {
|
||||
const view = render(<SecretText {...props(KEY, true)} />)
|
||||
const run = view.container.querySelector('[data-secret]')!
|
||||
const reveal = view.getByRole('button', { name: '显示密钥' })
|
||||
expect(run.textContent).toBe(masked(KEY))
|
||||
expect(reveal.getAttribute('aria-pressed')).toBe('false')
|
||||
expect(reveal.querySelector('svg')).not.toBeNull()
|
||||
|
||||
fireEvent.click(reveal)
|
||||
expect(run.textContent).toBe(KEY)
|
||||
const hide = view.getByRole('button', { name: '隐藏密钥' })
|
||||
expect(hide.getAttribute('aria-pressed')).toBe('true')
|
||||
|
||||
fireEvent.click(hide)
|
||||
expect(run.textContent).toBe(masked(KEY))
|
||||
})
|
||||
|
||||
it('starts masked again when a different credential reuses the same element', () => {
|
||||
const view = render(<SecretText {...props(KEY, true, en)} />)
|
||||
fireEvent.click(view.getByRole('button', { name: 'Show secret' }))
|
||||
view.rerender(<SecretText {...props(OTHER, true, en)} />)
|
||||
expect(view.container.querySelector('[data-secret]')!.textContent).toBe(masked(OTHER))
|
||||
expect(view.getByRole('button', { name: 'Show secret' }).getAttribute('aria-pressed')).toBe('false')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../locale/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../ui-renderer"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle(import.meta.dirname, ['lib/types/index.js'])
|
||||
@@ -1710,7 +1710,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
'useProjection: UseProjection',
|
||||
'useTrajectory: UseTrajectory',
|
||||
],
|
||||
keyDomain: 'fixed by the owner\'s key table { [Id in UserTextId]: { node: UserTextNode<Id> } }, already taken: command, file, folder, session, skill, text',
|
||||
keyDomain: 'fixed by the owner\'s key table { [Id in UserTextId]: { node: UserTextNode<Id> } }, already taken: command, file, folder, secret, session, skill, text',
|
||||
hookContext: '',
|
||||
slotInject: '',
|
||||
declaredBy: 'factory \'conversation.user.text\' (client-ui-conversation), so it exists while that definition is registered',
|
||||
@@ -1720,6 +1720,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
'client-ui-goal GoalCommandReference key \'command\'',
|
||||
'client-ui-reference FileReference key \'file\'',
|
||||
'client-ui-reference FolderReference key \'folder\'',
|
||||
'client-ui-secret-mask SecretText key \'secret\'',
|
||||
'client-ui-skill SkillReference key \'skill\'',
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
|
||||
+1
@@ -88,6 +88,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo
|
||||
| `@deepseek-ai/dsh-client-ui-reference` | no | Unified Web @file and @session reference source |
|
||||
| `@deepseek-ai/dsh-client-ui-renderer` | no | Browser UI renderer: React slot bindings, ctx.uiRenderer, and the assembled application root |
|
||||
| `@deepseek-ai/dsh-client-ui-schedule` | no | Host task management page and Session reminder catalog |
|
||||
| `@deepseek-ai/dsh-client-ui-secret-mask` | no | Web display masking for recognized credentials in sent user text |
|
||||
| `@deepseek-ai/dsh-client-ui-session` | no | Session Controller adapter for React and session-scoped slots |
|
||||
| `@deepseek-ai/dsh-client-ui-settings` | no | Settings domain base plugin: shared configuration forms and the canonical settings slot-type contract |
|
||||
| `@deepseek-ai/dsh-client-ui-settings-account` | yes | Manage DeepSeek login and open Platform billing pages |
|
||||
|
||||
Generated
+30
@@ -967,6 +967,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-ui-chat':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/client/ui-chat
|
||||
'@deepseek-ai/dsh-client-ui-conversation':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/client/ui-conversation
|
||||
'@deepseek-ai/dsh-deque':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/util/deque
|
||||
@@ -2627,6 +2630,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-ui-schedule':
|
||||
specifier: workspace:*
|
||||
version: link:../../client/ui-schedule
|
||||
'@deepseek-ai/dsh-client-ui-secret-mask':
|
||||
specifier: workspace:*
|
||||
version: link:../../client/ui-secret-mask
|
||||
'@deepseek-ai/dsh-client-ui-session':
|
||||
specifier: workspace:*
|
||||
version: link:../../client/ui-session
|
||||
@@ -4679,6 +4685,30 @@ importers:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1([email protected])
|
||||
|
||||
packages/client/ui-secret-mask:
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:~
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:*
|
||||
version: link:../locale
|
||||
'@deepseek-ai/dsh-client-test-runtime':
|
||||
specifier: workspace:*
|
||||
version: link:../../test-support/client-runtime
|
||||
'@deepseek-ai/dsh-client-ui-conversation':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-conversation
|
||||
'@deepseek-ai/dsh-client-ui-renderer':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-renderer
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-slots
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
|
||||
packages/client/ui-session:
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
|
||||
@@ -176,6 +176,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/host/plugin-inventory': { kind: 'none', reason: 'Host-side read-only Loader projection; registers nothing model-facing.' },
|
||||
'packages/host/open-in-app': { kind: 'none', reason: 'Host routes opening desktop applications for a human; registers nothing model-facing.' },
|
||||
'packages/client/ui-open-in-app': { kind: 'none', reason: 'Browser-side split button opening the workspace directory for a human; registers nothing model-facing.' },
|
||||
'packages/client/ui-secret-mask': { kind: 'none', reason: 'Browser-side display masking of sent user text; registers nothing model-facing.' },
|
||||
'packages/experimental/session-search': { kind: 'indirect', reason: 'The profile layer delegates model-visible behavior to its selected capability.' },
|
||||
'packages/experimental/ralph-bundle': { kind: 'indirect', reason: 'The profile layer delegates model-visible behavior to its selected capability.' },
|
||||
'packages/experimental/terminal-bundle': { kind: 'indirect', reason: 'The profile layer delegates model-visible behavior to its selected capability.' },
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
- text: sk-01234***************************
|
||||
- button "Show secret"
|
||||
- text: is the key for this task.
|
||||
@@ -0,0 +1,9 @@
|
||||
version: 1
|
||||
scenario: user-text-secret-mask
|
||||
profile: web
|
||||
composition: web-default
|
||||
recording: authored
|
||||
header:
|
||||
class: web-default
|
||||
session:
|
||||
source: ../seeded-history/session.v3.jsonl
|
||||
@@ -313,6 +313,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-directory-picker-browse": ["./packages/client/ui-directory-picker-browse/src"],
|
||||
"@deepseek-ai/dsh-client-ui-directory-picker-native": ["./packages/client/ui-directory-picker-native/src"],
|
||||
"@deepseek-ai/dsh-client-ui-open-in-app": ["./packages/client/ui-open-in-app/src"],
|
||||
"@deepseek-ai/dsh-client-ui-secret-mask": ["./packages/client/ui-secret-mask/src"],
|
||||
// sdk/ folders are role-named without their npm-side sdk/jsonrpc prefixes,
|
||||
// so their names do not match their directories and the generated aliases
|
||||
// below cannot map them; these three stay hand-written.
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
{ "path": "./packages/client/ui-commands" },
|
||||
{ "path": "./packages/client/ui-skill" },
|
||||
{ "path": "./packages/client/ui-reference" },
|
||||
{ "path": "./packages/client/ui-secret-mask" },
|
||||
{ "path": "./packages/client/ui-subagent" },
|
||||
{ "path": "./packages/experimental/client-ui-agent-team" },
|
||||
{ "path": "./packages/client/ui-dockkit" },
|
||||
|
||||
@@ -140,6 +140,7 @@
|
||||
"apps/web/tests/message-feedback.e2e.ts",
|
||||
"apps/web/tests/markdown-images.e2e.ts",
|
||||
"apps/web/tests/markdown-file-links.e2e.ts",
|
||||
"apps/web/tests/user-text-secret-mask.e2e.ts",
|
||||
"apps/web/tests/reference-composer.e2e.ts",
|
||||
"apps/web/tests/markdown-wide-table.e2e.ts",
|
||||
"apps/web/tests/thinking-markdown.e2e.ts",
|
||||
|
||||
Reference in new issue
Block a user