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:
Tom Boucher
2026-04-03 11:19:42 -04:00
parent 8903202d62
commit 00e0446b99
9 changed files with 207 additions and 5 deletions

View File

@@ -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 */ }
});
});