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.
- package/bin/install.js +21 -0
- package/bin/install.test.js +205 -0
- package/hooks/df-command-usage.js +17 -26
- package/hooks/df-dashboard-push.js +4 -4
- package/hooks/df-dashboard-push.test.js +256 -0
- package/hooks/df-execution-history.js +43 -53
- package/hooks/df-explore-protocol.js +30 -39
- package/hooks/df-invariant-check.js +88 -61
- package/hooks/df-invariant-check.test.js +315 -0
- package/hooks/df-quota-logger.js +11 -23
- package/hooks/df-quota-logger.test.js +324 -0
- package/hooks/df-snapshot-guard.js +32 -40
- package/hooks/df-statusline.js +3 -12
- package/hooks/df-stdin-migration.test.js +106 -0
- package/hooks/df-subagent-registry.js +42 -48
- package/hooks/df-tool-usage-spike.js +15 -26
- package/hooks/df-tool-usage.js +37 -47
- package/hooks/df-worktree-guard.js +28 -36
- package/hooks/lib/hook-stdin.js +47 -0
- package/hooks/lib/hook-stdin.test.js +200 -0
- package/hooks/lib/lint-no-bare-stdin.js +68 -0
- package/hooks/lib/lint-no-bare-stdin.test.js +82 -0
- package/package.json +1 -1
- package/src/commands/df/execute.md +25 -174
- package/src/eval/git-memory.js +8 -8
- package/src/eval/git-memory.test.js +128 -1
- package/src/eval/loop.js +3 -3
- package/src/eval/loop.test.js +158 -0
- package/templates/config-template.yaml +0 -6
|
@@ -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
|
+
});
|
package/hooks/df-quota-logger.js
CHANGED
|
@@ -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
|
-
*
|
|
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 =
|
|
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
|
|
52
|
+
function readUserConfig() {
|
|
58
53
|
try {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
}
|