Skip to content

Commit 1804ccb

Browse files
author
Yogthos
committed
fix: duplicate Ctrl+N/P/X handlers, write->edit allowlist mirroring, dead code removal
- Remove ~122 lines of unreachable duplicate Ctrl+N/P/X match arms in mod.rs — the early-block handler catches all three keys and continues, making the later match arms dead code. Fix Ctrl+X in the early block to call remove_chat + adjust chat_ui_states instead of computing 'next chat' index. - Fix session allowlist F2 alias re-prompt loop: when the user 'always allows' write/apply_patch, also register the pattern under the edit alias so enforce()'s most-restrictive merge doesn't return Ask every time. Mirrors edit→write/apply_patch in reverse direction too. load_session_allowlist routes through add_session_allowlist so persisted sessions also get the mirroring. - Delete duplicate validate_write_path function body that was copy-pasted into mod tests (shadow copy — tests weren't testing production code). - Rename validate_write_path → validate_path since it guards read/edit too. Fix error messages: 'Refusing to write to' → 'Refusing to use'. - Remove dead code: draw_search_bar (31 lines), content_cols (8 lines), color + resolve_color import, scroll_to_line (7 lines), set_alert_overlay_with_title (8 lines), alert_overlay_active (4 lines). - Remove 5 #[allow(dead_code)] annotations from items that are actually used. Replace block-level #[allow(dead_code)] on impl Renderer with targeted annotation on just the anchor marker method.
1 parent 2566862 commit 1804ccb

5 files changed

Lines changed: 185 additions & 306 deletions

File tree

src/permission/allowlist.rs

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,6 @@ pub(crate) fn add(allowlist: &mut Vec<(String, Pattern)>, tool: &str, pattern_st
2525
allowlist.push((tool.to_string(), pattern));
2626
}
2727

28-
pub(crate) fn load(allowlist: &mut Vec<(String, Pattern)>, entries: &[(String, String)]) {
29-
for (tool, pat) in entries {
30-
add(allowlist, tool, pat);
31-
}
32-
}
33-
3428
pub(crate) fn entries(allowlist: &[(String, Pattern)]) -> Vec<(String, String)> {
3529
allowlist
3630
.iter()
@@ -90,19 +84,6 @@ mod tests {
9084
assert_eq!(e.len(), 2, "got: {:?}", e);
9185
}
9286

93-
#[test]
94-
fn load_dedupes_against_existing() {
95-
let mut al = Vec::new();
96-
let entries_in = vec![
97-
("bash".to_string(), "cargo *".to_string()),
98-
("bash".to_string(), "cargo *".to_string()),
99-
];
100-
load(&mut al, &entries_in);
101-
assert_eq!(entries(&al).len(), 1);
102-
load(&mut al, &entries_in);
103-
assert_eq!(entries(&al).len(), 1);
104-
}
105-
10687
#[test]
10788
fn clear_empties_the_list() {
10889
let mut al = make_allowlist();

src/permission/checker.rs

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,7 @@ impl PermissionChecker {
473473
// dialogs for non-existent files. Absolute paths and
474474
// relative paths with directory components or file
475475
// extensions pass through to the normal check.
476-
if let Err(reason) = path::validate_write_path(path) {
476+
if let Err(reason) = path::validate_path(path) {
477477
return CheckResult::Denied(reason);
478478
}
479479

@@ -606,10 +606,31 @@ impl PermissionChecker {
606606

607607
pub fn add_session_allowlist(&mut self, tool: String, pattern_str: &str) {
608608
allowlist::add(&mut self.session_allowlist, &tool, pattern_str);
609+
// F2 write↔edit↔apply_patch aliasing: when the user "always
610+
// allows" any of these three, also register the pattern under
611+
// the other two so the alias check in enforce() doesn't
612+
// re-prompt. Without this, a user who "always allows" write
613+
// gets asked again on the next write because the edit-alias
614+
// check returns Ask with no allowlist match.
615+
match tool.as_str() {
616+
"write" | "apply_patch" => {
617+
allowlist::add(&mut self.session_allowlist, "edit", pattern_str);
618+
}
619+
"edit" => {
620+
allowlist::add(&mut self.session_allowlist, "write", pattern_str);
621+
allowlist::add(&mut self.session_allowlist, "apply_patch", pattern_str);
622+
}
623+
_ => {}
624+
}
609625
}
610626

611627
pub fn load_session_allowlist(&mut self, entries: &[(String, String)]) {
612-
allowlist::load(&mut self.session_allowlist, entries);
628+
// Route through add_session_allowlist (not allowlist::add
629+
// directly) so the write↔edit alias mirroring fires for
630+
// persisted sessions too.
631+
for (tool, pat) in entries {
632+
self.add_session_allowlist(tool.clone(), pat);
633+
}
613634
}
614635

615636
pub fn allowlist_entries(&self) -> Vec<(String, String)> {
@@ -1460,6 +1481,98 @@ mod tests {
14601481
);
14611482
}
14621483

1484+
/// F2 write↔edit aliasing: when a user "always allows" a write
1485+
/// path, the alias check against "edit" must also match so the
1486+
/// most-restrictive merge doesn't re-prompt on every subsequent
1487+
/// call. Without this, `enforce()` sees Allowed from write rules
1488+
/// but Ask from edit (no session-allowlist entry), and the
1489+
/// combined result is Ask — infinite re-prompt loop.
1490+
#[test]
1491+
fn add_session_allowlist_mirrors_write_to_edit() {
1492+
let mut cfg = PermissionConfig::default();
1493+
cfg.default = Some(Action::Ask);
1494+
let mut checker = PermissionChecker::new(
1495+
&cfg,
1496+
SecurityMode::Standard,
1497+
Some(std::path::PathBuf::from("/cwd-off-test-axis")),
1498+
);
1499+
checker.add_session_allowlist("write".to_string(), "/probe/src/**");
1500+
1501+
// The write tool itself hits the allowlist.
1502+
assert!(matches!(
1503+
checker.check_path("write", "/probe/src/main.rs"),
1504+
CheckResult::Allowed
1505+
));
1506+
// The edit alias MUST also match — this is what enforce() checks.
1507+
assert!(matches!(
1508+
checker.check_path("edit", "/probe/src/main.rs"),
1509+
CheckResult::Allowed,
1510+
),
1511+
"edit alias must reflect write session-allowlist entry"
1512+
);
1513+
1514+
// Reverse direction: "always allow" edit → write must match.
1515+
let mut checker2 = PermissionChecker::new(
1516+
&cfg,
1517+
SecurityMode::Standard,
1518+
Some(std::path::PathBuf::from("/cwd-off-test-axis")),
1519+
);
1520+
checker2.add_session_allowlist("edit".to_string(), "/probe/src/**");
1521+
assert!(matches!(
1522+
checker2.check_path("write", "/probe/src/main.rs"),
1523+
CheckResult::Allowed,
1524+
),
1525+
"write must reflect edit session-allowlist entry"
1526+
);
1527+
assert!(matches!(
1528+
checker2.check_path("apply_patch", "/probe/src/main.rs"),
1529+
CheckResult::Allowed,
1530+
),
1531+
"apply_patch must reflect edit session-allowlist entry"
1532+
);
1533+
1534+
// apply_patch → edit mirroring too.
1535+
let mut checker3 = PermissionChecker::new(
1536+
&cfg,
1537+
SecurityMode::Standard,
1538+
Some(std::path::PathBuf::from("/cwd-off-test-axis")),
1539+
);
1540+
checker3.add_session_allowlist("apply_patch".to_string(), "/probe/src/**");
1541+
assert!(matches!(
1542+
checker3.check_path("edit", "/probe/src/main.rs"),
1543+
CheckResult::Allowed,
1544+
),
1545+
"edit must reflect apply_patch session-allowlist entry"
1546+
);
1547+
1548+
// Via load_session_allowlist too (persisted-session path).
1549+
let mut checker4 = PermissionChecker::new(
1550+
&cfg,
1551+
SecurityMode::Standard,
1552+
Some(std::path::PathBuf::from("/cwd-off-test-axis")),
1553+
);
1554+
checker4.load_session_allowlist(&[("write".to_string(), "/probe/src/**".to_string())]);
1555+
assert!(matches!(
1556+
checker4.check_path("edit", "/probe/src/main.rs"),
1557+
CheckResult::Allowed,
1558+
),
1559+
"load_session_allowlist must also mirror write→edit"
1560+
);
1561+
1562+
// Non-aliased tools are unaffected.
1563+
let mut checker5 = fresh_checker();
1564+
checker5.add_session_allowlist("read".to_string(), "/tmp/**");
1565+
assert!(matches!(
1566+
checker5.check_path("read", "/tmp/foo.txt"),
1567+
CheckResult::Allowed,
1568+
));
1569+
// read doesn't alias to write/edit.
1570+
assert!(
1571+
!checker5.is_session_allowed("write", "/tmp/foo.txt"),
1572+
"read allowlist entry must not leak to write"
1573+
);
1574+
}
1575+
14631576
// load_session_allowlist roundtrip: persisted patterns from a previous
14641577
// session should match the way they did when saved.
14651578
#[test]

src/permission/path.rs

Lines changed: 25 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -162,13 +162,13 @@ fn lexical_normalize(p: &Path) -> std::path::PathBuf {
162162
/// trigger permission dialogs. Relative single-segment paths
163163
/// that are purely numeric ("1", "42") or trivially short
164164
/// ("a", "x") are never valid file names a well-behaved
165-
/// agent would genuinely want to write to; the model is
166-
/// confusing a counter, index, or file-descriptor number with
167-
/// a file path.
165+
/// agent would genuinely want to use; the model is confusing
166+
/// a counter, index, or file-descriptor number with a file
167+
/// path.
168168
///
169169
/// Returns `Ok(())` for plausible paths, `Err(reason)` for
170170
/// paths that should be hard-rejected.
171-
pub fn validate_write_path(path: &str) -> Result<(), String> {
171+
pub fn validate_path(path: &str) -> Result<(), String> {
172172
let p = Path::new(path);
173173
if p.is_absolute() {
174174
return Ok(());
@@ -186,13 +186,13 @@ pub fn validate_write_path(path: &str) -> Result<(), String> {
186186
// with no extension ("a", "xy").
187187
if path.chars().all(|c| c.is_ascii_digit()) {
188188
return Err(format!(
189-
"Refusing to write to numeric path {:?}. Use an absolute path with a real file name.",
189+
"Refusing to use numeric path {:?}. Use an absolute path with a real file name.",
190190
path,
191191
));
192192
}
193193
if path.chars().count() <= 2 {
194194
return Err(format!(
195-
"Refusing to write to trivial path {:?}. Use an absolute path with a real file name.",
195+
"Refusing to use trivial path {:?}. Use an absolute path with a real file name.",
196196
path,
197197
));
198198
}
@@ -286,89 +286,48 @@ mod tests {
286286
let _ = std::fs::remove_dir_all(&dir);
287287
}
288288

289-
// ── validate_write_path ──────────────────────────────────────
290-
291-
/// Reject paths that are clearly LLM hallucinations before they
292-
/// trigger permission dialogs. Relative single-segment paths
293-
/// that are purely numeric (\"1\", \"42\") or trivially short
294-
/// (\"a\", \"x\") are never valid file names a well-behaved
295-
/// agent would genuinely want to write to; the model is
296-
/// confusing a counter, index, or file-descriptor number with
297-
/// a file path.
298-
///
299-
/// Returns `Ok(())` for plausible paths, `Err(reason)` for
300-
/// paths that should be hard-rejected.
301-
pub fn validate_write_path(path: &str) -> Result<(), String> {
302-
let p = Path::new(path);
303-
if p.is_absolute() {
304-
return Ok(());
305-
}
306-
// Has a directory component — plausible relative path.
307-
if path.contains('/') || path.contains('\\') {
308-
return Ok(());
309-
}
310-
// Has a file extension — plausible filename.
311-
if path.contains('.') {
312-
return Ok(());
313-
}
314-
// Just a bare name. Reject single-segment names that are
315-
// purely numeric (\"1\", \"42\") or a single short token
316-
// with no extension (\"a\", \"xy\").
317-
if path.chars().all(|c| c.is_ascii_digit()) {
318-
return Err(format!(
319-
"Refusing to write to numeric path {:?}. Use an absolute path with a real file name.",
320-
path,
321-
));
322-
}
323-
if path.chars().count() <= 2 {
324-
return Err(format!(
325-
"Refusing to write to trivial path {:?}. Use an absolute path with a real file name.",
326-
path,
327-
));
328-
}
329-
Ok(())
330-
}
289+
// ── validate_path ────────────────────────────────────────────
331290

332291
#[test]
333292
fn validate_accepts_absolute_paths() {
334-
assert!(validate_write_path("/etc/hosts").is_ok());
335-
assert!(validate_write_path("/Users/bob/src/main.rs").is_ok());
293+
assert!(validate_path("/etc/hosts").is_ok());
294+
assert!(validate_path("/Users/bob/src/main.rs").is_ok());
336295
}
337296

338297
#[test]
339298
fn validate_accepts_relative_paths_with_separator() {
340-
assert!(validate_write_path("src/main.rs").is_ok());
341-
assert!(validate_write_path("lib/core.js").is_ok());
342-
assert!(validate_write_path("..\\windows\\path").is_ok());
299+
assert!(validate_path("src/main.rs").is_ok());
300+
assert!(validate_path("lib/core.js").is_ok());
301+
assert!(validate_path("..\\windows\\path").is_ok());
343302
}
344303

345304
#[test]
346305
fn validate_accepts_relative_names_with_extension() {
347-
assert!(validate_write_path("Cargo.toml").is_ok());
348-
assert!(validate_write_path("README.md").is_ok());
349-
assert!(validate_write_path("build.sh").is_ok());
306+
assert!(validate_path("Cargo.toml").is_ok());
307+
assert!(validate_path("README.md").is_ok());
308+
assert!(validate_path("build.sh").is_ok());
350309
}
351310

352311
#[test]
353312
fn validate_accepts_extensionless_names_that_are_not_trivial() {
354313
// Common extensionless filenames.
355-
assert!(validate_write_path("Makefile").is_ok());
356-
assert!(validate_write_path("Dockerfile").is_ok());
357-
assert!(validate_write_path("README").is_ok());
358-
assert!(validate_write_path("LICENSE").is_ok());
359-
assert!(validate_write_path("abc").is_ok());
314+
assert!(validate_path("Makefile").is_ok());
315+
assert!(validate_path("Dockerfile").is_ok());
316+
assert!(validate_path("README").is_ok());
317+
assert!(validate_path("LICENSE").is_ok());
318+
assert!(validate_path("abc").is_ok());
360319
}
361320

362321
#[test]
363322
fn validate_rejects_numeric_paths() {
364-
assert!(validate_write_path("1").is_err());
365-
assert!(validate_write_path("42").is_err());
366-
assert!(validate_write_path("007").is_err());
323+
assert!(validate_path("1").is_err());
324+
assert!(validate_path("42").is_err());
325+
assert!(validate_path("007").is_err());
367326
}
368327

369328
#[test]
370329
fn validate_rejects_short_nonsense_paths() {
371-
assert!(validate_write_path("a").is_err());
372-
assert!(validate_write_path("xy").is_err());
330+
assert!(validate_path("a").is_err());
331+
assert!(validate_path("xy").is_err());
373332
}
374333
}

0 commit comments

Comments
 (0)