deepflow 0.1.104 → 0.1.105

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 CHANGED
@@ -238,12 +238,33 @@ function isInstalled(claudeDir) {
238
238
  function copyDir(src, dest) {
239
239
  if (!fs.existsSync(src)) return;
240
240
 
241
+ const resolvedSrcRoot = path.resolve(src);
242
+ const resolvedDestRoot = path.resolve(dest);
243
+
241
244
  fs.mkdirSync(dest, { recursive: true });
242
245
 
243
246
  for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
244
247
  const srcPath = path.join(src, entry.name);
245
248
  const destPath = path.join(dest, entry.name);
246
249
 
250
+ // Reject symlinks to prevent symlink attacks
251
+ if (entry.isSymbolicLink()) {
252
+ process.stderr.write(`[deepflow] skipping symlink: ${srcPath}\n`);
253
+ continue;
254
+ }
255
+
256
+ // Guard against path traversal — resolved paths must stay under their roots
257
+ const resolvedSrc = path.resolve(srcPath);
258
+ const resolvedDest = path.resolve(destPath);
259
+ if (!resolvedSrc.startsWith(resolvedSrcRoot + path.sep) && resolvedSrc !== resolvedSrcRoot) {
260
+ process.stderr.write(`[deepflow] skipping path traversal attempt (src): ${srcPath}\n`);
261
+ continue;
262
+ }
263
+ if (!resolvedDest.startsWith(resolvedDestRoot + path.sep) && resolvedDest !== resolvedDestRoot) {
264
+ process.stderr.write(`[deepflow] skipping path traversal attempt (dest): ${destPath}\n`);
265
+ continue;
266
+ }
267
+
247
268
  if (entry.isDirectory()) {
248
269
  copyDir(srcPath, destPath);
249
270
  } else {
@@ -909,3 +909,208 @@ describe('copyDir logic', () => {
909
909
  assert.ok(fs.existsSync(path.join(newDest, 'a.md')));
910
910
  });
911
911
  });
912
+
913
+ // ---------------------------------------------------------------------------
914
+ // 6. copyDir security hardening — symlink rejection & path traversal guard
915
+ // ---------------------------------------------------------------------------
916
+
917
+ describe('copyDir security hardening (symlink & path traversal)', () => {
918
+ let tmpSrc;
919
+ let tmpDest;
920
+
921
+ /**
922
+ * Reproduces the hardened copyDir from install.js (commit f3b7f47).
923
+ * Includes isSymbolicLink() check and path traversal guard.
924
+ */
925
+ function copyDir(src, dest) {
926
+ if (!fs.existsSync(src)) return;
927
+
928
+ const resolvedSrcRoot = path.resolve(src);
929
+ const resolvedDestRoot = path.resolve(dest);
930
+
931
+ fs.mkdirSync(dest, { recursive: true });
932
+
933
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
934
+ const srcPath = path.join(src, entry.name);
935
+ const destPath = path.join(dest, entry.name);
936
+
937
+ // Reject symlinks to prevent symlink attacks
938
+ if (entry.isSymbolicLink()) {
939
+ process.stderr.write(`[deepflow] skipping symlink: ${srcPath}\n`);
940
+ continue;
941
+ }
942
+
943
+ // Guard against path traversal — resolved paths must stay under their roots
944
+ const resolvedSrc = path.resolve(srcPath);
945
+ const resolvedDest = path.resolve(destPath);
946
+ if (!resolvedSrc.startsWith(resolvedSrcRoot + path.sep) && resolvedSrc !== resolvedSrcRoot) {
947
+ process.stderr.write(`[deepflow] skipping path traversal attempt (src): ${srcPath}\n`);
948
+ continue;
949
+ }
950
+ if (!resolvedDest.startsWith(resolvedDestRoot + path.sep) && resolvedDest !== resolvedDestRoot) {
951
+ process.stderr.write(`[deepflow] skipping path traversal attempt (dest): ${destPath}\n`);
952
+ continue;
953
+ }
954
+
955
+ if (entry.isDirectory()) {
956
+ copyDir(srcPath, destPath);
957
+ } else {
958
+ fs.copyFileSync(srcPath, destPath);
959
+ }
960
+ }
961
+ }
962
+
963
+ beforeEach(() => {
964
+ tmpSrc = makeTmpDir();
965
+ tmpDest = makeTmpDir();
966
+ });
967
+
968
+ afterEach(() => {
969
+ rmrf(tmpSrc);
970
+ rmrf(tmpDest);
971
+ });
972
+
973
+ // -- Symlink rejection --
974
+
975
+ test('skips symlinked files and does not copy them to dest', () => {
976
+ // Create a real file outside the src tree
977
+ const outsideFile = path.join(os.tmpdir(), `df-symlink-target-${Date.now()}.txt`);
978
+ fs.writeFileSync(outsideFile, 'secret content');
979
+
980
+ // Create a symlink inside the src dir pointing to the outside file
981
+ fs.symlinkSync(outsideFile, path.join(tmpSrc, 'link-to-secret.txt'));
982
+
983
+ // Also create a normal file to ensure it IS copied
984
+ fs.writeFileSync(path.join(tmpSrc, 'normal.md'), '# normal');
985
+
986
+ copyDir(tmpSrc, tmpDest);
987
+
988
+ // The symlink should NOT have been copied
989
+ assert.ok(
990
+ !fs.existsSync(path.join(tmpDest, 'link-to-secret.txt')),
991
+ 'Symlinked file should be skipped and not appear in dest'
992
+ );
993
+ // The normal file should still be copied
994
+ assert.ok(
995
+ fs.existsSync(path.join(tmpDest, 'normal.md')),
996
+ 'Normal files should still be copied alongside skipped symlinks'
997
+ );
998
+
999
+ // Cleanup
1000
+ fs.unlinkSync(outsideFile);
1001
+ });
1002
+
1003
+ test('skips symlinked directories and does not copy them to dest', () => {
1004
+ // Create a real directory outside src
1005
+ const outsideDir = makeTmpDir();
1006
+ fs.writeFileSync(path.join(outsideDir, 'inside.txt'), 'hidden');
1007
+
1008
+ // Create a directory symlink inside src
1009
+ fs.symlinkSync(outsideDir, path.join(tmpSrc, 'link-to-dir'));
1010
+
1011
+ // Normal subdir to verify it copies
1012
+ fs.mkdirSync(path.join(tmpSrc, 'real-sub'));
1013
+ fs.writeFileSync(path.join(tmpSrc, 'real-sub', 'file.md'), '# real');
1014
+
1015
+ copyDir(tmpSrc, tmpDest);
1016
+
1017
+ assert.ok(
1018
+ !fs.existsSync(path.join(tmpDest, 'link-to-dir')),
1019
+ 'Symlinked directory should be skipped'
1020
+ );
1021
+ assert.ok(
1022
+ fs.existsSync(path.join(tmpDest, 'real-sub', 'file.md')),
1023
+ 'Real subdirectories should still be copied'
1024
+ );
1025
+
1026
+ rmrf(outsideDir);
1027
+ });
1028
+
1029
+ test('skips relative symlinks within the source tree', () => {
1030
+ // Create a real file and a relative symlink to it
1031
+ fs.writeFileSync(path.join(tmpSrc, 'real.md'), '# real');
1032
+ fs.symlinkSync('real.md', path.join(tmpSrc, 'relative-link.md'));
1033
+
1034
+ copyDir(tmpSrc, tmpDest);
1035
+
1036
+ assert.ok(
1037
+ fs.existsSync(path.join(tmpDest, 'real.md')),
1038
+ 'Real file should be copied'
1039
+ );
1040
+ assert.ok(
1041
+ !fs.existsSync(path.join(tmpDest, 'relative-link.md')),
1042
+ 'Even relative symlinks should be skipped'
1043
+ );
1044
+ });
1045
+
1046
+ // -- Normal files continue to copy correctly --
1047
+
1048
+ test('copies normal files correctly when no symlinks or traversal present', () => {
1049
+ fs.writeFileSync(path.join(tmpSrc, 'a.md'), '# a');
1050
+ fs.writeFileSync(path.join(tmpSrc, 'b.txt'), 'content b');
1051
+ fs.mkdirSync(path.join(tmpSrc, 'sub'));
1052
+ fs.writeFileSync(path.join(tmpSrc, 'sub', 'c.md'), '# c');
1053
+
1054
+ copyDir(tmpSrc, tmpDest);
1055
+
1056
+ assert.equal(fs.readFileSync(path.join(tmpDest, 'a.md'), 'utf8'), '# a');
1057
+ assert.equal(fs.readFileSync(path.join(tmpDest, 'b.txt'), 'utf8'), 'content b');
1058
+ assert.equal(fs.readFileSync(path.join(tmpDest, 'sub', 'c.md'), 'utf8'), '# c');
1059
+ });
1060
+
1061
+ test('copies deeply nested directories correctly', () => {
1062
+ fs.mkdirSync(path.join(tmpSrc, 'l1', 'l2', 'l3'), { recursive: true });
1063
+ fs.writeFileSync(path.join(tmpSrc, 'l1', 'l2', 'l3', 'deep.md'), '# deep');
1064
+
1065
+ copyDir(tmpSrc, tmpDest);
1066
+
1067
+ assert.ok(fs.existsSync(path.join(tmpDest, 'l1', 'l2', 'l3', 'deep.md')));
1068
+ assert.equal(
1069
+ fs.readFileSync(path.join(tmpDest, 'l1', 'l2', 'l3', 'deep.md'), 'utf8'),
1070
+ '# deep'
1071
+ );
1072
+ });
1073
+
1074
+ // -- Source-level verification that the guards exist in install.js --
1075
+
1076
+ test('install.js copyDir checks isSymbolicLink()', () => {
1077
+ const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
1078
+ assert.ok(
1079
+ src.includes('entry.isSymbolicLink()'),
1080
+ 'copyDir should check entry.isSymbolicLink() to reject symlinks'
1081
+ );
1082
+ });
1083
+
1084
+ test('install.js copyDir has path traversal guard using startsWith', () => {
1085
+ const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
1086
+ // The guard resolves src/dest and checks they stay under their root
1087
+ assert.ok(
1088
+ src.includes('resolvedSrc.startsWith(resolvedSrcRoot + path.sep)'),
1089
+ 'copyDir should guard source paths against traversal using startsWith'
1090
+ );
1091
+ assert.ok(
1092
+ src.includes('resolvedDest.startsWith(resolvedDestRoot + path.sep)'),
1093
+ 'copyDir should guard dest paths against traversal using startsWith'
1094
+ );
1095
+ });
1096
+
1097
+ test('install.js copyDir logs stderr warning for skipped symlinks', () => {
1098
+ const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
1099
+ assert.ok(
1100
+ src.includes('[deepflow] skipping symlink:'),
1101
+ 'copyDir should log a warning to stderr when skipping a symlink'
1102
+ );
1103
+ });
1104
+
1105
+ test('install.js copyDir logs stderr warning for path traversal attempts', () => {
1106
+ const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
1107
+ assert.ok(
1108
+ src.includes('[deepflow] skipping path traversal attempt (src):'),
1109
+ 'copyDir should log a warning for source path traversal'
1110
+ );
1111
+ assert.ok(
1112
+ src.includes('[deepflow] skipping path traversal attempt (dest):'),
1113
+ 'copyDir should log a warning for dest path traversal'
1114
+ );
1115
+ });
1116
+ });
@@ -41,9 +41,9 @@ function getStdinSync() {
41
41
  }
42
42
  }
43
43
 
44
- /** Read .deepflow/config.yaml and extract dashboard_url (no yaml dep — regex parse). */
45
- function getDashboardUrl(cwd) {
46
- const configPath = path.join(cwd, '.deepflow', 'config.yaml');
44
+ /** Read ~/.deepflow/config.yaml and extract dashboard_url (no yaml dep — regex parse). */
45
+ function getDashboardUrl() {
46
+ const configPath = path.join(os.homedir(), '.deepflow', 'config.yaml');
47
47
  if (!fs.existsSync(configPath)) return null;
48
48
  try {
49
49
  const content = fs.readFileSync(configPath, 'utf8');
@@ -112,7 +112,7 @@ function postJson(url, payload) {
112
112
  async function main() {
113
113
  try {
114
114
  const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
115
- const dashboardUrl = getDashboardUrl(cwd);
115
+ const dashboardUrl = getDashboardUrl();
116
116
 
117
117
  // Silently skip if not configured
118
118
  if (!dashboardUrl) process.exit(0);
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Tests for hooks/df-dashboard-push.js (security-hardening, T2)
3
+ *
4
+ * Validates that the dashboard push hook reads dashboard_url from
5
+ * ~/.deepflow/config.yaml (home directory) rather than the project
6
+ * directory, and silently skips when not configured.
7
+ *
8
+ * Uses Node.js built-in node:test to avoid adding dependencies.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const { test, describe, beforeEach, afterEach } = require('node:test');
14
+ const assert = require('node:assert/strict');
15
+ const fs = require('node:fs');
16
+ const path = require('node:path');
17
+ const os = require('os');
18
+ const { execFileSync } = require('node:child_process');
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const HOOK_PATH = path.resolve(__dirname, 'df-dashboard-push.js');
25
+
26
+ function makeTmpDir() {
27
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'df-dashboard-push-test-'));
28
+ }
29
+
30
+ function rmrf(dir) {
31
+ if (fs.existsSync(dir)) {
32
+ fs.rmSync(dir, { recursive: true, force: true });
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Run the dashboard push hook in --background mode as a child process.
38
+ * Overrides HOME so getDashboardUrl reads from our fake home dir.
39
+ * Returns { stdout, stderr, code }.
40
+ */
41
+ function runHook({ home, cwd, hookInput } = {}) {
42
+ const env = { ...process.env };
43
+ if (home) env.HOME = home;
44
+ if (hookInput !== undefined) env._DF_HOOK_INPUT = hookInput;
45
+ // Ensure CLAUDE_PROJECT_DIR points to cwd so main() uses it
46
+ if (cwd) env.CLAUDE_PROJECT_DIR = cwd;
47
+
48
+ try {
49
+ const stdout = execFileSync(
50
+ process.execPath,
51
+ [HOOK_PATH, '--background'],
52
+ {
53
+ cwd: cwd || os.tmpdir(),
54
+ encoding: 'utf8',
55
+ timeout: 10000,
56
+ env,
57
+ stdio: ['pipe', 'pipe', 'pipe'],
58
+ }
59
+ );
60
+ return { stdout, stderr: '', code: 0 };
61
+ } catch (err) {
62
+ return {
63
+ stdout: err.stdout || '',
64
+ stderr: err.stderr || '',
65
+ code: err.status ?? 1,
66
+ };
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Write a config.yaml into {dir}/.deepflow/config.yaml with given content.
72
+ */
73
+ function writeConfig(dir, content) {
74
+ const configDir = path.join(dir, '.deepflow');
75
+ fs.mkdirSync(configDir, { recursive: true });
76
+ fs.writeFileSync(path.join(configDir, 'config.yaml'), content, 'utf8');
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Tests
81
+ // ---------------------------------------------------------------------------
82
+
83
+ describe('df-dashboard-push hook', () => {
84
+ let fakeHome;
85
+ let fakeCwd;
86
+
87
+ beforeEach(() => {
88
+ fakeHome = makeTmpDir();
89
+ fakeCwd = makeTmpDir();
90
+ });
91
+
92
+ afterEach(() => {
93
+ rmrf(fakeHome);
94
+ rmrf(fakeCwd);
95
+ });
96
+
97
+ // -------------------------------------------------------------------------
98
+ // Config resolution: reads from HOME, not CWD
99
+ // -------------------------------------------------------------------------
100
+
101
+ describe('config resolution', () => {
102
+ test('exits 0 when no config file exists at HOME', () => {
103
+ const result = runHook({ home: fakeHome, cwd: fakeCwd });
104
+ assert.equal(result.code, 0);
105
+ });
106
+
107
+ test('exits 0 when config exists at HOME but has no dashboard_url key', () => {
108
+ writeConfig(fakeHome, 'build_command: "npm run build"\ntest_command: "npm test"\n');
109
+ const result = runHook({ home: fakeHome, cwd: fakeCwd });
110
+ assert.equal(result.code, 0);
111
+ });
112
+
113
+ test('exits 0 when dashboard_url is empty string', () => {
114
+ writeConfig(fakeHome, 'dashboard_url: ""\n');
115
+ const result = runHook({ home: fakeHome, cwd: fakeCwd });
116
+ assert.equal(result.code, 0);
117
+ });
118
+
119
+ test('exits 0 when dashboard_url is bare empty (no quotes)', () => {
120
+ writeConfig(fakeHome, 'dashboard_url: \n');
121
+ const result = runHook({ home: fakeHome, cwd: fakeCwd });
122
+ assert.equal(result.code, 0);
123
+ });
124
+
125
+ test('ignores dashboard_url in project cwd config (reads from HOME only)', () => {
126
+ // Put a valid URL in the project config, but NOT in home config
127
+ writeConfig(fakeCwd, 'dashboard_url: "http://localhost:9999"\n');
128
+ // Home has no config at all
129
+ const result = runHook({ home: fakeHome, cwd: fakeCwd });
130
+ // Should exit 0 without attempting POST because HOME has no config
131
+ assert.equal(result.code, 0);
132
+ });
133
+
134
+ test('reads dashboard_url from HOME config even when project has none', () => {
135
+ // Home has a dashboard_url pointing to an unreachable port
136
+ writeConfig(fakeHome, 'dashboard_url: "http://127.0.0.1:19999"\n');
137
+ // Project has no config
138
+ // Hook should attempt POST, fail silently, exit 0
139
+ const result = runHook({ home: fakeHome, cwd: fakeCwd, hookInput: '{}' });
140
+ assert.equal(result.code, 0);
141
+ });
142
+ });
143
+
144
+ // -------------------------------------------------------------------------
145
+ // YAML parsing edge cases
146
+ // -------------------------------------------------------------------------
147
+
148
+ describe('dashboard_url parsing', () => {
149
+ test('extracts unquoted URL', () => {
150
+ writeConfig(fakeHome, 'dashboard_url: http://127.0.0.1:19999\n');
151
+ const result = runHook({ home: fakeHome, cwd: fakeCwd, hookInput: '{}' });
152
+ // Attempts POST, fails silently on unreachable port, exits 0
153
+ assert.equal(result.code, 0);
154
+ });
155
+
156
+ test('extracts single-quoted URL', () => {
157
+ writeConfig(fakeHome, "dashboard_url: 'http://127.0.0.1:19999'\n");
158
+ const result = runHook({ home: fakeHome, cwd: fakeCwd, hookInput: '{}' });
159
+ assert.equal(result.code, 0);
160
+ });
161
+
162
+ test('extracts double-quoted URL', () => {
163
+ writeConfig(fakeHome, 'dashboard_url: "http://127.0.0.1:19999"\n');
164
+ const result = runHook({ home: fakeHome, cwd: fakeCwd, hookInput: '{}' });
165
+ assert.equal(result.code, 0);
166
+ });
167
+
168
+ test('handles dashboard_url with leading spaces (indented)', () => {
169
+ writeConfig(fakeHome, ' dashboard_url: http://127.0.0.1:19999\n');
170
+ const result = runHook({ home: fakeHome, cwd: fakeCwd, hookInput: '{}' });
171
+ assert.equal(result.code, 0);
172
+ });
173
+
174
+ test('ignores commented-out dashboard_url', () => {
175
+ writeConfig(fakeHome, '# dashboard_url: http://127.0.0.1:19999\n');
176
+ const result = runHook({ home: fakeHome, cwd: fakeCwd });
177
+ // The regex uses ^ so a comment line should not match
178
+ assert.equal(result.code, 0);
179
+ });
180
+ });
181
+
182
+ // -------------------------------------------------------------------------
183
+ // Error handling / resilience
184
+ // -------------------------------------------------------------------------
185
+
186
+ describe('error handling', () => {
187
+ test('exits 0 when config file is unreadable (permissions)', () => {
188
+ writeConfig(fakeHome, 'dashboard_url: http://localhost:9999\n');
189
+ const configPath = path.join(fakeHome, '.deepflow', 'config.yaml');
190
+ fs.chmodSync(configPath, 0o000);
191
+ const result = runHook({ home: fakeHome, cwd: fakeCwd });
192
+ assert.equal(result.code, 0);
193
+ // Restore permissions for cleanup
194
+ fs.chmodSync(configPath, 0o644);
195
+ });
196
+
197
+ test('exits 0 when hook input is invalid JSON', () => {
198
+ writeConfig(fakeHome, 'dashboard_url: http://127.0.0.1:19999\n');
199
+ const result = runHook({
200
+ home: fakeHome,
201
+ cwd: fakeCwd,
202
+ hookInput: '{not valid json',
203
+ });
204
+ assert.equal(result.code, 0);
205
+ });
206
+
207
+ test('exits 0 when hook input is empty', () => {
208
+ writeConfig(fakeHome, 'dashboard_url: http://127.0.0.1:19999\n');
209
+ const result = runHook({
210
+ home: fakeHome,
211
+ cwd: fakeCwd,
212
+ hookInput: '',
213
+ });
214
+ assert.equal(result.code, 0);
215
+ });
216
+
217
+ test('exits 0 when dashboard_url has invalid URL format', () => {
218
+ writeConfig(fakeHome, 'dashboard_url: not-a-url\n');
219
+ const result = runHook({
220
+ home: fakeHome,
221
+ cwd: fakeCwd,
222
+ hookInput: '{}',
223
+ });
224
+ // postJson should handle invalid URL gracefully
225
+ assert.equal(result.code, 0);
226
+ });
227
+ });
228
+
229
+ // -------------------------------------------------------------------------
230
+ // Foreground mode (spawns background and exits immediately)
231
+ // -------------------------------------------------------------------------
232
+
233
+ describe('foreground mode', () => {
234
+ test('exits 0 immediately without --background flag', () => {
235
+ const env = { ...process.env, HOME: fakeHome };
236
+ try {
237
+ const stdout = execFileSync(
238
+ process.execPath,
239
+ [HOOK_PATH],
240
+ {
241
+ cwd: fakeCwd,
242
+ encoding: 'utf8',
243
+ timeout: 5000,
244
+ env,
245
+ stdio: ['pipe', 'pipe', 'pipe'],
246
+ input: '{}',
247
+ }
248
+ );
249
+ assert.equal(typeof stdout, 'string');
250
+ } catch (err) {
251
+ // Even if it errors, check code is 0 (fire-and-forget exit)
252
+ assert.equal(err.status, 0, 'foreground mode should exit 0');
253
+ }
254
+ });
255
+ });
256
+ });
@@ -16,7 +16,7 @@
16
16
 
17
17
  const fs = require('fs');
18
18
  const path = require('path');
19
- const { execSync } = require('child_process');
19
+ const { execFileSync } = require('child_process');
20
20
  const { extractSection } = require('./df-spec-lint');
21
21
 
22
22
  // ── LSP availability check (REQ-5, AC-11) ────────────────────────────────────
@@ -94,7 +94,7 @@ function detectLanguageServer(projectRoot, diffFilePaths) {
94
94
  */
95
95
  function isBinaryAvailable(binary) {
96
96
  try {
97
- execSync(`which ${binary}`, { stdio: 'ignore' });
97
+ execFileSync('which', [binary], { stdio: 'ignore' });
98
98
  return true;
99
99
  } catch (_) {
100
100
  return false;
@@ -1064,7 +1064,7 @@ function loadActiveSpec(cwd) {
1064
1064
 
1065
1065
  function extractDiffFromLastCommit(cwd) {
1066
1066
  try {
1067
- return execSync('git diff HEAD~1 HEAD', {
1067
+ return execFileSync('git', ['diff', 'HEAD~1', 'HEAD'], {
1068
1068
  encoding: 'utf8',
1069
1069
  cwd,
1070
1070
  stdio: ['ignore', 'pipe', 'ignore'],
@@ -0,0 +1,141 @@
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 } = 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
+ });
@@ -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
  }
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Tests for hooks/df-quota-logger.js — readUserConfig() function.
3
+ *
4
+ * Tests cover:
5
+ * 1. Happy path: reads anthropic_token from a well-formed config.yaml
6
+ * 2. Quoted values: single-quoted and double-quoted tokens are unwrapped
7
+ * 3. Missing file: returns null when config file does not exist
8
+ * 4. Missing key: returns null when anthropic_token is absent from file
9
+ * 5. Malformed yaml: handles files with no matching lines gracefully
10
+ * 6. Whitespace variations: extra spaces around colon and value
11
+ * 7. Multiple keys: extracts correct token when other keys are present
12
+ * 8. Empty value: returns null-ish or empty when value is blank
13
+ *
14
+ * Uses Node.js built-in node:test to avoid adding dependencies.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const { test, describe } = require('node:test');
20
+ const assert = require('node:assert/strict');
21
+ const fs = require('node:fs');
22
+ const path = require('node:path');
23
+ const os = require('node:os');
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Helpers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const HOOK_SRC_PATH = path.resolve(__dirname, 'df-quota-logger.js');
30
+ const HOOK_SRC = fs.readFileSync(HOOK_SRC_PATH, 'utf8');
31
+
32
+ function makeTmpDir() {
33
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'df-quota-logger-test-'));
34
+ }
35
+
36
+ function rmrf(dir) {
37
+ if (fs.existsSync(dir)) {
38
+ fs.rmSync(dir, { recursive: true, force: true });
39
+ }
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Extract readUserConfig() from source so we can test it in isolation.
44
+ // We replace the USER_CONFIG constant with a provided path and strip out
45
+ // the top-level main() call and background spawn logic.
46
+ // ---------------------------------------------------------------------------
47
+
48
+ function buildReadUserConfig(configPath) {
49
+ // Extract just the readUserConfig function body from source,
50
+ // replacing USER_CONFIG reference with the provided path.
51
+ const fn = new Function('fs', 'USER_CONFIG', `
52
+ function readUserConfig() {
53
+ try {
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
+ }
60
+ }
61
+ return null;
62
+ } catch (_e) {
63
+ return null;
64
+ }
65
+ }
66
+ return readUserConfig;
67
+ `);
68
+ return fn(fs, configPath);
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Tests
73
+ // ---------------------------------------------------------------------------
74
+
75
+ describe('readUserConfig()', () => {
76
+
77
+ // -- Happy paths ----------------------------------------------------------
78
+
79
+ test('returns token from a simple config.yaml', () => {
80
+ const dir = makeTmpDir();
81
+ try {
82
+ const configPath = path.join(dir, 'config.yaml');
83
+ fs.writeFileSync(configPath, 'anthropic_token: sk-ant-abc123\n');
84
+ const readUserConfig = buildReadUserConfig(configPath);
85
+ assert.equal(readUserConfig(), 'sk-ant-abc123');
86
+ } finally {
87
+ rmrf(dir);
88
+ }
89
+ });
90
+
91
+ test('returns token when other keys are present', () => {
92
+ const dir = makeTmpDir();
93
+ try {
94
+ const configPath = path.join(dir, 'config.yaml');
95
+ fs.writeFileSync(configPath, [
96
+ 'build_command: npm run build',
97
+ 'test_command: npm test',
98
+ 'anthropic_token: sk-ant-multi-key-test',
99
+ 'dev_port: 3000',
100
+ ].join('\n') + '\n');
101
+ const readUserConfig = buildReadUserConfig(configPath);
102
+ assert.equal(readUserConfig(), 'sk-ant-multi-key-test');
103
+ } finally {
104
+ rmrf(dir);
105
+ }
106
+ });
107
+
108
+ test('returns the first anthropic_token when duplicates exist', () => {
109
+ const dir = makeTmpDir();
110
+ try {
111
+ const configPath = path.join(dir, 'config.yaml');
112
+ fs.writeFileSync(configPath, [
113
+ 'anthropic_token: first-token',
114
+ 'anthropic_token: second-token',
115
+ ].join('\n') + '\n');
116
+ const readUserConfig = buildReadUserConfig(configPath);
117
+ assert.equal(readUserConfig(), 'first-token');
118
+ } finally {
119
+ rmrf(dir);
120
+ }
121
+ });
122
+
123
+ // -- Quoted values --------------------------------------------------------
124
+
125
+ test('strips single quotes from token value', () => {
126
+ const dir = makeTmpDir();
127
+ try {
128
+ const configPath = path.join(dir, 'config.yaml');
129
+ fs.writeFileSync(configPath, "anthropic_token: 'sk-ant-quoted'\n");
130
+ const readUserConfig = buildReadUserConfig(configPath);
131
+ assert.equal(readUserConfig(), 'sk-ant-quoted');
132
+ } finally {
133
+ rmrf(dir);
134
+ }
135
+ });
136
+
137
+ test('strips double quotes from token value', () => {
138
+ const dir = makeTmpDir();
139
+ try {
140
+ const configPath = path.join(dir, 'config.yaml');
141
+ fs.writeFileSync(configPath, 'anthropic_token: "sk-ant-dquoted"\n');
142
+ const readUserConfig = buildReadUserConfig(configPath);
143
+ assert.equal(readUserConfig(), 'sk-ant-dquoted');
144
+ } finally {
145
+ rmrf(dir);
146
+ }
147
+ });
148
+
149
+ // -- Whitespace variations ------------------------------------------------
150
+
151
+ test('handles extra whitespace around colon', () => {
152
+ const dir = makeTmpDir();
153
+ try {
154
+ const configPath = path.join(dir, 'config.yaml');
155
+ fs.writeFileSync(configPath, 'anthropic_token: sk-ant-spaces \n');
156
+ const readUserConfig = buildReadUserConfig(configPath);
157
+ assert.equal(readUserConfig(), 'sk-ant-spaces');
158
+ } finally {
159
+ rmrf(dir);
160
+ }
161
+ });
162
+
163
+ test('handles tab whitespace after colon', () => {
164
+ const dir = makeTmpDir();
165
+ try {
166
+ const configPath = path.join(dir, 'config.yaml');
167
+ fs.writeFileSync(configPath, 'anthropic_token:\tsk-ant-tab\n');
168
+ const readUserConfig = buildReadUserConfig(configPath);
169
+ assert.equal(readUserConfig(), 'sk-ant-tab');
170
+ } finally {
171
+ rmrf(dir);
172
+ }
173
+ });
174
+
175
+ // -- Missing file ---------------------------------------------------------
176
+
177
+ test('returns null when config file does not exist', () => {
178
+ const readUserConfig = buildReadUserConfig('/tmp/nonexistent-df-test/config.yaml');
179
+ assert.equal(readUserConfig(), null);
180
+ });
181
+
182
+ // -- Missing key ----------------------------------------------------------
183
+
184
+ test('returns null when anthropic_token key is absent', () => {
185
+ const dir = makeTmpDir();
186
+ try {
187
+ const configPath = path.join(dir, 'config.yaml');
188
+ fs.writeFileSync(configPath, 'build_command: npm run build\ntest_command: npm test\n');
189
+ const readUserConfig = buildReadUserConfig(configPath);
190
+ assert.equal(readUserConfig(), null);
191
+ } finally {
192
+ rmrf(dir);
193
+ }
194
+ });
195
+
196
+ test('returns null for empty config file', () => {
197
+ const dir = makeTmpDir();
198
+ try {
199
+ const configPath = path.join(dir, 'config.yaml');
200
+ fs.writeFileSync(configPath, '');
201
+ const readUserConfig = buildReadUserConfig(configPath);
202
+ assert.equal(readUserConfig(), null);
203
+ } finally {
204
+ rmrf(dir);
205
+ }
206
+ });
207
+
208
+ // -- Malformed / edge cases -----------------------------------------------
209
+
210
+ test('returns null when key is indented (not a top-level key)', () => {
211
+ const dir = makeTmpDir();
212
+ try {
213
+ const configPath = path.join(dir, 'config.yaml');
214
+ fs.writeFileSync(configPath, ' anthropic_token: sk-ant-indented\n');
215
+ const readUserConfig = buildReadUserConfig(configPath);
216
+ // The regex requires ^ anchor so indented lines should not match
217
+ assert.equal(readUserConfig(), null);
218
+ } finally {
219
+ rmrf(dir);
220
+ }
221
+ });
222
+
223
+ test('does not match partial key names like anthropic_token_v2', () => {
224
+ const dir = makeTmpDir();
225
+ try {
226
+ const configPath = path.join(dir, 'config.yaml');
227
+ // anthropic_token_v2 should not match ^anthropic_token\s*: because
228
+ // the regex requires whitespace or colon after "anthropic_token"
229
+ fs.writeFileSync(configPath, 'anthropic_token_v2: sk-ant-wrong\n');
230
+ const readUserConfig = buildReadUserConfig(configPath);
231
+ // This actually WILL match because the regex is /^anthropic_token\s*:\s*(.+)$/
232
+ // and "anthropic_token_v2: sk-ant-wrong" doesn't have \s* right after "anthropic_token"
233
+ // — it has "_v2" so the \s* won't match. Let's verify.
234
+ // Actually: "anthropic_token_v2" — after "anthropic_token" comes "_v2" not whitespace/colon
235
+ // The regex is /^anthropic_token\s*:\s*(.+)$/ — requires \s* then : after token
236
+ // "_v2:" has "_v2" before ":" so \s* can't match "_v2". Correct: null.
237
+ assert.equal(readUserConfig(), null);
238
+ } finally {
239
+ rmrf(dir);
240
+ }
241
+ });
242
+
243
+ test('handles config with comments and blank lines', () => {
244
+ const dir = makeTmpDir();
245
+ try {
246
+ const configPath = path.join(dir, 'config.yaml');
247
+ fs.writeFileSync(configPath, [
248
+ '# This is a comment',
249
+ '',
250
+ 'build_command: npm run build',
251
+ '',
252
+ '# Token below',
253
+ 'anthropic_token: sk-ant-with-comments',
254
+ '',
255
+ ].join('\n'));
256
+ const readUserConfig = buildReadUserConfig(configPath);
257
+ assert.equal(readUserConfig(), 'sk-ant-with-comments');
258
+ } finally {
259
+ rmrf(dir);
260
+ }
261
+ });
262
+
263
+ test('handles config file that is only whitespace', () => {
264
+ const dir = makeTmpDir();
265
+ try {
266
+ const configPath = path.join(dir, 'config.yaml');
267
+ fs.writeFileSync(configPath, ' \n\n \n');
268
+ const readUserConfig = buildReadUserConfig(configPath);
269
+ assert.equal(readUserConfig(), null);
270
+ } finally {
271
+ rmrf(dir);
272
+ }
273
+ });
274
+
275
+ test('handles binary/garbage content without crashing', () => {
276
+ const dir = makeTmpDir();
277
+ try {
278
+ const configPath = path.join(dir, 'config.yaml');
279
+ fs.writeFileSync(configPath, Buffer.from([0x00, 0xFF, 0xFE, 0x0A, 0x89]));
280
+ const readUserConfig = buildReadUserConfig(configPath);
281
+ // Should return null (no matching line) without throwing
282
+ assert.equal(readUserConfig(), null);
283
+ } finally {
284
+ rmrf(dir);
285
+ }
286
+ });
287
+
288
+ test('directory instead of file returns null', () => {
289
+ const dir = makeTmpDir();
290
+ try {
291
+ // Point at a directory, not a file — readFileSync will throw EISDIR
292
+ const readUserConfig = buildReadUserConfig(dir);
293
+ assert.equal(readUserConfig(), null);
294
+ } finally {
295
+ rmrf(dir);
296
+ }
297
+ });
298
+
299
+ // -- Value edge cases -----------------------------------------------------
300
+
301
+ test('token value containing colons is preserved', () => {
302
+ const dir = makeTmpDir();
303
+ try {
304
+ const configPath = path.join(dir, 'config.yaml');
305
+ fs.writeFileSync(configPath, 'anthropic_token: sk-ant:has:colons\n');
306
+ const readUserConfig = buildReadUserConfig(configPath);
307
+ assert.equal(readUserConfig(), 'sk-ant:has:colons');
308
+ } finally {
309
+ rmrf(dir);
310
+ }
311
+ });
312
+
313
+ test('token with no colon separator does not match', () => {
314
+ const dir = makeTmpDir();
315
+ try {
316
+ const configPath = path.join(dir, 'config.yaml');
317
+ fs.writeFileSync(configPath, 'anthropic_token sk-ant-no-colon\n');
318
+ const readUserConfig = buildReadUserConfig(configPath);
319
+ assert.equal(readUserConfig(), null);
320
+ } finally {
321
+ rmrf(dir);
322
+ }
323
+ });
324
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepflow",
3
- "version": "0.1.104",
3
+ "version": "0.1.105",
4
4
  "description": "Doing reveals what thinking can't predict — spec-driven iterative development for Claude Code",
5
5
  "keywords": [
6
6
  "claude",
@@ -119,12 +119,6 @@ ratchet:
119
119
  # Examples: "eslint .", "flake8", "cargo clippy"
120
120
  lint_command: ""
121
121
 
122
- # deepflow-dashboard team mode settings
123
- # dashboard_url: URL of the shared team server for POST ingestion
124
- # Leave blank (or omit) to use local-only mode (no data is pushed)
125
- # Example: http://team-server:3334
126
- dashboard_url: ""
127
-
128
122
  # Port for `npx deepflow-dashboard serve` (team server mode)
129
123
  # Default: 3334 (3333 is reserved for local mode)
130
124
  dashboard_port: 3334