@oked/scan 0.0.0-stage → 0.6.25
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/LICENSE +201 -0
- package/README.md +146 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +132 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/read.d.ts +29 -0
- package/dist/read.js +370 -0
- package/dist/redact.d.ts +20 -0
- package/dist/redact.js +69 -0
- package/dist/report.d.ts +84 -0
- package/dist/report.js +439 -0
- package/dist/rules.d.ts +23 -0
- package/dist/rules.js +63 -0
- package/package.json +59 -4
package/dist/read.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { createReadStream, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { argvToShell } from "./redact.js";
|
|
5
|
+
import { SHELL_TOOL_NAMES } from "./rules.js";
|
|
6
|
+
const CODEX_LINE_TYPES = new Set(["session_meta", "response_item", "turn_context", "event_msg"]);
|
|
7
|
+
const CLAUDE_LINE_TYPES = new Set([
|
|
8
|
+
"assistant",
|
|
9
|
+
"user",
|
|
10
|
+
"summary",
|
|
11
|
+
"system",
|
|
12
|
+
"attachment",
|
|
13
|
+
"progress",
|
|
14
|
+
"file-history-snapshot",
|
|
15
|
+
]);
|
|
16
|
+
function isDir(path) {
|
|
17
|
+
try {
|
|
18
|
+
return statSync(path).isDirectory();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function isFile(path) {
|
|
25
|
+
try {
|
|
26
|
+
return statSync(path).isFile();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function walkFiles(dir, accept) {
|
|
33
|
+
const out = [];
|
|
34
|
+
const stack = [dir];
|
|
35
|
+
while (stack.length > 0) {
|
|
36
|
+
const current = stack.pop();
|
|
37
|
+
if (!current)
|
|
38
|
+
break;
|
|
39
|
+
let entries;
|
|
40
|
+
try {
|
|
41
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
if (entry.isSymbolicLink())
|
|
48
|
+
continue;
|
|
49
|
+
const path = join(current, entry.name);
|
|
50
|
+
if (entry.isDirectory())
|
|
51
|
+
stack.push(path);
|
|
52
|
+
else if (entry.isFile() && accept(entry.name))
|
|
53
|
+
out.push(path);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
function parseTime(value) {
|
|
59
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
60
|
+
return value < 1e12 ? value * 1000 : value;
|
|
61
|
+
if (typeof value === "string" && value.trim()) {
|
|
62
|
+
const parsed = Date.parse(value);
|
|
63
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
function timestampString(value) {
|
|
68
|
+
const ms = parseTime(value);
|
|
69
|
+
if (ms === null)
|
|
70
|
+
return null;
|
|
71
|
+
return new Date(ms).toISOString();
|
|
72
|
+
}
|
|
73
|
+
function inWindow(timestamp, sinceMs, nowMs) {
|
|
74
|
+
if (sinceMs === null)
|
|
75
|
+
return true;
|
|
76
|
+
if (timestamp === null)
|
|
77
|
+
return true;
|
|
78
|
+
const ms = Date.parse(timestamp);
|
|
79
|
+
if (!Number.isFinite(ms))
|
|
80
|
+
return true;
|
|
81
|
+
return ms >= nowMs - sinceMs;
|
|
82
|
+
}
|
|
83
|
+
async function readJsonl(file, onRecord) {
|
|
84
|
+
const stream = createReadStream(file, { encoding: "utf8" });
|
|
85
|
+
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
86
|
+
let bad = 0;
|
|
87
|
+
let lineNo = 0;
|
|
88
|
+
try {
|
|
89
|
+
for await (const line of lines) {
|
|
90
|
+
lineNo += 1;
|
|
91
|
+
const trimmed = line.replace(/^\uFEFF/, "").trim();
|
|
92
|
+
if (!trimmed)
|
|
93
|
+
continue;
|
|
94
|
+
try {
|
|
95
|
+
onRecord(JSON.parse(trimmed), lineNo);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
bad += 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
lines.close();
|
|
104
|
+
}
|
|
105
|
+
return bad;
|
|
106
|
+
}
|
|
107
|
+
function asObject(value) {
|
|
108
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
109
|
+
return null;
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
function parseJsonObject(value) {
|
|
113
|
+
if (typeof value === "string") {
|
|
114
|
+
const trimmed = value.trim();
|
|
115
|
+
if (!trimmed.startsWith("{"))
|
|
116
|
+
return null;
|
|
117
|
+
try {
|
|
118
|
+
return asObject(JSON.parse(trimmed));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return asObject(value);
|
|
125
|
+
}
|
|
126
|
+
function commandFromFields(record) {
|
|
127
|
+
const raw = record.command ?? record.cmd;
|
|
128
|
+
if (typeof raw === "string" && raw.trim())
|
|
129
|
+
return raw;
|
|
130
|
+
if (Array.isArray(raw) && raw.length > 0 && raw.every((part) => typeof part === "string")) {
|
|
131
|
+
const command = argvToShell(raw);
|
|
132
|
+
return command.trim() ? command : null;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
function locate(root) {
|
|
137
|
+
const claudeDir = join(root, ".claude", "projects");
|
|
138
|
+
const codexDir = join(root, ".codex", "sessions");
|
|
139
|
+
const claudePresent = isDir(claudeDir);
|
|
140
|
+
const codexPresent = isDir(codexDir);
|
|
141
|
+
if (claudePresent || codexPresent) {
|
|
142
|
+
return { claudeDir, codexDir, claudePresent, codexPresent, sniff: false };
|
|
143
|
+
}
|
|
144
|
+
if (basename(root) === "projects" && basename(dirname(root)) === ".claude" && isDir(root)) {
|
|
145
|
+
return { claudeDir: root, codexDir, claudePresent: true, codexPresent: false, sniff: false };
|
|
146
|
+
}
|
|
147
|
+
if (basename(root) === "sessions" && isDir(root)) {
|
|
148
|
+
return { claudeDir, codexDir: root, claudePresent: false, codexPresent: true, sniff: false };
|
|
149
|
+
}
|
|
150
|
+
return { claudeDir, codexDir, claudePresent: false, codexPresent: false, sniff: true };
|
|
151
|
+
}
|
|
152
|
+
function hostFromText(text) {
|
|
153
|
+
const lines = text.split(/\n/).slice(0, 40);
|
|
154
|
+
for (const line of lines) {
|
|
155
|
+
const trimmed = line.trim();
|
|
156
|
+
if (!trimmed)
|
|
157
|
+
continue;
|
|
158
|
+
try {
|
|
159
|
+
const value = asObject(JSON.parse(trimmed));
|
|
160
|
+
const type = typeof value?.type === "string" ? value.type : "";
|
|
161
|
+
if (CODEX_LINE_TYPES.has(type))
|
|
162
|
+
return "codex";
|
|
163
|
+
if (CLAUDE_LINE_TYPES.has(type))
|
|
164
|
+
return "claude-code";
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// Keep scanning. A bad first line does not decide the file.
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
async function sniffHostAsync(file) {
|
|
173
|
+
const stream = createReadStream(file, { encoding: "utf8", start: 0, end: 65535 });
|
|
174
|
+
let text = "";
|
|
175
|
+
try {
|
|
176
|
+
for await (const chunk of stream)
|
|
177
|
+
text += chunk;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
return hostFromText(text);
|
|
183
|
+
}
|
|
184
|
+
function contentBlocks(message) {
|
|
185
|
+
const content = message.content;
|
|
186
|
+
if (Array.isArray(content)) {
|
|
187
|
+
return content.filter((block) => block && typeof block === "object");
|
|
188
|
+
}
|
|
189
|
+
const one = asObject(content);
|
|
190
|
+
return one ? [one] : [];
|
|
191
|
+
}
|
|
192
|
+
function claudeCommandsFrom(value, file, lineNo) {
|
|
193
|
+
const entry = asObject(value);
|
|
194
|
+
if (!entry)
|
|
195
|
+
return [];
|
|
196
|
+
const type = typeof entry.type === "string" ? entry.type : "";
|
|
197
|
+
const message = asObject(entry.message);
|
|
198
|
+
const role = typeof message?.role === "string" ? message.role : "";
|
|
199
|
+
if (type !== "assistant" && role !== "assistant" && type !== "tool_use")
|
|
200
|
+
return [];
|
|
201
|
+
const timestamp = timestampString(entry.timestamp);
|
|
202
|
+
const blocks = [];
|
|
203
|
+
if (type === "tool_use")
|
|
204
|
+
blocks.push(entry);
|
|
205
|
+
if (message)
|
|
206
|
+
blocks.push(...contentBlocks(message));
|
|
207
|
+
const found = [];
|
|
208
|
+
for (const block of blocks) {
|
|
209
|
+
if (block.type !== "tool_use")
|
|
210
|
+
continue;
|
|
211
|
+
if (block.name !== "Bash")
|
|
212
|
+
continue;
|
|
213
|
+
const input = parseJsonObject(block.input) ?? {};
|
|
214
|
+
const command = commandFromFields(input);
|
|
215
|
+
if (!command)
|
|
216
|
+
continue;
|
|
217
|
+
const toolId = typeof block.id === "string" && block.id ? block.id : `${file}:${lineNo}:${found.length}`;
|
|
218
|
+
found.push({
|
|
219
|
+
host: "claude-code",
|
|
220
|
+
command,
|
|
221
|
+
timestamp,
|
|
222
|
+
id: `claude:${toolId}`,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return found;
|
|
226
|
+
}
|
|
227
|
+
function codexCommandFrom(value, file, lineNo) {
|
|
228
|
+
const entry = asObject(value);
|
|
229
|
+
if (!entry)
|
|
230
|
+
return null;
|
|
231
|
+
const payload = asObject(entry.payload) ?? entry;
|
|
232
|
+
const payloadType = typeof payload.type === "string" ? payload.type : "";
|
|
233
|
+
const lineType = typeof entry.type === "string" ? entry.type : "";
|
|
234
|
+
let name = "";
|
|
235
|
+
let fields = null;
|
|
236
|
+
let callId = "";
|
|
237
|
+
if (lineType === "response_item" && payloadType === "function_call") {
|
|
238
|
+
name = typeof payload.name === "string" ? payload.name : "";
|
|
239
|
+
fields = parseJsonObject(payload.arguments);
|
|
240
|
+
callId = typeof payload.call_id === "string" ? payload.call_id : "";
|
|
241
|
+
}
|
|
242
|
+
else if (lineType === "response_item" && payloadType === "local_shell_call") {
|
|
243
|
+
name = "local_shell";
|
|
244
|
+
const action = asObject(payload.action);
|
|
245
|
+
fields = action;
|
|
246
|
+
callId = typeof payload.call_id === "string" ? payload.call_id : "";
|
|
247
|
+
}
|
|
248
|
+
else if (lineType === "event_msg" && (payloadType === "exec_command_begin" || payloadType === "exec_start")) {
|
|
249
|
+
name = "exec_command";
|
|
250
|
+
fields = payload;
|
|
251
|
+
callId = typeof payload.call_id === "string" ? payload.call_id : "";
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
if (!SHELL_TOOL_NAMES.has(name) || !fields)
|
|
257
|
+
return null;
|
|
258
|
+
const command = commandFromFields(fields);
|
|
259
|
+
if (!command)
|
|
260
|
+
return null;
|
|
261
|
+
const timestamp = timestampString(entry.timestamp ?? payload.timestamp);
|
|
262
|
+
const id = callId ? `codex:${callId}` : `codex:${file}:${lineNo}`;
|
|
263
|
+
return { host: "codex", command, timestamp, id };
|
|
264
|
+
}
|
|
265
|
+
function pushUnique(target, seen, command, sinceMs, nowMs) {
|
|
266
|
+
if (!inWindow(command.timestamp, sinceMs, nowMs))
|
|
267
|
+
return;
|
|
268
|
+
if (seen.has(command.id))
|
|
269
|
+
return;
|
|
270
|
+
seen.add(command.id);
|
|
271
|
+
target.push(command);
|
|
272
|
+
}
|
|
273
|
+
async function readClaudeFile(file, target, seen, sinceMs, nowMs) {
|
|
274
|
+
const pending = [];
|
|
275
|
+
const bad = await readJsonl(file, (value, lineNo) => {
|
|
276
|
+
for (const command of claudeCommandsFrom(value, file, lineNo))
|
|
277
|
+
pending.push(command);
|
|
278
|
+
});
|
|
279
|
+
for (const command of pending)
|
|
280
|
+
pushUnique(target, seen, command, sinceMs, nowMs);
|
|
281
|
+
return { bad };
|
|
282
|
+
}
|
|
283
|
+
async function readCodexFile(file, target, seen, sinceMs, nowMs) {
|
|
284
|
+
const pending = [];
|
|
285
|
+
const bad = await readJsonl(file, (value, lineNo) => {
|
|
286
|
+
const command = codexCommandFrom(value, file, lineNo);
|
|
287
|
+
if (command)
|
|
288
|
+
pending.push(command);
|
|
289
|
+
});
|
|
290
|
+
for (const command of pending)
|
|
291
|
+
pushUnique(target, seen, command, sinceMs, nowMs);
|
|
292
|
+
return { bad };
|
|
293
|
+
}
|
|
294
|
+
export async function collectCommands(options) {
|
|
295
|
+
const located = locate(options.root);
|
|
296
|
+
const seen = new Set();
|
|
297
|
+
const commands = [];
|
|
298
|
+
let badLines = 0;
|
|
299
|
+
let unreadableFiles = 0;
|
|
300
|
+
let claudeFiles = 0;
|
|
301
|
+
let codexFiles = 0;
|
|
302
|
+
const take = async (file, host) => {
|
|
303
|
+
const before = commands.length;
|
|
304
|
+
try {
|
|
305
|
+
const result = host === "claude-code"
|
|
306
|
+
? await readClaudeFile(file, commands, seen, options.sinceMs, options.nowMs)
|
|
307
|
+
: await readCodexFile(file, commands, seen, options.sinceMs, options.nowMs);
|
|
308
|
+
badLines += result.bad;
|
|
309
|
+
if (host === "claude-code")
|
|
310
|
+
claudeFiles += 1;
|
|
311
|
+
else
|
|
312
|
+
codexFiles += 1;
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
commands.length = before;
|
|
316
|
+
unreadableFiles += 1;
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
if (located.claudePresent) {
|
|
320
|
+
for (const file of walkFiles(located.claudeDir, (name) => name.endsWith(".jsonl"))) {
|
|
321
|
+
await take(file, "claude-code");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (located.codexPresent) {
|
|
325
|
+
for (const file of walkFiles(located.codexDir, (name) => name.startsWith("rollout-") && name.endsWith(".jsonl"))) {
|
|
326
|
+
await take(file, "codex");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (located.sniff && options.explicitRoot) {
|
|
330
|
+
const files = isFile(options.root)
|
|
331
|
+
? options.root.endsWith(".jsonl")
|
|
332
|
+
? [options.root]
|
|
333
|
+
: []
|
|
334
|
+
: isDir(options.root)
|
|
335
|
+
? walkFiles(options.root, (name) => name.endsWith(".jsonl"))
|
|
336
|
+
: [];
|
|
337
|
+
for (const file of files) {
|
|
338
|
+
const host = await sniffHostAsync(file);
|
|
339
|
+
if (host)
|
|
340
|
+
await take(file, host);
|
|
341
|
+
}
|
|
342
|
+
if (files.length > 0) {
|
|
343
|
+
if (claudeFiles > 0)
|
|
344
|
+
located.claudePresent = true;
|
|
345
|
+
if (codexFiles > 0)
|
|
346
|
+
located.codexPresent = true;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const claudeCommands = commands.filter((command) => command.host === "claude-code").length;
|
|
350
|
+
const codexCommands = commands.filter((command) => command.host === "codex").length;
|
|
351
|
+
return {
|
|
352
|
+
commands,
|
|
353
|
+
badLines,
|
|
354
|
+
unreadableFiles,
|
|
355
|
+
hosts: {
|
|
356
|
+
"claude-code": {
|
|
357
|
+
directory: located.claudePresent && located.sniff ? options.root : located.claudeDir,
|
|
358
|
+
present: located.claudePresent,
|
|
359
|
+
files: claudeFiles,
|
|
360
|
+
commands: claudeCommands,
|
|
361
|
+
},
|
|
362
|
+
codex: {
|
|
363
|
+
directory: located.codexPresent && located.sniff ? options.root : located.codexDir,
|
|
364
|
+
present: located.codexPresent,
|
|
365
|
+
files: codexFiles,
|
|
366
|
+
commands: codexCommands,
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
}
|
package/dist/redact.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Display length for one command in the report. */
|
|
2
|
+
export declare const COMMAND_DISPLAY_MAX = 120;
|
|
3
|
+
/**
|
|
4
|
+
* Replace secrets-looking tokens with `[redacted]`.
|
|
5
|
+
* The command text is kept so the report still shows what would have paused.
|
|
6
|
+
*/
|
|
7
|
+
export declare function redactSecrets(command: string): string;
|
|
8
|
+
/** Show a home directory as `~` so a pasted report does not include the account name. */
|
|
9
|
+
export declare function replaceHome(command: string, home?: string): string;
|
|
10
|
+
export declare function displayPath(path: string, home?: string): string;
|
|
11
|
+
/** Redact, then truncate. Redaction runs on the full string so a cut cannot leave a token prefix. */
|
|
12
|
+
export declare function displayCommand(command: string, home?: string): string;
|
|
13
|
+
/** Quote one argv element the way a shell would, so `classify()` sees the same words. */
|
|
14
|
+
export declare function quoteArg(value: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* Turn a Codex argv into one shell string.
|
|
17
|
+
* A single element is already the command (it may contain spaces).
|
|
18
|
+
* Several elements are argv: `["bash","-lc","rm -rf /var"]` becomes `bash -lc 'rm -rf /var'`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function argvToShell(argv: readonly string[]): string;
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
/** Display length for one command in the report. */
|
|
3
|
+
export const COMMAND_DISPLAY_MAX = 120;
|
|
4
|
+
const TOKEN_PATTERNS = [
|
|
5
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}/g,
|
|
6
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}/g,
|
|
7
|
+
/\bglpat-[A-Za-z0-9_-]{20,}/g,
|
|
8
|
+
/\bnpm_[A-Za-z0-9]{20,}/g,
|
|
9
|
+
/\bsk-ant-[A-Za-z0-9_-]{10,}/g,
|
|
10
|
+
/\bsk-[A-Za-z0-9]{20,}/g,
|
|
11
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
12
|
+
/\bASIA[0-9A-Z]{16}\b/g,
|
|
13
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}/g,
|
|
14
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
|
|
15
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
16
|
+
];
|
|
17
|
+
/**
|
|
18
|
+
* Replace secrets-looking tokens with `[redacted]`.
|
|
19
|
+
* The command text is kept so the report still shows what would have paused.
|
|
20
|
+
*/
|
|
21
|
+
export function redactSecrets(command) {
|
|
22
|
+
let out = command;
|
|
23
|
+
out = out.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]");
|
|
24
|
+
// Greedy userinfo so a password that itself contains @ is still covered.
|
|
25
|
+
// `https://example.com/a@b` has no userinfo: the host cannot include a slash.
|
|
26
|
+
out = out.replace(/([a-z][a-z0-9+.-]*:\/\/)([^/\s]+)@/gi, "$1[redacted]@");
|
|
27
|
+
for (const pattern of TOKEN_PATTERNS)
|
|
28
|
+
out = out.replace(pattern, "[redacted]");
|
|
29
|
+
out = out.replace(/(^|[\s;|&])((?:[A-Za-z_][A-Za-z0-9_]*?)(?:key|token|secret|password|passwd|credential)[A-Za-z0-9_]*)=(?:"[^"]*"|'[^']*'|[^\s]+)/gi, "$1$2=[redacted]");
|
|
30
|
+
out = out.replace(/(^|\s)(--(?:password|token|api-key|api_key|secret|auth|authorization))(?:=|\s+)(?:"[^"]*"|'[^']*'|[^\s]+)/gi, "$1$2 [redacted]");
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/** Show a home directory as `~` so a pasted report does not include the account name. */
|
|
34
|
+
export function replaceHome(command, home = homedir()) {
|
|
35
|
+
if (!home || home === "/" || !command.includes(home))
|
|
36
|
+
return command;
|
|
37
|
+
return command.split(home).join("~");
|
|
38
|
+
}
|
|
39
|
+
export function displayPath(path, home = homedir()) {
|
|
40
|
+
if (!home || home === "/" || !path.startsWith(home))
|
|
41
|
+
return path;
|
|
42
|
+
const rest = path.slice(home.length);
|
|
43
|
+
if (!rest)
|
|
44
|
+
return "~";
|
|
45
|
+
return rest.startsWith("/") ? `~${rest}` : `~/${rest}`;
|
|
46
|
+
}
|
|
47
|
+
/** Redact, then truncate. Redaction runs on the full string so a cut cannot leave a token prefix. */
|
|
48
|
+
export function displayCommand(command, home = homedir()) {
|
|
49
|
+
const redacted = redactSecrets(replaceHome(command, home));
|
|
50
|
+
if (redacted.length <= COMMAND_DISPLAY_MAX)
|
|
51
|
+
return redacted;
|
|
52
|
+
return `${redacted.slice(0, COMMAND_DISPLAY_MAX - 3)}...`;
|
|
53
|
+
}
|
|
54
|
+
/** Quote one argv element the way a shell would, so `classify()` sees the same words. */
|
|
55
|
+
export function quoteArg(value) {
|
|
56
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value))
|
|
57
|
+
return value;
|
|
58
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Turn a Codex argv into one shell string.
|
|
62
|
+
* A single element is already the command (it may contain spaces).
|
|
63
|
+
* Several elements are argv: `["bash","-lc","rm -rf /var"]` becomes `bash -lc 'rm -rf /var'`.
|
|
64
|
+
*/
|
|
65
|
+
export function argvToShell(argv) {
|
|
66
|
+
if (argv.length === 1)
|
|
67
|
+
return argv[0];
|
|
68
|
+
return argv.map(quoteArg).join(" ");
|
|
69
|
+
}
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { RiskTier } from "@oked/sdk";
|
|
2
|
+
import { type Host } from "./rules.js";
|
|
3
|
+
export declare function tierLabel(tier: RiskTier): string;
|
|
4
|
+
export declare function categoryLabel(category: string): string;
|
|
5
|
+
export declare class ScanUsageError extends Error {
|
|
6
|
+
constructor(message: string);
|
|
7
|
+
}
|
|
8
|
+
export interface CommandRow {
|
|
9
|
+
host: Host;
|
|
10
|
+
tier: RiskTier;
|
|
11
|
+
/** Plain words for `tier`. `--json` keeps `tier` as the machine name. */
|
|
12
|
+
tierLabel: string;
|
|
13
|
+
category: string;
|
|
14
|
+
/** Plain words for `category`. `--json` keeps `category` as the machine name. */
|
|
15
|
+
categoryLabel: string;
|
|
16
|
+
/** Redacted and truncated. Safe to paste. */
|
|
17
|
+
command: string;
|
|
18
|
+
paused: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* True when Claude Code would ask only because the Bash sandbox is off.
|
|
21
|
+
* Test runners and scripts. Kept out of the closing paused count.
|
|
22
|
+
*/
|
|
23
|
+
routine: boolean;
|
|
24
|
+
timestamp: string | null;
|
|
25
|
+
}
|
|
26
|
+
export interface RiskyGroup {
|
|
27
|
+
host: Host;
|
|
28
|
+
tier: RiskTier;
|
|
29
|
+
tierLabel: string;
|
|
30
|
+
category: string;
|
|
31
|
+
categoryLabel: string;
|
|
32
|
+
command: string;
|
|
33
|
+
count: number;
|
|
34
|
+
}
|
|
35
|
+
export interface HostSummary {
|
|
36
|
+
directory: string;
|
|
37
|
+
present: boolean;
|
|
38
|
+
files: number;
|
|
39
|
+
commands: number;
|
|
40
|
+
}
|
|
41
|
+
export interface ScanReport {
|
|
42
|
+
version: string;
|
|
43
|
+
classifier: string;
|
|
44
|
+
localOnly: true;
|
|
45
|
+
network: false;
|
|
46
|
+
telemetry: false;
|
|
47
|
+
generatedAt: string;
|
|
48
|
+
sinceDays: number | null;
|
|
49
|
+
hosts: Record<Host, HostSummary>;
|
|
50
|
+
totals: {
|
|
51
|
+
commands: number;
|
|
52
|
+
paused: number;
|
|
53
|
+
byTier: Record<RiskTier, number>;
|
|
54
|
+
byCategory: Record<string, number>;
|
|
55
|
+
};
|
|
56
|
+
pausedByCategory: Record<string, number>;
|
|
57
|
+
topRisky: RiskyGroup[];
|
|
58
|
+
/** Every classified command, redacted. Grouped duplicates are listed once in `topRisky`. */
|
|
59
|
+
commands: CommandRow[];
|
|
60
|
+
badLines: number;
|
|
61
|
+
unreadableFiles: number;
|
|
62
|
+
notes: string[];
|
|
63
|
+
/** Shell command that installs the host hook for the history that was found. */
|
|
64
|
+
next: string;
|
|
65
|
+
}
|
|
66
|
+
export interface ScanOptions {
|
|
67
|
+
/** Home directory, or a transcript root when `explicitRoot` is set. Defaults to the user home. */
|
|
68
|
+
root?: string;
|
|
69
|
+
explicitRoot?: boolean;
|
|
70
|
+
/** Only commands timestamped within this many days. Undated commands stay. */
|
|
71
|
+
sinceDays?: number;
|
|
72
|
+
now?: Date;
|
|
73
|
+
home?: string;
|
|
74
|
+
}
|
|
75
|
+
export interface FormatReportOptions {
|
|
76
|
+
/** Print the classification notes. The default text report leaves them out. */
|
|
77
|
+
verbose?: boolean;
|
|
78
|
+
}
|
|
79
|
+
export declare function nextStep(hosts: {
|
|
80
|
+
claude: boolean;
|
|
81
|
+
codex: boolean;
|
|
82
|
+
}): string;
|
|
83
|
+
export declare function formatReport(report: ScanReport, options?: FormatReportOptions): string;
|
|
84
|
+
export declare function scan(options?: ScanOptions): Promise<ScanReport>;
|