deepflow 0.1.103 → 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-dynamic-hooks.test.js +461 -0
- package/bin/install.js +171 -250
- package/bin/install.test.js +205 -0
- package/bin/lineage-ingest.js +70 -0
- package/hooks/df-check-update.js +1 -0
- package/hooks/df-command-usage.js +18 -0
- package/hooks/df-dashboard-push.js +5 -4
- package/hooks/df-dashboard-push.test.js +256 -0
- package/hooks/df-execution-history.js +1 -0
- package/hooks/df-explore-protocol.js +83 -0
- package/hooks/df-explore-protocol.test.js +228 -0
- package/hooks/df-hook-event-tags.test.js +127 -0
- package/hooks/df-invariant-check.js +4 -3
- package/hooks/df-invariant-check.test.js +141 -0
- package/hooks/df-quota-logger.js +12 -23
- package/hooks/df-quota-logger.test.js +324 -0
- package/hooks/df-snapshot-guard.js +1 -0
- package/hooks/df-spec-lint.js +58 -1
- package/hooks/df-spec-lint.test.js +412 -0
- package/hooks/df-statusline.js +1 -0
- package/hooks/df-subagent-registry.js +1 -0
- package/hooks/df-tool-usage.js +13 -3
- package/hooks/df-worktree-guard.js +1 -0
- package/package.json +1 -1
- package/src/commands/df/debate.md +1 -1
- package/src/commands/df/eval.md +117 -0
- package/src/commands/df/execute.md +1 -1
- package/src/commands/df/fix.md +104 -0
- package/src/eval/git-memory.js +159 -0
- package/src/eval/git-memory.test.js +439 -0
- package/src/eval/hypothesis.js +80 -0
- package/src/eval/hypothesis.test.js +169 -0
- package/src/eval/loop.js +378 -0
- package/src/eval/loop.test.js +306 -0
- package/src/eval/metric-collector.js +163 -0
- package/src/eval/metric-collector.test.js +369 -0
- package/src/eval/metric-pivot.js +119 -0
- package/src/eval/metric-pivot.test.js +350 -0
- package/src/eval/mutator-prompt.js +106 -0
- package/src/eval/mutator-prompt.test.js +180 -0
- package/templates/config-template.yaml +5 -6
- package/templates/eval-fixture-template/config.yaml +39 -0
- package/templates/eval-fixture-template/fixture/.deepflow/decisions.md +5 -0
- package/templates/eval-fixture-template/fixture/hooks/invariant.js +28 -0
- package/templates/eval-fixture-template/fixture/package.json +12 -0
- package/templates/eval-fixture-template/fixture/specs/doing-example-task.md +18 -0
- package/templates/eval-fixture-template/fixture/src/commands/df/example.md +18 -0
- package/templates/eval-fixture-template/fixture/src/config.js +40 -0
- package/templates/eval-fixture-template/fixture/src/index.js +19 -0
- package/templates/eval-fixture-template/fixture/src/pipeline.js +40 -0
- package/templates/eval-fixture-template/fixture/src/skills/example-skill/SKILL.md +32 -0
- package/templates/eval-fixture-template/fixture/src/spec-loader.js +35 -0
- package/templates/eval-fixture-template/fixture/src/task-runner.js +32 -0
- package/templates/eval-fixture-template/fixture/src/verifier.js +37 -0
- package/templates/eval-fixture-template/hypotheses.md +14 -0
- package/templates/eval-fixture-template/spec.md +34 -0
- package/templates/eval-fixture-template/tests/behavior.test.js +69 -0
- package/templates/eval-fixture-template/tests/guard.test.js +108 -0
- package/templates/eval-fixture-template.test.js +318 -0
- package/templates/explore-agent.md +5 -74
- package/templates/explore-protocol.md +44 -0
- package/templates/spec-template.md +4 -0
package/bin/install.test.js
CHANGED
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Scans specs/*.md for derives-from frontmatter and writes .deepflow/lineage.jsonl
|
|
4
|
+
*
|
|
5
|
+
* Each line: { "child": "fix-auth", "parent": "done-auth", "child_status": "doing", "scanned_at": "ISO" }
|
|
6
|
+
*
|
|
7
|
+
* Usage: node bin/lineage-ingest.js [--specs-dir specs/] [--out .deepflow/lineage.jsonl]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { parseFrontmatter } = require('../hooks/df-spec-lint.js');
|
|
13
|
+
|
|
14
|
+
function main() {
|
|
15
|
+
const args = process.argv.slice(2);
|
|
16
|
+
const specsDir = getArg(args, '--specs-dir') || 'specs';
|
|
17
|
+
const outFile = getArg(args, '--out') || path.join('.deepflow', 'lineage.jsonl');
|
|
18
|
+
|
|
19
|
+
if (!fs.existsSync(specsDir)) {
|
|
20
|
+
process.stderr.write(`lineage-ingest: specs dir not found: ${specsDir}\n`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const files = fs.readdirSync(specsDir).filter(f => f.endsWith('.md'));
|
|
25
|
+
const entries = [];
|
|
26
|
+
const now = new Date().toISOString();
|
|
27
|
+
|
|
28
|
+
for (const file of files) {
|
|
29
|
+
const content = fs.readFileSync(path.join(specsDir, file), 'utf8');
|
|
30
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
31
|
+
const derivesFrom = frontmatter['derives-from'];
|
|
32
|
+
|
|
33
|
+
if (!derivesFrom) continue;
|
|
34
|
+
|
|
35
|
+
const specName = file.replace(/\.md$/, '');
|
|
36
|
+
let childStatus = 'planned';
|
|
37
|
+
if (specName.startsWith('doing-')) childStatus = 'doing';
|
|
38
|
+
else if (specName.startsWith('done-')) childStatus = 'done';
|
|
39
|
+
|
|
40
|
+
entries.push({
|
|
41
|
+
child: specName,
|
|
42
|
+
parent: derivesFrom,
|
|
43
|
+
child_status: childStatus,
|
|
44
|
+
scanned_at: now
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Ensure output dir exists
|
|
49
|
+
const outDir = path.dirname(outFile);
|
|
50
|
+
if (outDir && !fs.existsSync(outDir)) {
|
|
51
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Overwrite — full scan each time, no append
|
|
55
|
+
const lines = entries.map(e => JSON.stringify(e)).join('\n');
|
|
56
|
+
fs.writeFileSync(outFile, lines ? lines + '\n' : '');
|
|
57
|
+
|
|
58
|
+
process.stdout.write(`lineage-ingest: ${entries.length} lineage entries written to ${outFile}\n`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getArg(args, flag) {
|
|
62
|
+
const idx = args.indexOf(flag);
|
|
63
|
+
return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { main };
|
|
67
|
+
|
|
68
|
+
if (require.main === module) {
|
|
69
|
+
main();
|
|
70
|
+
}
|
package/hooks/df-check-update.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// @hook-event: PreToolUse, PostToolUse, SessionEnd
|
|
2
3
|
/**
|
|
3
4
|
* deepflow command usage tracker
|
|
4
5
|
* Tracks df:* command invocations with token deltas and tool call counts.
|
|
@@ -6,6 +7,7 @@
|
|
|
6
7
|
* Events:
|
|
7
8
|
* PreToolUse — detect Skill calls matching df:*, close previous command, open new marker
|
|
8
9
|
* PostToolUse — increment tool_calls_count on the active marker
|
|
10
|
+
* SessionStart — close orphaned marker on /clear or /compact (context reset)
|
|
9
11
|
* SessionEnd — close any open marker so the last command gets a record
|
|
10
12
|
*
|
|
11
13
|
* Marker: .deepflow/active-command.json
|
|
@@ -45,6 +47,8 @@ function main() {
|
|
|
45
47
|
handlePreToolUse(deepflowDir, markerPath, usagePath, tokenHistoryPath);
|
|
46
48
|
} else if (event === 'PostToolUse') {
|
|
47
49
|
handlePostToolUse(markerPath);
|
|
50
|
+
} else if (event === 'SessionStart') {
|
|
51
|
+
handleSessionStart(markerPath, usagePath, tokenHistoryPath);
|
|
48
52
|
} else if (event === 'SessionEnd') {
|
|
49
53
|
handleSessionEnd(deepflowDir, markerPath, usagePath, tokenHistoryPath);
|
|
50
54
|
}
|
|
@@ -111,6 +115,20 @@ function handlePostToolUse(markerPath) {
|
|
|
111
115
|
}
|
|
112
116
|
}
|
|
113
117
|
|
|
118
|
+
/**
|
|
119
|
+
* On /clear or /compact, context resets — close any orphaned marker.
|
|
120
|
+
* Only fires for source=clear|compact (not startup/resume).
|
|
121
|
+
*/
|
|
122
|
+
function handleSessionStart(markerPath, usagePath, tokenHistoryPath) {
|
|
123
|
+
if (!safeExists(markerPath)) return;
|
|
124
|
+
let payload;
|
|
125
|
+
try { payload = JSON.parse(raw); } catch { return; }
|
|
126
|
+
const source = payload.source || '';
|
|
127
|
+
if (source === 'clear' || source === 'compact') {
|
|
128
|
+
closeCommand(markerPath, usagePath, tokenHistoryPath);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
114
132
|
function handleSessionEnd(deepflowDir, markerPath, usagePath, tokenHistoryPath) {
|
|
115
133
|
if (!safeExists(markerPath)) return;
|
|
116
134
|
closeCommand(markerPath, usagePath, tokenHistoryPath);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// @hook-event: SessionEnd
|
|
2
3
|
/**
|
|
3
4
|
* deepflow dashboard push — SessionEnd hook
|
|
4
5
|
* Collects session summary (tokens, duration, tool calls, model), gets
|
|
@@ -40,9 +41,9 @@ function getStdinSync() {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
/** Read
|
|
44
|
-
function getDashboardUrl(
|
|
45
|
-
const configPath = path.join(
|
|
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');
|
|
46
47
|
if (!fs.existsSync(configPath)) return null;
|
|
47
48
|
try {
|
|
48
49
|
const content = fs.readFileSync(configPath, 'utf8');
|
|
@@ -111,7 +112,7 @@ function postJson(url, payload) {
|
|
|
111
112
|
async function main() {
|
|
112
113
|
try {
|
|
113
114
|
const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
114
|
-
const dashboardUrl = getDashboardUrl(
|
|
115
|
+
const dashboardUrl = getDashboardUrl();
|
|
115
116
|
|
|
116
117
|
// Silently skip if not configured
|
|
117
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
|
+
});
|