noctrace 0.4.2 → 0.5.0
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/dist/client/assets/{index-t0_dKSyq.js → index-CfD3iTQS.js} +22 -2
- package/dist/client/index.html +1 -1
- package/dist/server/server/routes/api.js +12 -4
- package/dist/server/server/watcher.js +3 -0
- package/dist/server/shared/health.js +2 -0
- package/dist/server/shared/parser.js +30 -0
- package/dist/server/shared/security-tips.js +315 -0
- package/dist/server/shared/tips.js +25 -0
- package/package.json +1 -1
package/dist/client/index.html
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet" />
|
|
10
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
+
<script type="module" crossorigin src="/assets/index-CfD3iTQS.js"></script>
|
|
11
11
|
<link rel="stylesheet" crossorigin href="/assets/index-nE1IMGzA.css">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
@@ -6,10 +6,11 @@ import { Router } from 'express';
|
|
|
6
6
|
import fs from 'node:fs/promises';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { WebSocket } from 'ws';
|
|
9
|
-
import { parseJsonlContent, parseCompactionBoundaries, extractSessionId, extractAgentIds, parseSubAgentContent, } from '../../shared/parser.js';
|
|
9
|
+
import { parseJsonlContent, parseCompactionBoundaries, extractSessionId, extractSessionTitle, extractAgentIds, parseSubAgentContent, } from '../../shared/parser.js';
|
|
10
10
|
import { computeContextHealth } from '../../shared/health.js';
|
|
11
11
|
import { parseAssistantTurns, computeDrift } from '../../shared/drift.js';
|
|
12
12
|
import { attachEfficiencyTips } from '../../shared/tips.js';
|
|
13
|
+
import { attachSecurityTips } from '../../shared/security-tips.js';
|
|
13
14
|
/**
|
|
14
15
|
* Read ~/.claude/sessions/*.json and return a Set of sessionIds
|
|
15
16
|
* whose PID is still a running claude process.
|
|
@@ -193,9 +194,11 @@ export function buildApiRouter(claudeHome, wss) {
|
|
|
193
194
|
let permissionMode = null;
|
|
194
195
|
let isRemoteControlled = false;
|
|
195
196
|
let isActive = false;
|
|
197
|
+
let sessionTitle = null;
|
|
198
|
+
let fileContent = null;
|
|
196
199
|
try {
|
|
197
|
-
|
|
198
|
-
const lines =
|
|
200
|
+
fileContent = await fs.readFile(filePath, 'utf8');
|
|
201
|
+
const lines = fileContent.split('\n');
|
|
199
202
|
// Extract metadata from lines (scan first 50 for speed)
|
|
200
203
|
const scanLimit = Math.min(lines.length, 50);
|
|
201
204
|
for (let i = 0; i < scanLimit; i++) {
|
|
@@ -226,13 +229,15 @@ export function buildApiRouter(claudeHome, wss) {
|
|
|
226
229
|
// Active if: live process in registry OR file modified within last 2 minutes
|
|
227
230
|
// Registry covers CLI sessions; mtime covers Desktop app sessions
|
|
228
231
|
isActive = runningSessions.has(id) || (Date.now() - stat.mtime.getTime() < 120_000);
|
|
232
|
+
// Extract optional session title — scans all lines, best-effort
|
|
233
|
+
sessionTitle = extractSessionTitle(fileContent);
|
|
229
234
|
}
|
|
230
235
|
catch {
|
|
231
236
|
// Unreadable file — still include with null startTime
|
|
232
237
|
}
|
|
233
238
|
let driftFactor = null;
|
|
234
239
|
try {
|
|
235
|
-
const content = await fs.readFile(filePath, 'utf8');
|
|
240
|
+
const content = fileContent ?? await fs.readFile(filePath, 'utf8');
|
|
236
241
|
const sessionTurns = parseAssistantTurns(content);
|
|
237
242
|
const sessionDrift = computeDrift(sessionTurns);
|
|
238
243
|
driftFactor = sessionTurns.length >= 5 ? sessionDrift.driftFactor : null;
|
|
@@ -251,6 +256,7 @@ export function buildApiRouter(claudeHome, wss) {
|
|
|
251
256
|
permissionMode,
|
|
252
257
|
isRemoteControlled,
|
|
253
258
|
driftFactor,
|
|
259
|
+
title: sessionTitle,
|
|
254
260
|
});
|
|
255
261
|
}
|
|
256
262
|
// Sort by lastModified descending (most recent first)
|
|
@@ -349,6 +355,8 @@ export function buildApiRouter(claudeHome, wss) {
|
|
|
349
355
|
}
|
|
350
356
|
// Attach efficiency tips to wasteful rows (mutates rows in place)
|
|
351
357
|
attachEfficiencyTips(rows, boundaries);
|
|
358
|
+
// Attach security tips (mutates rows in place)
|
|
359
|
+
attachSecurityTips(rows);
|
|
352
360
|
// Count total tips across all rows (including children) for the client toolbar
|
|
353
361
|
function countTips(r) {
|
|
354
362
|
return r.reduce((sum, row) => sum + row.tips.length + countTips(row.children), 0);
|
|
@@ -8,6 +8,7 @@ import { parseJsonlContent, parseCompactionBoundaries } from '../shared/parser.j
|
|
|
8
8
|
import { computeContextHealth } from '../shared/health.js';
|
|
9
9
|
import { parseAssistantTurns, computeDrift } from '../shared/drift.js';
|
|
10
10
|
import { attachEfficiencyTips } from '../shared/tips.js';
|
|
11
|
+
import { attachSecurityTips } from '../shared/security-tips.js';
|
|
11
12
|
/**
|
|
12
13
|
* Watch a single JSONL session file for appended content.
|
|
13
14
|
* On each file change, reads only the newly appended bytes, parses them into
|
|
@@ -67,6 +68,8 @@ export function watchSession(filePath, callbacks) {
|
|
|
67
68
|
const drift = computeDrift(turns);
|
|
68
69
|
// Attach efficiency tips to wasteful rows (mutates allRows in place)
|
|
69
70
|
attachEfficiencyTips(allRows, boundaries);
|
|
71
|
+
// Attach security tips (mutates allRows in place)
|
|
72
|
+
attachSecurityTips(allRows);
|
|
70
73
|
callbacks.onNewRows(allRows, health, boundaries, drift);
|
|
71
74
|
}
|
|
72
75
|
catch (err) {
|
|
@@ -206,6 +206,7 @@ export function computeContextHealth(rows, compactionCount) {
|
|
|
206
206
|
score: 100,
|
|
207
207
|
fillPercent: 0,
|
|
208
208
|
compactionCount: 0,
|
|
209
|
+
compactionThrash: false,
|
|
209
210
|
rereadRatio: 0,
|
|
210
211
|
errorAcceleration: 1,
|
|
211
212
|
toolEfficiency: 1,
|
|
@@ -230,6 +231,7 @@ export function computeContextHealth(rows, compactionCount) {
|
|
|
230
231
|
score: Math.round(composite),
|
|
231
232
|
fillPercent,
|
|
232
233
|
compactionCount,
|
|
234
|
+
compactionThrash: compactionCount >= 3,
|
|
233
235
|
rereadRatio,
|
|
234
236
|
errorAcceleration,
|
|
235
237
|
toolEfficiency,
|
|
@@ -432,6 +432,36 @@ export function extractSessionId(content) {
|
|
|
432
432
|
}
|
|
433
433
|
return null;
|
|
434
434
|
}
|
|
435
|
+
/**
|
|
436
|
+
* Extract a human-readable session title from JSONL content.
|
|
437
|
+
* Checks top-level fields `sessionTitle`, `title`, and `displayName` on any record,
|
|
438
|
+
* and also inspects system records for a `title` field.
|
|
439
|
+
* Returns null if no title is found.
|
|
440
|
+
*/
|
|
441
|
+
export function extractSessionTitle(content) {
|
|
442
|
+
const lines = content.split('\n');
|
|
443
|
+
for (let i = 0; i < lines.length; i++) {
|
|
444
|
+
const line = lines[i].trim();
|
|
445
|
+
if (!line)
|
|
446
|
+
continue;
|
|
447
|
+
let parsed;
|
|
448
|
+
try {
|
|
449
|
+
parsed = JSON.parse(line);
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (!isObj(parsed))
|
|
455
|
+
continue;
|
|
456
|
+
// Check top-level title fields in priority order
|
|
457
|
+
for (const field of ['sessionTitle', 'title', 'displayName']) {
|
|
458
|
+
const val = parsed[field];
|
|
459
|
+
if (typeof val === 'string' && val.trim())
|
|
460
|
+
return val.trim();
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
435
465
|
/**
|
|
436
466
|
* Parse a sub-agent JSONL file into flat WaterfallRow objects.
|
|
437
467
|
* Does not attempt agent nesting — all tool calls are returned as a flat array.
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Pre-compiled regex constants
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
/** Matches common API key / secret token patterns. */
|
|
5
|
+
const SECRET_PATTERNS = [
|
|
6
|
+
/AKIA[0-9A-Z]{16}/,
|
|
7
|
+
/sk-[a-zA-Z0-9]{20,}/,
|
|
8
|
+
/sk-ant-[a-zA-Z0-9-]{20,}/,
|
|
9
|
+
/ghp_[a-zA-Z0-9]{36}/,
|
|
10
|
+
/npm_[a-zA-Z0-9]{36}/,
|
|
11
|
+
/xoxb-[0-9]{10,}/,
|
|
12
|
+
/-----BEGIN .{0,30}PRIVATE KEY-----/,
|
|
13
|
+
];
|
|
14
|
+
/** curl/wget piped directly to a shell interpreter. */
|
|
15
|
+
const CURL_PIPE_BASH = /curl[^|]*\|[^|]*(bash|sh|zsh|python|node)\b|wget[^|]*\|[^|]*(bash|sh)\b|eval\s*\$\(curl/;
|
|
16
|
+
/** Outbound data transfer via curl/wget/nc. */
|
|
17
|
+
const DATA_EXFIL = /curl[^&\n]*(-d\s|--data|--data-raw|-F\s)|cat[^|]*\|[^|]*(curl|wget|nc)\b|base64[^|]*\|[^|]*(curl|wget)\b/;
|
|
18
|
+
/** localhost / loopback exclusion for data exfiltration. */
|
|
19
|
+
const LOCALHOST_PATTERN = /localhost|127\.0\.0\.1/;
|
|
20
|
+
/** Write/Edit to shell profile files. */
|
|
21
|
+
const SHELL_PROFILE = /\.(bashrc|zshrc|bash_profile|zprofile|profile)$/;
|
|
22
|
+
/** Zero-width and bidirectional Unicode control characters. */
|
|
23
|
+
const HIDDEN_UNICODE = /[\u200B\u200C\u200D\u2060\uFEFF\u202A-\u202E]/;
|
|
24
|
+
/** Individual prompt-injection keyword patterns. */
|
|
25
|
+
const INJECTION_KEYWORDS = [
|
|
26
|
+
/ignore.*previous.*instructions/i,
|
|
27
|
+
/disregard.*above/i,
|
|
28
|
+
/you are now/i,
|
|
29
|
+
/new instructions:/i,
|
|
30
|
+
/system prompt/i,
|
|
31
|
+
];
|
|
32
|
+
/** Destructive shell / SQL commands. */
|
|
33
|
+
const DESTRUCTIVE_CMD = /rm\s+.*-.*r.*-.*f|rm\s+-rf|DROP\s+(TABLE|DATABASE)|TRUNCATE\s+TABLE|DELETE\s+FROM\s+\w+\s*;/i;
|
|
34
|
+
/** git push --force (but NOT --force-with-lease). */
|
|
35
|
+
const FORCE_PUSH = /git\s+push\s+.*--force(?!-with-lease)|git\s+push\s+.*-f\b/;
|
|
36
|
+
/** Sensitive credential / config file paths. */
|
|
37
|
+
const SENSITIVE_FILE = /\.env($|\.)|\.pem$|\.key$|id_rsa|id_ed25519|\.ssh\/|credentials\.json|\.aws\/|\.npmrc|\.docker\/config|kubeconfig/;
|
|
38
|
+
/** Overly permissive chmod. */
|
|
39
|
+
const PERMISSION_WEAKENING = /chmod\s+(777|666)|chmod\s+-R\s+7/;
|
|
40
|
+
/** sudo invocation at the start of a command. */
|
|
41
|
+
const SUDO_CMD = /^sudo\s/;
|
|
42
|
+
/** Binary file downloaded from the internet. */
|
|
43
|
+
const BINARY_DOWNLOAD = /curl[^&\n]*-o[^&\n]*\.(sh|bin|exe)|wget[^&\n]*\.(sh|bin|exe|deb|rpm)/;
|
|
44
|
+
/** Tool names that operate on files (Write / Edit / MultiEdit). */
|
|
45
|
+
const FILE_WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
|
|
46
|
+
/** Maximum number of output characters to scan (performance guard). */
|
|
47
|
+
const OUTPUT_SCAN_LIMIT = 3000;
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Internal helpers
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
/**
|
|
52
|
+
* Append a tip to a row, skipping duplicates by tip id.
|
|
53
|
+
*/
|
|
54
|
+
function addTip(row, tip) {
|
|
55
|
+
if (!row.tips.some((t) => t.id === tip.id)) {
|
|
56
|
+
row.tips.push(tip);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Return the first OUTPUT_SCAN_LIMIT characters of a string, or '' if null.
|
|
61
|
+
*/
|
|
62
|
+
function truncatedOutput(output) {
|
|
63
|
+
if (output === null)
|
|
64
|
+
return '';
|
|
65
|
+
return output.length > OUTPUT_SCAN_LIMIT ? output.slice(0, OUTPUT_SCAN_LIMIT) : output;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Return true if any SECRET_PATTERNS regex matches the given text.
|
|
69
|
+
*/
|
|
70
|
+
function containsSecret(text) {
|
|
71
|
+
return SECRET_PATTERNS.some((re) => re.test(text));
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Return true if the output text appears to contain prompt-injection patterns.
|
|
75
|
+
* Requires 2+ distinct keyword matches within 200 characters of each other.
|
|
76
|
+
* Skips Markdown documentation files (too many false positives).
|
|
77
|
+
*/
|
|
78
|
+
function hasPossibleInjection(output, filePath) {
|
|
79
|
+
if (filePath !== null && filePath.endsWith('.md'))
|
|
80
|
+
return false;
|
|
81
|
+
const matches = [];
|
|
82
|
+
for (const re of INJECTION_KEYWORDS) {
|
|
83
|
+
const m = re.exec(output);
|
|
84
|
+
if (m !== null)
|
|
85
|
+
matches.push(m.index);
|
|
86
|
+
}
|
|
87
|
+
if (matches.length < 2)
|
|
88
|
+
return false;
|
|
89
|
+
// Check whether any two match positions are within 200 chars of each other.
|
|
90
|
+
const sorted = matches.slice().sort((a, b) => a - b);
|
|
91
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
92
|
+
if (sorted[i + 1] - sorted[i] <= 200)
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Flatten rows and all their children into a single ordered array.
|
|
99
|
+
*/
|
|
100
|
+
function flattenRows(rows) {
|
|
101
|
+
const result = [];
|
|
102
|
+
for (const row of rows) {
|
|
103
|
+
result.push(row);
|
|
104
|
+
if (row.children.length > 0) {
|
|
105
|
+
result.push(...flattenRows(row.children));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Public API
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
/**
|
|
114
|
+
* Scan rows for security-relevant patterns and attach tips. Mutates rows in place.
|
|
115
|
+
*
|
|
116
|
+
* Detection rules (CRITICAL):
|
|
117
|
+
* 1. secrets-in-output — credential patterns in tool output
|
|
118
|
+
* 2. secrets-in-command — credential patterns in Bash input.command
|
|
119
|
+
* 3. curl-pipe-bash — remote code execution via pipe
|
|
120
|
+
* 4. data-exfiltration — outbound data transfer (excluding localhost)
|
|
121
|
+
* 5. shell-profile-mod — Write/Edit to .bashrc / .zshrc etc.
|
|
122
|
+
* 6. hidden-unicode — zero-width / bidirectional chars in output
|
|
123
|
+
* 7. prompt-injection — instruction-like text in tool output
|
|
124
|
+
*
|
|
125
|
+
* Detection rules (WARNING):
|
|
126
|
+
* 8. destructive-command — rm -rf, DROP TABLE, DELETE without WHERE
|
|
127
|
+
* 9. force-push — git push --force (not --force-with-lease)
|
|
128
|
+
* 10. sensitive-file — .env, .pem, .key, .ssh/, AWS/npm credentials
|
|
129
|
+
* 11. permission-weakening — chmod 777/666
|
|
130
|
+
* 12. sudo-usage — sudo at the start of a command
|
|
131
|
+
*
|
|
132
|
+
* Detection rules (INFO):
|
|
133
|
+
* 13. binary-download — curl/wget downloading .sh / .bin / .exe
|
|
134
|
+
*
|
|
135
|
+
* @param rows Top-level WaterfallRow[] (may include agent rows with children).
|
|
136
|
+
*/
|
|
137
|
+
export function attachSecurityTips(rows) {
|
|
138
|
+
const flat = flattenRows(rows);
|
|
139
|
+
for (const row of flat) {
|
|
140
|
+
if (row.type !== 'tool')
|
|
141
|
+
continue;
|
|
142
|
+
const output = truncatedOutput(row.output);
|
|
143
|
+
const command = row.toolName === 'Bash' && typeof row.input['command'] === 'string'
|
|
144
|
+
? row.input['command']
|
|
145
|
+
: null;
|
|
146
|
+
const filePath = FILE_WRITE_TOOLS.has(row.toolName) && typeof row.input['file_path'] === 'string'
|
|
147
|
+
? row.input['file_path']
|
|
148
|
+
: null;
|
|
149
|
+
const readFilePath = row.toolName === 'Read' && typeof row.input['file_path'] === 'string'
|
|
150
|
+
? row.input['file_path']
|
|
151
|
+
: null;
|
|
152
|
+
const anyFilePath = filePath ?? readFilePath;
|
|
153
|
+
// ------------------------------------------------------------------
|
|
154
|
+
// 1. secrets-in-output (CRITICAL)
|
|
155
|
+
// ------------------------------------------------------------------
|
|
156
|
+
if (output && containsSecret(output)) {
|
|
157
|
+
addTip(row, {
|
|
158
|
+
id: 'secrets-in-output',
|
|
159
|
+
title: 'Secret detected in output',
|
|
160
|
+
message: 'An API key or secret token appeared in this tool output. It is now in your session log on disk. ' +
|
|
161
|
+
'Rotate the credential and add the source file to .gitignore.',
|
|
162
|
+
severity: 'critical',
|
|
163
|
+
category: 'security',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
// ------------------------------------------------------------------
|
|
167
|
+
// 2. secrets-in-command (CRITICAL)
|
|
168
|
+
// ------------------------------------------------------------------
|
|
169
|
+
if (command !== null && containsSecret(command)) {
|
|
170
|
+
addTip(row, {
|
|
171
|
+
id: 'secrets-in-command',
|
|
172
|
+
title: 'Secret in command',
|
|
173
|
+
message: 'A credential appears to be hardcoded in this shell command. Use environment variables instead.',
|
|
174
|
+
severity: 'critical',
|
|
175
|
+
category: 'security',
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
// ------------------------------------------------------------------
|
|
179
|
+
// 3. curl-pipe-bash (CRITICAL)
|
|
180
|
+
// ------------------------------------------------------------------
|
|
181
|
+
if (command !== null && CURL_PIPE_BASH.test(command)) {
|
|
182
|
+
addTip(row, {
|
|
183
|
+
id: 'curl-pipe-bash',
|
|
184
|
+
title: 'Remote code execution',
|
|
185
|
+
message: 'Downloading and piping directly to a shell interpreter. This bypasses security checks. ' +
|
|
186
|
+
'Review the URL and consider using a package manager.',
|
|
187
|
+
severity: 'critical',
|
|
188
|
+
category: 'security',
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
// ------------------------------------------------------------------
|
|
192
|
+
// 4. data-exfiltration (CRITICAL)
|
|
193
|
+
// ------------------------------------------------------------------
|
|
194
|
+
if (command !== null && DATA_EXFIL.test(command) && !LOCALHOST_PATTERN.test(command)) {
|
|
195
|
+
addTip(row, {
|
|
196
|
+
id: 'data-exfiltration',
|
|
197
|
+
title: 'Outbound data transfer',
|
|
198
|
+
message: 'Data is being sent to an external URL. Verify the destination and confirm no sensitive data is being transmitted.',
|
|
199
|
+
severity: 'critical',
|
|
200
|
+
category: 'security',
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
// ------------------------------------------------------------------
|
|
204
|
+
// 5. shell-profile-mod (CRITICAL)
|
|
205
|
+
// ------------------------------------------------------------------
|
|
206
|
+
if (filePath !== null && SHELL_PROFILE.test(filePath)) {
|
|
207
|
+
addTip(row, {
|
|
208
|
+
id: 'shell-profile-mod',
|
|
209
|
+
title: 'Shell profile modified',
|
|
210
|
+
message: 'Changes to shell profiles persist across all future terminal sessions. Review what was added.',
|
|
211
|
+
severity: 'critical',
|
|
212
|
+
category: 'security',
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
// ------------------------------------------------------------------
|
|
216
|
+
// 6. hidden-unicode (CRITICAL)
|
|
217
|
+
// ------------------------------------------------------------------
|
|
218
|
+
if (output && HIDDEN_UNICODE.test(output)) {
|
|
219
|
+
addTip(row, {
|
|
220
|
+
id: 'hidden-unicode',
|
|
221
|
+
title: 'Hidden Unicode characters',
|
|
222
|
+
message: 'Invisible control characters detected in tool output. These can conceal instructions that ' +
|
|
223
|
+
'Claude processes but humans cannot see.',
|
|
224
|
+
severity: 'critical',
|
|
225
|
+
category: 'security',
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
// ------------------------------------------------------------------
|
|
229
|
+
// 7. prompt-injection (CRITICAL)
|
|
230
|
+
// ------------------------------------------------------------------
|
|
231
|
+
if (output && hasPossibleInjection(output, anyFilePath)) {
|
|
232
|
+
addTip(row, {
|
|
233
|
+
id: 'prompt-injection',
|
|
234
|
+
title: 'Possible prompt injection',
|
|
235
|
+
message: 'Tool output contains instruction-like text that could manipulate Claude. This may indicate ' +
|
|
236
|
+
'adversarial content in project files or dependencies.',
|
|
237
|
+
severity: 'critical',
|
|
238
|
+
category: 'security',
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
// ------------------------------------------------------------------
|
|
242
|
+
// 8. destructive-command (WARNING)
|
|
243
|
+
// ------------------------------------------------------------------
|
|
244
|
+
if (command !== null && DESTRUCTIVE_CMD.test(command)) {
|
|
245
|
+
addTip(row, {
|
|
246
|
+
id: 'destructive-command',
|
|
247
|
+
title: 'Destructive command',
|
|
248
|
+
message: 'This command can cause irreversible data loss. Verify the target is correct.',
|
|
249
|
+
severity: 'warning',
|
|
250
|
+
category: 'security',
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
// ------------------------------------------------------------------
|
|
254
|
+
// 9. force-push (WARNING)
|
|
255
|
+
// ------------------------------------------------------------------
|
|
256
|
+
if (command !== null && FORCE_PUSH.test(command)) {
|
|
257
|
+
addTip(row, {
|
|
258
|
+
id: 'force-push',
|
|
259
|
+
title: 'Force push',
|
|
260
|
+
message: 'Force push rewrites remote history. Use --force-with-lease for safer pushes, or verify this is intentional.',
|
|
261
|
+
severity: 'warning',
|
|
262
|
+
category: 'security',
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
// ------------------------------------------------------------------
|
|
266
|
+
// 10. sensitive-file (WARNING) — Read, Write, Edit, MultiEdit
|
|
267
|
+
// ------------------------------------------------------------------
|
|
268
|
+
const sensitiveFilePath = typeof row.input['file_path'] === 'string' ? row.input['file_path'] : null;
|
|
269
|
+
if (sensitiveFilePath !== null && SENSITIVE_FILE.test(sensitiveFilePath)) {
|
|
270
|
+
addTip(row, {
|
|
271
|
+
id: 'sensitive-file',
|
|
272
|
+
title: 'Sensitive file accessed',
|
|
273
|
+
message: 'Credential file contents are now in the session log (~/.claude/projects/). Consider whether this access was necessary.',
|
|
274
|
+
severity: 'warning',
|
|
275
|
+
category: 'security',
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
// ------------------------------------------------------------------
|
|
279
|
+
// 11. permission-weakening (WARNING)
|
|
280
|
+
// ------------------------------------------------------------------
|
|
281
|
+
if (command !== null && PERMISSION_WEAKENING.test(command)) {
|
|
282
|
+
addTip(row, {
|
|
283
|
+
id: 'permission-weakening',
|
|
284
|
+
title: 'Overly permissive permissions',
|
|
285
|
+
message: 'World-writable permissions (777/666). Use 755 for directories, 644 for files.',
|
|
286
|
+
severity: 'warning',
|
|
287
|
+
category: 'security',
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
// ------------------------------------------------------------------
|
|
291
|
+
// 12. sudo-usage (WARNING)
|
|
292
|
+
// ------------------------------------------------------------------
|
|
293
|
+
if (command !== null && SUDO_CMD.test(command)) {
|
|
294
|
+
addTip(row, {
|
|
295
|
+
id: 'sudo-usage',
|
|
296
|
+
title: 'Root privilege escalation',
|
|
297
|
+
message: 'Claude should not need sudo for development tasks. Consider a non-root approach.',
|
|
298
|
+
severity: 'warning',
|
|
299
|
+
category: 'security',
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
// ------------------------------------------------------------------
|
|
303
|
+
// 13. binary-download (INFO)
|
|
304
|
+
// ------------------------------------------------------------------
|
|
305
|
+
if (command !== null && BINARY_DOWNLOAD.test(command)) {
|
|
306
|
+
addTip(row, {
|
|
307
|
+
id: 'binary-download',
|
|
308
|
+
title: 'Binary download',
|
|
309
|
+
message: 'Binary downloaded from the internet. Verify the source URL and consider using a package manager with signature verification.',
|
|
310
|
+
severity: 'info',
|
|
311
|
+
category: 'security',
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
@@ -43,6 +43,7 @@ function flattenRows(rows) {
|
|
|
43
43
|
* 6. High context fill — first row where contextFillPercent >= 80
|
|
44
44
|
* 7. No delegation — 50+ total tool rows with zero agent rows
|
|
45
45
|
* 8. Post-compaction re-read — isReread after a compaction boundary
|
|
46
|
+
* 9. Compaction thrash — 3+ compaction boundaries (thrash loop)
|
|
46
47
|
*
|
|
47
48
|
* A row may accumulate multiple tips. Duplicate tip ids on the same row are silently skipped.
|
|
48
49
|
*
|
|
@@ -206,4 +207,28 @@ export function attachEfficiencyTips(rows, compactionBoundaries) {
|
|
|
206
207
|
severity: 'info',
|
|
207
208
|
});
|
|
208
209
|
}
|
|
210
|
+
// -------------------------------------------------------------------------
|
|
211
|
+
// Rule 9: Compaction thrash (post-scan)
|
|
212
|
+
// Triggered when the session has been compacted 3+ times.
|
|
213
|
+
// Attach the tip to the row nearest (at or after) the 3rd compaction boundary.
|
|
214
|
+
// -------------------------------------------------------------------------
|
|
215
|
+
if (sortedBoundaries.length >= 3) {
|
|
216
|
+
const thirdBoundary = sortedBoundaries[2];
|
|
217
|
+
// Find the first tool row whose startTime is at or after the 3rd boundary.
|
|
218
|
+
let targetRow = flat.find((r) => r.type === 'tool' && r.startTime >= thirdBoundary);
|
|
219
|
+
// If no row comes after (session ended right at compaction), use the last tool row.
|
|
220
|
+
if (targetRow === undefined && toolRows.length > 0) {
|
|
221
|
+
targetRow = toolRows[toolRows.length - 1];
|
|
222
|
+
}
|
|
223
|
+
if (targetRow !== undefined) {
|
|
224
|
+
addTip(targetRow, {
|
|
225
|
+
id: 'compaction-thrash',
|
|
226
|
+
title: 'Compaction thrash loop',
|
|
227
|
+
message: 'This session has been compacted 3+ times, meaning context fills up immediately after each ' +
|
|
228
|
+
'compaction. Start a new session with /clear, or break your work into smaller tasks. ' +
|
|
229
|
+
'Add persistent context to CLAUDE.md so it survives compaction.',
|
|
230
|
+
severity: 'critical',
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
209
234
|
}
|