@mingozhou/dsh-replay 0.4.1 → 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.
- package/README.md +225 -148
- package/README.zh.md +225 -154
- package/assets/icon.png +0 -0
- package/lib/client.js +954 -2418
- package/lib/core.js +557 -86
- package/lib/index.js +1043 -51
- package/lib/types/src/client/api.d.ts +44 -0
- package/lib/types/src/client/components/AuditView.d.ts +2 -1
- package/lib/types/src/client/components/CompareView.d.ts +2 -1
- package/lib/types/src/client/components/DashboardView.d.ts +8 -0
- package/lib/types/src/client/components/ExportDialog.d.ts +9 -0
- package/lib/types/src/client/components/ReplayApp.d.ts +4 -2
- package/lib/types/src/client/components/ReplayModal.d.ts +1 -1
- package/lib/types/src/client/components/SessionPicker.d.ts +4 -3
- package/lib/types/src/client/components/TimelineView.d.ts +2 -1
- package/lib/types/src/client/export.d.ts +6 -1
- package/lib/types/src/client/i18n.d.ts +8 -1
- package/lib/types/src/client/pricing.d.ts +6 -0
- package/lib/types/src/core/audit.d.ts +18 -0
- package/lib/types/src/core/compare.d.ts +8 -3
- package/lib/types/src/core/cost.d.ts +21 -3
- package/lib/types/src/core/index.d.ts +1 -0
- package/lib/types/src/core/redact.d.ts +40 -0
- package/lib/types/src/core/timeline.d.ts +9 -0
- package/lib/types/src/index.d.ts +52 -1
- package/lib/viewer.js +282 -29
- package/package.json +118 -107
- package/scripts/build.mjs +7 -1
- package/src/client/api.ts +114 -10
- package/src/client/components/AuditView.tsx +89 -10
- package/src/client/components/CompareView.tsx +89 -13
- package/src/client/components/DashboardView.tsx +232 -0
- package/src/client/components/ExportDialog.tsx +136 -0
- package/src/client/components/ItemDetail.tsx +36 -4
- package/src/client/components/OverviewView.tsx +150 -34
- package/src/client/components/ReplayApp.tsx +207 -60
- package/src/client/components/ReplayModal.tsx +41 -6
- package/src/client/components/SessionPicker.tsx +55 -27
- package/src/client/components/TimelineView.tsx +106 -41
- package/src/client/export.ts +20 -4
- package/src/client/i18n.ts +266 -45
- package/src/client/index.ts +7 -5
- package/src/client/mascot-image.ts +1 -1
- package/src/client/pricing.ts +90 -0
- package/src/client/styles.css +250 -1
- package/src/client/viewer-entry.tsx +25 -4
- package/src/core/audit.ts +274 -67
- package/src/core/compare.ts +0 -0
- package/src/core/cost.ts +94 -16
- package/src/core/index.ts +1 -0
- package/src/core/redact.ts +182 -0
- package/src/core/timeline.ts +107 -12
- 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
|
-
|
|
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
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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 =
|
|
201
|
+
const name = toolName(event)
|
|
78
202
|
if (!SHELL_TOOLS.test(name)) return undefined
|
|
79
|
-
const args =
|
|
80
|
-
const hit = DANGEROUS_SHELL.find(
|
|
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:
|
|
83
|
-
detail: args
|
|
84
|
-
messageKey:
|
|
85
|
-
params:
|
|
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
|
-
|
|
92
|
-
|
|
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 =
|
|
224
|
+
const name = toolName(event)
|
|
96
225
|
if (!WRITE_TOOLS.test(name)) return undefined
|
|
97
|
-
const args =
|
|
98
|
-
const
|
|
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:
|
|
101
|
-
detail: args
|
|
102
|
-
messageKey: sensitive ? 'f.file.sensitive' : 'f.file.
|
|
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
|
|
113
|
-
|
|
114
|
-
if (
|
|
115
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
133
|
-
detail:
|
|
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 (!/
|
|
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
|
-
|
|
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
|
-
? {
|
|
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
|
-
|
|
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: `
|
|
172
|
-
detail:
|
|
173
|
-
messageKey: 'f.
|
|
174
|
-
params: {
|
|
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({
|
|
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
|
}
|
package/src/core/compare.ts
CHANGED
|
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;
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
{
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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