codex-toolkit 0.4.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.
@@ -0,0 +1,180 @@
1
+ // scope-guard — the core hook of codex-toolkit.
2
+ //
3
+ // Problem: AI coding agents (Codex CLI included) tend to expand their action
4
+ // surface beyond the task you actually asked for. "Fix the bug in auth.ts"
5
+ // turns into "while I'm here, let me also reformat 30 unrelated files and
6
+ // rewrite the README." This hook is the line of defense.
7
+ //
8
+ // How it works:
9
+ // 1. You declare the task scope in a config file (see examples/scope-guard.config.example.json).
10
+ // Examples: { allow: ["src/auth/**", "tests/auth/**"] }
11
+ // 2. When Codex is about to mutate a file, the hook reads the target path
12
+ // and checks it against the declared scope.
13
+ // 3. If the path is OUT of scope, the hook returns a `deny` decision with
14
+ // a human-readable reason. Codex's permission system then surfaces that
15
+ // to the user (or auto-blocks, depending on approval policy).
16
+ //
17
+ // Run modes:
18
+ // - "enforce" -> deny out-of-scope edits outright (default)
19
+ // - "ask" -> ask the user to confirm out-of-scope edits
20
+ // - "off" -> disabled (the hook still logs the decision)
21
+
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import process from 'node:process';
25
+ import {
26
+ DECISIONS,
27
+ FILE_MUTATING_TOOLS,
28
+ emitDecision,
29
+ emitError,
30
+ extractTargetPath,
31
+ parseHookInput,
32
+ } from './hook-protocol.js';
33
+
34
+ const DEFAULT_CONFIG = {
35
+ mode: 'enforce',
36
+ allow: ['**/*'],
37
+ deny: [],
38
+ log: true,
39
+ };
40
+
41
+ function loadConfig() {
42
+ // Config resolution order (closest wins):
43
+ // 1. $CODEX_TOOLKIT_SCOPE_GUARD_CONFIG (explicit override)
44
+ // 2. <cwd>/.codex-toolkit/scope-guard.json
45
+ // 3. ~/.codex/scope-guard.json
46
+ const explicit = process.env.CODEX_TOOLKIT_SCOPE_GUARD_CONFIG;
47
+ const candidates = explicit
48
+ ? [explicit]
49
+ : [
50
+ path.join(process.cwd(), '.codex-toolkit', 'scope-guard.json'),
51
+ path.join(process.env.HOME || '', '.codex', 'scope-guard.json'),
52
+ ];
53
+ for (const file of candidates) {
54
+ if (!file) continue;
55
+ try {
56
+ const raw = fs.readFileSync(file, 'utf8');
57
+ const parsed = JSON.parse(raw);
58
+ return { ...DEFAULT_CONFIG, ...parsed, __source: file };
59
+ } catch (err) {
60
+ if (err.code !== 'ENOENT') {
61
+ emitError(`scope-guard: failed to read config ${file}: ${err.message}`);
62
+ }
63
+ }
64
+ }
65
+ return { ...DEFAULT_CONFIG, __source: null };
66
+ }
67
+
68
+ // Minimal glob matching supporting **, *, ? and character classes.
69
+ // We deliberately avoid pulling in a glob library — the scope of codex-toolkit
70
+ // is "small and shippable", and a 30-line matcher covers 99% of real cases.
71
+ function globToRegex(glob) {
72
+ let re = '';
73
+ for (let i = 0; i < glob.length; i++) {
74
+ const c = glob[i];
75
+ if (c === '*') {
76
+ if (glob[i + 1] === '*') {
77
+ re += '.*';
78
+ i++;
79
+ if (glob[i + 1] === '/') i++;
80
+ } else {
81
+ re += '[^/]*';
82
+ }
83
+ } else if (c === '?') {
84
+ re += '[^/]';
85
+ } else if (c === '.') {
86
+ re += '\\.';
87
+ } else if ('+(){}|^$\\'.includes(c)) {
88
+ re += '\\' + c;
89
+ } else {
90
+ re += c;
91
+ }
92
+ }
93
+ return new RegExp('^' + re + '$');
94
+ }
95
+
96
+ function matchesAny(target, patterns) {
97
+ const norm = target.split(path.sep).join('/');
98
+ return (patterns || []).some((p) => globToRegex(p).test(norm));
99
+ }
100
+
101
+ function decide(config, targetPath) {
102
+ if (!targetPath) {
103
+ return { decision: DECISIONS.ALLOW, reason: null };
104
+ }
105
+ if (matchesAny(targetPath, config.deny)) {
106
+ return {
107
+ decision: DECISIONS.DENY,
108
+ reason: `scope-guard: "${targetPath}" matches a deny pattern. Refusing the edit.`,
109
+ };
110
+ }
111
+ if (matchesAny(targetPath, config.allow)) {
112
+ return { decision: DECISIONS.ALLOW, reason: null };
113
+ }
114
+ if (config.mode === 'off') {
115
+ return { decision: DECISIONS.ALLOW, reason: null };
116
+ }
117
+ if (config.mode === 'ask') {
118
+ return {
119
+ decision: DECISIONS.ASK,
120
+ reason: `scope-guard: "${targetPath}" is outside the declared scope ${JSON.stringify(
121
+ config.allow
122
+ )}. Approve this out-of-scope edit?`,
123
+ };
124
+ }
125
+ return {
126
+ decision: DECISIONS.DENY,
127
+ reason: `scope-guard: "${targetPath}" is outside the declared scope ${JSON.stringify(
128
+ config.allow
129
+ )}. Update your .codex-toolkit/scope-guard.json if this edit is intentional.`,
130
+ };
131
+ }
132
+
133
+ export function evaluate(event) {
134
+ const config = loadConfig();
135
+ if (!FILE_MUTATING_TOOLS.has(event.toolName)) {
136
+ return { decision: DECISIONS.ALLOW, reason: null, skipped: true };
137
+ }
138
+ const target = extractTargetPath(event.toolInput);
139
+ const result = decide(config, target);
140
+ if (config.log) {
141
+ process.stderr.write(
142
+ `[scope-guard] tool=${event.toolName} target=${target ?? '(none)'} -> ${result.decision}\n`
143
+ );
144
+ }
145
+ return { ...result, config: { ...config, __source: undefined } };
146
+ }
147
+
148
+ // --- CLI entry point ---------------------------------------------------------
149
+
150
+ function readStdin() {
151
+ return new Promise((resolve) => {
152
+ let data = '';
153
+ process.stdin.setEncoding('utf8');
154
+ process.stdin.on('data', (chunk) => (data += chunk));
155
+ process.stdin.on('end', () => resolve(data));
156
+ });
157
+ }
158
+
159
+ async function main() {
160
+ const raw = await readStdin();
161
+ const parsed = parseHookInput(raw);
162
+ if (!parsed.ok) {
163
+ emitError(`scope-guard: ${parsed.error}`);
164
+ return;
165
+ }
166
+ const result = evaluate(parsed);
167
+ emitDecision(result.decision, result.reason);
168
+ if (result.decision === DECISIONS.DENY) {
169
+ process.exit(2);
170
+ }
171
+ }
172
+
173
+ const isMain =
174
+ import.meta.url === `file://${process.argv[1]}` ||
175
+ process.argv[1]?.endsWith('scope-guard.js');
176
+ if (isMain) {
177
+ main().catch((err) => emitError(err.stack || err.message));
178
+ }
179
+
180
+ export default { evaluate };
@@ -0,0 +1,179 @@
1
+ // shield-destructive-cmd — block the small set of shell commands that can
2
+ // destroy a project (or worse, the whole home directory) in one keystroke.
3
+ //
4
+ // Philosophy: the goal is not to be a security product. The goal is to catch
5
+ // the 1% of commands that, if Codex gets the syntax wrong, will take your
6
+ // project with it. We default-deny on the canonical destructive patterns;
7
+ // users can override per-project via config.
8
+ //
9
+ // Triggers on PreToolUse for shell-style tools (Bash, shell, exec).
10
+ // Configuration: <cwd>/.codex-toolkit/shield-destructive-cmd.json
11
+ // {
12
+ // "mode": "enforce" | "ask" | "off",
13
+ // "extra_patterns": ["regex", ...], // appended to the default list
14
+ // "allow_overrides": ["regex", ...], // matched against the FULL command
15
+ // }
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import process from 'node:process';
20
+ import {
21
+ DECISIONS,
22
+ SHELL_TOOLS,
23
+ emitDecision,
24
+ emitError,
25
+ extractShellCommand,
26
+ parseHookInput,
27
+ } from './hook-protocol.js';
28
+
29
+ // Default deny list. Each entry is [name, regex, why]. Order does not matter;
30
+ // every matching pattern is reported.
31
+ //
32
+ // We use `(?=\s|$)` (or `(?=\s|;|$)`) at the end of patterns that may
33
+ // terminate on a non-word character such as `~` or `.`. Plain `\b` does not
34
+ // match between two non-word characters (e.g. between `~` and a space), so
35
+ // the standard "end of argument" anchor would miss `rm -rf ~` and similar.
36
+ const DEFAULT_DENY = [
37
+ ['rm-rf-root', /\brm\s+(-[rRfF]+\s+)*\/(?=\s|$|;)/i, 'recursively deleting root (/)'],
38
+ ['rm-rf-home', /\brm\s+(-[rRfF]+\s+)*(~|\$HOME|\/Users\/[^/\s]+)(?=\s|$|;)/i, 'recursively deleting the home directory'],
39
+ ['rm-rf-cwd', /\brm\s+(-[rRfF]+\s+)*\.(?=\s|$|;)/i, 'recursively deleting the current directory'],
40
+ ['rm-rf-star', /\brm\s+(-[rRfF]+\s+)*\*(?=\s|$|;)/i, 'rm with a bare glob can sweep more than you expect'],
41
+
42
+ ['git-force-push', /\bgit\s+push\s+(-[^\s]*\bf\b|--force(-with-lease)?)\b/i, 'force-push rewrites remote history'],
43
+ ['git-hard-reset', /\bgit\s+reset\s+--hard\b/i, 'hard reset discards uncommitted changes'],
44
+ ['git-clean-fd', /\bgit\s+clean\s+-[fFdD]+\b/i, 'git clean -fd removes untracked files'],
45
+
46
+ ['drop-database', /\bdrop\s+(database|schema)\b/i, 'DROP DATABASE/SCHEMA wipes data'],
47
+ ['drop-table', /\bdrop\s+table\b/i, 'DROP TABLE wipes a table'],
48
+ ['truncate', /\btruncate\s+(table\s+)?\w+/i, 'TRUNCATE wipes all rows in a table'],
49
+
50
+ ['kubectl-delete-pod', /\bkubectl\s+delete\s+(pod|deployment|namespace|ns)\b(?![^]*--dry-run)/i, 'kubectl delete on a live object (use --dry-run)'],
51
+ ['docker-system-prune', /\bdocker\s+system\s+prune\s+-a\b/i, 'docker system prune -a removes all stopped containers and images'],
52
+
53
+ ['fork-bomb', /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/, 'classic shell fork bomb'],
54
+ ['dd-zero', /\bdd\s+[^|;&]*\bif=\/dev\/zero\b[^|;&]*\bof=\/dev\/(sd|nvme|disk)\b/i, 'dd of=/dev/sd* with /dev/zero bricks the disk'],
55
+ ['chmod-777-recursive', /\bchmod\s+-R\s+777\s+\//, 'recursive chmod 777 on / exposes everything'],
56
+ ];
57
+
58
+ const DEFAULT_CONFIG = {
59
+ mode: 'enforce',
60
+ extra_patterns: [],
61
+ allow_overrides: [],
62
+ log: true,
63
+ };
64
+
65
+ function loadConfig() {
66
+ const candidates = [
67
+ process.env.CODEX_TOOLKIT_SHIELD_DESTRUCTIVE_CONFIG,
68
+ path.join(process.cwd(), '.codex-toolkit', 'shield-destructive-cmd.json'),
69
+ path.join(process.env.HOME || '', '.codex', 'shield-destructive-cmd.json'),
70
+ ].filter(Boolean);
71
+ for (const file of candidates) {
72
+ try {
73
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
74
+ return { ...DEFAULT_CONFIG, ...parsed };
75
+ } catch (err) {
76
+ if (err.code !== 'ENOENT') {
77
+ emitError(`shield-destructive-cmd: failed to read ${file}: ${err.message}`);
78
+ }
79
+ }
80
+ }
81
+ return { ...DEFAULT_CONFIG };
82
+ }
83
+
84
+ function compileUserPatterns(patterns) {
85
+ const out = [];
86
+ for (const p of patterns || []) {
87
+ try {
88
+ out.push(new RegExp(p, 'i'));
89
+ } catch (err) {
90
+ emitError(`shield-destructive-cmd: invalid pattern "${p}": ${err.message}`);
91
+ }
92
+ }
93
+ return out;
94
+ }
95
+
96
+ export function evaluate(event) {
97
+ const config = loadConfig();
98
+ if (config.mode === 'off') {
99
+ return { decision: DECISIONS.ALLOW, reason: null, skipped: true };
100
+ }
101
+ if (!SHELL_TOOLS.has(event.toolName)) {
102
+ return { decision: DECISIONS.ALLOW, reason: null, skipped: true };
103
+ }
104
+ const command = extractShellCommand(event.toolInput);
105
+ if (!command) {
106
+ return { decision: DECISIONS.ALLOW, reason: null, skipped: true };
107
+ }
108
+
109
+ // Allow overrides match the whole command; if any allow matches, the hook
110
+ // exits early (without consulting the deny list).
111
+ for (const re of compileUserPatterns(config.allow_overrides)) {
112
+ if (re.test(command)) {
113
+ if (config.log) {
114
+ process.stderr.write(`[shield-destructive-cmd] allowed by override: ${command}\n`);
115
+ }
116
+ return { decision: DECISIONS.ALLOW, reason: null, override: true };
117
+ }
118
+ }
119
+
120
+ // Deny list: built-in + user extras. Collect all matches for the report.
121
+ const deny = [
122
+ ...DEFAULT_DENY,
123
+ ...compileUserPatterns(config.extra_patterns).map((re, i) => [
124
+ `user-${i}`,
125
+ re,
126
+ 'matched a user-defined destructive pattern',
127
+ ]),
128
+ ];
129
+ const hits = [];
130
+ for (const [name, re, why] of deny) {
131
+ if (re.test(command)) hits.push({ name, why });
132
+ }
133
+ if (hits.length === 0) {
134
+ return { decision: DECISIONS.ALLOW, reason: null };
135
+ }
136
+ const summary = hits.map((h) => ` - ${h.name}: ${h.why}`).join('\n');
137
+ const reason = `shield-destructive-cmd: refused command\n ${command}\nMatched destructive pattern(s):\n${summary}\nIf this is intentional, set "allow_overrides" in .codex-toolkit/shield-destructive-cmd.json.`;
138
+ if (config.log) {
139
+ process.stderr.write(`[shield-destructive-cmd] deny: ${hits.map((h) => h.name).join(', ')}\n`);
140
+ }
141
+ if (config.mode === 'ask') {
142
+ return { decision: DECISIONS.ASK, reason };
143
+ }
144
+ return { decision: DECISIONS.DENY, reason };
145
+ }
146
+
147
+ // --- CLI entry point ---------------------------------------------------------
148
+
149
+ function readStdin() {
150
+ return new Promise((resolve) => {
151
+ let data = '';
152
+ process.stdin.setEncoding('utf8');
153
+ process.stdin.on('data', (chunk) => (data += chunk));
154
+ process.stdin.on('end', () => resolve(data));
155
+ });
156
+ }
157
+
158
+ async function main() {
159
+ const raw = await readStdin();
160
+ const parsed = parseHookInput(raw);
161
+ if (!parsed.ok) {
162
+ emitError(`shield-destructive-cmd: ${parsed.error}`);
163
+ return;
164
+ }
165
+ const result = evaluate(parsed);
166
+ emitDecision(result.decision, result.reason);
167
+ if (result.decision === DECISIONS.DENY) {
168
+ process.exit(2);
169
+ }
170
+ }
171
+
172
+ const isMain =
173
+ import.meta.url === `file://${process.argv[1]}` ||
174
+ process.argv[1]?.endsWith('shield-destructive-cmd.js');
175
+ if (isMain) {
176
+ main().catch((err) => emitError(err.stack || err.message));
177
+ }
178
+
179
+ export default { evaluate };
@@ -0,0 +1,192 @@
1
+ // shield-env-guard — block writes to credential and secret files.
2
+ //
3
+ // We catch the canonical accidental-leak patterns: .env files, SSH keys,
4
+ // PEMs, AWS / GCP / npm / pip credential files, and anything that looks
5
+ // like a secrets directory. The deny list is intentionally conservative —
6
+ // "are we sure this isn't a secret?" is a question we want the user to
7
+ // answer explicitly, not the agent.
8
+ //
9
+ // Triggers on PreToolUse for file-mutating tools.
10
+ // Configuration: <cwd>/.codex-toolkit/shield-env-guard.json
11
+ // {
12
+ // "mode": "enforce" | "ask" | "off",
13
+ // "extra_patterns": ["..."], // append paths/globs to deny
14
+ // "allow_overrides": ["..."], // paths/globs explicitly allowed
15
+ // }
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import process from 'node:process';
20
+ import {
21
+ DECISIONS,
22
+ FILE_MUTATING_TOOLS,
23
+ emitDecision,
24
+ emitError,
25
+ extractTargetPath,
26
+ parseHookInput,
27
+ } from './hook-protocol.js';
28
+
29
+ // Sensitive path globs. Matched with case-insensitive shell-style globs.
30
+ const DEFAULT_DENY_GLOBS = [
31
+ // dotenv
32
+ '.env',
33
+ '.env.*',
34
+ '**/.env',
35
+ '**/.env.*',
36
+ // SSH keys
37
+ '**/id_rsa',
38
+ '**/id_dsa',
39
+ '**/id_ecdsa',
40
+ '**/id_ed25519',
41
+ '**/id_ed25519-sk',
42
+ '**/id_ecdsa-sk',
43
+ '**/.ssh/id_*',
44
+ // PEM / cert / key files (broad)
45
+ '**/*.pem',
46
+ '**/*.key',
47
+ '**/*.p12',
48
+ '**/*.pfx',
49
+ // Cloud creds
50
+ '**/.aws/credentials',
51
+ '**/credentials.json',
52
+ '**/gcloud-service-account*.json',
53
+ '**/service-account*.json',
54
+ // Package manager tokens
55
+ '**/.npmrc',
56
+ '**/.pypirc',
57
+ '**/.netrc',
58
+ // Sealed-secrets directories
59
+ '**/secrets/**',
60
+ '**/credentials/**',
61
+ '**/.gnupg/**',
62
+ ];
63
+
64
+ const DEFAULT_CONFIG = {
65
+ mode: 'enforce',
66
+ extra_patterns: [],
67
+ allow_overrides: [],
68
+ log: true,
69
+ };
70
+
71
+ function loadConfig() {
72
+ const candidates = [
73
+ process.env.CODEX_TOOLKIT_SHIELD_ENV_CONFIG,
74
+ path.join(process.cwd(), '.codex-toolkit', 'shield-env-guard.json'),
75
+ path.join(process.env.HOME || '', '.codex', 'shield-env-guard.json'),
76
+ ].filter(Boolean);
77
+ for (const file of candidates) {
78
+ try {
79
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
80
+ return { ...DEFAULT_CONFIG, ...parsed };
81
+ } catch (err) {
82
+ if (err.code !== 'ENOENT') {
83
+ emitError(`shield-env-guard: failed to read ${file}: ${err.message}`);
84
+ }
85
+ }
86
+ }
87
+ return { ...DEFAULT_CONFIG };
88
+ }
89
+
90
+ function globToRegex(glob) {
91
+ let re = '';
92
+ for (let i = 0; i < glob.length; i++) {
93
+ const c = glob[i];
94
+ if (c === '*') {
95
+ if (glob[i + 1] === '*') {
96
+ re += '.*';
97
+ i++;
98
+ if (glob[i + 1] === '/') i++;
99
+ } else {
100
+ re += '[^/]*';
101
+ }
102
+ } else if (c === '?') {
103
+ re += '[^/]';
104
+ } else if (c === '.') {
105
+ re += '\\.';
106
+ } else if ('+(){}|^$\\'.includes(c)) {
107
+ re += '\\' + c;
108
+ } else {
109
+ re += c;
110
+ }
111
+ }
112
+ return new RegExp('^' + re + '$', 'i');
113
+ }
114
+
115
+ function compileGlobs(globs) {
116
+ return (globs || []).map((g) => globToRegex(g));
117
+ }
118
+
119
+ function matchesAny(target, regexes) {
120
+ if (!target) return false;
121
+ const norm = target.split(path.sep).join('/');
122
+ return regexes.some((re) => re.test(norm));
123
+ }
124
+
125
+ export function evaluate(event) {
126
+ const config = loadConfig();
127
+ if (config.mode === 'off') {
128
+ return { decision: DECISIONS.ALLOW, reason: null, skipped: true };
129
+ }
130
+ if (!FILE_MUTATING_TOOLS.has(event.toolName)) {
131
+ return { decision: DECISIONS.ALLOW, reason: null, skipped: true };
132
+ }
133
+ const target = extractTargetPath(event.toolInput);
134
+ if (!target) {
135
+ return { decision: DECISIONS.ALLOW, reason: null };
136
+ }
137
+
138
+ const allowRe = compileGlobs(config.allow_overrides);
139
+ if (matchesAny(target, allowRe)) {
140
+ if (config.log) {
141
+ process.stderr.write(`[shield-env-guard] allowed by override: ${target}\n`);
142
+ }
143
+ return { decision: DECISIONS.ALLOW, reason: null, override: true };
144
+ }
145
+
146
+ const denyRe = compileGlobs([...DEFAULT_DENY_GLOBS, ...(config.extra_patterns || [])]);
147
+ if (!matchesAny(target, denyRe)) {
148
+ return { decision: DECISIONS.ALLOW, reason: null };
149
+ }
150
+ const reason = `shield-env-guard: refused to write to "${target}" — matches a sensitive path (SSH key, .env, cloud cred, package-manager token, or secrets dir). If this is intentional, set "allow_overrides" in .codex-toolkit/shield-env-guard.json.`;
151
+ if (config.log) {
152
+ process.stderr.write(`[shield-env-guard] deny: ${target}\n`);
153
+ }
154
+ if (config.mode === 'ask') {
155
+ return { decision: DECISIONS.ASK, reason };
156
+ }
157
+ return { decision: DECISIONS.DENY, reason };
158
+ }
159
+
160
+ // --- CLI entry point ---------------------------------------------------------
161
+
162
+ function readStdin() {
163
+ return new Promise((resolve) => {
164
+ let data = '';
165
+ process.stdin.setEncoding('utf8');
166
+ process.stdin.on('data', (chunk) => (data += chunk));
167
+ process.stdin.on('end', () => resolve(data));
168
+ });
169
+ }
170
+
171
+ async function main() {
172
+ const raw = await readStdin();
173
+ const parsed = parseHookInput(raw);
174
+ if (!parsed.ok) {
175
+ emitError(`shield-env-guard: ${parsed.error}`);
176
+ return;
177
+ }
178
+ const result = evaluate(parsed);
179
+ emitDecision(result.decision, result.reason);
180
+ if (result.decision === DECISIONS.DENY) {
181
+ process.exit(2);
182
+ }
183
+ }
184
+
185
+ const isMain =
186
+ import.meta.url === `file://${process.argv[1]}` ||
187
+ process.argv[1]?.endsWith('shield-env-guard.js');
188
+ if (isMain) {
189
+ main().catch((err) => emitError(err.stack || err.message));
190
+ }
191
+
192
+ export default { evaluate };
@@ -0,0 +1,91 @@
1
+ // state-store — tiny JSON-file-backed key/value store used by hooks that
2
+ // need to remember things across hook invocations (diff-budget, tool-pace-check).
3
+ //
4
+ // Codex invokes each hook as a fresh subprocess, so any in-memory state would
5
+ // be lost between calls. We persist to a small JSON file under the user's
6
+ // project directory (or CODEX_HOME if no project is found).
7
+ //
8
+ // All operations are atomic-ish: we read, mutate, write to a temp file, then
9
+ // rename. We never partially overwrite. If the file is corrupt, we recover
10
+ // gracefully (treat as empty) and back the bad file up to `.corrupt-<ts>`.
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import os from 'node:os';
15
+ import process from 'node:process';
16
+
17
+ const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');function defaultStateDir() {
18
+ // Tests can override the location via CODEX_HOME. In production we prefer
19
+ // a project-level directory (one .codex-toolkit/ per repo), with CODEX_HOME
20
+ // as the fallback. Reading CODEX_HOME lazily (each call) so test fixtures
21
+ // can set the env var *after* importing this module.
22
+ if (process.env.CODEX_HOME) {
23
+ try {
24
+ fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
25
+ fs.accessSync(process.env.CODEX_HOME, fs.constants.W_OK);
26
+ return process.env.CODEX_HOME;
27
+ } catch {
28
+ /* fall through to project-level */
29
+ }
30
+ }
31
+ const projectDir = path.join(process.cwd(), '.codex-toolkit');
32
+ try {
33
+ fs.mkdirSync(projectDir, { recursive: true });
34
+ fs.accessSync(projectDir, fs.constants.W_OK);
35
+ return projectDir;
36
+ } catch {
37
+ return CODEX_HOME;
38
+ }
39
+ }
40
+
41
+ export function resolveStateFile(name) {
42
+ const safe = String(name).replace(/[^a-z0-9._-]/gi, '_');
43
+ return path.join(defaultStateDir(), `${safe}.json`);
44
+ }
45
+
46
+ export function readState(file) {
47
+ try {
48
+ const raw = fs.readFileSync(file, 'utf8');
49
+ const parsed = JSON.parse(raw);
50
+ return parsed && typeof parsed === 'object' ? parsed : {};
51
+ } catch (err) {
52
+ if (err.code === 'ENOENT') return {};
53
+ // Corrupt file: back it up, treat as empty.
54
+ try {
55
+ fs.renameSync(file, `${file}.corrupt-${Date.now()}`);
56
+ } catch {
57
+ /* swallow */
58
+ }
59
+ return {};
60
+ }
61
+ }
62
+
63
+ export function writeState(file, value) {
64
+ fs.mkdirSync(path.dirname(file), { recursive: true });
65
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
66
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2));
67
+ fs.renameSync(tmp, file);
68
+ }
69
+
70
+ export function updateState(file, mutator, defaultValue = {}) {
71
+ const current = readState(file);
72
+ const next = mutator({ ...defaultValue, ...current }) || current;
73
+ writeState(file, next);
74
+ return next;
75
+ }
76
+
77
+ // Best-effort: extract a session/thread id from the event. We tolerate
78
+ // several shapes; if none is present, we fall back to a per-cwd key.
79
+ export function sessionKey(event) {
80
+ const candidates = [
81
+ event?.raw?.session_id,
82
+ event?.raw?.thread_id,
83
+ event?.raw?.sessionId,
84
+ event?.raw?.threadId,
85
+ event?.raw?.conversation_id,
86
+ ];
87
+ for (const c of candidates) {
88
+ if (typeof c === 'string' && c.length > 0) return c;
89
+ }
90
+ return `cwd:${process.cwd()}`;
91
+ }