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
@@ -77,6 +77,10 @@ class _PathPresetControlState extends State<PathPresetControl> {
d = d == '~' ? home : '$home${d.substring(1)}';
}
if (d.isEmpty || !(d.startsWith('/') || RegExp(r'^[A-Za-z]:[/\\]').hasMatch(d))) return null;
// One dir per entry — an embedded PATH separator would smuggle extra
// (possibly empty → CWD) tokens into the joined PATH.
final body = RegExp(r'^[A-Za-z]:').hasMatch(d) ? d.substring(2) : d;
if (body.contains(':') || body.contains(';')) return null;
while (d.length > 1 && d.endsWith('/')) {
d = d.substring(0, d.length - 1);
}
@@ -293,7 +297,7 @@ class _PathPresetControlState extends State<PathPresetControl> {
onTap: () => _add(services, cwd),
builder: (ctx, hovered, _) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : tokens.buttonBackground, borderRadius: BorderRadius.circular(4)),
decoration: BoxDecoration(color: hovered ? tokens.buttonHoverBackground : tokens.buttonBackground, borderRadius: BorderRadius.circular(4)),
child: ClideText(addLabel, color: tokens.buttonForeground, fontSize: clideFontCaption),
),
),
+2 -2
View File
@@ -63,7 +63,7 @@ import 'package:clide/src/daemon/search_commands.dart';
import 'package:clide/src/editor/registry.dart' show EditorRegistry;
import 'package:clide/src/git/client.dart';
import 'package:clide/src/cli/argv_dispatch.dart';
import 'package:clide/src/env/path_preset.dart' show applyPathPreset, pathPresetKey, presetDirsFrom;
import 'package:clide/src/env/path_preset.dart' show applyPathPreset, pathPresetKey, presetDirsFrom, presetLookupRoot;
import 'package:clide/src/env/shell_env.dart' show primeLoginShellPath, resolvedToolPath;
import 'package:clide/src/env/supporter_binaries.dart';
import 'package:clide/widgets/src/icons/phosphor_glyphs.g.dart' show kPhosphorGlyphs;
@@ -322,7 +322,7 @@ Future<void> main() async {
final settings = kernelSettings;
final base = resolvedToolPath();
if (settings == null) return base;
return applyPathPreset(base, presetDirsFrom((k) => settings.get<Object>(k), cwd ?? workRoot.path));
return applyPathPreset(base, presetDirsFrom((k) => settings.get<Object>(k), presetLookupRoot(cwd, workRoot.path)));
},
);
// D-6 parity (T-219, D-83): make the tabs the user sees in the GUI
+5
View File
@@ -174,6 +174,11 @@ Future<IpcResponse> _dispatch(
}
final absolute = d.startsWith('/') || RegExp(r'^[A-Za-z]:[/\\]').hasMatch(d);
if (!absolute) return (out, 'not an absolute path: $raw');
// One dir per entry: an embedded PATH separator would expand into extra
// tokens at join time — and a stray trailing ':' yields an EMPTY token,
// which POSIX shells resolve as CWD (the classic dot-in-PATH hazard).
final body = RegExp(r'^[A-Za-z]:').hasMatch(d) ? d.substring(2) : d;
if (body.contains(':') || body.contains(';')) return (out, 'entry contains a PATH separator: $raw');
while (d.length > 1 && d.endsWith('/')) {
d = d.substring(0, d.length - 1);
}
+39 -7
View File
@@ -29,8 +29,8 @@ const pathPresetKeyPrefix = 'app.env.pathPrepend.';
/// The settings key holding [workspaceRoot]'s preset — app layer, suffixed
/// with the FNV-1a hash (the same one D-70 derives for the socket path) of the
/// [presetRootFor]-resolved repo root, so all worktrees of a repo share one key.
String pathPresetKey(String workspaceRoot, {bool Function(String path)? isFile, String? Function(String path)? readFile}) =>
'$pathPresetKeyPrefix${fnv1a64Hex(canonicalWorkspaceKey(presetRootFor(workspaceRoot, isFile: isFile, readFile: readFile)))}';
String pathPresetKey(String workspaceRoot, {bool Function(String path)? isFile, String? Function(String path)? readFile, bool Function(String path)? isDir}) =>
'$pathPresetKeyPrefix${fnv1a64Hex(canonicalWorkspaceKey(presetRootFor(workspaceRoot, isFile: isFile, readFile: readFile, isDir: isDir)))}';
/// The directory whose identity keys the preset: the MAIN repo root when
/// [workspaceRoot] is a linked git worktree, else [workspaceRoot] itself
@@ -42,11 +42,20 @@ String pathPresetKey(String workspaceRoot, {bool Function(String path)? isFile,
/// that shape — a normal repo (`.git` directory), no `.git` at all, a
/// submodule pointer — keys off [workspaceRoot] unchanged.
///
/// [isFile]/[readFile] are injectable for tests; defaults touch the real fs.
String presetRootFor(String workspaceRoot, {bool Function(String path)? isFile, String? Function(String path)? readFile}) {
/// The pointer content is REPO-controlled, so the resolved target is
/// validated before it is trusted: the candidate main root must actually
/// hold a `.git` directory (a genuine repo), else the pointer is ignored and
/// the workspace keys off itself. Without that check a crafted `.git` file
/// could alias an arbitrary path's preset key (same-user only — the preset
/// values themselves stay user-authored — but the boundary is cheap to hold).
///
/// [isFile]/[readFile]/[isDir] are injectable for tests; defaults touch the
/// real fs.
String presetRootFor(String workspaceRoot, {bool Function(String path)? isFile, String? Function(String path)? readFile, bool Function(String path)? isDir}) {
final root = _stripTrailingSep(workspaceRoot);
final probe = isFile ?? _isFile;
final read = readFile ?? _readFile;
final dirProbe = isDir ?? _isDir;
final gitPointer = '$root/.git';
if (!probe(gitPointer)) return root;
final content = read(gitPointer);
@@ -58,7 +67,21 @@ String presetRootFor(String workspaceRoot, {bool Function(String path)? isFile,
const marker = '/.git/worktrees/';
final idx = resolved.indexOf(marker);
if (idx <= 0) return root;
return resolved.substring(0, idx);
final mainRoot = resolved.substring(0, idx);
if (!dirProbe('$mainRoot/.git')) return root;
return mainRoot;
}
/// The root to key a spawn-time preset lookup on: [workspaceRoot] when [cwd]
/// is the workspace root or anywhere below it (a pane spawned in a subdir
/// must share the workspace's preset), else [cwd] itself (a spawn in an
/// unrelated directory keys off that directory's own repo).
String presetLookupRoot(String? cwd, String workspaceRoot) {
final ws = _stripTrailingSep(workspaceRoot);
if (cwd == null || cwd.isEmpty) return ws;
final c = _stripTrailingSep(cwd);
if (c == ws || c.startsWith('$ws/')) return ws;
return c;
}
/// Read [workspaceRoot]'s preset through an injected settings [read] (key →
@@ -69,8 +92,9 @@ List<String> presetDirsFrom(
String workspaceRoot, {
bool Function(String path)? isFile,
String? Function(String path)? readFile,
bool Function(String path)? isDir,
}) {
final raw = read(pathPresetKey(workspaceRoot, isFile: isFile, readFile: readFile));
final raw = read(pathPresetKey(workspaceRoot, isFile: isFile, readFile: readFile, isDir: isDir));
if (raw is! List) return const [];
return [
for (final e in raw)
@@ -81,11 +105,17 @@ List<String> presetDirsFrom(
/// Pure prepend: [preset] dirs (de-duplicated, order kept) ahead of [base],
/// with base entries that repeat a preset dir dropped so the preset always
/// wins. An empty preset returns [base] unchanged.
///
/// Contract: one directory per entry. An entry containing [sep] is malformed
/// (the CLI/UI reject it at input time; this guards stored values that
/// predate the check) and is skipped — joined verbatim it would smuggle
/// extra tokens into PATH, and a trailing separator yields an EMPTY token,
/// which POSIX shells resolve as CWD.
String applyPathPreset(String base, List<String> preset, {String sep = ':'}) {
final dirs = <String>[];
for (final d in preset) {
final t = d.trim();
if (t.isNotEmpty && !dirs.contains(t)) dirs.add(t);
if (t.isNotEmpty && !t.contains(sep) && !dirs.contains(t)) dirs.add(t);
}
if (dirs.isEmpty) return base;
final baseParts = base.isEmpty ? const <String>[] : base.split(sep);
@@ -108,6 +138,8 @@ List<String> missingLoginShellDirs({required String? loginPath, required String
bool _isFile(String path) => FileSystemEntity.typeSync(path) == FileSystemEntityType.file;
bool _isDir(String path) => FileSystemEntity.typeSync(path) == FileSystemEntityType.directory;
String? _readFile(String path) {
try {
return File(path).readAsStringSync();