@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.
Files changed (28) hide show
  1. package/bin/__tests__/change-firewall.semantic-firewall.test.js +2134 -0
  2. package/bin/__tests__/codex-hooks.test.js +136 -64
  3. package/bin/__tests__/develop-contract.test.js +55 -17
  4. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +342 -0
  5. package/bin/__tests__/package-inventory.test.js +29 -11
  6. package/bin/__tests__/release-preflight.semantic-firewall.test.js +586 -0
  7. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +378 -0
  8. package/bin/__tests__/specs-v2-execution-closeout.test.js +59 -12
  9. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +78 -14
  10. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +222 -42
  11. package/bin/__tests__/validator-grounding.test.js +103 -4
  12. package/package.json +3 -2
  13. package/src/claude/hooks/__tests__/completion-authority.test.js +31 -1
  14. package/src/claude/hooks/__tests__/privacy-block.test.js +111 -29
  15. package/src/claude/hooks/privacy-block.cjs +38 -14
  16. package/src/claude/scripts/change-firewall.cjs +1748 -0
  17. package/src/claude/scripts/spec-authoring-digest.cjs +151 -0
  18. package/src/claude/scripts/spec-authoring-validation.cjs +126 -0
  19. package/src/claude/scripts/spec-final-state.cjs +46 -0
  20. package/src/claude/scripts/spec-ground.cjs +46 -10
  21. package/src/claude/scripts/spec-readiness.cjs +324 -17
  22. package/src/claude/scripts/spec-scaffold.cjs +268 -28
  23. package/src/claude/scripts/validate-spec-output.cjs +262 -10
  24. package/src/claude/skills/specs/references/review.md +14 -2
  25. package/src/claude/skills/specs/templates/spec-state.json +14 -2
  26. package/src/codex/hooks/lib/privacy-state.cjs +23 -22
  27. package/src/codex/hooks/privacy-approval.cjs +7 -3
  28. 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
- // --- Regression: variable indirection and command substitution (F-01) ---
138
- // Dynamic expansion in any read operand fails closed. Single-quoted dollars are literal.
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 README.md "$(printf safe)"',
156
- 'cat README.md *',
157
- 'cat README.md ~/README.md',
158
- 'cat README.md {README.md,LICENSE}',
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(`asks for read command with unresolved variable/substitution: ${command}`, () => {
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), 'ask', `should ask for ${command}`);
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
- 'echo .envoy',
190
- 'echo not.env',
191
- 'echo .env',
192
- "printf '%s\\n' .env",
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 alone is harmless but dereferencing it in a read fails closed', () => {
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), 'ask');
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
- const access = commandAccess(command);
139
- return access.dynamic ? [...access.paths, DYNAMIC_READ_PATH] : access.paths;
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) {