@mingozhou/dsh-replay 0.3.3 → 0.4.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.
Files changed (53) hide show
  1. package/README.md +225 -164
  2. package/README.zh.md +225 -164
  3. package/assets/icon.png +0 -0
  4. package/lib/client.js +955 -2419
  5. package/lib/core.js +557 -86
  6. package/lib/index.js +1043 -51
  7. package/lib/types/src/client/api.d.ts +44 -0
  8. package/lib/types/src/client/components/AuditView.d.ts +2 -1
  9. package/lib/types/src/client/components/CompareView.d.ts +2 -1
  10. package/lib/types/src/client/components/DashboardView.d.ts +8 -0
  11. package/lib/types/src/client/components/ExportDialog.d.ts +9 -0
  12. package/lib/types/src/client/components/ReplayApp.d.ts +4 -2
  13. package/lib/types/src/client/components/ReplayModal.d.ts +1 -1
  14. package/lib/types/src/client/components/SessionPicker.d.ts +4 -3
  15. package/lib/types/src/client/components/TimelineView.d.ts +2 -1
  16. package/lib/types/src/client/export.d.ts +6 -1
  17. package/lib/types/src/client/i18n.d.ts +8 -1
  18. package/lib/types/src/client/pricing.d.ts +6 -0
  19. package/lib/types/src/core/audit.d.ts +18 -0
  20. package/lib/types/src/core/compare.d.ts +8 -3
  21. package/lib/types/src/core/cost.d.ts +21 -3
  22. package/lib/types/src/core/index.d.ts +1 -0
  23. package/lib/types/src/core/redact.d.ts +40 -0
  24. package/lib/types/src/core/timeline.d.ts +9 -0
  25. package/lib/types/src/index.d.ts +52 -1
  26. package/lib/viewer.js +282 -29
  27. package/package.json +118 -107
  28. package/scripts/build.mjs +8 -2
  29. package/src/client/api.ts +114 -10
  30. package/src/client/components/AuditView.tsx +89 -10
  31. package/src/client/components/CompareView.tsx +89 -13
  32. package/src/client/components/DashboardView.tsx +232 -0
  33. package/src/client/components/ExportDialog.tsx +136 -0
  34. package/src/client/components/ItemDetail.tsx +36 -4
  35. package/src/client/components/OverviewView.tsx +150 -34
  36. package/src/client/components/ReplayApp.tsx +207 -60
  37. package/src/client/components/ReplayModal.tsx +41 -6
  38. package/src/client/components/SessionPicker.tsx +55 -27
  39. package/src/client/components/TimelineView.tsx +106 -41
  40. package/src/client/export.ts +20 -4
  41. package/src/client/i18n.ts +266 -45
  42. package/src/client/index.ts +7 -5
  43. package/src/client/mascot-image.ts +1 -1
  44. package/src/client/pricing.ts +90 -0
  45. package/src/client/styles.css +250 -1
  46. package/src/client/viewer-entry.tsx +25 -4
  47. package/src/core/audit.ts +274 -67
  48. package/src/core/compare.ts +0 -0
  49. package/src/core/cost.ts +94 -16
  50. package/src/core/index.ts +1 -0
  51. package/src/core/redact.ts +182 -0
  52. package/src/core/timeline.ts +107 -12
  53. package/src/index.ts +290 -54
package/src/core/audit.ts CHANGED
@@ -3,16 +3,35 @@
3
3
  * with a severity, grouped for the audit view. Rules are data, not code —
4
4
  * extend `DEFAULT_RULES` or pass your own list.
5
5
  *
6
+ * Design goals, in order:
7
+ * 1. Signal, not activity. A clean session should produce zero findings;
8
+ * routine file writes and ordinary network reads are not "risks".
9
+ * 2. Windows is a first-class platform. `pwsh`, PowerShell aliases and
10
+ * Windows paths are scanned just like bash on Unix.
11
+ * 3. Baseline is not a finding. Every session starts with a permission
12
+ * preset and sandbox mode; only a later *change* is interesting.
13
+ *
6
14
  * Severity vocabulary mirrors a standard status palette:
7
15
  * 'critical' | 'serious' | 'warning' | 'info'.
8
16
  */
9
17
  import type { ParsedSession, SessionEvent } from './types.js'
18
+ import { containsSecret, redactText } from './redact.js'
10
19
 
11
20
  export type Severity = 'critical' | 'serious' | 'warning' | 'info'
12
21
 
22
+ export type AuditCategory =
23
+ | 'shell'
24
+ | 'file'
25
+ | 'network'
26
+ | 'approval'
27
+ | 'config'
28
+ | 'error'
29
+ | 'secret'
30
+
13
31
  export interface AuditFinding {
14
32
  ruleId: string
15
33
  severity: Severity
34
+ category?: AuditCategory
16
35
  seq: number
17
36
  time: number
18
37
  /** English title (stable fallback). */
@@ -25,6 +44,7 @@ export interface AuditFinding {
25
44
 
26
45
  export interface AuditRule {
27
46
  id: string
47
+ category?: AuditCategory
28
48
  severity: Severity | ((event: SessionEvent) => Severity)
29
49
  /** Return a finding title when the event matches, undefined otherwise. */
30
50
  match: (event: SessionEvent) => {
@@ -32,12 +52,19 @@ export interface AuditRule {
32
52
  detail?: string
33
53
  messageKey?: string
34
54
  params?: Record<string, string>
55
+ category?: AuditCategory
35
56
  } | undefined
36
57
  }
37
58
 
38
59
  export interface AuditReport {
39
60
  findings: AuditFinding[]
40
61
  counts: Record<Severity, number>
62
+ /** Number of raw events scanned. */
63
+ scanned: number
64
+ /** Number of rules evaluated. */
65
+ rules: number
66
+ /** Baseline/duplicate config events deliberately ignored. */
67
+ suppressed: number
41
68
  }
42
69
 
43
70
  function get(obj: unknown, key: string): unknown {
@@ -45,138 +72,280 @@ function get(obj: unknown, key: string): unknown {
45
72
  }
46
73
 
47
74
  function str(value: unknown): string {
48
- return typeof value === 'string' ? value : JSON.stringify(value) ?? ''
75
+ if (typeof value === 'string') return value
76
+ try {
77
+ return JSON.stringify(value) ?? ''
78
+ } catch {
79
+ return ''
80
+ }
81
+ }
82
+
83
+ function clipped(value: string, max = 500): string {
84
+ return redactText(value).text.slice(0, max)
85
+ }
86
+
87
+ function toolName(event: SessionEvent): string {
88
+ return str(get(event.data, 'name'))
89
+ }
90
+
91
+ function argsOf(event: SessionEvent): string {
92
+ return str(get(event.data, 'arguments'))
93
+ }
94
+
95
+ function parseArgs(raw: string): Record<string, unknown> | undefined {
96
+ try {
97
+ const value = JSON.parse(raw) as unknown
98
+ return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined
99
+ } catch {
100
+ return undefined
101
+ }
102
+ }
103
+
104
+ const PATH_KEYS = [
105
+ 'path',
106
+ 'file_path',
107
+ 'filePath',
108
+ 'target',
109
+ 'destination',
110
+ 'dest',
111
+ 'filename',
112
+ 'file',
113
+ 'name',
114
+ ] as const
115
+
116
+ function pathOf(raw: string): string | undefined {
117
+ const args = parseArgs(raw)
118
+ if (args === undefined) return undefined
119
+ for (const key of PATH_KEYS) {
120
+ const value = args[key]
121
+ if (typeof value === 'string' && value !== '' && /[\\/.]/u.test(value)) return value
122
+ }
123
+ return undefined
49
124
  }
50
125
 
51
- const SHELL_TOOLS = /^(bash|shell|exec|run_command|terminal|subprocess)/iu
52
- const WRITE_TOOLS = /^(write|edit|create_file|str_replace|apply_patch|notebook_edit|fs_write|mv|rm|delete)/iu
53
- const NETWORK_TOOLS = /^(fetch|web_fetch|http|curl|download|web_search|browser)/iu
54
-
55
- const DANGEROUS_SHELL = [
56
- { re: /\brm\s+(-[a-z]*[rf][a-z]*\s+)+/iu, why: 'recursive/forced delete', key: 'rm-rf' },
57
- { re: /\bsudo\b/u, why: 'privilege escalation', key: 'sudo' },
58
- { re: /\bchmod\s+([0-7]{3,4}|\+x)/u, why: 'permission change', key: 'chmod' },
59
- { re: /\b(curl|wget)\b[^|;&]*\|\s*(ba)?sh\b/iu, why: 'pipe remote script to shell', key: 'curl-pipe' },
60
- { re: /\bgit\s+push\s+.*--force/iu, why: 'force push', key: 'force-push' },
61
- { re: /\b(dd|mkfs|fdisk)\b/u, why: 'raw disk operation', key: 'disk' },
62
- { re: /(\bssh\b|\bscp\b|\brsync\b.*@)/u, why: 'remote host access', key: 'remote' },
63
- { re: /\b(export|echo)\b.*\b(API_?KEY|TOKEN|SECRET|PASSWORD)\b/iu, why: 'credential material in command', key: 'creds' },
126
+ const SHELL_TOOLS = /^(bash|shell|exec|run_command|terminal|subprocess|pwsh|powershell|cmd|run_code|code_execution)/iu
127
+ const WRITE_TOOLS = /^(write|write_file|edit|edit_file|create_file|str_replace|multi_edit|apply_patch|notebook_edit|fs_write|mv|move|rename|copy|rm|remove|delete|del|remove_item|rmdir|rd)/iu
128
+ const DESTRUCTIVE_TOOLS = /^(rm|remove|delete|del|remove_item|rmdir|rd|mv|move|rename)/iu
129
+ const READ_TOOLS = /^(read|read_file|view|open|cat|type|get-content|grep|search|glob|list|find|head|tail|less|more|strings|xxd)/iu
130
+ const NETWORK_TOOLS = /^(fetch|web_fetch|http|curl|download|web_search|browser|invoke-webrequest|iwr|invoke-restmethod|start-bitstransfer)/iu
131
+
132
+ interface ShellPattern {
133
+ re: RegExp
134
+ why: string
135
+ key: string
136
+ severity: Severity
137
+ }
138
+
139
+ /**
140
+ * Shell patterns are deliberately small and readable. Each one maps to a
141
+ * stable `why` key so the UI can localize the reason.
142
+ */
143
+ const DANGEROUS_SHELL: ShellPattern[] = [
144
+ // --- destructive deletes -------------------------------------------------
145
+ { re: /\brm\s+(-[a-z]*[rf][a-z]*\s+)+/iu, why: 'recursive/forced delete', key: 'rm-rf', severity: 'critical' },
146
+ { re: /\bremove-item\b[^\n]*(?:-recurse|-force)/iu, why: 'recursive/forced delete', key: 'rm-rf', severity: 'critical' },
147
+ { re: /\b(?:del|erase)\b[^\n]*\/[sq]/iu, why: 'forced delete', key: 'rm-rf', severity: 'critical' },
148
+ { re: /\b(?:rd|rmdir)\b[^\n]*\/s/iu, why: 'recursive directory delete', key: 'rm-rf', severity: 'critical' },
149
+ // --- privilege / system --------------------------------------------------
150
+ { re: /\bsudo\b/iu, why: 'privilege escalation', key: 'sudo', severity: 'critical' },
151
+ { re: /\b(?:takeown|icacls)\b[^\n]*(?:grant|\/grant|\/f|\/reset)/iu, why: 'ownership/permission change', key: 'chmod', severity: 'serious' },
152
+ { re: /\bchmod\s+([0-7]{3,4}|\+x)/u, why: 'permission change', key: 'chmod', severity: 'serious' },
153
+ { re: /\bset-executionpolicy\b/iu, why: 'script execution policy change', key: 'exec-policy', severity: 'serious' },
154
+ { re: /\b(?:bcdedit|vssadmin\s+delete|wevtutil\s+cl)\b/iu, why: 'boot/shadow/log tampering', key: 'system-tamper', severity: 'critical' },
155
+ { re: /\b(?:reg\s+delete|schtasks\b[^\n]*\/create|sc\s+create|net\s+user|net\s+localgroup)\b/iu, why: 'persistence or account change', key: 'persistence', severity: 'serious' },
156
+ // --- disk / remote code --------------------------------------------------
157
+ { re: /\b(?:dd|mkfs|fdisk|diskpart|format)\b/iu, why: 'raw disk operation', key: 'disk', severity: 'critical' },
158
+ { re: /\b(?:iwr|invoke-webrequest|curl|wget|start-bitstransfer)\b[^\n|;]*(?:\||;)\s*(?:iex|invoke-expression)\b/iu, why: 'pipe remote script to shell', key: 'curl-pipe', severity: 'critical' },
159
+ { re: /\b(?:downloadstring|downloadfile)\b/iu, why: 'download and execute', key: 'curl-pipe', severity: 'critical' },
160
+ { re: /\b(?:powershell|pwsh)\b[^\n]*-e(?:nc|ncodedcommand)?\s+[A-Za-z0-9+/=]{40,}/iu, why: 'encoded PowerShell command', key: 'encoded-shell', severity: 'critical' },
161
+ // --- credentials / exfiltration -----------------------------------------
162
+ { re: /\b(?:export|\$env:|\bset\b|\becho\b)[^\n]*(?:api[_-]?key|token|secret|password|passwd)/iu, why: 'credential material in command', key: 'creds', severity: 'serious' },
163
+ { re: /\b(?:get-content|type|cat)\b[^\n]*(?:\.env\b|id_rsa|\.ssh[\\/]|credentials)/iu, why: 'credential file read', key: 'creds', severity: 'serious' },
164
+ { re: /\b(?:curl|wget|iwr|invoke-webrequest|invoke-restmethod)\b[^\n]*(?:-d\b|--data|--upload-file|-f\b|\.env\b|secret|token|password)/iu, why: 'possible credential exfiltration', key: 'exfil', severity: 'critical' },
165
+ { re: /\b(?:ssh|scp|rsync)\b[^\n]*(?:@|\bremote\b)/iu, why: 'remote host access', key: 'remote', severity: 'warning' },
166
+ { re: /\b(?:invoke-command|enter-pssession|winrs)\b/iu, why: 'remote execution', key: 'remote', severity: 'serious' },
167
+ // --- supply chain / repo ------------------------------------------------
168
+ { re: /\bgit\s+push\s+[^\n]*--force/iu, why: 'force push', key: 'force-push', severity: 'serious' },
169
+ { re: /\b(?:npm|pnpm|yarn)\s+publish\b/iu, why: 'package publish', key: 'publish', severity: 'warning' },
170
+ { re: /\bgh\s+release\s+create\b/iu, why: 'release publish', key: 'publish', severity: 'warning' },
64
171
  ]
65
172
 
66
- const SENSITIVE_PATH = /(\.env\b|\.ssh\/|id_rsa|\.aws\/|credentials|\.npmrc|\.netrc|secrets?\.|\.pem\b)/iu
173
+ /** File paths that deserve a second look when read or written. */
174
+ const SENSITIVE_PATH = /(?:^|[\\/])(?:\.env(?:\.[\w-]+)?(?:[\\/]|$)|\.ssh(?:[\\/]|$)|id_rsa|id_ed25519|\.aws(?:[\\/]|$)|\.kube(?:[\\/]|$)|\.docker(?:[\\/]config\.json|$)|credentials|\.npmrc|\.netrc|secrets?\.|\.pem|\.pfx|\.p12|ntuser\.dat|\.git[\\/]config|\.config[\\/]gh[\\/]hosts\.yml)/iu
175
+
176
+ const NETWORK_RISK = /(?:--data|-d\s|--upload-file|-f\s|body\s*[:=]|\.env\b|secret|token|password|passwd|private[_-]?key|authorization|api[_-]?key)/iu
177
+
178
+ function messageText(message: unknown): string {
179
+ const content = get(message, 'content')
180
+ if (!Array.isArray(content)) return ''
181
+ return content
182
+ .map(block => (typeof get(block, 'text') === 'string' ? (get(block, 'text') as string) : ''))
183
+ .join('')
184
+ }
185
+
186
+ function findDanger(event: SessionEvent): ShellPattern | undefined {
187
+ if (event.type !== 'tool/call') return undefined
188
+ const name = toolName(event)
189
+ if (!SHELL_TOOLS.test(name)) return undefined
190
+ const args = argsOf(event)
191
+ return DANGEROUS_SHELL.find(pattern => pattern.re.test(args))
192
+ }
67
193
 
68
194
  export const DEFAULT_RULES: AuditRule[] = [
69
195
  {
70
196
  id: 'shell-exec',
71
- severity: event => {
72
- const args = str(get(event.data, 'arguments'))
73
- return DANGEROUS_SHELL.some(p => p.re.test(args)) ? 'critical' : 'warning'
74
- },
197
+ category: 'shell',
198
+ severity: event => findDanger(event)?.severity ?? 'warning',
75
199
  match: event => {
76
200
  if (event.type !== 'tool/call') return undefined
77
- const name = str(get(event.data, 'name'))
201
+ const name = toolName(event)
78
202
  if (!SHELL_TOOLS.test(name)) return undefined
79
- const args = str(get(event.data, 'arguments'))
80
- const hit = DANGEROUS_SHELL.find(p => p.re.test(args))
203
+ const args = argsOf(event)
204
+ const hit = DANGEROUS_SHELL.find(pattern => pattern.re.test(args))
205
+ if (hit === undefined) return undefined // ordinary shell is activity, not a finding
81
206
  return {
82
- title: hit ? `shell: ${hit.why}` : `shell command via ${name}`,
83
- detail: args.slice(0, 400),
84
- messageKey: hit ? 'f.shell.dangerous' : 'f.shell.plain',
85
- params: (hit ? { whyKey: hit.key, why: hit.why } : { name }) as Record<string, string>,
207
+ title: `shell: ${hit.why}`,
208
+ detail: clipped(args),
209
+ messageKey: 'f.shell.dangerous',
210
+ params: { whyKey: hit.key, why: hit.why, name },
86
211
  }
87
212
  },
88
213
  },
89
214
  {
90
215
  id: 'file-write',
91
- severity: event =>
92
- SENSITIVE_PATH.test(str(get(event.data, 'arguments'))) ? 'critical' : 'info',
216
+ category: 'file',
217
+ severity: event => {
218
+ const args = argsOf(event)
219
+ const path = pathOf(args) ?? args
220
+ return SENSITIVE_PATH.test(path) || containsSecret(args) ? 'critical' : 'warning'
221
+ },
93
222
  match: event => {
94
223
  if (event.type !== 'tool/call') return undefined
95
- const name = str(get(event.data, 'name'))
224
+ const name = toolName(event)
96
225
  if (!WRITE_TOOLS.test(name)) return undefined
97
- const args = str(get(event.data, 'arguments'))
98
- const sensitive = SENSITIVE_PATH.test(args)
226
+ const args = argsOf(event)
227
+ const path = pathOf(args) ?? args
228
+ const sensitive = SENSITIVE_PATH.test(path)
229
+ const secret = containsSecret(args)
230
+ if (!sensitive && !secret && !DESTRUCTIVE_TOOLS.test(name)) return undefined
231
+ const reason = sensitive
232
+ ? 'file write touches a sensitive path'
233
+ : secret
234
+ ? 'credential material written to a file'
235
+ : `destructive file operation via ${name}`
99
236
  return {
100
- title: sensitive ? `file write touches sensitive path` : `file mutation via ${name}`,
101
- detail: args.slice(0, 400),
102
- messageKey: sensitive ? 'f.file.sensitive' : 'f.file.write',
103
- params: { name },
237
+ title: reason,
238
+ detail: clipped(args),
239
+ messageKey: sensitive ? 'f.file.sensitive' : secret ? 'f.file.secret' : 'f.file.destructive',
240
+ params: { name, ...(path !== args ? { path } : {}) },
104
241
  }
105
242
  },
106
243
  },
107
244
  {
108
245
  id: 'sensitive-read',
246
+ category: 'file',
109
247
  severity: 'serious',
110
248
  match: event => {
111
249
  if (event.type !== 'tool/call') return undefined
112
- const args = str(get(event.data, 'arguments'))
113
- const name = str(get(event.data, 'name'))
114
- if (WRITE_TOOLS.test(name) || SHELL_TOOLS.test(name)) return undefined
115
- if (!SENSITIVE_PATH.test(args)) return undefined
250
+ const name = toolName(event)
251
+ if (WRITE_TOOLS.test(name) || SHELL_TOOLS.test(name) || NETWORK_TOOLS.test(name)) return undefined
252
+ if (!READ_TOOLS.test(name)) return undefined
253
+ const args = argsOf(event)
254
+ const path = pathOf(args) ?? args
255
+ if (!SENSITIVE_PATH.test(path)) return undefined
116
256
  return {
117
257
  title: `access to sensitive path via ${name}`,
118
- detail: args.slice(0, 400),
258
+ detail: clipped(args),
119
259
  messageKey: 'f.read.sensitive',
120
- params: { name },
260
+ params: { name, ...(path !== args ? { path } : {}) },
121
261
  }
122
262
  },
123
263
  },
124
264
  {
125
265
  id: 'network',
126
- severity: 'info',
266
+ category: 'network',
267
+ severity: event => {
268
+ const args = argsOf(event)
269
+ return containsSecret(args) || SENSITIVE_PATH.test(args) ? 'critical' : 'warning'
270
+ },
127
271
  match: event => {
128
272
  if (event.type !== 'tool/call') return undefined
129
- const name = str(get(event.data, 'name'))
273
+ const name = toolName(event)
130
274
  if (!NETWORK_TOOLS.test(name)) return undefined
275
+ const args = argsOf(event)
276
+ if (!NETWORK_RISK.test(args)) return undefined // ordinary fetches are activity
131
277
  return {
132
- title: `network access via ${name}`,
133
- detail: str(get(event.data, 'arguments')).slice(0, 400),
278
+ title: `network request may carry sensitive data via ${name}`,
279
+ detail: clipped(args),
134
280
  messageKey: 'f.network',
135
281
  params: { name },
136
282
  }
137
283
  },
138
284
  },
285
+ {
286
+ id: 'secret-output',
287
+ category: 'secret',
288
+ severity: 'critical',
289
+ match: event => {
290
+ if (event.type !== 'tool/result') return undefined
291
+ const message = get(event.data, 'message')
292
+ const text = messageText(message) || str(message) || str(get(event.data, 'error'))
293
+ if (text === '' || !containsSecret(text)) return undefined
294
+ return {
295
+ title: 'tool output contains credential material',
296
+ detail: clipped(text, 300),
297
+ messageKey: 'f.output.secret',
298
+ }
299
+ },
300
+ },
139
301
  {
140
302
  id: 'approval-denied',
303
+ category: 'approval',
141
304
  severity: 'warning',
142
305
  match: event => {
143
306
  if (event.type !== 'approval/decided') return undefined
144
307
  const outcome = str(get(event.data, 'outcome') ?? get(event.data, 'decision'))
145
- if (!/den|reject|refus/iu.test(outcome)) return undefined
146
- return { title: 'user denied an approval', detail: outcome, messageKey: 'f.approval.denied' }
308
+ if (!/deny|denied|reject|refus/iu.test(outcome)) return undefined
309
+ return { title: 'user denied an approval', detail: clipped(outcome), messageKey: 'f.approval.denied' }
147
310
  },
148
311
  },
149
312
  {
150
313
  id: 'sandbox-change',
151
- severity: 'serious',
314
+ category: 'config',
315
+ severity: event => {
316
+ const mode = str(get(event.data, 'mode'))
317
+ return /danger|full/iu.test(mode) ? 'critical' : 'serious'
318
+ },
152
319
  match: event =>
153
320
  event.type === 'sandbox/mode'
154
- ? { title: 'sandbox mode changed', detail: JSON.stringify(event.data), messageKey: 'f.sandbox.change' }
321
+ ? {
322
+ title: `sandbox mode changed to ${str(get(event.data, 'mode')) || 'unknown'}`,
323
+ detail: clipped(JSON.stringify(event.data)),
324
+ messageKey: 'f.sandbox.change',
325
+ params: { mode: str(get(event.data, 'mode')) },
326
+ }
155
327
  : undefined,
156
328
  },
157
329
  {
158
330
  id: 'permission-change',
159
- severity: 'serious',
331
+ category: 'config',
332
+ severity: event => {
333
+ const preset = str(get(event.data, 'preset'))
334
+ return /danger|full/iu.test(preset) ? 'critical' : 'serious'
335
+ },
160
336
  match: event =>
161
337
  event.type === 'permission/preset'
162
- ? { title: 'permission preset changed', detail: JSON.stringify(event.data), messageKey: 'f.permission.change' }
163
- : undefined,
164
- },
165
- {
166
- id: 'subagent',
167
- severity: 'info',
168
- match: event =>
169
- event.type === 'subagent/descriptor'
170
338
  ? {
171
- title: `subagent spawned (${str(get(event.data, 'mode'))})`,
172
- detail: str(get(event.data, 'persona') ?? get(event.data, 'label') ?? ''),
173
- messageKey: 'f.subagent',
174
- params: { mode: str(get(event.data, 'mode')) },
339
+ title: `permission preset changed to ${str(get(event.data, 'preset')) || 'unknown'}`,
340
+ detail: clipped(JSON.stringify(event.data)),
341
+ messageKey: 'f.permission.change',
342
+ params: { preset: str(get(event.data, 'preset')) },
175
343
  }
176
344
  : undefined,
177
345
  },
178
346
  {
179
347
  id: 'tool-error',
348
+ category: 'error',
180
349
  severity: 'warning',
181
350
  match: event => {
182
351
  if (event.type !== 'tool/result') return undefined
@@ -184,19 +353,20 @@ export const DEFAULT_RULES: AuditRule[] = [
184
353
  if (error === undefined) return undefined
185
354
  return {
186
355
  title: `tool failed: ${str(get(error, 'name'))}`,
187
- detail: str(get(error, 'code')),
356
+ detail: clipped(str(get(error, 'code'))),
188
357
  messageKey: 'f.tool.error',
189
- params: { name: str(get(error, 'name')) },
358
+ params: { name: str(get(error, 'name')), code: str(get(error, 'code')) },
190
359
  }
191
360
  },
192
361
  },
193
362
  {
194
363
  id: 'turn-error',
364
+ category: 'error',
195
365
  severity: 'warning',
196
366
  match: event => {
197
367
  if (event.type !== 'turn/end') return undefined
198
368
  const kind = str(get(get(event.data, 'reason'), 'kind'))
199
- if (kind === 'completed' || kind === '') return undefined
369
+ if (kind === '' || kind === 'completed' || kind === 'aborted') return undefined
200
370
  return { title: `turn ended abnormally: ${kind}`, messageKey: 'f.turn.error', params: { kind } }
201
371
  },
202
372
  },
@@ -204,17 +374,54 @@ export const DEFAULT_RULES: AuditRule[] = [
204
374
 
205
375
  const SEVERITY_ORDER: Severity[] = ['critical', 'serious', 'warning', 'info']
206
376
 
377
+ const CONFIG_RULES = new Set(['sandbox-change', 'permission-change'])
378
+
207
379
  export function auditSession(
208
380
  session: ParsedSession,
209
381
  rules: AuditRule[] = DEFAULT_RULES,
210
382
  ): AuditReport {
383
+ // The first permission/sandbox event is the session baseline, not a change.
384
+ // A later event only matters when its value differs from the previous one.
385
+ const lastConfig = new Map<string, string>()
386
+ const changedConfig = new Set<number>()
387
+ let suppressed = 0
388
+ for (const event of session.events) {
389
+ if (
390
+ event.type !== 'permission/preset' &&
391
+ event.type !== 'sandbox/mode' &&
392
+ event.type !== 'approval/policy'
393
+ ) {
394
+ continue
395
+ }
396
+ const value = JSON.stringify(event.data)
397
+ if (!lastConfig.has(event.type)) {
398
+ lastConfig.set(event.type, value)
399
+ suppressed++
400
+ continue
401
+ }
402
+ if (lastConfig.get(event.type) === value) {
403
+ suppressed++
404
+ continue
405
+ }
406
+ lastConfig.set(event.type, value)
407
+ changedConfig.add(event.seq)
408
+ }
409
+
211
410
  const findings: AuditFinding[] = []
212
411
  for (const event of session.events) {
213
412
  for (const rule of rules) {
413
+ if (CONFIG_RULES.has(rule.id) && !changedConfig.has(event.seq)) continue
214
414
  const hit = rule.match(event)
215
415
  if (hit === undefined) continue
216
416
  const severity = typeof rule.severity === 'function' ? rule.severity(event) : rule.severity
217
- findings.push({ ruleId: rule.id, severity, seq: event.seq, time: event.time, ...hit })
417
+ findings.push({
418
+ ruleId: rule.id,
419
+ severity,
420
+ category: hit.category ?? rule.category,
421
+ seq: event.seq,
422
+ time: event.time,
423
+ ...hit,
424
+ })
218
425
  }
219
426
  }
220
427
  findings.sort(
@@ -223,5 +430,5 @@ export function auditSession(
223
430
  )
224
431
  const counts: Record<Severity, number> = { critical: 0, serious: 0, warning: 0, info: 0 }
225
432
  for (const finding of findings) counts[finding.severity]++
226
- return { findings, counts }
433
+ return { findings, counts, scanned: session.events.length, rules: rules.length, suppressed }
227
434
  }
Binary file
package/src/core/cost.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Cost estimation from token totals. Prices are USD per 1M tokens and are
3
- * ESTIMATES — provider list prices change; override the table if you need
4
- * exact numbers. Cache-write falls back to the input rate when a provider
5
- * doesn't price it separately.
3
+ * ESTIMATES — provider list prices change; the UI lets you override them.
4
+ * Cache-write falls back to the input rate when a provider doesn't price it
5
+ * separately.
6
6
  */
7
7
  import type { TokenTotals } from './timeline.js'
8
8
 
@@ -21,15 +21,48 @@ export interface PricingEntry {
21
21
  match: RegExp
22
22
  rate: PricingRate
23
23
  label: string
24
+ /** ISO date the default rates were last checked. */
25
+ updatedAt?: string
26
+ source?: string
24
27
  }
25
28
 
26
29
  /** Editable defaults — first match wins, most specific first. */
27
30
  export const DEFAULT_PRICING: PricingEntry[] = [
28
- { match: /deepseek.*pro/iu, rate: { input: 1.1, cacheRead: 0.27, output: 4.4 }, label: 'DeepSeek Pro' },
29
- { match: /deepseek/iu, rate: { input: 0.27, cacheRead: 0.07, output: 1.1 }, label: 'DeepSeek' },
30
- { match: /claude|anthropic/iu, rate: { input: 3, cacheRead: 0.3, cacheWrite: 3.75, output: 15 }, label: 'Claude' },
31
- { match: /gpt|^o\d|openai|codex/iu, rate: { input: 2.5, cacheRead: 1.25, output: 10 }, label: 'OpenAI' },
32
- { match: /gemini/iu, rate: { input: 1.25, cacheRead: 0.31, output: 10 }, label: 'Gemini' },
31
+ {
32
+ match: /deepseek.*pro/iu,
33
+ rate: { input: 1.1, cacheRead: 0.27, output: 4.4 },
34
+ label: 'DeepSeek Pro',
35
+ updatedAt: '2026-08-18',
36
+ source: 'provider list price (estimate)',
37
+ },
38
+ {
39
+ match: /deepseek/iu,
40
+ rate: { input: 0.27, cacheRead: 0.07, output: 1.1 },
41
+ label: 'DeepSeek',
42
+ updatedAt: '2026-08-18',
43
+ source: 'provider list price (estimate)',
44
+ },
45
+ {
46
+ match: /claude|anthropic/iu,
47
+ rate: { input: 3, cacheRead: 0.3, cacheWrite: 3.75, output: 15 },
48
+ label: 'Claude',
49
+ updatedAt: '2026-08-18',
50
+ source: 'provider list price (estimate)',
51
+ },
52
+ {
53
+ match: /gpt|^o\d|openai|codex/iu,
54
+ rate: { input: 2.5, cacheRead: 1.25, output: 10 },
55
+ label: 'OpenAI',
56
+ updatedAt: '2026-08-18',
57
+ source: 'provider list price (estimate)',
58
+ },
59
+ {
60
+ match: /gemini/iu,
61
+ rate: { input: 1.25, cacheRead: 0.31, output: 10 },
62
+ label: 'Gemini',
63
+ updatedAt: '2026-08-18',
64
+ source: 'provider list price (estimate)',
65
+ },
33
66
  ]
34
67
 
35
68
  export interface CostEstimate {
@@ -37,6 +70,25 @@ export interface CostEstimate {
37
70
  label: string
38
71
  }
39
72
 
73
+ export interface CostPart extends CostEstimate {
74
+ model: string
75
+ totals: TokenTotals
76
+ }
77
+
78
+ export interface CostEstimateDetailed extends CostEstimate {
79
+ parts: CostPart[]
80
+ }
81
+
82
+ function costOf(totals: TokenTotals, rate: PricingRate): number {
83
+ return (
84
+ (totals.input * rate.input +
85
+ totals.cacheRead * rate.cacheRead +
86
+ totals.cacheWrite * (rate.cacheWrite ?? rate.input) +
87
+ totals.output * rate.output) /
88
+ 1_000_000
89
+ )
90
+ }
91
+
40
92
  export function estimateCost(
41
93
  totals: TokenTotals,
42
94
  models: string[],
@@ -44,14 +96,28 @@ export function estimateCost(
44
96
  ): CostEstimate | undefined {
45
97
  const entry = pricing.find(p => models.some(m => p.match.test(m)))
46
98
  if (entry === undefined) return undefined
47
- const { rate } = entry
48
- const usd =
49
- (totals.input * rate.input +
50
- totals.cacheRead * rate.cacheRead +
51
- totals.cacheWrite * (rate.cacheWrite ?? rate.input) +
52
- totals.output * rate.output) /
53
- 1_000_000
54
- return { usd, label: entry.label }
99
+ return { usd: costOf(totals, entry.rate), label: entry.label }
100
+ }
101
+
102
+ /**
103
+ * Per-model cost. A session can switch models mid-run; charging every token at
104
+ * the first matching rate over- or under-estimates the bill.
105
+ */
106
+ export function estimateCostDetailed(
107
+ modelTotals: Record<string, TokenTotals>,
108
+ pricing: PricingEntry[] = DEFAULT_PRICING,
109
+ ): CostEstimateDetailed | undefined {
110
+ if (modelTotals === undefined || modelTotals === null) return undefined
111
+ const parts: CostPart[] = []
112
+ for (const [model, totals] of Object.entries(modelTotals)) {
113
+ const entry = pricing.find(p => p.match.test(model))
114
+ if (entry === undefined) continue
115
+ parts.push({ model, label: entry.label, usd: costOf(totals, entry.rate), totals })
116
+ }
117
+ if (parts.length === 0) return undefined
118
+ const usd = parts.reduce((sum, part) => sum + part.usd, 0)
119
+ const labels = [...new Set(parts.map(part => part.label))]
120
+ return { usd, label: labels.length === 1 ? labels[0] : `Mixed (${labels.length} models)`, parts }
55
121
  }
56
122
 
57
123
  export function formatUsd(usd: number): string {
@@ -59,3 +125,15 @@ export function formatUsd(usd: number): string {
59
125
  if (usd >= 0.01) return `$${usd.toFixed(3)}`
60
126
  return `$${usd.toFixed(4)}`
61
127
  }
128
+
129
+ /** Approximate CNY amount at a caller-supplied exchange rate. */
130
+ export function formatCny(usd: number, rate = 7.2): string {
131
+ const cny = usd * rate
132
+ if (cny >= 1) return `¥${cny.toFixed(2)}`
133
+ if (cny >= 0.01) return `¥${cny.toFixed(3)}`
134
+ return `¥${cny.toFixed(4)}`
135
+ }
136
+
137
+ export function formatMoney(usd: number, currency: 'USD' | 'CNY', rate = 7.2): string {
138
+ return currency === 'CNY' ? formatCny(usd, rate) : formatUsd(usd)
139
+ }
package/src/core/index.ts CHANGED
@@ -5,3 +5,4 @@ export * from './audit.js'
5
5
  export * from './forktree.js'
6
6
  export * from './compare.js'
7
7
  export * from './cost.js'
8
+ export * from './redact.js'