fix(env): harden the PATH preset per review (T-511)

Three holes from the T-511 adversarial review pass:

- An entry containing the PATH separator smuggled extra tokens into
  the joined PATH — a stray trailing ':' yields an EMPTY token, which
  POSIX shells resolve as CWD (the dot-in-PATH hazard). The CLI verb
  and the settings control now reject such entries, and applyPathPreset
  skips malformed stored values that predate the check.
- The gitdir pointer a worktree resolution follows is repo-controlled
  text; the resolved main root is now validated (must hold a real
  .git directory) before its preset key is trusted, so a crafted
  pointer can't alias an arbitrary path's preset.
- A pane spawned with a cwd below the workspace root hashed the
  subdirectory and silently missed the workspace preset; the lookup
  now keys any in-workspace cwd to the workspace root
  (presetLookupRoot).

Also: the Add button pairs buttonBackground with its own
buttonHoverBackground token instead of borrowing the list-item hover
token, and the hosted-Claude leg gains an end-to-end orchestrator test
(preset lookup → spawn env).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 18:17:35 +02:00
co-authored by Claude Fable 5
parent 49c82556a2
commit 0a44f95dfd
8 changed files with 157 additions and 17 deletions
@@ -53,6 +53,19 @@ void main() {
expect(spawnedArgs.single, isNot(contains('--effort')));
});
test('the workspace PATH preset reaches the spawned session env (D-106)', () async {
final envs = <Map<String, String>?>[];
final preset = ClaudeSessionOrchestrator(
processFactory: ({required sessionArgs, required cwd, env}) async {
envs.add(env);
return _FakeProc();
},
pathPresetFor: (cwd) => cwd == '/repo' ? const ['/opt/go/bin'] : const [],
);
await preset.spawn(SpawnSpec(id: 'p1', role: 'primary', sessionId: 'p1-uuid', cwd: '/repo'));
expect(envs.single?['PATH'], startsWith('/opt/go/bin:'), reason: 'preset dirs lead the delta PATH');
});
test('a fresh session gets the skills nudge; resume + fork do not (T-490)', () async {
String appendPrompt(List<String> args) {
final i = args.indexOf('--append-system-prompt');
@@ -81,6 +81,29 @@ void main() {
expect(dirs(f.tempDir.path), isEmpty);
});
testWidgets('an entry containing a PATH separator is rejected (CWD-token guard)', (tester) async {
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
await pump(tester);
await tester.pump();
await tester.enterText(find.byType(EditableText), '/opt/go/bin:');
await tester.tap(find.text('Add entry'));
await tester.pump();
expect(find.textContaining('absolute path'), findsOneWidget);
expect(dirs(f.tempDir.path), isEmpty);
});
testWidgets('~/ expands against HOME and Enter submits (parity with the CLI verb)', (tester) async {
final home = Platform.environment['HOME'];
if (home == null || home.isEmpty) return; // no HOME in this environment — the guard path is CLI-tested
await tester.runAsync(() => f.services.settings.setProjectDir(f.tempDir));
await pump(tester);
await tester.pump();
await tester.enterText(find.byType(EditableText), '~/go/bin/');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(dirs(f.tempDir.path), ['$home/go/bin']);
});
testWidgets('an existing dir renders without the missing tag', (tester) async {
await tester.runAsync(() async {
await f.services.settings.setProjectDir(f.tempDir);
+22 -2
View File
@@ -32,7 +32,7 @@ void main() {
String? loginPath;
var processPath = '/usr/bin:/bin';
void wire({String? cwd = '/repo', bool withStore = true}) {
void wire({String? cwd = '/repo', bool withStore = true, String? home = '/home/u'}) {
store = _FakeStore();
published = [];
existingDirs = {};
@@ -45,7 +45,7 @@ void main() {
publisher: () =>
(p, c, data) => published.add((publisher: p, channel: c, data: data)),
workspaceCwd: () => cwd,
home: () => '/home/u',
home: () => home,
dirExists: (dir) => existingDirs.contains(dir),
loginPath: () => loginPath,
processPath: () => processPath,
@@ -91,6 +91,26 @@ void main() {
expect(empty.error?.hint, contains('clear'));
});
test('add and remove apply the same guards as set (relative + separator)', () async {
wire();
for (final action in ['set', 'add', 'remove']) {
final rel = await run([action, 'go/bin']);
expect(rel.ok, isFalse, reason: '$action relative');
expect(rel.error?.message, contains('not an absolute path'));
final sep = await run([action, '/a:']);
expect(sep.ok, isFalse, reason: '$action separator');
expect(sep.error?.message, contains('PATH separator'));
}
expect(store.byCwd, isEmpty, reason: 'nothing was written');
});
test('~ with no HOME errors instead of storing a broken entry', () async {
wire(home: null);
final r = await run(['set', '~/go/bin']);
expect(r.ok, isFalse);
expect(r.error?.message, contains('cannot expand'));
});
test('a leading-dash entry is rejected by the schema (T-104 guard)', () async {
wire();
final r = await d.dispatch(
+48 -5
View File
@@ -34,6 +34,11 @@ void main() {
test('honours a custom separator', () {
expect(applyPathPreset(r'C:\bin', [r'C:\go'], sep: ';'), r'C:\go;C:\bin');
});
test('skips a malformed entry containing the separator (would smuggle a CWD token)', () {
expect(applyPathPreset('/usr/bin', ['/a:', '/ok']), '/ok:/usr/bin');
expect(applyPathPreset('/usr/bin', ['/a::/b']), '/usr/bin');
});
});
group('missingLoginShellDirs', () {
@@ -65,20 +70,45 @@ void main() {
test('an in-repo .worktrees worktree resolves to the main repo root (absolute gitdir)', () {
expect(
presetRootFor('/repo/.worktrees/fix', isFile: (p) => p == '/repo/.worktrees/fix/.git', readFile: (p) => 'gitdir: /repo/.git/worktrees/fix\n'),
presetRootFor(
'/repo/.worktrees/fix',
isFile: (p) => p == '/repo/.worktrees/fix/.git',
readFile: (p) => 'gitdir: /repo/.git/worktrees/fix\n',
isDir: (p) => p == '/repo/.git',
),
'/repo',
);
});
test('a relative gitdir pointer resolves against the worktree root', () {
expect(
presetRootFor('/repo/.worktrees/fix', isFile: (p) => p == '/repo/.worktrees/fix/.git', readFile: (p) => 'gitdir: ../../.git/worktrees/fix'),
presetRootFor(
'/repo/.worktrees/fix',
isFile: (p) => p == '/repo/.worktrees/fix/.git',
readFile: (p) => 'gitdir: ../../.git/worktrees/fix',
isDir: (p) => p == '/repo/.git',
),
'/repo',
);
});
test('a worktree outside the repo still resolves to the main root', () {
expect(presetRootFor('/tmp/wt', isFile: (p) => p == '/tmp/wt/.git', readFile: (_) => 'gitdir: /srv/repos/main/.git/worktrees/wt'), '/srv/repos/main');
expect(
presetRootFor(
'/tmp/wt',
isFile: (p) => p == '/tmp/wt/.git',
readFile: (_) => 'gitdir: /srv/repos/main/.git/worktrees/wt',
isDir: (p) => p == '/srv/repos/main/.git',
),
'/srv/repos/main',
);
});
test('a pointer whose target is not a real repo is ignored (repo-controlled content)', () {
expect(
presetRootFor('/evil', isFile: (p) => p == '/evil/.git', readFile: (_) => 'gitdir: /home/u/victim/.git/worktrees/x', isDir: (_) => false),
'/evil',
);
});
test('a gitdir pointer without the worktrees marker (submodule-style) keys off itself', () {
@@ -91,7 +121,15 @@ void main() {
});
test('backslashed gitdir (Windows-written pointer) still matches', () {
expect(presetRootFor('/repo/.worktrees/x', isFile: (p) => p == '/repo/.worktrees/x/.git', readFile: (_) => r'gitdir: /repo/.git\worktrees\x'), '/repo');
expect(
presetRootFor(
'/repo/.worktrees/x',
isFile: (p) => p == '/repo/.worktrees/x/.git',
readFile: (_) => r'gitdir: /repo/.git\worktrees\x',
isDir: (p) => p == '/repo/.git',
),
'/repo',
);
});
test('resolves a REAL worktree layout on disk (no injected probes)', () {
@@ -116,7 +154,12 @@ void main() {
test('a worktree and its main repo share one key; trailing slash is unified', () {
final main = pathPresetKey('/repo', isFile: (_) => false, readFile: (_) => null);
final slash = pathPresetKey('/repo/', isFile: (_) => false, readFile: (_) => null);
final wt = pathPresetKey('/repo/.worktrees/fix', isFile: (p) => p == '/repo/.worktrees/fix/.git', readFile: (_) => 'gitdir: /repo/.git/worktrees/fix');
final wt = pathPresetKey(
'/repo/.worktrees/fix',
isFile: (p) => p == '/repo/.worktrees/fix/.git',
readFile: (_) => 'gitdir: /repo/.git/worktrees/fix',
isDir: (p) => p == '/repo/.git',
);
expect(slash, main);
expect(wt, main);
expect(pathPresetKey('/other', isFile: (_) => false, readFile: (_) => null), isNot(main));