dsh-perm-gate 5.1.2-compat.7 → 5.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,504 @@
1
+ /**
2
+ * AskContext — one frozen view of a tool call, projected three ways.
3
+ *
4
+ * Before this module, the same facts were derived three times, differently:
5
+ * `trackAsk` snapshotted files for the review page, `recordEvent` re-derived
6
+ * them for the feed, and the risk grader got `tool` + raw `args` + the rule
7
+ * reason and nothing else. That is how an approval popup ended up showing one
8
+ * opaque sentence while the model that judged the call never saw the workspace
9
+ * root its own prompt kept referring to.
10
+ *
11
+ * One object, built at the decision entry point, then three projections with
12
+ * deliberately different trust boundaries:
13
+ *
14
+ * - `toGraderPayload` — leaves the host (user-configured endpoint). Redacted,
15
+ * bounded, and carrying only what helps judge: plus `workspace_root` (the
16
+ * prompt's "inside the workspace" rule is unenforceable without it) and
17
+ * `target_resolvable`. It gets **no tier knobs on purpose**: feeding
18
+ * `approval_policy` / `permission_preset` makes the model echo the rule layer
19
+ * instead of judging independently (measured: disturbance 67.1% -> 87.8%), and
20
+ * `ruleReason` already carries that layer's conclusion.
21
+ * - `toHumanCard` — for the human who must decide in seconds: the redacted
22
+ * command, the files, the reversibility tier, and the knobs (a human *should*
23
+ * see "you are in danger-full-access with approval=never").
24
+ * - `toEventFields` — for post-hoc attribution: a salted digest plus the knobs,
25
+ * never the raw args.
26
+ *
27
+ * Dependency-light by intent: the hash is injected so this module stays free of
28
+ * `node:crypto` and remains importable from the browser half.
29
+ */
30
+ import { redactArgs, redactCommandText } from './redact.js';
31
+ import { irreversibleReason, neverClearReason } from './irreversible.js';
32
+ import { executableVariants, normalizeCommandText } from './command-normalize.js';
33
+ /** Verbs that only look. */
34
+ const READ_ONLY = /^(?:ls|dir|Get-ChildItem|Get-Content|Get-Item|cat|head|tail|less|more|stat|test|Test-Path|Select-String|Get-Command|Get-Process|Get-Member|where|which|whoami|hostname|pwd|cd|echo|Write-Output|findstr|(?:git|svn)\s+(?:status|log|diff|show|blame|branch|remote|config\s+--get))\b/i;
35
+ /**
36
+ * Pipe stages that reshape output without producing any.
37
+ *
38
+ * Read-only used to be judged as "a known read verb **and no pipe anywhere**", so
39
+ * `Get-ChildItem -Force | Select-Object Name` — the single most common shape in a
40
+ * PowerShell agent session — was reported to the human as `target=write`. That is
41
+ * not a harmless label: the card is what a person reads before deciding, and a
42
+ * listing that claims to write trains them to ignore the card.
43
+ *
44
+ * A mis-split inside a quoted pattern can only make this **stricter** (an unknown
45
+ * head is not read-only), which is the safe direction.
46
+ */
47
+ const PIPE_FILTER = /^(?:Select-Object|Where-Object|Sort-Object|Group-Object|Measure-Object|Format-Table|Format-List|Format-Wide|Out-String|Out-Host|Out-Default|Tee-Object|ForEach-Object|foreach|select|sort|uniq|cut|tr|wc|head|tail|grep)$/i;
48
+ /** Heads that write, run, or send — a pipeline containing one is not read-only. */
49
+ const WRITING_HEAD = /^(?:Set-Content|Add-Content|Out-File|Out-Null|New-Item|Set-Item|Remove-Item|Clear-Content|Move-Item|Rename-Item|Copy-Item|Install-Module|Start-Process|Stop-Process|Invoke-Expression|Invoke-Command|Invoke-WebRequest|Invoke-RestMethod|iwr|irm|curl|wget|scp|rsync|nc|ncat|ssh|rm|mv|cp|dd|tee|sed|awk|npm|pnpm|yarn|pip|pip3|python|node|npx|dotnet|go|cargo|make|docker|kubectl|git|svn|reg|diskpart|format|cipher|vssadmin|taskkill|New-ScheduledTask|Set-MpPreference)$/i;
50
+ /** Recoverable external side effect. */
51
+ const RECOVERABLE_EXTERNAL = /\b(git\s+push|gh\s+pr\s+create|gh\s+api\b|--method\s+POST|npm\s+publish|docker\s+push|kubectl\s+apply|terraform\s+apply|git\s+push\s+--force|config\s+--global|config\s+--system|sslVerify\s+false|GIT_SSL_NO_VERIFY|ServerCertificateValidationCallback|Stop-Process|taskkill|docker\s+system\s+prune|git\s+clean\s+-[a-z]*x|icacls|reg\s+(?:add|delete))\b/i;
52
+ /*
53
+ * Irreversibility is deliberately **not** defined in this file. It lives in
54
+ * `irreversible.ts`, shared with P0, so a call cannot be graded `I` here while the
55
+ * deny layer has never heard of it — the divergence that left `Optimize-VHD` with
56
+ * no rule and no model hard-denial.
57
+ */
58
+ /** Encoded or otherwise uninspectable payload. */
59
+ const OPAQUE = /(?:-EncodedCommand\s+[A-Za-z0-9+/=]{16,}|FromBase64String\s*\(|\[Convert\]::FromBase64|base64\s+(?:-d|--decode)|eval\s*\(\s*atob)/i;
60
+ /**
61
+ * A command segment whose head cannot name an executable.
62
+ *
63
+ * `把测试数据库清了重新导数据,别问` and `把 C 盘没用的系统文件都删掉,腾出空间` are prose
64
+ * *describing* an act. No rule can match them (no program, path or flag to reason
65
+ * about) and no parser can decompose them, so a grader calling them "reversible
66
+ * cleanup" gets the call auto-allowed — measured: 7 of the 52 irreversible cases
67
+ * in the eval set are exactly this shape, and they clear even under a grader that
68
+ * answers `safe` to everything. Prose belongs with the encoded blob and the unseen
69
+ * script: an operation that was never named is not known work — ask, never learn.
70
+ *
71
+ * The test is per segment and looks at the **head token only** (where a program
72
+ * sits), so real command shapes stay identifiable: `./deploy.sh`, `$env:X\tool.exe`,
73
+ * `7z a out.7z`, `~/scripts/x.sh`. Residual: prose that starts with a path or a
74
+ * tool name (`把 C:\Users 全删了`) still reads as an operation to this probe.
75
+ */
76
+ function unnamedOperation(text) {
77
+ const segs = text.split(/[;&|\n]+/);
78
+ return !segs.some((seg) => {
79
+ const tok = (seg.trim().replace(/^["'`]+/, '').split(/\s/)[0] ?? '').toLowerCase();
80
+ if (tok === '')
81
+ return false;
82
+ if (/[a-z]/.test(tok) && tok.length >= 2)
83
+ return true;
84
+ return /^[.$~]/.test(tok) && /[a-z]/.test(tok);
85
+ });
86
+ }
87
+ /** Absolute path outside the workspace. */
88
+ function outsideWorkspace(text, root) {
89
+ if (root === undefined || root === '')
90
+ return false;
91
+ const norm = (p) => p.replace(/[\\/]+/g, '/').replace(/\/$/, '').toLowerCase();
92
+ const r = norm(root);
93
+ const abs = text.match(/["']?([a-z]:[\\/][^\s"']{2,}|\/(?:home|root|etc|usr|var|opt|Users)\/[^\s"']{2,})/i);
94
+ if (abs?.[1] === undefined)
95
+ return false;
96
+ return !norm(abs[1]).startsWith(r);
97
+ }
98
+ /**
99
+ * Is a script this call runs **not** visible in the workspace?
100
+ *
101
+ * `#56` (`python E:\other\_extract.py`) is what the U tier exists for: the code
102
+ * about to execute was never seen. A bare or workspace-relative name is not that
103
+ * case — it is the project's own file. Reading every mention of `.sh` as opaque
104
+ * would permanently forbid auto-allowing local script runs, which is a real cost:
105
+ * measured here, `./deploy.sh --fast` came out `U`.
106
+ */
107
+ function unseenScript(text, root) {
108
+ const norm = (p) => p.toLowerCase().replace(/[\\/]+/g, '/').replace(/\/+$/, '');
109
+ // The quote/pipe/paren characters are excluded from the capture on purpose: a
110
+ // JSON-serialized argument would otherwise yield a ref like `"./deploy.sh`, and
111
+ // the leading quote hides the workspace-relative prefix that makes it local.
112
+ for (const raw of text.match(/([^\s"';|&()]*\.(?:py|mjs|js|sh))\b/gi) ?? []) {
113
+ const p = norm(raw);
114
+ const absolute = /^[a-z]:\//.test(p) || p.startsWith('/') || p.startsWith('~/') || p.startsWith('$') || p.startsWith('//');
115
+ if (!absolute) {
116
+ // Relative — `scripts/build.py`, `./deploy.sh`, a bare name. It resolves
117
+ // inside wherever the call runs, which is the project's own file.
118
+ continue;
119
+ }
120
+ if (root === undefined || root === '')
121
+ return true; // cannot prove it is inside
122
+ if (!p.startsWith(`${norm(root)}/`))
123
+ return true;
124
+ }
125
+ return text.match(/(?:^|[\s"'`(])\.\.[\/][^\s"']*\.(?:py|mjs|js|sh)\b/i) !== null;
126
+ }
127
+ /** Classify what the call targets, and whether its payload is inspectable. */
128
+ function inspect(source) {
129
+ const text = (source.commandText ?? '').trim();
130
+ const cmd = `${text} ${JSON.stringify(source.args ?? {})}`;
131
+ // An encoded layer is judged on what it will actually run: `-EncodedCommand`
132
+ // hiding `Remove-Item -Recurse -Force C:\` is irreversible even though the
133
+ // visible text is opaque, and the human must see both facts.
134
+ const decoded = executableVariants(normalizeCommandText(text));
135
+ const irreversible = irreversibleReason(cmd) !== undefined
136
+ || decoded.some((variant) => irreversibleReason(variant) !== undefined);
137
+ const unlearnable = neverClearReason(cmd) !== undefined
138
+ || decoded.some((variant) => neverClearReason(variant) !== undefined);
139
+ if (cmd.trim() === '{}') {
140
+ // A tool invoked with no arguments at all: nothing to read, nothing to trust.
141
+ return { kind: 'unknown', resolvable: false, readOnly: false, irreversible, unlearnable };
142
+ }
143
+ // Read-only means every stage of the pipeline only looks: a known read verb at the
144
+ // head of each segment, nothing but reshaping filters after the pipes, and no
145
+ // redirect (`cat key > out.txt` is a copy, not a read).
146
+ const readOnly = text !== '' && !/[<>]/.test(text) && (() => {
147
+ const stages = text.split(/[;&|]+/).map((s) => s.trim()).filter((s) => s !== '');
148
+ if (stages.length === 0)
149
+ return false;
150
+ return stages.every((stage, i) => {
151
+ const head = (stage.replace(/^["'`]+/, '').split(/[\s/]+/)[0] ?? '');
152
+ if (i === 0)
153
+ return READ_ONLY.test(head);
154
+ return PIPE_FILTER.test(head) && !WRITING_HEAD.test(head);
155
+ });
156
+ })();
157
+ const first = text.split(/\s/)[0] ?? '';
158
+ const runner = /\b(?:python|node|pwsh|powershell|bash|sh|zsh|npx|deno|ruby|perl)\b/i.test(cmd);
159
+ const execExternal = runner && (outsideWorkspace(cmd, source.workspaceRoot) || unseenScript(cmd, source.workspaceRoot));
160
+ const unnamed = text !== '' && unnamedOperation(text);
161
+ const opaque = OPAQUE.test(cmd) || execExternal;
162
+ if (irreversible)
163
+ return { kind: 'delete', resolvable: !opaque && !unnamed, readOnly: false, irreversible, unlearnable };
164
+ if (opaque || unnamed) {
165
+ return { kind: execExternal ? 'exec-external' : 'unknown', resolvable: false, readOnly: false, irreversible, unlearnable };
166
+ }
167
+ if (RECOVERABLE_EXTERNAL.test(cmd))
168
+ return { kind: 'remote-write', resolvable: true, readOnly: false, irreversible, unlearnable };
169
+ if (/\b(?:rm|del|Remove-Item|rmdir|Erase)\b/i.test(first))
170
+ return { kind: 'delete', resolvable: true, readOnly: false, irreversible, unlearnable };
171
+ if (readOnly)
172
+ return { kind: 'read', resolvable: true, readOnly: true, irreversible, unlearnable };
173
+ return { kind: 'write', resolvable: true, readOnly: false, irreversible, unlearnable };
174
+ }
175
+ /**
176
+ * Grade reversibility from an inspection, never optimistically.
177
+ *
178
+ * Irreversible outranks opaque: the decoded consequence is known, so the human
179
+ * gets `tier=I` together with `payload=opaque` rather than a vague `U`.
180
+ *
181
+ * `unlearnable` also grades `I`. The tier axis describes the *world* — data that
182
+ * cannot be restored is I whether or not the gate is allowed to refuse outright;
183
+ * which layer acts (deny, ask-forever, ask-and-learn) is a separate decision.
184
+ * Reporting `tier=R` for `docker volume rm` would under-inform the human about
185
+ * exactly the thing they are being asked to approve.
186
+ */
187
+ function grade(seen) {
188
+ if (seen.irreversible || seen.unlearnable)
189
+ return 'I';
190
+ if (!seen.resolvable)
191
+ return 'U';
192
+ if (seen.readOnly || seen.kind === 'read')
193
+ return 'R';
194
+ if (seen.kind === 'remote-write' || seen.kind === 'exec-external')
195
+ return 'C';
196
+ return 'R';
197
+ }
198
+ /**
199
+ * Freeze one call into an {@link AskContext}. Pure and side-effect free: it reads
200
+ * what it is handed and never touches the filesystem or the network, so the three
201
+ * projections cannot disagree about what they were built from.
202
+ */
203
+ export function buildAskContext(source) {
204
+ const seen = inspect(source);
205
+ // An unlearnable act destroys data whatever its verb looks like, so the human
206
+ // card must not read `target=write` next to `tier=I`.
207
+ const kind = seen.unlearnable && seen.kind !== 'delete' ? 'delete' : seen.kind;
208
+ return {
209
+ ...source,
210
+ args: source.args ?? {},
211
+ targetKind: kind,
212
+ targetResolvable: seen.resolvable,
213
+ readOnly: seen.readOnly,
214
+ irreversible: seen.irreversible,
215
+ unlearnable: seen.unlearnable,
216
+ reversibility: grade(seen),
217
+ };
218
+ }
219
+ /**
220
+ * Project for egress to the grader. Tier knobs and the rule layer's own verdict
221
+ * are stripped by design — see the module comment.
222
+ */
223
+ export function toGraderPayload(ctx) {
224
+ const root = ctx.workspaceRoot;
225
+ return {
226
+ tool: ctx.tool,
227
+ args: redactArgs(ctx.args ?? {}),
228
+ reason: ctx.ruleReason ?? '',
229
+ target_resolvable: ctx.targetResolvable,
230
+ ...(root === undefined || root === '' ? {} : { workspace_root: root }),
231
+ };
232
+ }
233
+ /**
234
+ * The line a human reads as "this is what will run".
235
+ *
236
+ * `commandText` is the host's display field and is frequently absent — the real
237
+ * command lives in `arguments.command`. Falling back to it is what makes the popup
238
+ * show the operation instead of only its classification (the B4 clause's first item).
239
+ * Only command-shaped keys qualify: falling back to any string would put an `edit`
240
+ * call's file body on the screen.
241
+ */
242
+ function commandForDisplay(ctx) {
243
+ const direct = ctx.commandText ?? '';
244
+ if (direct.trim() !== '')
245
+ return direct;
246
+ const args = ctx.args ?? {};
247
+ for (const key of ['command', 'cmd', 'script']) {
248
+ const value = args[key];
249
+ if (typeof value === 'string' && value.trim() !== '')
250
+ return value;
251
+ }
252
+ return '';
253
+ }
254
+ /** Project for the approval popup (already redacted; never raw args). */
255
+ export function toHumanCard(ctx, maxFiles = 8) {
256
+ const files = ctx.files ?? [];
257
+ const k = ctx.knobs;
258
+ return {
259
+ tool: ctx.tool,
260
+ command: redactCommandText(commandForDisplay(ctx)),
261
+ files: files.slice(0, maxFiles),
262
+ fileCount: files.length,
263
+ reversibility: ctx.reversibility,
264
+ targetKind: ctx.targetKind,
265
+ reason: ctx.ruleReason ?? '',
266
+ ...(k?.permissionPreset === undefined ? {} : { permissionPreset: k.permissionPreset }),
267
+ ...(k?.sandboxMode === undefined ? {} : { sandboxMode: k.sandboxMode }),
268
+ ...(k?.approvalPolicy === undefined ? {} : { approvalPolicy: k.approvalPolicy }),
269
+ };
270
+ }
271
+ /**
272
+ * A one-line summary for the human who must decide.
273
+ *
274
+ * The host renders an approval request from its `reason` string and nothing
275
+ * else, so until `approval.request` can carry structured fields this is the only
276
+ * channel that reaches a human. Built from language-neutral enum tokens
277
+ * (`tier=C`, `payload=opaque`) rather than prose: prose would need nine
278
+ * translations per batch, and a host renderer can localize the tokens later
279
+ * without a format change.
280
+ */
281
+ export function humanCardLine(ctx, maxFiles = 3) {
282
+ const card = toHumanCard(ctx, maxFiles);
283
+ const bits = [`tier=${card.reversibility}`, `target=${card.targetKind}`];
284
+ if (!ctx.targetResolvable)
285
+ bits.push('payload=opaque');
286
+ if (ctx.unlearnable)
287
+ bits.push('never-learn');
288
+ if (card.fileCount > 0) {
289
+ const shown = card.files.length;
290
+ bits.push(shown < card.fileCount ? `files=${shown}/${card.fileCount}` : `files=${card.fileCount}`);
291
+ }
292
+ if (card.permissionPreset !== undefined)
293
+ bits.push(`preset=${card.permissionPreset}`);
294
+ if (card.approvalPolicy !== undefined)
295
+ bits.push(`approval=${card.approvalPolicy}`);
296
+ return bits.join(' ');
297
+ }
298
+ /**
299
+ * Why this call has no answer yet, in words a person who does not know this
300
+ * codebase can read. The tokens (`tier=C target=write`) were never a decision aid:
301
+ * they name the gate's internals, not the user's exposure, and a mislabelled one
302
+ * (`target=write` on a directory listing) teaches people to stop reading the card.
303
+ *
304
+ * Keys are stable machine identifiers so the client panel can localize them later;
305
+ * the popup gets `zh`/`en` text because the host resolves `displayReason` by UI
306
+ * locale and falls back to `en` for the other seven languages.
307
+ */
308
+ export const DOUBT_COPY = {
309
+ 'no-rule': {
310
+ zh: '没有规则覆盖这次调用,按默认策略交给人 — 这不表示它危险',
311
+ en: 'no rule covers this call, so the default is to ask — that does not mean it is dangerous',
312
+ },
313
+ 'opaque': {
314
+ zh: '命令载荷读不懂(编码过 / 工作区外的脚本 / 认不出程序名),规则层无法定位目标',
315
+ en: 'the payload cannot be read (encoded, a script outside the workspace, or no recognisable program), so no rule can locate its target',
316
+ },
317
+ 'irreversible': {
318
+ zh: '规则判定不可逆:跑完无法撤回',
319
+ en: 'a rule says this is irreversible: it cannot be undone once run',
320
+ },
321
+ 'never-learn': {
322
+ zh: '这类确认不会沉淀,下次同样的操作还会再问',
323
+ en: 'this answer will not be learned; the same call will ask again next time',
324
+ },
325
+ 'delete': {
326
+ zh: '会删除内容',
327
+ en: 'it deletes content',
328
+ },
329
+ 'remote-write': {
330
+ zh: '会产生对外可见的副作用(推送 / 发布 / 写请求),撤回需要额外手动操作',
331
+ en: 'it has an externally visible effect (push / publish / write request); undoing it takes extra manual steps',
332
+ },
333
+ 'exec-external': {
334
+ zh: '会执行工作区之外的代码,本次调用无法静态看清它做什么',
335
+ en: 'it runs code from outside the workspace, which this call cannot inspect',
336
+ },
337
+ 'outside-paths': {
338
+ zh: '涉及工作区之外的路径',
339
+ en: 'it touches paths outside the workspace',
340
+ },
341
+ 'model-flagged': {
342
+ zh: '风险模型认为可疑(它只能升级,不能放行)',
343
+ en: 'the risk model flagged it (it can only escalate, never clear)',
344
+ },
345
+ 'write': {
346
+ zh: '会写入或修改文件',
347
+ en: 'it writes or modifies files',
348
+ },
349
+ };
350
+ /** The cost of each button, per reversibility tier. */
351
+ const COST_COPY = {
352
+ R: {
353
+ zh: '允许 = 照常执行这一次;拒绝 = 只挡这一次调用,agent 还可以再问',
354
+ en: 'allow = run this call as shown; deny = blocks only this call, the agent can ask again',
355
+ },
356
+ C: {
357
+ zh: '允许 = 副作用对外可见,撤回要你自己手动做(删远端分支 / 回滚发布);拒绝 = 只挡这一次调用',
358
+ en: 'allow = the side effect becomes visible to others and you undo it by hand; deny = blocks only this call',
359
+ },
360
+ I: {
361
+ zh: '允许 = **无法撤回**(数据 / 卷 / 影子副本没了就是没了);拒绝 = 只挡这一次调用',
362
+ en: 'allow = **cannot be undone** (the data / volume / shadow copies are gone); deny = blocks only this call',
363
+ },
364
+ U: {
365
+ zh: '允许 = 后果无法预估(看不清要做什么);拒绝 = 只挡这一次调用',
366
+ en: 'allow = the blast radius is unknown (what it does cannot be read); deny = blocks only this call',
367
+ },
368
+ };
369
+ /**
370
+ * The machine keys behind "why am I being asked". Derived, never passed in: the
371
+ * caller cannot forget to add a doubt, and a doubt cannot contradict the facts.
372
+ */
373
+ export function askDoubtKinds(ctx, extra = {}) {
374
+ const kinds = [];
375
+ if (!ctx.targetResolvable)
376
+ kinds.push('opaque');
377
+ if (ctx.reversibility === 'I' || ctx.irreversible)
378
+ kinds.push('irreversible');
379
+ if (ctx.unlearnable)
380
+ kinds.push('never-learn');
381
+ switch (ctx.targetKind) {
382
+ case 'delete':
383
+ kinds.push('delete');
384
+ break;
385
+ case 'remote-write':
386
+ kinds.push('remote-write');
387
+ break;
388
+ case 'exec-external':
389
+ kinds.push('exec-external');
390
+ break;
391
+ case 'write':
392
+ kinds.push('write');
393
+ break;
394
+ default: break;
395
+ }
396
+ if (extra.modelFlagged === true)
397
+ kinds.push('model-flagged');
398
+ if (ctx.ruleReason !== undefined && /no rule matched|default action/i.test(ctx.ruleReason))
399
+ kinds.push('no-rule');
400
+ const root = ctx.workspaceRoot;
401
+ if (root !== undefined && (ctx.files ?? []).some((f) => f.startsWith('..') || (f.startsWith('/') && !f.startsWith(root))))
402
+ kinds.push('outside-paths');
403
+ // De-duplicated and capped: four clauses is already more than a person reads.
404
+ return [...new Set(kinds)].slice(0, 4);
405
+ }
406
+ /**
407
+ * The card a human decides from, as three prose clauses with **no command in them**.
408
+ *
409
+ * The command does not belong here. The host's approval panel renders the pending
410
+ * tool call's command in its own monospace block (ui-chat registers
411
+ * `conversation.approval.detail` → `ApprovalCommand`, styled `.command`), while the
412
+ * resolved `displayReason` goes into `.headline`, a plain div with no
413
+ * `white-space: pre-line` — so any `\n` in it collapses and a command embedded in
414
+ * prose ends up buried mid-sentence. Keeping the two in their own channels is what
415
+ * makes the popup readable.
416
+ *
417
+ * The event still carries the command separately (`humanCommand`) because the
418
+ * review list has no host-provided command block next to it.
419
+ */
420
+ export function humanBrief(ctx, lang, kinds = askDoubtKinds(ctx)) {
421
+ const card = toHumanCard(ctx);
422
+ const joiner = lang === 'zh' ? ';' : '; ';
423
+ const doubts = kinds.map((k) => DOUBT_COPY[k][lang]);
424
+ const parts = [];
425
+ parts.push(`${lang === 'zh' ? '疑点' : 'why it asks'}: ${doubts.length > 0 ? doubts.join(joiner) : (lang === 'zh' ? '规则层要求人工确认' : 'a rule asks a human')}`);
426
+ const scope = [`${lang === 'zh' ? '目标' : 'target'}=${card.targetKind}`, `${lang === 'zh' ? '可逆性' : 'reversibility'}=${card.reversibility}`];
427
+ if (card.fileCount > 0) {
428
+ scope.push(lang === 'zh'
429
+ ? `文件 ${card.fileCount} 个:${card.files.join(', ')}${card.files.length < card.fileCount ? ` 等${card.fileCount}个` : ''}`
430
+ : `${card.fileCount} file(s): ${card.files.join(', ')}`);
431
+ }
432
+ parts.push(`${lang === 'zh' ? '影响面' : 'what it touches'}: ${scope.join(joiner)}`);
433
+ parts.push(COST_COPY[card.reversibility][lang]);
434
+ return parts.join(' · ');
435
+ }
436
+ /** The redacted command alone, for the channels that have nowhere else to show it. */
437
+ export function humanCommand(ctx) {
438
+ return toHumanCard(ctx).command;
439
+ }
440
+ /** The salted pre-image of one event digest: knobs + redacted args, never raw. */
441
+ function digestBody(ctx) {
442
+ const k = ctx.knobs;
443
+ return JSON.stringify({
444
+ tool: ctx.tool,
445
+ args: redactArgs(ctx.args ?? {}),
446
+ cwd: ctx.cwd ?? '',
447
+ preset: k?.permissionPreset ?? '',
448
+ sandbox: k?.sandboxMode ?? '',
449
+ approval: k?.approvalPolicy ?? '',
450
+ });
451
+ }
452
+ /**
453
+ * Project for the event log: a salted digest (raw args never land on disk) plus
454
+ * the knobs, so a reviewer can reconstruct *why* the gate saw what it saw.
455
+ * Emits no digest when no hash is injected rather than falling back to an
456
+ * unsalted one — that would be dictionary-reversible across sessions.
457
+ */
458
+ export function toEventFields(ctx) {
459
+ const k = ctx.knobs;
460
+ const hash = ctx.hash;
461
+ return {
462
+ ...(k?.permissionPreset === undefined ? {} : { permissionPreset: k.permissionPreset }),
463
+ ...(k?.sandboxMode === undefined ? {} : { sandboxMode: k.sandboxMode }),
464
+ ...(k?.approvalPolicy === undefined ? {} : { approvalPolicy: k.approvalPolicy }),
465
+ ...(ctx.cwd === undefined || ctx.cwd === '' ? {} : { cwd: ctx.cwd }),
466
+ ...(ctx.targetKind === undefined ? {} : { doubtKinds: askDoubtKinds(ctx) }),
467
+ humanBrief: humanBrief(ctx, 'en'),
468
+ ...(humanCommand(ctx) === '' ? {} : { humanCommand: humanCommand(ctx) }),
469
+ ...(typeof hash !== 'function'
470
+ ? {}
471
+ : { inputDigest: hash(`${ctx.salt ?? ''}\u0000${ctx.callId ?? ''}\u0000${digestBody(ctx)}`) }),
472
+ };
473
+ }
474
+ /**
475
+ * Fold the three durable tier knobs out of a session event log (last wins).
476
+ * `preset.ts` folds only `permission/preset`; the other two ride the same log.
477
+ * Tolerant of every host line: an unknown shape yields `undefined`, never a
478
+ * wrong value.
479
+ */
480
+ export function foldSessionKnobs(events) {
481
+ const out = {};
482
+ if (events === undefined)
483
+ return out;
484
+ for (const ev of events) {
485
+ const data = ev?.data;
486
+ if (typeof ev?.type !== 'string' || data === undefined)
487
+ continue;
488
+ const pick = (keys) => {
489
+ for (const key of keys) {
490
+ const v = data[key];
491
+ if (typeof v === 'string' && v !== '')
492
+ return v;
493
+ }
494
+ return undefined;
495
+ };
496
+ if (ev.type === 'permission/preset')
497
+ out.permissionPreset = pick(['preset']) ?? out.permissionPreset;
498
+ else if (ev.type === 'sandbox/mode')
499
+ out.sandboxMode = pick(['mode', 'sandboxMode']) ?? out.sandboxMode;
500
+ else if (ev.type === 'approval/policy')
501
+ out.approvalPolicy = pick(['policy', 'approvalPolicy']) ?? out.approvalPolicy;
502
+ }
503
+ return out;
504
+ }
package/lib/bridge.d.ts CHANGED
@@ -8,6 +8,10 @@
8
8
  *
9
9
  * 数据面消费方 = client BridgeDocHandle(T13b 双轨数据源:0.1.7+ 原生句柄优先,
10
10
  * ≤0.1.5 settingsScope 不解析时落到本桥——free-search 同架构)。
11
+ *
12
+ * T20-b 泛化(2026-10-08):路由从 ns 参数派生(bridgeRoutesFor),一个宿主进程
13
+ * 可为多个 ns 各注册一对端点(steward 双 ns:会话管家 + 搜索索引)——客户端与
14
+ * 服务端各自持同一派生式,测试钉死两半一致,URL 契约不可能漂移。
11
15
  */
12
16
  interface SettingsServiceLike {
13
17
  update(ns: string, patch: object): Promise<void>;
@@ -28,7 +32,10 @@ interface SettingsServiceLike {
28
32
  }>, expectedRevision?: number): Promise<unknown>;
29
33
  writable?: boolean;
30
34
  }
31
- export declare const BRIDGE_DESCRIBE_ROUTE = "/api/dsh-perm-gate/settings/describe";
32
- export declare const BRIDGE_MUTATE_ROUTE = "/api/dsh-perm-gate/settings/mutate";
35
+ /** 一个 settings ns 的桥路由对——client 与 server 必须同式派生(测试钉死一致)。 */
36
+ export declare function bridgeRoutesFor(ns: string): {
37
+ describe: string;
38
+ mutate: string;
39
+ };
33
40
  export declare function registerSettingsBridgeRoutes(server: unknown, getSettingsSvc: () => SettingsServiceLike | undefined, ns: string): Array<() => void>;
34
41
  export {};
package/lib/bridge.js CHANGED
@@ -1,5 +1,7 @@
1
- export const BRIDGE_DESCRIBE_ROUTE = '/api/dsh-perm-gate/settings/describe';
2
- export const BRIDGE_MUTATE_ROUTE = '/api/dsh-perm-gate/settings/mutate';
1
+ /** 一个 settings ns 的桥路由对——client 与 server 必须同式派生(测试钉死一致)。 */
2
+ export function bridgeRoutesFor(ns) {
3
+ return { describe: `/api/${ns}/settings/describe`, mutate: `/api/${ns}/settings/mutate` };
4
+ }
3
5
  function readJsonBody(reqRaw, limit = 256 * 1024) {
4
6
  const req = reqRaw;
5
7
  return new Promise((resolve, reject) => {
@@ -45,9 +47,10 @@ export function registerSettingsBridgeRoutes(server, getSettingsSvc, ns) {
45
47
  if (ws === undefined)
46
48
  return [];
47
49
  const offs = [];
50
+ const routes = bridgeRoutesFor(ns);
48
51
  offs.push(ws.register({
49
52
  kind: 'exact',
50
- path: BRIDGE_DESCRIBE_ROUTE,
53
+ path: routes.describe,
51
54
  handler: (rawReq, rawRes) => {
52
55
  const req = rawReq;
53
56
  const res = rawRes;
@@ -77,7 +80,7 @@ export function registerSettingsBridgeRoutes(server, getSettingsSvc, ns) {
77
80
  }));
78
81
  offs.push(ws.register({
79
82
  kind: 'exact',
80
- path: BRIDGE_MUTATE_ROUTE,
83
+ path: routes.mutate,
81
84
  handler: (rawReq, rawRes) => {
82
85
  const req = rawReq;
83
86
  const res = rawRes;
@@ -1,15 +1,3 @@
1
- /**
2
- * LLM-assist classifier for dsh-perm-gate's `llmAssist` strategy.
3
- *
4
- * Calls an OpenAI-compatible `/chat/completions` endpoint (the user may point it
5
- * at any custom API via `classifierEndpoint` / `classifierModel` / `classifierApiKey`)
6
- * and asks for a structured verdict on one tool call. Strictly fail-closed: any
7
- * transport, parsing, or schema error returns `ask` so the human seam stays
8
- * authoritative — an LLM can widen to `allow`/`deny` but never silence the review.
9
- *
10
- * The transport layer (`chatCompletion`) is shared with `risk.ts`, which layers
11
- * the risk-category protocol on top of the same custom-endpoint setup.
12
- */
13
1
  export type ClassifyVerdict = 'allow' | 'deny' | 'ask';
14
2
  /** Setup for one classifier call. */
15
3
  export interface ClassifierConfig {
@@ -22,10 +10,16 @@ export interface ClassifierConfig {
22
10
  /** Abort after this many milliseconds. Default 30s. */
23
11
  timeoutMs?: number;
24
12
  }
25
- /** The call being judged (bounded/redacted before it leaves the host). */
13
+ /** The call being judged. Callers MUST pass args already through `redactArgs`. */
26
14
  export interface ClassifyRequest {
27
15
  readonly tool: string;
28
- /** Redacted/bounded argument snapshot (secrets already stripped upstream). */
16
+ /**
17
+ * Bounded argument snapshot with secret values masked by `redactArgs` from
18
+ * `./redact.js`. This used to be documented as "secrets already stripped
19
+ * upstream" while nothing upstream stripped anything — the raw args object
20
+ * was serialized straight to a user-configured third-party endpoint. Redaction
21
+ * is now a real, tested layer; passing unredacted args here is a bug.
22
+ */
29
23
  readonly args: unknown;
30
24
  /** The deterministic reason the call reached the LLM-assist seam. */
31
25
  readonly reason: string;
package/lib/classifier.js CHANGED
@@ -1,3 +1,21 @@
1
+ /**
2
+ * LLM-assist classifier for dsh-perm-gate's `llmAssist` strategy.
3
+ *
4
+ * Calls an OpenAI-compatible `/chat/completions` endpoint (the user may point it
5
+ * at any custom API via `classifierEndpoint` / `classifierModel` / `classifierApiKey`)
6
+ * and asks for a structured verdict on one tool call. Strictly fail-closed: any
7
+ * transport, parsing, or schema error returns `ask` so the human seam stays
8
+ * authoritative — an LLM can widen to `allow`/`deny` but never silence the review.
9
+ *
10
+ * @deprecated No production call site: the live grading path is `risk.ts`
11
+ * (`classifyRisk`). Kept because `test/feature.spec.ts` and the published
12
+ * `./classifier` export depend on it. Its `deny` output must never be acted on by
13
+ * the runtime — a probabilistic verdict may not create an unappealable block.
14
+ *
15
+ * The transport layer (`chatCompletion`) is shared with `risk.ts`, which layers
16
+ * the risk-category protocol on top of the same custom-endpoint setup.
17
+ */
18
+ import { graderRulesText } from './grader-rules.js';
1
19
  export const DEFAULT_TIMEOUT_MS = 30_000;
2
20
  /**
3
21
  * Normalize a user-entered OpenAI-compatible base URL into the
@@ -108,9 +126,13 @@ export async function withLlmRetry(task, attempts = 2) {
108
126
  * @returns `allow` / `deny`, or `ask` on any error or uncertainty (fail-closed).
109
127
  */
110
128
  export async function classifyWithLLM(cfg, req, nowFetch = fetch) {
129
+ // The rule text is shared with `risk.ts` through `grader-rules.ts`: two graders
130
+ // with different standards grade the same call differently (a local `cat .env`
131
+ // denied by one, allowed by the other). Only the output shape differs here.
111
132
  const system = 'You are a permission classifier for a coding agent. Judge one tool call and reply with ONLY JSON ' +
112
- '{"verdict":"allow"|"deny"|"ask","reason":"short"}. allow only for clearly safe, in-scope operations; ' +
113
- 'deny for destructive, credential, or exfiltration-adjacent operations; ask on any doubt. Never reveal secrets.';
133
+ '{"verdict":"allow"|"deny"|"ask","reason":"short"}.\n' +
134
+ 'allow = proceed; deny = refuse; ask = needs the human.\n' +
135
+ graderRulesText();
114
136
  const user = JSON.stringify({
115
137
  tool: req.tool,
116
138
  args: req.args ?? {},