deepflow 0.1.104 → 0.1.106

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,315 @@
1
+ /**
2
+ * Tests for hooks/df-invariant-check.js
3
+ *
4
+ * Validates the execSync → execFileSync migration (security hardening wave-1).
5
+ * Ensures shell-injection-prone execSync is fully replaced by execFileSync
6
+ * in isBinaryAvailable and extractDiffFromLastCommit.
7
+ *
8
+ * Uses Node.js built-in node:test to avoid adding dependencies.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const { test, describe } = require('node:test');
14
+ const assert = require('node:assert/strict');
15
+ const fs = require('node:fs');
16
+ const path = require('node:path');
17
+
18
+ const { isBinaryAvailable, checkConfigYamlGuard } = require('./df-invariant-check');
19
+
20
+ const HOOK_SOURCE = fs.readFileSync(
21
+ path.resolve(__dirname, 'df-invariant-check.js'),
22
+ 'utf8'
23
+ );
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // 1. No execSync usage anywhere in the source
27
+ // ---------------------------------------------------------------------------
28
+
29
+ describe('execSync removal (grep-based)', () => {
30
+ test('source does not import execSync from child_process', () => {
31
+ // Match the destructured import pattern: { execSync }
32
+ const importPattern = /\brequire\(['"]child_process['"]\).*\bexecSync\b/;
33
+ assert.equal(
34
+ importPattern.test(HOOK_SOURCE),
35
+ false,
36
+ 'execSync should not appear in the child_process require statement'
37
+ );
38
+ });
39
+
40
+ test('source does not call execSync anywhere', () => {
41
+ // Look for execSync( calls — but not execFileSync(
42
+ // We match word-boundary before execSync and ensure it's not preceded by "File"
43
+ const lines = HOOK_SOURCE.split('\n');
44
+ const offendingLines = lines.filter((line) => {
45
+ // Skip comments
46
+ if (line.trimStart().startsWith('//') || line.trimStart().startsWith('*')) return false;
47
+ // Match execSync but not execFileSync
48
+ return /\bexecSync\b/.test(line) && !/\bexecFileSync\b/.test(line);
49
+ });
50
+ assert.equal(
51
+ offendingLines.length,
52
+ 0,
53
+ `Found bare execSync usage on lines: ${offendingLines.map((l) => l.trim()).join('; ')}`
54
+ );
55
+ });
56
+
57
+ test('source imports execFileSync from child_process', () => {
58
+ const importPattern = /\bexecFileSync\b.*=.*require\(['"]child_process['"]\)/;
59
+ const altPattern = /require\(['"]child_process['"]\).*\bexecFileSync\b/;
60
+ assert.ok(
61
+ importPattern.test(HOOK_SOURCE) || altPattern.test(HOOK_SOURCE),
62
+ 'execFileSync should be imported from child_process'
63
+ );
64
+ });
65
+ });
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // 2. isBinaryAvailable behavioral tests
69
+ // ---------------------------------------------------------------------------
70
+
71
+ describe('isBinaryAvailable', () => {
72
+ test('returns true for a binary that exists (node)', () => {
73
+ // node is always available in the test environment
74
+ assert.equal(isBinaryAvailable('node'), true);
75
+ });
76
+
77
+ test('returns true for a binary that exists (git)', () => {
78
+ assert.equal(isBinaryAvailable('git'), true);
79
+ });
80
+
81
+ test('returns false for a binary that does not exist', () => {
82
+ assert.equal(
83
+ isBinaryAvailable('__nonexistent_binary_xyz_12345__'),
84
+ false
85
+ );
86
+ });
87
+
88
+ test('handles binary names with no shell injection risk', () => {
89
+ // execFileSync passes the argument as an array element, not through a shell.
90
+ // A name like "node; rm -rf /" should simply not be found, not executed.
91
+ assert.equal(isBinaryAvailable('node; rm -rf /'), false);
92
+ });
93
+ });
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // 3. extractDiffFromLastCommit uses execFileSync (source-level check)
97
+ // ---------------------------------------------------------------------------
98
+
99
+ describe('extractDiffFromLastCommit implementation', () => {
100
+ test('uses execFileSync with git as first argument', () => {
101
+ // Find the function body and verify execFileSync('git', [...]) pattern
102
+ const fnMatch = HOOK_SOURCE.match(
103
+ /function\s+extractDiffFromLastCommit[\s\S]*?^}/m
104
+ );
105
+ assert.ok(fnMatch, 'extractDiffFromLastCommit function should exist in source');
106
+
107
+ const fnBody = fnMatch[0];
108
+ assert.ok(
109
+ /execFileSync\(\s*['"]git['"]/.test(fnBody),
110
+ 'extractDiffFromLastCommit should call execFileSync with "git" as first argument'
111
+ );
112
+ });
113
+
114
+ test('does not use execSync in extractDiffFromLastCommit', () => {
115
+ const fnMatch = HOOK_SOURCE.match(
116
+ /function\s+extractDiffFromLastCommit[\s\S]*?^}/m
117
+ );
118
+ assert.ok(fnMatch, 'extractDiffFromLastCommit function should exist in source');
119
+
120
+ const fnBody = fnMatch[0];
121
+ // Ensure no bare execSync call (only execFileSync allowed)
122
+ const hasBareExecSync = /\bexecSync\b/.test(fnBody) && !/\bexecFileSync\b/.test(fnBody);
123
+ assert.equal(
124
+ hasBareExecSync,
125
+ false,
126
+ 'extractDiffFromLastCommit should not use execSync'
127
+ );
128
+ });
129
+
130
+ test('passes diff arguments as array elements, not a single string', () => {
131
+ const fnMatch = HOOK_SOURCE.match(
132
+ /function\s+extractDiffFromLastCommit[\s\S]*?^}/m
133
+ );
134
+ const fnBody = fnMatch[0];
135
+ // Should have ['diff', 'HEAD~1', 'HEAD'] or similar array syntax
136
+ assert.ok(
137
+ /execFileSync\(\s*['"]git['"]\s*,\s*\[/.test(fnBody),
138
+ 'git arguments should be passed as an array (second argument to execFileSync)'
139
+ );
140
+ });
141
+ });
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // 4. checkConfigYamlGuard — config.yaml/yml modification detection
145
+ // ---------------------------------------------------------------------------
146
+
147
+ describe('checkConfigYamlGuard', () => {
148
+ // Helper: build a minimal parsed-file object matching the shape used by check functions
149
+ function makeFiles(...paths) {
150
+ return paths.map((p) => ({ file: p, chunks: [] }));
151
+ }
152
+
153
+ test('detects .deepflow/config.yaml modification as HARD violation', () => {
154
+ const files = makeFiles('.deepflow/config.yaml');
155
+ const violations = checkConfigYamlGuard(files, '', 'implementation');
156
+
157
+ assert.equal(violations.length, 1);
158
+ assert.equal(violations[0].tag, 'CONFIG_GUARD');
159
+ assert.equal(violations[0].file, '.deepflow/config.yaml');
160
+ assert.equal(violations[0].line, 1);
161
+ assert.ok(violations[0].description.includes('[CONFIG_GUARD]'));
162
+ });
163
+
164
+ test('detects .deepflow/config.yml variant as HARD violation', () => {
165
+ const files = makeFiles('.deepflow/config.yml');
166
+ const violations = checkConfigYamlGuard(files, '', 'implementation');
167
+
168
+ assert.equal(violations.length, 1);
169
+ assert.equal(violations[0].tag, 'CONFIG_GUARD');
170
+ assert.equal(violations[0].file, '.deepflow/config.yml');
171
+ });
172
+
173
+ test('detects config.yaml inside a worktree sub-path', () => {
174
+ const worktreePath = '.claude/worktrees/agent-abc123/.deepflow/config.yaml';
175
+ const files = makeFiles(worktreePath);
176
+ const violations = checkConfigYamlGuard(files, '', 'implementation');
177
+
178
+ assert.equal(violations.length, 1);
179
+ assert.equal(violations[0].tag, 'CONFIG_GUARD');
180
+ assert.equal(violations[0].file, worktreePath);
181
+ });
182
+
183
+ test('detects config.yml inside a deeply nested worktree path', () => {
184
+ const deepPath = 'some/deep/path/.deepflow/config.yml';
185
+ const files = makeFiles(deepPath);
186
+ const violations = checkConfigYamlGuard(files, '', 'implementation');
187
+
188
+ assert.equal(violations.length, 1);
189
+ assert.equal(violations[0].tag, 'CONFIG_GUARD');
190
+ });
191
+
192
+ test('returns no violations for non-config files', () => {
193
+ const files = makeFiles(
194
+ 'src/index.js',
195
+ 'hooks/df-invariant-check.js',
196
+ 'package.json',
197
+ '.deepflow/decisions.md'
198
+ );
199
+ const violations = checkConfigYamlGuard(files, '', 'implementation');
200
+
201
+ assert.equal(violations.length, 0);
202
+ });
203
+
204
+ test('ignores files that partially match but are not config.yaml/yml', () => {
205
+ const files = makeFiles(
206
+ '.deepflow/config.yaml.bak',
207
+ '.deepflow/config.yamls',
208
+ '.deepflow/my-config.yaml',
209
+ 'config.yaml' // not under .deepflow/
210
+ );
211
+ const violations = checkConfigYamlGuard(files, '', 'implementation');
212
+
213
+ assert.equal(violations.length, 0);
214
+ });
215
+
216
+ test('returns one violation per matching file when multiple configs present', () => {
217
+ const files = makeFiles(
218
+ '.deepflow/config.yaml',
219
+ '.claude/worktrees/agent-xyz/.deepflow/config.yml'
220
+ );
221
+ const violations = checkConfigYamlGuard(files, '', 'implementation');
222
+
223
+ assert.equal(violations.length, 2);
224
+ assert.equal(violations[0].tag, 'CONFIG_GUARD');
225
+ assert.equal(violations[1].tag, 'CONFIG_GUARD');
226
+ });
227
+
228
+ test('returns empty array when files list is empty', () => {
229
+ const violations = checkConfigYamlGuard([], '', 'implementation');
230
+ assert.equal(violations.length, 0);
231
+ });
232
+
233
+ test('works regardless of specContent and taskType arguments', () => {
234
+ const files = makeFiles('.deepflow/config.yaml');
235
+
236
+ // Different specContent and taskType should not affect the result
237
+ const v1 = checkConfigYamlGuard(files, 'some spec content', 'spike');
238
+ const v2 = checkConfigYamlGuard(files, '', 'bootstrap');
239
+
240
+ assert.equal(v1.length, 1);
241
+ assert.equal(v2.length, 1);
242
+ });
243
+
244
+ test('violation description mentions the offending file path', () => {
245
+ const targetPath = '.claude/worktrees/agent-foo/.deepflow/config.yaml';
246
+ const files = makeFiles(targetPath);
247
+ const violations = checkConfigYamlGuard(files, '', 'implementation');
248
+
249
+ assert.ok(
250
+ violations[0].description.includes(targetPath),
251
+ 'description should include the exact file path'
252
+ );
253
+ });
254
+ });
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // 5. T3 — stdin hang fix: readStdinIfMain guard and no raw stdin listeners
258
+ // ---------------------------------------------------------------------------
259
+
260
+ describe('stdin hang fix (T3)', () => {
261
+ test('source does not contain process.stdin.on calls', () => {
262
+ // The old code used process.stdin.on('data') directly, which caused hangs
263
+ // when the file was required by tests. readStdinIfMain replaces this.
264
+ const lines = HOOK_SOURCE.split('\n');
265
+ const offendingLines = lines.filter((line) => {
266
+ if (line.trimStart().startsWith('//') || line.trimStart().startsWith('*')) return false;
267
+ return /process\.stdin\.on\b/.test(line);
268
+ });
269
+ assert.equal(
270
+ offendingLines.length,
271
+ 0,
272
+ `Found process.stdin.on in source: ${offendingLines.map((l) => l.trim()).join('; ')}`
273
+ );
274
+ });
275
+
276
+ test('source calls readStdinIfMain', () => {
277
+ // readStdinIfMain(module, callback) should be the entry point for stdin reading
278
+ assert.ok(
279
+ /readStdinIfMain\s*\(\s*module\b/.test(HOOK_SOURCE),
280
+ 'source should call readStdinIfMain(module, ...) to guard stdin reading'
281
+ );
282
+ });
283
+
284
+ test('source imports readStdinIfMain from hook-stdin', () => {
285
+ assert.ok(
286
+ /require\(['"]\.\/lib\/hook-stdin['"]\)/.test(HOOK_SOURCE),
287
+ 'source should require ./lib/hook-stdin'
288
+ );
289
+ assert.ok(
290
+ /readStdinIfMain/.test(HOOK_SOURCE),
291
+ 'readStdinIfMain should be destructured from the import'
292
+ );
293
+ });
294
+
295
+ test('--invariants CLI entry point is preserved', () => {
296
+ // The CLI path must still exist: require.main === module && --invariants
297
+ assert.ok(
298
+ /require\.main\s*===\s*module/.test(HOOK_SOURCE),
299
+ 'source should have require.main === module guard for CLI mode'
300
+ );
301
+ assert.ok(
302
+ /--invariants/.test(HOOK_SOURCE),
303
+ 'source should reference --invariants flag'
304
+ );
305
+ });
306
+
307
+ test('requiring the module does not hang (exits immediately)', () => {
308
+ // AC-5: node -e "require('./hooks/df-invariant-check.js')" should exit fast.
309
+ // We already required it at the top of this file without hanging,
310
+ // so reaching this test at all proves require() does not block.
311
+ // Additionally, verify the exported API is still accessible.
312
+ assert.equal(typeof isBinaryAvailable, 'function');
313
+ assert.equal(typeof checkConfigYamlGuard, 'function');
314
+ });
315
+ });
@@ -4,7 +4,7 @@
4
4
  * deepflow quota logger
5
5
  * Logs Anthropic API quota/usage data to ~/.claude/quota-history.jsonl
6
6
  * Runs on SessionStart and SessionEnd events.
7
- * Exits silently (code 0) on non-macOS or when Keychain token is absent.
7
+ * Reads anthropic_token from ~/.deepflow/config.yaml; exits silently when token is absent.
8
8
  */
9
9
 
10
10
  'use strict';
@@ -12,15 +12,10 @@
12
12
  const fs = require('fs');
13
13
  const path = require('path');
14
14
  const os = require('os');
15
- const { execFileSync } = require('child_process');
16
15
  const https = require('https');
17
16
 
18
17
  const QUOTA_LOG = path.join(os.homedir(), '.claude', 'quota-history.jsonl');
19
-
20
- // Only supported on macOS (Keychain access)
21
- if (process.platform !== 'darwin') {
22
- process.exit(0);
23
- }
18
+ const USER_CONFIG = path.join(os.homedir(), '.deepflow', 'config.yaml');
24
19
 
25
20
  // Spawn background process so hook returns immediately
26
21
  if (process.argv[2] !== '--background') {
@@ -37,7 +32,7 @@ if (process.argv[2] !== '--background') {
37
32
 
38
33
  async function main() {
39
34
  try {
40
- const token = getToken();
35
+ const token = readUserConfig();
41
36
  if (!token) {
42
37
  process.exit(0);
43
38
  }
@@ -54,23 +49,16 @@ async function main() {
54
49
  process.exit(0);
55
50
  }
56
51
 
57
- function getToken() {
52
+ function readUserConfig() {
58
53
  try {
59
- const raw = execFileSync(
60
- 'security',
61
- ['find-generic-password', '-s', 'Claude Code-credentials', '-w'],
62
- { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }
63
- ).toString().trim();
64
-
65
- if (!raw) return null;
66
-
67
- // The stored value may be a JSON blob with an access_token field
68
- try {
69
- const parsed = JSON.parse(raw);
70
- return parsed.access_token || parsed.token || raw;
71
- } catch (_e) {
72
- return raw;
54
+ const content = fs.readFileSync(USER_CONFIG, 'utf8');
55
+ for (const line of content.split('\n')) {
56
+ const match = line.match(/^anthropic_token\s*:\s*(.+)$/);
57
+ if (match) {
58
+ return match[1].trim().replace(/^['"]|['"]$/g, '');
59
+ }
73
60
  }
61
+ return null;
74
62
  } catch (_e) {
75
63
  return null;
76
64
  }