@haposoft/cafekit 0.16.0-rc.4 → 0.16.0-rc.6
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/bin/__tests__/change-firewall.semantic-firewall.test.js +2134 -0
- package/bin/__tests__/codex-hooks.test.js +136 -64
- package/bin/__tests__/develop-contract.test.js +55 -17
- package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +342 -0
- package/bin/__tests__/package-inventory.test.js +29 -11
- package/bin/__tests__/release-preflight.semantic-firewall.test.js +586 -0
- package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +378 -0
- package/bin/__tests__/specs-v2-execution-closeout.test.js +59 -12
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +78 -14
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +222 -42
- package/bin/__tests__/validator-grounding.test.js +103 -4
- package/package.json +3 -2
- package/src/claude/hooks/__tests__/completion-authority.test.js +31 -1
- package/src/claude/hooks/__tests__/privacy-block.test.js +111 -29
- package/src/claude/hooks/privacy-block.cjs +38 -14
- package/src/claude/scripts/change-firewall.cjs +1748 -0
- package/src/claude/scripts/spec-authoring-digest.cjs +151 -0
- package/src/claude/scripts/spec-authoring-validation.cjs +126 -0
- package/src/claude/scripts/spec-final-state.cjs +46 -0
- package/src/claude/scripts/spec-ground.cjs +46 -10
- package/src/claude/scripts/spec-readiness.cjs +324 -17
- package/src/claude/scripts/spec-scaffold.cjs +268 -28
- package/src/claude/scripts/validate-spec-output.cjs +262 -10
- package/src/claude/skills/specs/references/review.md +14 -2
- package/src/claude/skills/specs/templates/spec-state.json +14 -2
- package/src/codex/hooks/lib/privacy-state.cjs +23 -22
- package/src/codex/hooks/privacy-approval.cjs +7 -3
- package/src/codex/hooks/privacy-block.cjs +45 -22
|
@@ -98,6 +98,9 @@ test('an exempt-looking symlink to .env still asks based on its target', () => {
|
|
|
98
98
|
}
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
+
// A secret named in a read operand the analyzer can resolve, however it is
|
|
102
|
+
// spelled: quoted, concatenated, line-continued, ANSI-C escaped, or reached
|
|
103
|
+
// through a nested shell or inline runtime.
|
|
101
104
|
for (const command of [
|
|
102
105
|
'cat ".env"',
|
|
103
106
|
'cat .env',
|
|
@@ -109,17 +112,6 @@ for (const command of [
|
|
|
109
112
|
"bash -c 'cat .e''nv'",
|
|
110
113
|
'cat .e\\\nnv',
|
|
111
114
|
"cat $'\\x2e\\x65\\x6e\\x76'",
|
|
112
|
-
'env -u UNUSED cat "$FILE"',
|
|
113
|
-
'timeout 1 cat "$FILE"',
|
|
114
|
-
'nice -n 10 cat "$FILE"',
|
|
115
|
-
"eval 'cat \"$FILE\"'",
|
|
116
|
-
"find . -exec sh -c 'cat \"$FILE\"' \\;",
|
|
117
|
-
'source "$FILE"',
|
|
118
|
-
'xargs -a "$LIST" cat',
|
|
119
|
-
'python3 -c \'open(os.environ["FILE"]).read()\'',
|
|
120
|
-
'node -e \'fs.readFileSync(process.env.FILE)\'',
|
|
121
|
-
'cat < "$FILE"',
|
|
122
|
-
'cat -n "$FILE"',
|
|
123
115
|
]) {
|
|
124
116
|
test(`asks natively for Bash sensitive reference: ${command}`, () => {
|
|
125
117
|
const dir = tmpDir();
|
|
@@ -134,8 +126,67 @@ for (const command of [
|
|
|
134
126
|
});
|
|
135
127
|
}
|
|
136
128
|
|
|
137
|
-
// ---
|
|
138
|
-
//
|
|
129
|
+
// --- Shapes the command analyzer cannot return ---
|
|
130
|
+
// It drops any word carrying an expansion, and it only walks readers it
|
|
131
|
+
// recognises. A sensitive name still has to be caught when it arrives with a
|
|
132
|
+
// glob or `~`, or when an unknown tool is handed it through a file-shaped
|
|
133
|
+
// option.
|
|
134
|
+
for (const command of [
|
|
135
|
+
'cat ~/.ssh/id_rsa',
|
|
136
|
+
'cat ~/.aws/credentials',
|
|
137
|
+
'cat certs/*.pem',
|
|
138
|
+
'ssh -i ~/.ssh/id_ed25519 host',
|
|
139
|
+
'docker compose --env-file .env up',
|
|
140
|
+
'curl --config ~/.netrc https://example.com',
|
|
141
|
+
'kubectl --kubeconfig ~/.kube/kubeconfig get pods',
|
|
142
|
+
'openssl rsa -in server.key',
|
|
143
|
+
'cat secrets.yaml',
|
|
144
|
+
]) {
|
|
145
|
+
test(`asks for sensitive name via expansion or file-shaped option: ${command}`, () => {
|
|
146
|
+
const dir = tmpDir();
|
|
147
|
+
try {
|
|
148
|
+
const { code, stdout } = runHook({ tool_name: 'Bash', tool_input: { command } }, dir);
|
|
149
|
+
assert.strictEqual(code, 0);
|
|
150
|
+
assert.strictEqual(permissionDecision(stdout), 'ask', `should ask for ${command}`);
|
|
151
|
+
} finally {
|
|
152
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- Naming a secret is not reading one (fix: 740798c) ---
|
|
158
|
+
// The analyzer returns the operands of readers it recognises. A command that
|
|
159
|
+
// only spells a secret's name -- an echo, a grep pattern, a pathspec -- reads
|
|
160
|
+
// nothing, and asking there is the noise that trains people to click through
|
|
161
|
+
// the prompt that matters.
|
|
162
|
+
for (const command of [
|
|
163
|
+
'echo .env',
|
|
164
|
+
'echo "update .env"',
|
|
165
|
+
'echo .envoy',
|
|
166
|
+
'echo not.env',
|
|
167
|
+
"printf '%s\\n' .env",
|
|
168
|
+
'git log -- .env',
|
|
169
|
+
'git status sessions.md',
|
|
170
|
+
'grep .env README.md',
|
|
171
|
+
"sed -n '/.env/p' README.md",
|
|
172
|
+
]) {
|
|
173
|
+
test(`allows naming a secret without reading it: ${command}`, () => {
|
|
174
|
+
const dir = tmpDir();
|
|
175
|
+
try {
|
|
176
|
+
fs.writeFileSync(path.join(dir, 'README.md'), 'hi');
|
|
177
|
+
const { stdout } = runHook({ tool_name: 'Bash', tool_input: { command } }, dir);
|
|
178
|
+
assert.strictEqual(permissionDecision(stdout), null, `should allow ${command}`);
|
|
179
|
+
} finally {
|
|
180
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- Accepted gap: pure indirection ---
|
|
186
|
+
// The target of `$FILE` is unknowable without running the shell. Treating every
|
|
187
|
+
// unresolved expansion as sensitive asked on `cat ~/.zshrc`, `wc -l *.cjs` and
|
|
188
|
+
// `du -sh */` too, so these are allowed by design. `less "$HOME/.env"` is the
|
|
189
|
+
// edge of that decision: a literal secret joined to a variable goes through.
|
|
139
190
|
for (const command of [
|
|
140
191
|
'cat $FILE',
|
|
141
192
|
'cat "$FILE"',
|
|
@@ -147,28 +198,58 @@ for (const command of [
|
|
|
147
198
|
'tail ${MY_FILE}',
|
|
148
199
|
'less "$HOME/.env"',
|
|
149
200
|
'cat README.md $EXTRA',
|
|
150
|
-
'head README.md $UNRELATED',
|
|
151
201
|
'echo ok; cat README.md $FILE',
|
|
152
202
|
'cat README.md | head $FILE',
|
|
153
203
|
"bash -c 'cat $FILE'",
|
|
154
204
|
'echo $(cat $FILE)',
|
|
155
|
-
'cat
|
|
156
|
-
'cat
|
|
157
|
-
'cat
|
|
158
|
-
'cat
|
|
205
|
+
'env -u UNUSED cat "$FILE"',
|
|
206
|
+
'timeout 1 cat "$FILE"',
|
|
207
|
+
'nice -n 10 cat "$FILE"',
|
|
208
|
+
"eval 'cat \"$FILE\"'",
|
|
209
|
+
"find . -exec sh -c 'cat \"$FILE\"' \\;",
|
|
210
|
+
'source "$FILE"',
|
|
211
|
+
'xargs -a "$LIST" cat',
|
|
212
|
+
'python3 -c \'open(os.environ["FILE"]).read()\'',
|
|
213
|
+
'node -e \'fs.readFileSync(process.env.FILE)\'',
|
|
214
|
+
'cat < "$FILE"',
|
|
215
|
+
'cat -n "$FILE"',
|
|
159
216
|
]) {
|
|
160
|
-
test(`
|
|
217
|
+
test(`allows pure indirection with no sensitive literal: ${command}`, () => {
|
|
161
218
|
const dir = tmpDir();
|
|
162
219
|
try {
|
|
220
|
+
fs.writeFileSync(path.join(dir, '.env'), 'SECRET=1');
|
|
163
221
|
const { code, stdout } = runHook({ tool_name: 'Bash', tool_input: { command } }, dir);
|
|
164
222
|
assert.strictEqual(code, 0);
|
|
165
|
-
assert.strictEqual(permissionDecision(stdout),
|
|
223
|
+
assert.strictEqual(permissionDecision(stdout), null, `should allow ${command}`);
|
|
166
224
|
} finally {
|
|
167
225
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
168
226
|
}
|
|
169
227
|
});
|
|
170
228
|
}
|
|
171
229
|
|
|
230
|
+
// --- An inert heredoc body is prose, not shell (fix: e867051) ---
|
|
231
|
+
test('allows a quoted-delimiter heredoc whose body quotes a read command', () => {
|
|
232
|
+
const dir = tmpDir();
|
|
233
|
+
try {
|
|
234
|
+
const command = "gh pr create --body \"$(cat <<'BODY'\nRun `cat .env` to inspect config\nBODY\n)\"";
|
|
235
|
+
const { stdout } = runHook({ tool_name: 'Bash', tool_input: { command } }, dir);
|
|
236
|
+
assert.strictEqual(permissionDecision(stdout), null, 'quoted delimiter expands nothing');
|
|
237
|
+
} finally {
|
|
238
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('still asks for an unquoted-delimiter heredoc that does expand a read', () => {
|
|
243
|
+
const dir = tmpDir();
|
|
244
|
+
try {
|
|
245
|
+
const command = 'bash -c "cat <<BODY\n$(cat .env)\nBODY"';
|
|
246
|
+
const { stdout } = runHook({ tool_name: 'Bash', tool_input: { command } }, dir);
|
|
247
|
+
assert.strictEqual(permissionDecision(stdout), 'ask', 'unquoted delimiter expands');
|
|
248
|
+
} finally {
|
|
249
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
172
253
|
for (const command of [
|
|
173
254
|
'echo $VAR',
|
|
174
255
|
'echo "$HOME"',
|
|
@@ -186,15 +267,12 @@ for (const command of [
|
|
|
186
267
|
"cat README.md '~'",
|
|
187
268
|
"cat README.md '{README.md,LICENSE}'",
|
|
188
269
|
"echo '$(cat $FILE)'",
|
|
189
|
-
'
|
|
190
|
-
'
|
|
191
|
-
'
|
|
192
|
-
|
|
193
|
-
'git status sessions.md',
|
|
270
|
+
'cat ~/.zshrc',
|
|
271
|
+
'wc -l README.md',
|
|
272
|
+
'cat *.md',
|
|
273
|
+
'du -sh */',
|
|
194
274
|
'python3 -c "print(os.environ[\'HOME\'])"',
|
|
195
275
|
'head -n "$COUNT" README.md',
|
|
196
|
-
'grep .env README.md',
|
|
197
|
-
"sed -n '/.env/p' README.md",
|
|
198
276
|
]) {
|
|
199
277
|
test(`allows harmless dynamic / safe static with extra var: ${command}`, () => {
|
|
200
278
|
const dir = tmpDir();
|
|
@@ -210,13 +288,17 @@ for (const command of [
|
|
|
210
288
|
});
|
|
211
289
|
}
|
|
212
290
|
|
|
213
|
-
test('assignment
|
|
291
|
+
test('an assignment naming a secret does not ask, with or without a later read', () => {
|
|
214
292
|
const dir = tmpDir();
|
|
215
293
|
try {
|
|
216
294
|
const assigned = runHook({ tool_name: 'Bash', tool_input: { command: 'FILE=.env; cat README.md' } }, dir);
|
|
217
295
|
assert.strictEqual(permissionDecision(assigned.stdout), null);
|
|
296
|
+
// Pure indirection: the hook cannot resolve $FILE without running the shell.
|
|
218
297
|
const read = runHook({ tool_name: 'Bash', tool_input: { command: 'FILE=.env; cat "$FILE"' } }, dir);
|
|
219
|
-
assert.strictEqual(permissionDecision(read.stdout),
|
|
298
|
+
assert.strictEqual(permissionDecision(read.stdout), null);
|
|
299
|
+
// Spelling the target out again does ask.
|
|
300
|
+
const literal = runHook({ tool_name: 'Bash', tool_input: { command: 'FILE=.env; cat .env' } }, dir);
|
|
301
|
+
assert.strictEqual(permissionDecision(literal.stdout), 'ask');
|
|
220
302
|
} finally {
|
|
221
303
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
222
304
|
}
|
|
@@ -17,10 +17,7 @@
|
|
|
17
17
|
try {
|
|
18
18
|
const fs = require('fs');
|
|
19
19
|
const path = require('path');
|
|
20
|
-
const {
|
|
21
|
-
DYNAMIC_READ_PATH,
|
|
22
|
-
commandAccess,
|
|
23
|
-
} = require('./lib/privacy-command-analysis.cjs');
|
|
20
|
+
const { commandAccess } = require('./lib/privacy-command-analysis.cjs');
|
|
24
21
|
|
|
25
22
|
const RESTRICTED_PATTERNS = [
|
|
26
23
|
/^\.env(?:[.\[*?{]|$)/i,
|
|
@@ -37,13 +34,7 @@ try {
|
|
|
37
34
|
/\.keystore$/i,
|
|
38
35
|
/\.jks$/i,
|
|
39
36
|
/auth\.json$/i,
|
|
40
|
-
/token(s)?\.json$/i
|
|
41
|
-
/\.cafekit/i,
|
|
42
|
-
/hmac\.key/i,
|
|
43
|
-
/completion-authority/i,
|
|
44
|
-
/session-state/i,
|
|
45
|
-
/hook-log/i,
|
|
46
|
-
/sessions/i
|
|
37
|
+
/token(s)?\.json$/i
|
|
47
38
|
];
|
|
48
39
|
|
|
49
40
|
const ALLOWED_EXEMPTIONS = [
|
|
@@ -65,7 +56,6 @@ try {
|
|
|
65
56
|
}
|
|
66
57
|
|
|
67
58
|
function isSensitive(filePath) {
|
|
68
|
-
if (filePath === DYNAMIC_READ_PATH) return true;
|
|
69
59
|
const base = path.basename(filePath);
|
|
70
60
|
return RESTRICTED_PATTERNS.some((rule) => rule.test(base) || rule.test(filePath));
|
|
71
61
|
}
|
|
@@ -135,8 +125,42 @@ try {
|
|
|
135
125
|
}
|
|
136
126
|
|
|
137
127
|
function extractBashPaths(command) {
|
|
138
|
-
|
|
139
|
-
|
|
128
|
+
// The analyzer parses the command and returns the operands of every reading
|
|
129
|
+
// command it recognises, nested shells and `find -exec` included. Treating
|
|
130
|
+
// every unresolved expansion as sensitive, as this did before, asked on
|
|
131
|
+
// `cat $FILE`, `wc -l *.cjs` and `cat ~/.zshrc` alike. Noise is what trains
|
|
132
|
+
// people to click through the prompt that matters.
|
|
133
|
+
// A heredoc whose delimiter is quoted (<<'BODY') is inert: the shell
|
|
134
|
+
// expands nothing inside it, so no word in the body is ever read. The
|
|
135
|
+
// analyzer has no notion of heredocs and parses the body as shell, so prose
|
|
136
|
+
// that quotes a command -- a PR body carrying `cat .env` in backticks --
|
|
137
|
+
// came back as a real command substitution. Blank those bodies out first.
|
|
138
|
+
// An unquoted delimiter (<<BODY) does expand, so it is left to the
|
|
139
|
+
// analyzer.
|
|
140
|
+
const scrubbed = String(command).replace(
|
|
141
|
+
/(<<-?[ \t]*)(['"])([A-Za-z_]\w*)\2[^\n]*\n[\s\S]*?\n[ \t]*\3\b/g,
|
|
142
|
+
'$1$2$3$2'
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const paths = new Set(commandAccess(scrubbed).paths);
|
|
146
|
+
|
|
147
|
+
// Two shapes the analyzer cannot return. It drops any word carrying an
|
|
148
|
+
// expansion, so `~/.ssh/id_rsa` and `certs/*.pem` still have to be matched
|
|
149
|
+
// by name. And it only walks commands it recognises, so an unknown tool
|
|
150
|
+
// handed a secret through a file-shaped option, such as
|
|
151
|
+
// `docker compose --env-file .env`, is invisible to it.
|
|
152
|
+
const FILE_OPTION = /^--?[\w-]*(?:file|config|key|cert|identity)$/i;
|
|
153
|
+
const tokens = scrubbed.split(/[\s|;&<>"'()`]+/);
|
|
154
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
155
|
+
const token = tokens[index];
|
|
156
|
+
const shaped = /^~|[*?[{]/.test(token);
|
|
157
|
+
const optionFed = index > 0 && FILE_OPTION.test(tokens[index - 1]);
|
|
158
|
+
if (!shaped && !optionFed) continue;
|
|
159
|
+
const value = token.replace(/^~/, '').replace(/^["'()`]+|["'(),]+$/g, '');
|
|
160
|
+
if (value && isSensitive(value) && !isSafe(value)) paths.add(value);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return [...paths];
|
|
140
164
|
}
|
|
141
165
|
|
|
142
166
|
function extractPaths(toolName, input) {
|