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.
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/bin/codex-toolkit.js +6 -0
- package/examples/auto-lint.config.example.json +11 -0
- package/examples/scope-guard.config.example.json +15 -0
- package/package.json +65 -0
- package/src/auto-lint.js +254 -0
- package/src/diff-budget.js +173 -0
- package/src/hook-protocol.js +146 -0
- package/src/index.js +12 -0
- package/src/installer.js +331 -0
- package/src/scope-guard.js +180 -0
- package/src/shield-destructive-cmd.js +179 -0
- package/src/shield-env-guard.js +192 -0
- package/src/state-store.js +91 -0
- package/src/tool-pace-check.js +130 -0
|
@@ -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
|
+
}
|