@pinta-ai/pinta-codex 1.2.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.
Files changed (61) hide show
  1. package/.codex-plugin/plugin.json +40 -0
  2. package/CHANGELOG.md +50 -0
  3. package/LICENSE +136 -0
  4. package/README.md +214 -0
  5. package/dist/core/config.d.ts +10 -0
  6. package/dist/core/config.js +100 -0
  7. package/dist/core/config.js.map +1 -0
  8. package/dist/core/identity.d.ts +1 -0
  9. package/dist/core/identity.js +3 -0
  10. package/dist/core/identity.js.map +1 -0
  11. package/dist/core/otlp.d.ts +57 -0
  12. package/dist/core/otlp.js +182 -0
  13. package/dist/core/otlp.js.map +1 -0
  14. package/dist/core/redact.d.ts +57 -0
  15. package/dist/core/redact.js +149 -0
  16. package/dist/core/redact.js.map +1 -0
  17. package/dist/core/retry-queue.d.ts +26 -0
  18. package/dist/core/retry-queue.js +127 -0
  19. package/dist/core/retry-queue.js.map +1 -0
  20. package/dist/core/trace.d.ts +10 -0
  21. package/dist/core/trace.js +61 -0
  22. package/dist/core/trace.js.map +1 -0
  23. package/dist/core/transport.d.ts +10 -0
  24. package/dist/core/transport.js +77 -0
  25. package/dist/core/transport.js.map +1 -0
  26. package/dist/core/types.d.ts +48 -0
  27. package/dist/core/types.js +45 -0
  28. package/dist/core/types.js.map +1 -0
  29. package/dist/handlers/default.d.ts +6 -0
  30. package/dist/handlers/default.js +11 -0
  31. package/dist/handlers/default.js.map +1 -0
  32. package/dist/handlers/post-tool-use.d.ts +3 -0
  33. package/dist/handlers/post-tool-use.js +15 -0
  34. package/dist/handlers/post-tool-use.js.map +1 -0
  35. package/dist/handlers/pre-tool-use.d.ts +3 -0
  36. package/dist/handlers/pre-tool-use.js +15 -0
  37. package/dist/handlers/pre-tool-use.js.map +1 -0
  38. package/dist/handlers/session.d.ts +3 -0
  39. package/dist/handlers/session.js +15 -0
  40. package/dist/handlers/session.js.map +1 -0
  41. package/dist/handlers/stop.d.ts +3 -0
  42. package/dist/handlers/stop.js +15 -0
  43. package/dist/handlers/stop.js.map +1 -0
  44. package/dist/handlers/user-prompt.d.ts +3 -0
  45. package/dist/handlers/user-prompt.js +15 -0
  46. package/dist/handlers/user-prompt.js.map +1 -0
  47. package/dist/index.d.ts +1 -0
  48. package/dist/index.js +53 -0
  49. package/dist/index.js.map +1 -0
  50. package/hooks.json +54 -0
  51. package/package.json +43 -0
  52. package/tools/.data/events.json +1585 -0
  53. package/tools/_lib.ts +274 -0
  54. package/tools/doctor.ts +250 -0
  55. package/tools/install-hooks.ts +71 -0
  56. package/tools/mock-server.ts +436 -0
  57. package/tools/setup.ts +288 -0
  58. package/tools/test-otlp.ts +58 -0
  59. package/tools/test-redact.ts +286 -0
  60. package/tools/tsconfig.json +13 -0
  61. package/tools/uninstall-hooks.ts +39 -0
package/tools/setup.ts ADDED
@@ -0,0 +1,288 @@
1
+ /**
2
+ * One-shot installer for end users.
3
+ *
4
+ * npm install && npm run setup
5
+ *
6
+ * Does everything a user needs in a single interactive command:
7
+ * 1. Builds dist/ (runs tsc if dist/index.js missing or --rebuild)
8
+ * 2. Prompts for OTLP endpoint & headers (migrates legacy keys if present)
9
+ * 3. Writes them to ~/.codex/pinta-codex.env
10
+ * 4. Enables `codex_hooks = true` in ~/.codex/config.toml (idempotent)
11
+ * 5. Merges this plugin's hooks into ~/.codex/hooks.json with absolute paths
12
+ * 6. Prints a summary of what to run next
13
+ *
14
+ * Safe to re-run. Each step is idempotent.
15
+ */
16
+
17
+ import { spawnSync } from "node:child_process";
18
+ import fs from "node:fs";
19
+ import readline from "node:readline";
20
+ import { stdin, stdout } from "node:process";
21
+ import {
22
+ CODEX_CONFIG_PATH,
23
+ CODEX_ENV_PATH,
24
+ CODEX_HOME,
25
+ CODEX_HOOKS_PATH,
26
+ PLUGIN_ENTRY,
27
+ PLUGIN_ROOT,
28
+ ensureCodexHooksEnabled,
29
+ loadResolvedTemplate,
30
+ mergeHooks,
31
+ migrateLegacyEnvKeys,
32
+ readEnvFile,
33
+ readJson,
34
+ writeEnvFile,
35
+ type HooksFile,
36
+ } from "./_lib.js";
37
+
38
+ const ANSI = {
39
+ reset: "\x1b[0m",
40
+ bold: "\x1b[1m",
41
+ dim: "\x1b[2m",
42
+ green: "\x1b[32m",
43
+ yellow: "\x1b[33m",
44
+ red: "\x1b[31m",
45
+ cyan: "\x1b[36m",
46
+ };
47
+
48
+ function tty(): boolean {
49
+ return Boolean(stdout.isTTY);
50
+ }
51
+ function ok(msg: string): void {
52
+ const mark = tty() ? `${ANSI.green}✔${ANSI.reset}` : "OK";
53
+ stdout.write(` ${mark} ${msg}\n`);
54
+ }
55
+ function info(msg: string): void {
56
+ const mark = tty() ? `${ANSI.cyan}ℹ${ANSI.reset}` : "--";
57
+ stdout.write(` ${mark} ${msg}\n`);
58
+ }
59
+ function heading(title: string): void {
60
+ stdout.write(
61
+ tty() ? `\n${ANSI.bold}${title}${ANSI.reset}\n` : `\n## ${title}\n`,
62
+ );
63
+ }
64
+
65
+ // --- steps ---
66
+
67
+ async function stepBuild(): Promise<void> {
68
+ heading("1. Build dist/");
69
+ const rebuild = process.argv.includes("--rebuild");
70
+ if (fs.existsSync(PLUGIN_ENTRY) && !rebuild) {
71
+ ok(`dist/index.js already present (pass --rebuild to force)`);
72
+ return;
73
+ }
74
+ info("running tsc...");
75
+ const result = spawnSync("npm", ["run", "build"], {
76
+ cwd: PLUGIN_ROOT,
77
+ stdio: "inherit",
78
+ });
79
+ if (result.status !== 0) {
80
+ stderrFail(`tsc failed with exit ${result.status}`);
81
+ }
82
+ ok(`built ${PLUGIN_ENTRY}`);
83
+ }
84
+
85
+ async function stepEnv(ask: AskFn): Promise<{ endpoint: string; headers: string }> {
86
+ heading("2. OTLP endpoint & headers");
87
+
88
+ // Migrate legacy keys to OTel-spec naming (writes .bak if migration occurs)
89
+ const renamed = migrateLegacyEnvKeys(CODEX_ENV_PATH);
90
+ for (const key of renamed) {
91
+ info(`migrated legacy key ${key} → OTel-spec name (.bak written)`);
92
+ }
93
+
94
+ const existingFile = readEnvFile(CODEX_ENV_PATH);
95
+ const currentEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT
96
+ ?? existingFile.OTEL_EXPORTER_OTLP_ENDPOINT
97
+ ?? "http://localhost:4318";
98
+ const currentHeaders = process.env.OTEL_EXPORTER_OTLP_HEADERS
99
+ ?? existingFile.OTEL_EXPORTER_OTLP_HEADERS
100
+ ?? "";
101
+
102
+ const endpoint = await ask("OTLP endpoint URL", currentEndpoint, {
103
+ validate: (v) => /^https?:\/\//.test(v) ? null : "must start with http:// or https://",
104
+ });
105
+ const headers = await ask("OTLP headers (key1=val1,key2=val2; blank if none)", currentHeaders, {
106
+ mask: true,
107
+ optional: true,
108
+ });
109
+
110
+ const merged: Record<string, string> = {
111
+ ...existingFile,
112
+ OTEL_EXPORTER_OTLP_ENDPOINT: endpoint,
113
+ };
114
+ if (headers) {
115
+ merged.OTEL_EXPORTER_OTLP_HEADERS = headers;
116
+ } else {
117
+ delete merged.OTEL_EXPORTER_OTLP_HEADERS;
118
+ }
119
+ writeEnvFile(CODEX_ENV_PATH, merged);
120
+ ok(`wrote ${CODEX_ENV_PATH}`);
121
+ return { endpoint, headers };
122
+ }
123
+
124
+ async function stepConfigToml(): Promise<void> {
125
+ heading("3. Enable codex_hooks feature");
126
+ let current = "";
127
+ try {
128
+ current = fs.readFileSync(CODEX_CONFIG_PATH, "utf-8");
129
+ } catch {
130
+ // file may not exist yet
131
+ }
132
+ const { next, changed } = ensureCodexHooksEnabled(current);
133
+ if (!changed) {
134
+ ok(`codex_hooks already enabled in ${CODEX_CONFIG_PATH}`);
135
+ return;
136
+ }
137
+ fs.mkdirSync(CODEX_HOME, { recursive: true });
138
+ fs.writeFileSync(CODEX_CONFIG_PATH, next, "utf-8");
139
+ ok(`updated ${CODEX_CONFIG_PATH}`);
140
+ }
141
+
142
+ async function stepHooks(): Promise<void> {
143
+ heading("4. Register hooks");
144
+ const incoming = loadResolvedTemplate();
145
+ const existing: HooksFile = readJson(CODEX_HOOKS_PATH, { hooks: {} });
146
+ const { next: merged, staleRemoved } = mergeHooks(existing, incoming);
147
+ const serialized = JSON.stringify(merged, null, 2) + "\n";
148
+ const previous = fs.existsSync(CODEX_HOOKS_PATH)
149
+ ? fs.readFileSync(CODEX_HOOKS_PATH, "utf-8")
150
+ : null;
151
+ if (previous === serialized) {
152
+ ok(`${Object.keys(incoming.hooks).length} event(s) already registered in ${CODEX_HOOKS_PATH}`);
153
+ return;
154
+ }
155
+ fs.mkdirSync(CODEX_HOME, { recursive: true });
156
+ fs.writeFileSync(CODEX_HOOKS_PATH, serialized, "utf-8");
157
+ if (staleRemoved > 0) {
158
+ ok(`removed ${staleRemoved} stale pinta-codex ${staleRemoved === 1 ? "entry" : "entries"} (prior install at a different path)`);
159
+ }
160
+ ok(`wrote ${Object.keys(incoming.hooks).length} event(s) to ${CODEX_HOOKS_PATH}`);
161
+ }
162
+
163
+ async function stepSummary(endpoint: string): Promise<void> {
164
+ heading("Done");
165
+ stdout.write(
166
+ ` Run Codex to start streaming events:\n` +
167
+ ` ${tty() ? ANSI.bold : ""}codex${tty() ? ANSI.reset : ""}\n` +
168
+ `\n Useful:\n` +
169
+ ` npm run doctor # health check\n` +
170
+ ` npm run uninstall-hooks # remove this plugin's entries\n` +
171
+ `\n Endpoint: ${endpoint}\n` +
172
+ ` Env file: ${CODEX_ENV_PATH}\n` +
173
+ ` Hooks: ${CODEX_HOOKS_PATH}\n`,
174
+ );
175
+ }
176
+
177
+ // --- helpers ---
178
+
179
+ function stderrFail(msg: string): never {
180
+ process.stderr.write(`[setup] ${msg}\n`);
181
+ process.exit(1);
182
+ }
183
+
184
+ interface PromptOpts {
185
+ mask?: boolean;
186
+ optional?: boolean;
187
+ validate?: (v: string) => string | null;
188
+ }
189
+
190
+ type AskFn = (label: string, defaultValue: string, opts?: PromptOpts) => Promise<string>;
191
+
192
+ /**
193
+ * Build an ask() function that works in two modes:
194
+ * - TTY: classic readline prompt loop with validation retries.
195
+ * - Piped stdin (non-TTY): read all stdin upfront, consume one line per
196
+ * question. We cannot retry a rejected value because there's no more input
197
+ * — rejections throw instead.
198
+ *
199
+ * Why the split: node:readline/promises' `rl.question` hangs on the 2nd call
200
+ * when stdin is piped, making an interactive-only implementation unusable for
201
+ * scripted/CI setup runs.
202
+ */
203
+ function buildAsk(): AskFn {
204
+ if (stdin.isTTY) {
205
+ const rl = readline.createInterface({ input: stdin, output: stdout });
206
+ const question = (prompt: string): Promise<string> =>
207
+ new Promise((resolve) => rl.question(prompt, resolve));
208
+ const ask: AskFn = async (label, defaultValue, opts = {}) => {
209
+ const shown = opts.mask && defaultValue ? "***" : defaultValue;
210
+ const suffix = defaultValue ? ` [${shown}]` : "";
211
+ while (true) {
212
+ const answer = (await question(` ${label}${suffix}: `)).trim();
213
+ const value = answer === "" ? defaultValue : answer;
214
+ if (!value && !opts.optional) {
215
+ stdout.write(` (required)\n`);
216
+ continue;
217
+ }
218
+ const err = opts.validate?.(value);
219
+ if (err) {
220
+ stdout.write(` ${tty() ? ANSI.red : ""}${err}${tty() ? ANSI.reset : ""}\n`);
221
+ continue;
222
+ }
223
+ return value;
224
+ }
225
+ };
226
+ (ask as AskFn & { close: () => void }).close = () => rl.close();
227
+ return ask;
228
+ }
229
+
230
+ // Non-TTY: slurp all lines eagerly.
231
+ const lines: string[] = [];
232
+ let drained = false;
233
+ let pending: Array<() => void> = [];
234
+ const drain = (chunk: Buffer | string): void => {
235
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf-8");
236
+ for (const raw of text.split("\n")) lines.push(raw);
237
+ // Split leaves a trailing empty element if input ended with \n; keep it
238
+ // because it's a legitimate "user pressed enter with nothing" answer.
239
+ };
240
+ stdin.on("data", drain);
241
+ stdin.once("end", () => {
242
+ drained = true;
243
+ const waiters = pending;
244
+ pending = [];
245
+ for (const w of waiters) w();
246
+ });
247
+
248
+ const waitForDrain = (): Promise<void> =>
249
+ drained ? Promise.resolve() : new Promise((resolve) => pending.push(resolve));
250
+
251
+ const ask: AskFn = async (label, defaultValue, opts = {}) => {
252
+ await waitForDrain();
253
+ const raw = lines.shift();
254
+ const answer = (raw ?? "").trim();
255
+ const value = answer === "" ? defaultValue : answer;
256
+ if (!value && !opts.optional) {
257
+ throw new Error(`${label}: required (stdin exhausted).`);
258
+ }
259
+ const err = opts.validate?.(value);
260
+ if (err) {
261
+ throw new Error(`${label}: ${err}`);
262
+ }
263
+ return value;
264
+ };
265
+ return ask;
266
+ }
267
+
268
+ // --- main ---
269
+
270
+ async function main(): Promise<void> {
271
+ stdout.write(
272
+ tty()
273
+ ? `\n${ANSI.bold}Pinta Codex — one-shot setup${ANSI.reset}\n`
274
+ : `\nPinta Codex — one-shot setup\n`,
275
+ );
276
+ const ask = buildAsk();
277
+ try {
278
+ await stepBuild();
279
+ const { endpoint } = await stepEnv(ask);
280
+ await stepConfigToml();
281
+ await stepHooks();
282
+ await stepSummary(endpoint);
283
+ } finally {
284
+ (ask as AskFn & { close?: () => void }).close?.();
285
+ }
286
+ }
287
+
288
+ main().catch((err) => stderrFail(err instanceof Error ? err.message : String(err)));
@@ -0,0 +1,58 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import { buildOtlpPayload } from '../src/core/otlp.js';
3
+
4
+ let failures = 0;
5
+ function check(name: string, fn: () => void): void {
6
+ try {
7
+ fn();
8
+ process.stdout.write(` ok ${name}\n`);
9
+ } catch (err) {
10
+ failures++;
11
+ process.stdout.write(` FAIL ${name}\n ${err instanceof Error ? err.message : err}\n`);
12
+ }
13
+ }
14
+
15
+ check('flattenEvent emits ingest.type=codex as first attribute', () => {
16
+ const payload = buildOtlpPayload({
17
+ event: {
18
+ hook_event_name: 'UserPromptSubmit',
19
+ session_id: 's1',
20
+ transcript_path: '/tmp/t',
21
+ cwd: '/home/u',
22
+ prompt: 'hi',
23
+ },
24
+ traceId: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
25
+ now: 1700000000000,
26
+ });
27
+ const attrs = payload.resourceSpans[0].scopeSpans[0].spans[0].attributes;
28
+ assert.equal(attrs[0].key, 'ingest.type', `expected first attr to be ingest.type, got ${attrs[0].key}`);
29
+ assert.deepEqual(attrs[0].value, { stringValue: 'codex' });
30
+ assert.equal(attrs[1].key, 'codex.hook', `expected second attr to be codex.hook, got ${attrs[1].key}`);
31
+ });
32
+
33
+ check('flattenEvent still emits codex.hook and other codex.* attrs', () => {
34
+ const payload = buildOtlpPayload({
35
+ event: {
36
+ hook_event_name: 'PreToolUse',
37
+ session_id: 's1',
38
+ transcript_path: '/tmp/t',
39
+ cwd: '/home/u',
40
+ tool_name: 'Bash',
41
+ tool_input: { command: 'ls' },
42
+ tool_use_id: 'tu1',
43
+ },
44
+ traceId: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
45
+ now: 1700000000000,
46
+ });
47
+ const attrs = payload.resourceSpans[0].scopeSpans[0].spans[0].attributes;
48
+ const keys = attrs.map((a: { key: string }) => a.key);
49
+ assert.ok(keys.includes('codex.hook'), `missing codex.hook; keys=${keys.join(',')}`);
50
+ assert.ok(keys.includes('codex.tool_name'), `missing codex.tool_name; keys=${keys.join(',')}`);
51
+ assert.ok(keys.includes('codex.tool_use_id'), `missing codex.tool_use_id; keys=${keys.join(',')}`);
52
+ });
53
+
54
+ if (failures > 0) {
55
+ process.stdout.write(`\n${failures} failure(s)\n`);
56
+ process.exit(1);
57
+ }
58
+ process.stdout.write('\nOK\n');
@@ -0,0 +1,286 @@
1
+ import { strict as assert } from "node:assert";
2
+ import {
3
+ truncate,
4
+ redact,
5
+ MAX_BYTES,
6
+ PATTERNS,
7
+ collectMatches,
8
+ resolveOverlaps,
9
+ applyMatches,
10
+ } from "../src/core/redact.js";
11
+
12
+ let failures = 0;
13
+ function check(name: string, fn: () => void): void {
14
+ try {
15
+ fn();
16
+ process.stdout.write(` ok ${name}\n`);
17
+ } catch (err) {
18
+ failures++;
19
+ process.stdout.write(` FAIL ${name}\n ${err instanceof Error ? err.message : err}\n`);
20
+ }
21
+ }
22
+
23
+ // --- truncate ---
24
+ check("truncate: short input passes through", () => {
25
+ assert.equal(truncate("hello"), "hello");
26
+ });
27
+
28
+ check("truncate: long input is cut and tagged", () => {
29
+ const input = "a".repeat(MAX_BYTES + 100);
30
+ const out = truncate(input);
31
+ assert.equal(out.startsWith("a".repeat(MAX_BYTES)), true);
32
+ const headBytes = Buffer.from(out.split("…[TRUNCATED:")[0], "utf-8").length;
33
+ assert.equal(headBytes, MAX_BYTES, `head should be exactly MAX_BYTES, got ${headBytes}`);
34
+ assert.match(out, new RegExp(`…\\[TRUNCATED:${MAX_BYTES + 100}\\]$`));
35
+ });
36
+
37
+ check("truncate: input exactly at cap passes through", () => {
38
+ const input = "a".repeat(MAX_BYTES);
39
+ assert.equal(truncate(input), input);
40
+ });
41
+
42
+ check("truncate: counts bytes not codepoints (multibyte char)", () => {
43
+ // "한" is 3 bytes UTF-8. With a tiny synthetic cap we can't override MAX_BYTES,
44
+ // so verify the byte-aware byte count by ensuring a 100KB+ multibyte string
45
+ // is truncated and the original byte length appears in the tag.
46
+ const oneChar = "한"; // 3 bytes
47
+ const charsToExceed = Math.ceil(MAX_BYTES / 3) + 10;
48
+ const input = oneChar.repeat(charsToExceed);
49
+ const origBytes = Buffer.from(input, "utf-8").length;
50
+ const out = truncate(input);
51
+ const headBytes = Buffer.from(out.split("…[TRUNCATED:")[0], "utf-8").length;
52
+ // After slicing on a byte boundary, toString("utf-8") replaces any partial
53
+ // codepoint at the tail with U+FFFD (3 bytes), so headBytes may be up to
54
+ // MAX_BYTES + 2 when the boundary lands mid-codepoint. Allow for that.
55
+ assert.ok(headBytes <= MAX_BYTES + 2, `head exceeds MAX_BYTES+2: ${headBytes}`);
56
+ assert.match(out, new RegExp(`…\\[TRUNCATED:${origBytes}\\]$`));
57
+ });
58
+
59
+ interface PatternCase {
60
+ type: string;
61
+ input: string;
62
+ /** Substring(s) the *output* must contain. */
63
+ expectContains: string[];
64
+ /** Substring(s) the *output* must NOT contain (typically the original secret). */
65
+ expectMissing?: string[];
66
+ context?: "bash";
67
+ }
68
+
69
+ function applySinglePattern(input: string, type: string): string {
70
+ // Direct application of a single pattern's matches via the same algorithm
71
+ // redact() will use. We re-run regex here without the full pipeline so a
72
+ // pattern test fails when the regex is wrong, not when resolution is wrong.
73
+ const pattern = PATTERNS.find((x) => x.type === type);
74
+ if (!pattern) throw new Error(`no PATTERN with type=${type}`);
75
+ const re = new RegExp(pattern.regex.source, pattern.regex.flags);
76
+ let out = "";
77
+ let last = 0;
78
+ let m: RegExpExecArray | null;
79
+ while ((m = re.exec(input)) !== null) {
80
+ const cg = pattern.captureGroup ?? 0;
81
+ const captured = m[cg];
82
+ if (captured === undefined) continue;
83
+ const captureStart = m.index + m[0].indexOf(captured);
84
+ const captureEnd = captureStart + captured.length;
85
+ out += input.slice(last, captureStart) + `[REDACTED:${pattern.type}]`;
86
+ last = captureEnd;
87
+ if (m.index === re.lastIndex) re.lastIndex++; // zero-length guard
88
+ }
89
+ out += input.slice(last);
90
+ return out;
91
+ }
92
+
93
+ const PATTERN_CASES: PatternCase[] = [
94
+ {
95
+ type: "aws_access_key",
96
+ input: "key=AKIAIOSFODNN7EXAMPLE end",
97
+ expectContains: ["[REDACTED:aws_access_key]"],
98
+ expectMissing: ["AKIAIOSFODNN7EXAMPLE"],
99
+ },
100
+ {
101
+ type: "aws_secret_key",
102
+ input: "AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
103
+ expectContains: ["[REDACTED:aws_secret_key]"],
104
+ expectMissing: ["wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"],
105
+ },
106
+ {
107
+ type: "gcp_service_account",
108
+ input: '{"type":"service_account","project_id":"foo","private_key":"-----BEGIN PRIVATE KEY-----\\nABC\\n-----END PRIVATE KEY-----"}',
109
+ expectContains: ["[REDACTED:gcp_service_account]"],
110
+ expectMissing: ['"project_id":"foo"', "BEGIN PRIVATE KEY"],
111
+ },
112
+ {
113
+ type: "github_token",
114
+ input: "token=ghp_abcdefghijklmnopqrstuvwxyz0123456789AB end",
115
+ expectContains: ["[REDACTED:github_token]"],
116
+ expectMissing: ["ghp_abcdefghijklmnopqrstuvwxyz0123456789AB"],
117
+ },
118
+ {
119
+ type: "gitlab_token",
120
+ input: "GitLab: glpat-abcdefghijklmno12345",
121
+ expectContains: ["[REDACTED:gitlab_token]"],
122
+ },
123
+ {
124
+ type: "slack_token",
125
+ input: "xoxb-1234567890-abcdef0123456789",
126
+ expectContains: ["[REDACTED:slack_token]"],
127
+ },
128
+ {
129
+ type: "openai_key",
130
+ input: "OpenAI: sk-proj-abcdefghij0123456789ABCDEFGHIJ0123456789klmn",
131
+ expectContains: ["[REDACTED:openai_key]"],
132
+ },
133
+ {
134
+ type: "anthropic_key",
135
+ input: "Key: sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKL_-",
136
+ expectContains: ["[REDACTED:anthropic_key]"],
137
+ },
138
+ {
139
+ type: "stripe_key",
140
+ input: "stripe sk_live_abcdefghijklmnop0123456789",
141
+ expectContains: ["[REDACTED:stripe_key]"],
142
+ },
143
+ {
144
+ type: "jwt",
145
+ input: "Token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqb2huIn0.abc123def456ghi789jkl",
146
+ expectContains: ["[REDACTED:jwt]"],
147
+ },
148
+ {
149
+ type: "private_key_block",
150
+ input: "-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAKj34GkxF...\n-----END RSA PRIVATE KEY-----",
151
+ expectContains: ["[REDACTED:private_key_block]"],
152
+ expectMissing: ["MIIBOgIBAAJBAKj34GkxF"],
153
+ },
154
+ {
155
+ type: "bearer_token",
156
+ input: "Authorization: Bearer abc123def456ghi789",
157
+ expectContains: ["Bearer [REDACTED:bearer_token]"],
158
+ expectMissing: ["abc123def456ghi789"],
159
+ },
160
+ {
161
+ type: "basic_auth",
162
+ input: "Authorization: Basic dXNlcjpwYXNzd29yZA==",
163
+ expectContains: ["Basic [REDACTED:basic_auth]"],
164
+ },
165
+ {
166
+ type: "db_url_password",
167
+ input: "DATABASE_URL=postgres://app:s3cret!@db:5432/foo",
168
+ expectContains: ["postgres://app:[REDACTED:db_url_password]@db:5432/foo"],
169
+ expectMissing: ["s3cret!"],
170
+ },
171
+ {
172
+ type: "cli_password_flag",
173
+ input: "mysql --password=hunter2 mydb",
174
+ expectContains: ["[REDACTED:cli_password_flag]"],
175
+ expectMissing: ["hunter2"],
176
+ },
177
+ {
178
+ type: "cli_password_short",
179
+ input: "mysql -phunter2 mydb",
180
+ expectContains: ["[REDACTED:cli_password_short]"],
181
+ expectMissing: ["hunter2"],
182
+ context: "bash",
183
+ },
184
+ {
185
+ type: "env_var_secret",
186
+ input: "OPENAI_API_KEY=sk-abc123def456",
187
+ expectContains: ["OPENAI_API_KEY=[REDACTED:env_var_secret]"],
188
+ expectMissing: ["sk-abc123def456"],
189
+ },
190
+ ];
191
+
192
+ for (const tc of PATTERN_CASES) {
193
+ check(`pattern ${tc.type}`, () => {
194
+ const out = applySinglePattern(tc.input, tc.type);
195
+ for (const c of tc.expectContains) {
196
+ assert.ok(out.includes(c), `expected output to contain "${c}"; got: ${out}`);
197
+ }
198
+ for (const m of tc.expectMissing ?? []) {
199
+ assert.ok(!out.includes(m), `expected output to NOT contain "${m}"; got: ${out}`);
200
+ }
201
+ });
202
+ }
203
+
204
+ function pipeline(input: string, opts: { context?: "bash" } = {}): string {
205
+ const all = collectMatches(input, opts);
206
+ const kept = resolveOverlaps(all);
207
+ return applyMatches(input, kept);
208
+ }
209
+
210
+ check("overlap: bearer absorbs github_token inside it", () => {
211
+ const input = "Authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz0123456789AB";
212
+ const out = pipeline(input);
213
+ assert.ok(out.includes("Bearer [REDACTED:bearer_token]"), out);
214
+ assert.ok(!out.includes("[REDACTED:github_token]"), out);
215
+ });
216
+
217
+ check("overlap: env_var_secret wins over openai_key when capture spans key", () => {
218
+ const input = "OPENAI_API_KEY=sk-proj-abcdefghij0123456789ABCDEFGHIJ0123456789klmn";
219
+ const out = pipeline(input);
220
+ // env_var_secret has a wider whole-match (starts at OPENAI_API_KEY, ends at value end),
221
+ // so it wins under the start-asc / longest-on-tie rule.
222
+ assert.ok(out.includes("OPENAI_API_KEY=[REDACTED:env_var_secret]"), out);
223
+ assert.ok(!out.includes("[REDACTED:openai_key]"), out);
224
+ });
225
+
226
+ check("ordering: independent matches both applied", () => {
227
+ const input = "first AKIAIOSFODNN7EXAMPLE then ghp_abcdefghijklmnopqrstuvwxyz0123456789AB";
228
+ const out = pipeline(input);
229
+ assert.ok(out.includes("[REDACTED:aws_access_key]"), out);
230
+ assert.ok(out.includes("[REDACTED:github_token]"), out);
231
+ });
232
+
233
+ check("context guard: cli_password_short ignored without context", () => {
234
+ const input = "mysql -phunter2 mydb";
235
+ const out = pipeline(input); // no context
236
+ assert.equal(out.includes("[REDACTED:cli_password_short]"), false);
237
+ });
238
+
239
+ check("context guard: cli_password_short applied with bash context", () => {
240
+ const input = "mysql -phunter2 mydb";
241
+ const out = pipeline(input, { context: "bash" });
242
+ assert.ok(out.includes("[REDACTED:cli_password_short]"), out);
243
+ });
244
+
245
+ check("redact: empty string passes through", () => {
246
+ assert.equal(redact("", {}), "");
247
+ });
248
+
249
+ check("redact: combines multiple secrets", () => {
250
+ const input = "AKIAIOSFODNN7EXAMPLE and ghp_abcdefghijklmnopqrstuvwxyz0123456789AB";
251
+ const out = redact(input);
252
+ assert.ok(out.includes("[REDACTED:aws_access_key]"), out);
253
+ assert.ok(out.includes("[REDACTED:github_token]"), out);
254
+ });
255
+
256
+ // --- negative (false-positive guards) ---
257
+ check("negative: 40-byte commit SHA ignored", () => {
258
+ const input = "commit 0123456789abcdef0123456789abcdef01234567 by alice";
259
+ assert.equal(redact(input), input);
260
+ });
261
+
262
+ check("negative: UUID ignored", () => {
263
+ const input = "uuid=550e8400-e29b-41d4-a716-446655440000 done";
264
+ assert.equal(redact(input), input);
265
+ });
266
+
267
+ check("negative: random base64 thumbnail not masked (no context)", () => {
268
+ const input = "thumb=YWJjZGVmZ2hpamtsbW5vcA== loaded";
269
+ assert.equal(redact(input), input);
270
+ });
271
+
272
+ check("negative: 40-char base64 alone (no aws_secret context) ignored", () => {
273
+ const input = "blob=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY end";
274
+ assert.equal(redact(input), input);
275
+ });
276
+
277
+ check("negative: dash-p in normal text without bash context ignored", () => {
278
+ const input = "see also -pretty option";
279
+ assert.equal(redact(input), input);
280
+ });
281
+
282
+ if (failures > 0) {
283
+ process.stdout.write(`\n${failures} failure(s)\n`);
284
+ process.exit(1);
285
+ }
286
+ process.stdout.write("\nOK\n");
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "types": ["node"],
9
+ "lib": ["ES2022"],
10
+ "noEmit": true
11
+ },
12
+ "include": ["./**/*.ts"]
13
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Remove this plugin's entries from `~/.codex/hooks.json`.
3
+ *
4
+ * Matches entries whose `command` references this plugin's dist/index.js
5
+ * absolute path. Other users' hook entries are left untouched.
6
+ */
7
+
8
+ import fs from "node:fs";
9
+ import {
10
+ CODEX_HOOKS_PATH,
11
+ readJson,
12
+ stripPintaCodex,
13
+ type HooksFile,
14
+ } from "./_lib.js";
15
+
16
+ const dryRun = process.argv.includes("--dry-run");
17
+
18
+ function main(): void {
19
+ if (!fs.existsSync(CODEX_HOOKS_PATH)) {
20
+ process.stdout.write(`[uninstall-hooks] nothing to do: ${CODEX_HOOKS_PATH} does not exist\n`);
21
+ return;
22
+ }
23
+ const existing: HooksFile = readJson(CODEX_HOOKS_PATH, { hooks: {} });
24
+ const { next, removed } = stripPintaCodex(existing);
25
+ const serialized = JSON.stringify(next, null, 2) + "\n";
26
+
27
+ if (dryRun) {
28
+ process.stdout.write(
29
+ `--- ${CODEX_HOOKS_PATH} (dry run, ${removed} entries would be removed) ---\n`,
30
+ );
31
+ process.stdout.write(serialized);
32
+ return;
33
+ }
34
+
35
+ fs.writeFileSync(CODEX_HOOKS_PATH, serialized, "utf-8");
36
+ process.stdout.write(`[uninstall-hooks] removed ${removed} entries from ${CODEX_HOOKS_PATH}\n`);
37
+ }
38
+
39
+ main();