fix: four bug fixes — bold plan checkboxes, BACKLOG recommendations, executor_model regression tests, session_id path traversal
- fix(#1572): phase complete now marks bold-wrapped plan checkboxes in ROADMAP.md (`- [ ] **01-01**` format) by allowing optional `**` around plan IDs in the planCheckboxPattern regex in both phase.cjs and roadmap.cjs - fix(#1569): manager init no longer recommends 999.x (BACKLOG) phases as next actions; add guard in cmdManagerInit that skips phases matching /^999(?:\.|$)/ - fix(#1568): add regression tests confirming init execute-phase respects model_overrides for executor_model, including when resolve_model_ids is 'omit' - fix(#1533): reject session_id values containing path traversal sequences (../, /, \) in gsd-context-monitor and gsd-statusline before constructing /tmp file paths; add security tests covering both hooks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
|
||||
const {
|
||||
validatePath,
|
||||
@@ -414,3 +415,93 @@ describe('validateFieldName', () => {
|
||||
assert.ok(!validateFieldName('-field').valid);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Hook session_id path traversal (#1533) ────────────────────────────────
|
||||
// Verify that gsd-context-monitor and gsd-statusline reject session_id values
|
||||
// containing path traversal sequences before constructing temp file paths.
|
||||
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
function runHook(hookPath, inputJson) {
|
||||
try {
|
||||
const result = execFileSync(process.execPath, [hookPath], {
|
||||
input: JSON.stringify(inputJson),
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 3000,
|
||||
});
|
||||
return { exitCode: 0, stdout: result };
|
||||
} catch (err) {
|
||||
return { exitCode: err.status || 1, stdout: err.stdout || '', stderr: err.stderr || '' };
|
||||
}
|
||||
}
|
||||
|
||||
describe('gsd-context-monitor session_id path traversal', () => {
|
||||
const monitorPath = path.join(__dirname, '..', 'hooks', 'gsd-context-monitor.js');
|
||||
const tmpDir = os.tmpdir();
|
||||
|
||||
test('exits silently for session_id with ../ traversal', () => {
|
||||
const maliciousId = '../../../etc/passwd';
|
||||
const result = runHook(monitorPath, { session_id: maliciousId });
|
||||
assert.strictEqual(result.exitCode, 0, 'hook should exit 0 for malicious session_id');
|
||||
assert.strictEqual(result.stdout.trim(), '', 'hook should produce no output for malicious session_id');
|
||||
const escapedPath = path.join(tmpDir, 'claude-ctx-' + maliciousId + '.json');
|
||||
assert.ok(!fs.existsSync(escapedPath), 'traversal file must not be created');
|
||||
});
|
||||
|
||||
test('exits silently for session_id with / separator', () => {
|
||||
const maliciousId = 'foo/bar';
|
||||
const result = runHook(monitorPath, { session_id: maliciousId });
|
||||
assert.strictEqual(result.exitCode, 0);
|
||||
assert.strictEqual(result.stdout.trim(), '');
|
||||
});
|
||||
|
||||
test('exits silently for session_id with backslash', () => {
|
||||
const maliciousId = 'foo\\bar';
|
||||
const result = runHook(monitorPath, { session_id: maliciousId });
|
||||
assert.strictEqual(result.exitCode, 0);
|
||||
assert.strictEqual(result.stdout.trim(), '');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gsd-statusline session_id path traversal', () => {
|
||||
const statuslinePath = path.join(__dirname, '..', 'hooks', 'gsd-statusline.js');
|
||||
const tmpDir = os.tmpdir();
|
||||
|
||||
const baseInput = {
|
||||
model: { display_name: 'Claude' },
|
||||
context_window: { remaining_percentage: 80 },
|
||||
workspace: { current_dir: os.tmpdir() },
|
||||
};
|
||||
|
||||
test('does not write bridge file for session_id with ../ traversal', () => {
|
||||
const maliciousId = '../../../etc/gsd-test';
|
||||
const bridgePath = path.join(tmpDir, 'claude-ctx-' + maliciousId + '.json');
|
||||
try { fs.unlinkSync(bridgePath); } catch { /* intentionally empty */ }
|
||||
|
||||
runHook(statuslinePath, { ...baseInput, session_id: maliciousId });
|
||||
|
||||
assert.ok(!fs.existsSync(bridgePath), 'bridge file must not be written for traversal session_id');
|
||||
});
|
||||
|
||||
test('does not write bridge file for session_id with forward slash', () => {
|
||||
const maliciousId = 'sub/path';
|
||||
const bridgePath = path.join(tmpDir, 'claude-ctx-' + maliciousId + '.json');
|
||||
try { fs.unlinkSync(bridgePath); } catch { /* intentionally empty */ }
|
||||
|
||||
runHook(statuslinePath, { ...baseInput, session_id: maliciousId });
|
||||
|
||||
assert.ok(!fs.existsSync(bridgePath), 'bridge file must not be written for session_id with /');
|
||||
});
|
||||
|
||||
test('writes bridge file for safe session_id', () => {
|
||||
const safeId = 'abc123-safe-session';
|
||||
const bridgePath = path.join(tmpDir, 'claude-ctx-' + safeId + '.json');
|
||||
try { fs.unlinkSync(bridgePath); } catch { /* intentionally empty */ }
|
||||
|
||||
runHook(statuslinePath, { ...baseInput, session_id: safeId });
|
||||
|
||||
assert.ok(fs.existsSync(bridgePath), 'bridge file must be written for safe session_id');
|
||||
try { fs.unlinkSync(bridgePath); } catch { /* intentionally empty */ }
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user