@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/report.js
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { displayCommand, displayPath } from "./redact.js";
|
|
6
|
+
import { collectCommands } from "./read.js";
|
|
7
|
+
import { classifierVersion, classifyCommand, pausesForApproval } from "./rules.js";
|
|
8
|
+
const TOP_RISKY = 8;
|
|
9
|
+
const TIER_RANK = {
|
|
10
|
+
high_stakes: 0,
|
|
11
|
+
review: 1,
|
|
12
|
+
warning: 2,
|
|
13
|
+
safe: 3,
|
|
14
|
+
};
|
|
15
|
+
const CLASSIFICATION_NOTES = [
|
|
16
|
+
"Tiers come from @oked/sdk classify(), the same function the hooks call. Custom rules are not applied.",
|
|
17
|
+
"A command would have paused when the tier is review or high_stakes. warning and safe would have run.",
|
|
18
|
+
"Claude Code is classified with the Bash sandbox not containing child processes, which is the default. Test runners and scripts then count as review.",
|
|
19
|
+
"Codex is classified the way the Codex hook classifies commands. Those runners stay allowed there.",
|
|
20
|
+
];
|
|
21
|
+
const DECISION_NOTE = "Decisions happen in Telegram or on the web dashboard (https://oked.ai/dashboard). The iPhone app is coming soon.";
|
|
22
|
+
const NOTES = [...CLASSIFICATION_NOTES, DECISION_NOTE];
|
|
23
|
+
/** How many high-stakes kinds to name in the closing line. */
|
|
24
|
+
const NAMED_CATEGORIES = 3;
|
|
25
|
+
const TIER_LABEL = {
|
|
26
|
+
high_stakes: "High-stakes (always asks)",
|
|
27
|
+
review: "Worth a look (asks)",
|
|
28
|
+
warning: "Runs, logged",
|
|
29
|
+
safe: "Runs",
|
|
30
|
+
};
|
|
31
|
+
const TIER_GLOSS = {
|
|
32
|
+
high_stakes: "Destructive or hard to undo.",
|
|
33
|
+
review: "OKed asks before it runs.",
|
|
34
|
+
warning: "It runs, and OKed logs it.",
|
|
35
|
+
safe: "It runs. No log line.",
|
|
36
|
+
};
|
|
37
|
+
/** Short name used in the per-tier count line. */
|
|
38
|
+
const TIER_GROUP = {
|
|
39
|
+
high_stakes: "high-stakes",
|
|
40
|
+
review: "worth a look",
|
|
41
|
+
warning: "runs, logged",
|
|
42
|
+
safe: "runs",
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Plain words for classifier kinds. Kinds that share a phrase are counted
|
|
46
|
+
* together. unknown_bash stays out of the closing sentence.
|
|
47
|
+
*/
|
|
48
|
+
const CATEGORY_PHRASES = {
|
|
49
|
+
file_delete: { one: "rm -rf / file delete", many: "rm -rf / file delete" },
|
|
50
|
+
file_create: { one: "file create", many: "file creates" },
|
|
51
|
+
file_edit: { one: "file edit", many: "file edits" },
|
|
52
|
+
file_append: { one: "file append", many: "file appends" },
|
|
53
|
+
file_copy: { one: "file copy", many: "file copies" },
|
|
54
|
+
file_move: { one: "file move", many: "file moves" },
|
|
55
|
+
git_force_push: { one: "force-push", many: "force-pushes" },
|
|
56
|
+
git_reset_hard: { one: "hard reset", many: "hard resets" },
|
|
57
|
+
git_clean: { one: "git clean", many: "git clean" },
|
|
58
|
+
git_checkout: { one: "discarded change", many: "discarded changes" },
|
|
59
|
+
git_branch_delete: { one: "branch delete", many: "branch deletes" },
|
|
60
|
+
git_push: { one: "git push", many: "git pushes" },
|
|
61
|
+
git_commit: { one: "git commit", many: "git commits" },
|
|
62
|
+
git_restore: { one: "git restore", many: "git restores" },
|
|
63
|
+
git_pr_create: { one: "pull request", many: "pull requests" },
|
|
64
|
+
git_pr_merge: { one: "merge", many: "merges" },
|
|
65
|
+
git_pr_write: { one: "pull request edit", many: "pull request edits" },
|
|
66
|
+
gh_write: { one: "GitHub write", many: "GitHub writes" },
|
|
67
|
+
sql_drop: { one: "destructive SQL", many: "destructive SQL" },
|
|
68
|
+
sql_truncate: { one: "destructive SQL", many: "destructive SQL" },
|
|
69
|
+
sql_delete_rows: { one: "destructive SQL", many: "destructive SQL" },
|
|
70
|
+
sql_delete_all_rows: { one: "destructive SQL", many: "destructive SQL" },
|
|
71
|
+
sql_update_every_row: { one: "destructive SQL", many: "destructive SQL" },
|
|
72
|
+
sql_alter: { one: "SQL alter", many: "SQL alters" },
|
|
73
|
+
sql_create: { one: "SQL create", many: "SQL creates" },
|
|
74
|
+
sql_update_rows: { one: "SQL update", many: "SQL updates" },
|
|
75
|
+
sql_insert: { one: "SQL insert", many: "SQL inserts" },
|
|
76
|
+
sql_query: { one: "SQL query", many: "SQL queries" },
|
|
77
|
+
sql_migrate: { one: "SQL migrate", many: "SQL migrates" },
|
|
78
|
+
deploy: { one: "prod deploy", many: "prod deploys" },
|
|
79
|
+
npx_deploy: { one: "prod deploy", many: "prod deploys" },
|
|
80
|
+
http_post: { one: "curl POST/PUT/DELETE", many: "curl POST/PUT/DELETE" },
|
|
81
|
+
http_put: { one: "curl POST/PUT/DELETE", many: "curl POST/PUT/DELETE" },
|
|
82
|
+
http_delete: { one: "curl POST/PUT/DELETE", many: "curl POST/PUT/DELETE" },
|
|
83
|
+
http_get: { one: "HTTP GET", many: "HTTP GETs" },
|
|
84
|
+
http_pipe_to_shell: { one: "pipe to a shell", many: "pipes to a shell" },
|
|
85
|
+
docker_rm: { one: "docker rm", many: "docker rm" },
|
|
86
|
+
docker_rmi: { one: "docker image removal", many: "docker image removals" },
|
|
87
|
+
docker_prune: { one: "docker prune", many: "docker prune" },
|
|
88
|
+
docker_down: { one: "docker compose down", many: "docker compose down" },
|
|
89
|
+
docker_up: { one: "docker compose up", many: "docker compose up" },
|
|
90
|
+
npm_publish: { one: "npm publish", many: "npm publish" },
|
|
91
|
+
npm_unpublish: { one: "npm unpublish", many: "npm unpublish" },
|
|
92
|
+
npm_install: { one: "npm install", many: "npm install" },
|
|
93
|
+
npm_test: { one: "test runner", many: "test runners" },
|
|
94
|
+
npm_run: { one: "npm script", many: "npm scripts" },
|
|
95
|
+
script_run: { one: "script", many: "scripts" },
|
|
96
|
+
chmod_777: { one: "chmod 777", many: "chmod 777" },
|
|
97
|
+
kill_process: { one: "process kill", many: "process kills" },
|
|
98
|
+
sudo: { one: "sudo", many: "sudo" },
|
|
99
|
+
ssh_remote: { one: "ssh", many: "ssh" },
|
|
100
|
+
email_send: { one: "email send", many: "email sends" },
|
|
101
|
+
email_delete: { one: "email delete", many: "email deletes" },
|
|
102
|
+
email_purge: { one: "mailbox purge", many: "mailbox purges" },
|
|
103
|
+
payment_charge: { one: "payment", many: "payments" },
|
|
104
|
+
payment_create: { one: "payment", many: "payments" },
|
|
105
|
+
shell_pipeline: { one: "shell pipeline", many: "shell pipelines" },
|
|
106
|
+
unknown_bash: { one: "other command", many: "other commands" },
|
|
107
|
+
unknown_tool: { one: "other tool", many: "other tools" },
|
|
108
|
+
};
|
|
109
|
+
/** Kinds with no specific name stay out of the closing sentence. */
|
|
110
|
+
const CLOSING_SKIP = new Set(["unknown_bash", "unknown_tool"]);
|
|
111
|
+
function phraseFor(category) {
|
|
112
|
+
const known = CATEGORY_PHRASES[category];
|
|
113
|
+
if (known)
|
|
114
|
+
return known;
|
|
115
|
+
const words = category.replace(/_/g, " ");
|
|
116
|
+
return { one: words, many: words };
|
|
117
|
+
}
|
|
118
|
+
export function tierLabel(tier) {
|
|
119
|
+
return TIER_LABEL[tier];
|
|
120
|
+
}
|
|
121
|
+
export function categoryLabel(category) {
|
|
122
|
+
return phraseFor(category).one;
|
|
123
|
+
}
|
|
124
|
+
export class ScanUsageError extends Error {
|
|
125
|
+
constructor(message) {
|
|
126
|
+
super(message);
|
|
127
|
+
this.name = "ScanUsageError";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function packageVersion() {
|
|
131
|
+
const path = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
132
|
+
const json = JSON.parse(readFileSync(path, "utf8"));
|
|
133
|
+
return json.version ?? "0.0.0";
|
|
134
|
+
}
|
|
135
|
+
export function nextStep(hosts) {
|
|
136
|
+
if (hosts.claude && hosts.codex) {
|
|
137
|
+
return "npm install -g @oked/claude-code @oked/codex && oked init && oked-codex init";
|
|
138
|
+
}
|
|
139
|
+
if (hosts.codex && !hosts.claude) {
|
|
140
|
+
return "npm install -g @oked/codex && oked-codex init";
|
|
141
|
+
}
|
|
142
|
+
return "npm install -g @oked/claude-code && oked init";
|
|
143
|
+
}
|
|
144
|
+
function emptyTiers() {
|
|
145
|
+
return { safe: 0, warning: 0, review: 0, high_stakes: 0 };
|
|
146
|
+
}
|
|
147
|
+
function showHost(scan, home) {
|
|
148
|
+
return {
|
|
149
|
+
directory: displayPath(scan.directory, home),
|
|
150
|
+
present: scan.present,
|
|
151
|
+
files: scan.files,
|
|
152
|
+
commands: scan.commands,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function pad(n) {
|
|
156
|
+
return String(n).padStart(4);
|
|
157
|
+
}
|
|
158
|
+
function hostLine(label, host, missing) {
|
|
159
|
+
const name = label.padEnd(14);
|
|
160
|
+
if (!host.present)
|
|
161
|
+
return `${name}no ${missing} at ${host.directory}`;
|
|
162
|
+
if (host.files === 0)
|
|
163
|
+
return `${name}no session files in ${host.directory}`;
|
|
164
|
+
const commands = host.commands === 1 ? "command" : "commands";
|
|
165
|
+
const files = host.files === 1 ? "file" : "files";
|
|
166
|
+
return `${name}${host.commands} ${commands} in ${host.files} ${files}`;
|
|
167
|
+
}
|
|
168
|
+
function englishList(parts) {
|
|
169
|
+
if (parts.length <= 1)
|
|
170
|
+
return parts[0] ?? "";
|
|
171
|
+
if (parts.length === 2)
|
|
172
|
+
return `${parts[0]} and ${parts[1]}`;
|
|
173
|
+
return `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
|
|
174
|
+
}
|
|
175
|
+
function countedPhrases(rows, skipUnknown) {
|
|
176
|
+
const buckets = new Map();
|
|
177
|
+
for (const row of rows) {
|
|
178
|
+
if (skipUnknown && CLOSING_SKIP.has(row.category))
|
|
179
|
+
continue;
|
|
180
|
+
const plain = phraseFor(row.category);
|
|
181
|
+
const existing = buckets.get(plain.many);
|
|
182
|
+
if (existing)
|
|
183
|
+
existing.count += 1;
|
|
184
|
+
else
|
|
185
|
+
buckets.set(plain.many, { count: 1, one: plain.one, many: plain.many });
|
|
186
|
+
}
|
|
187
|
+
return [...buckets.values()]
|
|
188
|
+
.sort((a, b) => b.count - a.count || a.one.localeCompare(b.one))
|
|
189
|
+
.map((bucket) => `${bucket.count} ${bucket.count === 1 ? bucket.one : bucket.many}`);
|
|
190
|
+
}
|
|
191
|
+
/** The two or three most common high-stakes kinds, in plain words. */
|
|
192
|
+
function namedHighStakes(commands) {
|
|
193
|
+
const rows = commands.filter((row) => row.tier === "high_stakes");
|
|
194
|
+
return countedPhrases(rows, true).slice(0, NAMED_CATEGORIES);
|
|
195
|
+
}
|
|
196
|
+
function headlinePaused(report) {
|
|
197
|
+
return report.commands.filter((row) => row.paused && !row.routine).length;
|
|
198
|
+
}
|
|
199
|
+
function routinePaused(report) {
|
|
200
|
+
return report.commands.filter((row) => row.paused && row.routine).length;
|
|
201
|
+
}
|
|
202
|
+
function historyFound(report) {
|
|
203
|
+
return report.hosts["claude-code"].files > 0 || report.hosts.codex.files > 0;
|
|
204
|
+
}
|
|
205
|
+
function closingLine(report) {
|
|
206
|
+
const paused = headlinePaused(report);
|
|
207
|
+
if (paused === 0 && routinePaused(report) === 0) {
|
|
208
|
+
return "No risky commands found in this history, and OKed would pause things like rm -rf, force-push and prod deploys.";
|
|
209
|
+
}
|
|
210
|
+
if (paused === 0)
|
|
211
|
+
return null;
|
|
212
|
+
const named = namedHighStakes(report.commands);
|
|
213
|
+
const lead = `OKed would have paused ${paused} of these commands`;
|
|
214
|
+
if (named.length === 0)
|
|
215
|
+
return `${lead}.`;
|
|
216
|
+
return `${lead}, including ${englishList(named)}.`;
|
|
217
|
+
}
|
|
218
|
+
function tierBreakdown(report) {
|
|
219
|
+
const lines = [];
|
|
220
|
+
for (const tier of ["high_stakes", "review"]) {
|
|
221
|
+
const rows = report.commands.filter((row) => row.paused && row.tier === tier);
|
|
222
|
+
if (rows.length === 0)
|
|
223
|
+
continue;
|
|
224
|
+
const parts = countedPhrases(rows, false);
|
|
225
|
+
lines.push(` ${rows.length} ${TIER_GROUP[tier]}: ${parts.join(", ")}`);
|
|
226
|
+
}
|
|
227
|
+
return lines;
|
|
228
|
+
}
|
|
229
|
+
function routineLines(report) {
|
|
230
|
+
const count = routinePaused(report);
|
|
231
|
+
if (count === 0)
|
|
232
|
+
return [];
|
|
233
|
+
const noun = count === 1 ? "command" : "commands";
|
|
234
|
+
return [
|
|
235
|
+
`${count} routine ${noun} (test runners, scripts) would also ask under Claude Code's default sandbox setting.`,
|
|
236
|
+
"To allow those without a prompt, turn on the Bash sandbox and set allowUnsandboxedCommands to false in ~/.claude/settings.json or .claude/settings.local.json.",
|
|
237
|
+
];
|
|
238
|
+
}
|
|
239
|
+
const COUNT_COL = 32;
|
|
240
|
+
function countLine(label, n) {
|
|
241
|
+
return `${label.padEnd(COUNT_COL)}${pad(n)}`;
|
|
242
|
+
}
|
|
243
|
+
function pushNotes(lines, report, options) {
|
|
244
|
+
if (!options.verbose)
|
|
245
|
+
return;
|
|
246
|
+
for (const note of report.notes) {
|
|
247
|
+
if (note === DECISION_NOTE)
|
|
248
|
+
continue;
|
|
249
|
+
lines.push(note);
|
|
250
|
+
}
|
|
251
|
+
lines.push("");
|
|
252
|
+
}
|
|
253
|
+
function pushInstall(lines, report, lead) {
|
|
254
|
+
lines.push(DECISION_NOTE);
|
|
255
|
+
lines.push("");
|
|
256
|
+
lines.push(lead);
|
|
257
|
+
lines.push(` ${report.next}`);
|
|
258
|
+
lines.push("https://oked.ai");
|
|
259
|
+
lines.push("");
|
|
260
|
+
}
|
|
261
|
+
export function formatReport(report, options = {}) {
|
|
262
|
+
const lines = [];
|
|
263
|
+
lines.push(`OKed scan ${report.version}`);
|
|
264
|
+
lines.push("Local only. This scan does not use the network and does not send telemetry.");
|
|
265
|
+
lines.push("");
|
|
266
|
+
lines.push(hostLine("Claude Code", report.hosts["claude-code"], "transcripts"));
|
|
267
|
+
lines.push(hostLine("Codex CLI", report.hosts.codex, "sessions"));
|
|
268
|
+
lines.push(`Window ${report.sinceDays === null ? "all history" : `last ${report.sinceDays} ${report.sinceDays === 1 ? "day" : "days"}`}`);
|
|
269
|
+
if (report.badLines > 0) {
|
|
270
|
+
lines.push(`Skipped ${report.badLines} unreadable ${report.badLines === 1 ? "line" : "lines"}.`);
|
|
271
|
+
}
|
|
272
|
+
if (report.unreadableFiles > 0) {
|
|
273
|
+
lines.push(`Skipped ${report.unreadableFiles} unreadable ${report.unreadableFiles === 1 ? "file" : "files"}.`);
|
|
274
|
+
}
|
|
275
|
+
lines.push("");
|
|
276
|
+
if (!historyFound(report)) {
|
|
277
|
+
lines.push("No Claude Code or Codex history was found at the paths checked.");
|
|
278
|
+
lines.push("Pass --path <dir> to read a different directory.");
|
|
279
|
+
lines.push("");
|
|
280
|
+
pushNotes(lines, report, options);
|
|
281
|
+
pushInstall(lines, report, "Get a yes/no in Telegram before your agent runs a risky command:");
|
|
282
|
+
return lines.join("\n");
|
|
283
|
+
}
|
|
284
|
+
const ran = report.totals.commands - report.totals.paused;
|
|
285
|
+
lines.push(countLine("Commands", report.totals.commands));
|
|
286
|
+
lines.push(countLine("Would have paused", report.totals.paused));
|
|
287
|
+
for (const tier of ["high_stakes", "review"]) {
|
|
288
|
+
lines.push(countLine(` ${TIER_LABEL[tier]}`, report.totals.byTier[tier]));
|
|
289
|
+
lines.push(` ${TIER_GLOSS[tier]}`);
|
|
290
|
+
}
|
|
291
|
+
lines.push(countLine("Would have run", ran));
|
|
292
|
+
for (const tier of ["warning", "safe"]) {
|
|
293
|
+
lines.push(countLine(` ${TIER_LABEL[tier]}`, report.totals.byTier[tier]));
|
|
294
|
+
lines.push(` ${TIER_GLOSS[tier]}`);
|
|
295
|
+
}
|
|
296
|
+
lines.push("");
|
|
297
|
+
lines.push("What would pause");
|
|
298
|
+
const breakdown = tierBreakdown(report);
|
|
299
|
+
if (breakdown.length === 0)
|
|
300
|
+
lines.push(" (none)");
|
|
301
|
+
else
|
|
302
|
+
lines.push(...breakdown);
|
|
303
|
+
lines.push("");
|
|
304
|
+
lines.push("Top risky commands");
|
|
305
|
+
if (report.topRisky.length === 0)
|
|
306
|
+
lines.push(" (none)");
|
|
307
|
+
else {
|
|
308
|
+
let lastTier = null;
|
|
309
|
+
for (const row of report.topRisky) {
|
|
310
|
+
if (row.tier !== lastTier) {
|
|
311
|
+
lines.push(` ${TIER_LABEL[row.tier]}`);
|
|
312
|
+
lastTier = row.tier;
|
|
313
|
+
}
|
|
314
|
+
const label = phraseFor(row.category).one;
|
|
315
|
+
lines.push(` ${String(row.count).padStart(3)} ${row.host.padEnd(12)} ${label.padEnd(22)} ${row.command}`);
|
|
316
|
+
}
|
|
317
|
+
const pausedGroups = pausedGroupCount(report);
|
|
318
|
+
if (pausedGroups > report.topRisky.length) {
|
|
319
|
+
lines.push(` Showing ${report.topRisky.length} of ${pausedGroups}. Re-run with --json for every paused command.`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const routine = routineLines(report);
|
|
323
|
+
if (routine.length > 0) {
|
|
324
|
+
lines.push("");
|
|
325
|
+
lines.push(...routine);
|
|
326
|
+
}
|
|
327
|
+
lines.push("");
|
|
328
|
+
pushNotes(lines, report, options);
|
|
329
|
+
lines.push(DECISION_NOTE);
|
|
330
|
+
lines.push("");
|
|
331
|
+
const closing = closingLine(report);
|
|
332
|
+
if (closing)
|
|
333
|
+
lines.push(closing);
|
|
334
|
+
lines.push("Get a yes/no in Telegram before your agent runs them:");
|
|
335
|
+
lines.push(` ${report.next}`);
|
|
336
|
+
lines.push("https://oked.ai");
|
|
337
|
+
lines.push("");
|
|
338
|
+
return lines.join("\n");
|
|
339
|
+
}
|
|
340
|
+
function pausedGroupCount(report) {
|
|
341
|
+
const keys = new Set();
|
|
342
|
+
for (const row of report.commands) {
|
|
343
|
+
if (!row.paused)
|
|
344
|
+
continue;
|
|
345
|
+
keys.add(`${row.host}\0${row.tier}\0${row.category}\0${row.command}`);
|
|
346
|
+
}
|
|
347
|
+
return keys.size;
|
|
348
|
+
}
|
|
349
|
+
export async function scan(options = {}) {
|
|
350
|
+
const home = options.home ?? homedir();
|
|
351
|
+
const root = options.root ?? home;
|
|
352
|
+
const explicitRoot = options.explicitRoot ?? false;
|
|
353
|
+
if (explicitRoot && !existsSync(root)) {
|
|
354
|
+
throw new ScanUsageError(`Path not found: ${root}`);
|
|
355
|
+
}
|
|
356
|
+
if (options.sinceDays !== undefined) {
|
|
357
|
+
if (!Number.isInteger(options.sinceDays) || options.sinceDays < 0) {
|
|
358
|
+
throw new ScanUsageError("--since expects a whole number of days (0 or more)");
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const now = options.now ?? new Date();
|
|
362
|
+
const collected = await collectCommands({
|
|
363
|
+
root,
|
|
364
|
+
explicitRoot,
|
|
365
|
+
sinceMs: options.sinceDays === undefined ? null : options.sinceDays * 24 * 60 * 60 * 1000,
|
|
366
|
+
nowMs: now.getTime(),
|
|
367
|
+
});
|
|
368
|
+
const byTier = emptyTiers();
|
|
369
|
+
const byCategory = {};
|
|
370
|
+
const pausedByCategory = {};
|
|
371
|
+
const commands = [];
|
|
372
|
+
const groups = new Map();
|
|
373
|
+
for (const raw of collected.commands) {
|
|
374
|
+
const { tier, category, routine } = classifyCommand(raw.host, raw.command);
|
|
375
|
+
const paused = pausesForApproval(tier);
|
|
376
|
+
const command = displayCommand(raw.command, home);
|
|
377
|
+
const rowTierLabel = tierLabel(tier);
|
|
378
|
+
const rowCategoryLabel = categoryLabel(category);
|
|
379
|
+
byTier[tier] += 1;
|
|
380
|
+
byCategory[category] = (byCategory[category] ?? 0) + 1;
|
|
381
|
+
if (paused)
|
|
382
|
+
pausedByCategory[category] = (pausedByCategory[category] ?? 0) + 1;
|
|
383
|
+
commands.push({
|
|
384
|
+
host: raw.host,
|
|
385
|
+
tier,
|
|
386
|
+
tierLabel: rowTierLabel,
|
|
387
|
+
category,
|
|
388
|
+
categoryLabel: rowCategoryLabel,
|
|
389
|
+
command,
|
|
390
|
+
paused,
|
|
391
|
+
routine,
|
|
392
|
+
timestamp: raw.timestamp,
|
|
393
|
+
});
|
|
394
|
+
if (!paused)
|
|
395
|
+
continue;
|
|
396
|
+
const key = `${raw.host}\0${tier}\0${category}\0${command}`;
|
|
397
|
+
const existing = groups.get(key);
|
|
398
|
+
if (existing)
|
|
399
|
+
existing.count += 1;
|
|
400
|
+
else {
|
|
401
|
+
groups.set(key, {
|
|
402
|
+
host: raw.host,
|
|
403
|
+
tier,
|
|
404
|
+
tierLabel: rowTierLabel,
|
|
405
|
+
category,
|
|
406
|
+
categoryLabel: rowCategoryLabel,
|
|
407
|
+
command,
|
|
408
|
+
count: 1,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const topRisky = [...groups.values()].sort((a, b) => TIER_RANK[a.tier] - TIER_RANK[b.tier] || b.count - a.count || a.command.localeCompare(b.command));
|
|
413
|
+
const claude = showHost(collected.hosts["claude-code"], home);
|
|
414
|
+
const codex = showHost(collected.hosts.codex, home);
|
|
415
|
+
const paused = byTier.review + byTier.high_stakes;
|
|
416
|
+
return {
|
|
417
|
+
version: packageVersion(),
|
|
418
|
+
classifier: classifierVersion(),
|
|
419
|
+
localOnly: true,
|
|
420
|
+
network: false,
|
|
421
|
+
telemetry: false,
|
|
422
|
+
generatedAt: now.toISOString(),
|
|
423
|
+
sinceDays: options.sinceDays === undefined ? null : options.sinceDays,
|
|
424
|
+
hosts: { "claude-code": claude, codex },
|
|
425
|
+
totals: {
|
|
426
|
+
commands: commands.length,
|
|
427
|
+
paused,
|
|
428
|
+
byTier,
|
|
429
|
+
byCategory,
|
|
430
|
+
},
|
|
431
|
+
pausedByCategory,
|
|
432
|
+
topRisky: topRisky.slice(0, TOP_RISKY),
|
|
433
|
+
commands,
|
|
434
|
+
badLines: collected.badLines,
|
|
435
|
+
unreadableFiles: collected.unreadableFiles,
|
|
436
|
+
notes: NOTES,
|
|
437
|
+
next: nextStep({ claude: claude.present, codex: codex.present }),
|
|
438
|
+
};
|
|
439
|
+
}
|
package/dist/rules.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type RiskTier } from "@oked/sdk";
|
|
2
|
+
export type Host = "claude-code" | "codex";
|
|
3
|
+
/**
|
|
4
|
+
* Shell tool names the Codex hook maps onto Bash before `classify()`.
|
|
5
|
+
* `Bash` is included because the hook treats that name the same way.
|
|
6
|
+
* Keep this set in sync with `SHELL_NAMES` in `packages/codex/src/map.ts`.
|
|
7
|
+
*/
|
|
8
|
+
export declare const SHELL_TOOL_NAMES: Set<string>;
|
|
9
|
+
export declare const PAUSED_TIERS: Set<RiskTier>;
|
|
10
|
+
export declare function pausesForApproval(tier: RiskTier): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Same `classify()` the hooks call.
|
|
13
|
+
* Claude Code passes `sandboxEnforced: false` when the Bash sandbox is not
|
|
14
|
+
* containing child processes. That is the default (the sandbox is off unless
|
|
15
|
+
* the user turns it on). Codex omits the option, so test runners stay allowed.
|
|
16
|
+
* Custom user rules are not applied.
|
|
17
|
+
*/
|
|
18
|
+
export declare function classifyCommand(host: Host, command: string): {
|
|
19
|
+
tier: RiskTier;
|
|
20
|
+
category: string;
|
|
21
|
+
routine: boolean;
|
|
22
|
+
};
|
|
23
|
+
export declare function classifierVersion(): string;
|
package/dist/rules.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { CLASSIFIER_VERSION, classify, describeFields } from "@oked/sdk";
|
|
2
|
+
/**
|
|
3
|
+
* Shell tool names the Codex hook maps onto Bash before `classify()`.
|
|
4
|
+
* `Bash` is included because the hook treats that name the same way.
|
|
5
|
+
* Keep this set in sync with `SHELL_NAMES` in `packages/codex/src/map.ts`.
|
|
6
|
+
*/
|
|
7
|
+
export const SHELL_TOOL_NAMES = new Set([
|
|
8
|
+
"Bash",
|
|
9
|
+
"bash",
|
|
10
|
+
"shell",
|
|
11
|
+
"exec",
|
|
12
|
+
"exec_command",
|
|
13
|
+
"shell_command",
|
|
14
|
+
"local_shell",
|
|
15
|
+
"container.exec",
|
|
16
|
+
]);
|
|
17
|
+
export const PAUSED_TIERS = new Set(["review", "high_stakes"]);
|
|
18
|
+
export function pausesForApproval(tier) {
|
|
19
|
+
return PAUSED_TIERS.has(tier);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Same `classify()` the hooks call.
|
|
23
|
+
* Claude Code passes `sandboxEnforced: false` when the Bash sandbox is not
|
|
24
|
+
* containing child processes. That is the default (the sandbox is off unless
|
|
25
|
+
* the user turns it on). Codex omits the option, so test runners stay allowed.
|
|
26
|
+
* Custom user rules are not applied.
|
|
27
|
+
*/
|
|
28
|
+
export function classifyCommand(host, command) {
|
|
29
|
+
const options = host === "claude-code" ? { sandboxEnforced: false } : undefined;
|
|
30
|
+
let tier = "review";
|
|
31
|
+
try {
|
|
32
|
+
tier = classify("Bash", { command }, options);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
tier = "review";
|
|
36
|
+
}
|
|
37
|
+
let category = "unknown_bash";
|
|
38
|
+
try {
|
|
39
|
+
const fields = describeFields("Bash", { command }, options ?? {});
|
|
40
|
+
if (fields?.Kind)
|
|
41
|
+
category = fields.Kind;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
category = "unknown_bash";
|
|
45
|
+
}
|
|
46
|
+
// Claude Code asks for a test runner or script only when the Bash sandbox
|
|
47
|
+
// is off, which is the default. The same command is safe when that flag is
|
|
48
|
+
// omitted, which is what Codex and a sandbox that is on both do.
|
|
49
|
+
let plain = tier;
|
|
50
|
+
if (host === "claude-code") {
|
|
51
|
+
try {
|
|
52
|
+
plain = classify("Bash", { command });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
plain = tier;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const routine = host === "claude-code" && plain === "safe" && tier === "review";
|
|
59
|
+
return { tier, category, routine };
|
|
60
|
+
}
|
|
61
|
+
export function classifierVersion() {
|
|
62
|
+
return CLASSIFIER_VERSION;
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oked/scan",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.6.25",
|
|
4
|
+
"description": "Local audit of Claude Code and Codex command history against OKed's default approval rules",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"oked-scan": "./dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"prebuild": "npm run build --workspace=@oked/sdk",
|
|
19
|
+
"build": "tsc",
|
|
20
|
+
"test": "tsx --test test/*.test.ts",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"keywords": [
|
|
29
|
+
"oked",
|
|
30
|
+
"ai-agent",
|
|
31
|
+
"approval",
|
|
32
|
+
"human-in-the-loop",
|
|
33
|
+
"hooks",
|
|
34
|
+
"audit",
|
|
35
|
+
"scan"
|
|
36
|
+
],
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/oked-ai/oked-sdk.git",
|
|
40
|
+
"directory": "packages/scan"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/oked-ai/oked-sdk/issues"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://oked.ai",
|
|
46
|
+
"license": "Apache-2.0",
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=18"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@oked/sdk": "0.6.25"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/node": "^26.6.5",
|
|
58
|
+
"tsx": "^4.23.15",
|
|
59
|
+
"typescript": "^7.0.2"
|
|
60
|
+
}
|
|
61
|
+
}
|