Skip to content

Commit 2559208

Browse files
yogthosYogthos
andauthored
fix(audit r2): permission rules + markdown tables + /retry + bold streaming + atomic save + UX polish (#56)
* fix(audit): permission rules for apply_patch/lsp/question + markdown tables Round 1 of the follow-up audit fixes. ## PermissionConfig: apply_patch, lsp, question (CRITICAL/HIGH) Three tools called `check_perm(..., "apply_patch", _)` / `check_perm(..., "lsp", _)` / `check_perm(..., "question", _)` but their keys were never registered in `PermissionConfig`. Users could only allow/deny them via the global `*` default — no per-pattern rules. Each added as `Option<ToolPerm>` on `PermissionConfig` and wired into `checker.rs`'s tool-rule map. Users can now write rules like: "apply_patch": { "**/*.rs": "allow", "**": "ask" } "lsp": "allow" "question": "ask" ## Markdown tables now render (CRITICAL) `Tag::Table` / `TableHead` / `TableRow` / `TableCell` had empty match arms; pipe-delimited tables in agent replies silently dropped. `pulldown_cmark::Parser::new` doesn't emit table events by default either — needs `Options::ENABLE_TABLES`. Now enables tables and renders as box-drawn cards: │ col1 │ col2 │ col3 │ ├──────┼──────┼──────┤ │ a │ b │ c │ │ d │ e │ f │ Column widths computed from longest cell content, capped to fit inside `max_width` so a runaway cell can't break alignment. Header row paints in `theme::header()`, body in `theme::agent()`, separator in `theme::dim()`. Long cells truncate with `…` to preserve the right border. ## Test plan - [x] `cargo test --features plugin` -> 612 pass, 0 fail. - [x] Both build profiles -> 0 warnings. * fix(audit r2): /retry, streaming bold, apply_patch cache + CRLF Round 2 — HIGH fixes. ## /retry pops the previous assistant response Previously /retry only restored the last user prompt into the editor and left the failed assistant reply in the session. On retry the agent saw its own bad answer as context. Now calls `undo_last(session)` first to pop the trailing assistant message (plus the user message if `undo_last`'s pair logic fires), then restores the user prompt to the editor and re-renders. ## write_line + write now apply Bold for bright colors `render_viewport` applied `Attribute::Bold` to bright tones for the phosphor glow effect; the streaming paths (`write_line` and `write`) didn't. Streamed agent text rendered flat until the next full repaint shifted it to bold. Both streaming paths now check `theme::is_bright` and wrap output in `Bold` / `NormalIntensity`, matching the viewport's per-row paint. ## apply_patch CRLF normalization on update `apply_patch`'s `update` operation did a raw substring match on the file's on-disk bytes. The LLM almost always emits `\n` for line breaks even when the file is CRLF on disk, so the substring match failed on Windows-style files. `edit.rs` already had this normalization; copying the same pattern here: 1. Read original. 2. If CRLF detected, build a `\n`-normalized working copy for matching. 3. Normalize `old_text` and `new_text` to `\n` for the match + replace. 4. Re-apply CRLF on write-back so the file's line endings aren't silently changed. ## apply_patch clears cache once after the batch Per-op `cache.clear()` was wasteful — a 5-op batch cleared 5 times. Now the cache is cleared exactly once after all ops in the batch finish (success or partial failure). ## Test plan - [x] `cargo test --features plugin` -> 612 pass, 0 fail. - [x] Both build profiles -> 0 warnings. * fix(audit r3): atomic save, auto-compact UX, set_label init, bash fallback split Round 3 — MEDIUM fixes. ## Atomic session save `save_session` did `fs::write(path, json)` directly. A crash mid- write left a truncated `.json` that `find_recent_sessions` would skip silently — the user would notice their session was missing without knowing why. Two concurrent dirge processes saving the same id could also interleave bytes. Now write-temp-then-rename: 1. Write to `dir/.{id}.json.tmp`. 2. `sync_all()` (best-effort; non-fatal on unsupported FS). 3. `rename(tmp, target)`. 4. Clean up the tmp if rename fails. Rename is atomic on every OS we target as long as both paths live on the same filesystem (they do — same dir). ## Auto-compact failure is visible now The old auto-compact error was a single dim red line that scrolled past unnoticed. Users kept typing into an over-full context and got mysterious context-length errors next turn. Replaced with a framed alert: ╭─ ⚠ AUTO-COMPACT FAILED ─...─╮ │ cause: <error> │ context is over the threshold — replies may start │ hitting context-length errors. Try /compress │ manually, /clear to start fresh, or restart with │ a larger context_window in config. ╰─...─╯ Same style as the permission alert; impossible to miss. The success-path banner also got a soft accent treatment (`▒░ auto-compacting context ░▒`) so the user sees auto-compact running rather than wondering why the next prompt is slow. ## set_label uses ensure_back_compat_initialized Every other mutation method (`add_message`, `pop_last_message`, `switch_to_leaf`, `fork_at`, `compress`) calls `ensure_back_compat_initialized`. `set_label` was still using the older split call. Switched for consistency so future label-aware code reading `message_store` doesn't trip on an uninitialized store. ## Bash segment splitting without `semantic-bash` feature Without the tree-sitter feature, complex compound commands like `safe_cmd && rm -rf /` were checked as a single string against the bash permission rules. If `safe_cmd && rm` didn't match any deny pattern, the dangerous part squeaked through. Now does a best-effort coarse split on `&&` / `;` / `||` and checks each segment separately. Command substitution / subshell constructs (`$(...)`, backticks, `<(...)`, `>(...)`) still need the full parser, so when one is detected we fall back to whole-command check — that surfaces the unfamiliar form before any segment runs, letting the user explicitly allow or deny. ## Test plan - [x] `cargo test --features plugin` -> 612 pass, 0 fail. - [x] Both build profiles -> 0 warnings. * fix(audit r4): bold streaming for prompt/input/search, avatar redraw on alert Round 4 — LOW fixes (final audit round). ## Prompt + input + search bar get Bold-glow The bottom row (prompt indicator, input text, search bar) rendered without `Attribute::Bold` for their bright colors. Chat above bloomed; the bottom area looked dimmer. Each site now checks `theme::is_bright` and wraps the write in Bold / NormalIntensity, matching the viewport's per-row paint. Status row stays unbolded — `theme::dim()` is dim by design (two-tone phosphor depth) so `is_bright` correctly returns false. ## Avatar updates instantly on permission alert Setting the avatar state to `Alert` happened before the alert box rendered, but the bottom-row repaint that actually shows the new face waited for the next event (typically the user's keystroke answering the prompt). The face still showed the in-flight tool (Reading/Writing/Bash) while the alert was up. Now `draw_bottom` runs explicitly right after the state change so the `(O_O)` Alert face appears at the same moment the alert box does. ## Test plan - [x] `cargo test --features plugin` -> 612 pass, 0 fail. - [x] Both build profiles -> 0 warnings. Note: the previously-flagged "grep.rs glob_to_regex doesn't escape dots" finding turned out to be a false positive — line 43 of `grep.rs` does push `\\.` for `.` correctly. Skipped. --------- Co-authored-by: Yogthos <yogthos@gmail.com>
1 parent 7d4c6ce commit 2559208

10 files changed

Lines changed: 384 additions & 24 deletions

File tree

src/agent/tools/apply_patch.rs

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,24 @@ fn apply_create(path: &str, content: &str) -> Result<String, String> {
7676
fn apply_update(path: &str, old_text: &str, new_text: &str) -> Result<String, String> {
7777
let original = std::fs::read_to_string(path).map_err(|e| format!("read failed: {}", e))?;
7878

79-
if !original.contains(old_text) {
79+
// CRLF normalization to match `edit.rs`. The LLM almost always
80+
// generates `\n` in `old_text` even when the file is CRLF on
81+
// disk; without normalization the literal substring match fails.
82+
// We normalize a working copy for matching but preserve the
83+
// original's line endings on the write-back.
84+
let crlf = original.contains("\r\n");
85+
let normalized = if crlf {
86+
original.replace("\r\n", "\n")
87+
} else {
88+
original.clone()
89+
};
90+
let needle = old_text.replace("\r\n", "\n");
91+
92+
if !normalized.contains(&needle) {
8093
return Err(format!("text not found in {}", path));
8194
}
8295

83-
let matches: Vec<_> = original.match_indices(old_text).collect();
96+
let matches: Vec<_> = normalized.match_indices(&needle).collect();
8497
if matches.len() > 1 {
8598
return Err(format!(
8699
"text matches {} locations in {} — provide more context to make unique",
@@ -89,8 +102,20 @@ fn apply_update(path: &str, old_text: &str, new_text: &str) -> Result<String, St
89102
));
90103
}
91104

92-
let updated = original.replacen(old_text, new_text, 1);
93-
std::fs::write(path, &updated).map_err(|e| format!("write failed: {}", e))?;
105+
let replacement = if crlf {
106+
new_text.replace("\r\n", "\n")
107+
} else {
108+
new_text.to_string()
109+
};
110+
let updated_normalized = normalized.replacen(&needle, &replacement, 1);
111+
// Restore CRLF line endings on write-back so we don't silently
112+
// re-format the user's file.
113+
let to_write = if crlf {
114+
updated_normalized.replace('\n', "\r\n")
115+
} else {
116+
updated_normalized
117+
};
118+
std::fs::write(path, &to_write).map_err(|e| format!("write failed: {}", e))?;
94119
Ok(format!("updated {}", path))
95120
}
96121

@@ -206,9 +231,6 @@ impl Tool for ApplyPatchTool {
206231

207232
match result {
208233
Ok(msg) => {
209-
if let Some(ref cache) = self.cache {
210-
cache.clear();
211-
}
212234
// Record the touched path(s) for the info panel. Rename
213235
// adds the *new* path; delete still records the path the
214236
// user/agent operated on so the panel reflects the action.
@@ -235,6 +257,14 @@ impl Tool for ApplyPatchTool {
235257
}
236258
}
237259

260+
// Clear the cache once after the batch instead of once per op.
261+
// Per-op clearing was correct but wasteful — a 5-op batch
262+
// would clear 5 times. Subsequent tool calls within the same
263+
// turn now see a single clean cache.
264+
if let Some(ref cache) = self.cache {
265+
cache.clear();
266+
}
267+
238268
Ok(results.join("\n"))
239269
}
240270
}

src/agent/tools/bash.rs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,35 @@ async fn check_bash_segments(
133133
}
134134
#[cfg(not(feature = "semantic-bash"))]
135135
{
136-
check_perm(permission, ask_tx, "bash", command).await
136+
// Best-effort coarse split when tree-sitter isn't compiled in.
137+
// Without it, a command like `safe_cmd && rm -rf /` would be
138+
// checked as a single string against the bash rules and might
139+
// squeak through if `safe_cmd && rm` doesn't match any deny.
140+
// Split on the unambiguous compound separators (`&&`, `;`,
141+
// `||`) so each segment is checked individually. This won't
142+
// catch command substitution or subshells — those need the
143+
// tree-sitter feature for correct parsing — but it covers the
144+
// common compound case.
145+
let segments = command
146+
.split(|c| c == ';')
147+
.flat_map(|s| s.split("&&"))
148+
.flat_map(|s| s.split("||"))
149+
.map(|s| s.trim())
150+
.filter(|s| !s.is_empty())
151+
.collect::<Vec<&str>>();
152+
// Flag command substitution / subshell constructs that need a
153+
// full parser. Surface as one whole-command check so the user
154+
// sees the unfamiliar form before any segment runs.
155+
let has_substitution = command.contains("$(")
156+
|| command.contains('`')
157+
|| command.contains("<(")
158+
|| command.contains(">(");
159+
if has_substitution {
160+
return check_perm(permission, ask_tx, "bash", command).await;
161+
}
162+
for segment in &segments {
163+
check_perm(permission, ask_tx, "bash", segment).await?;
164+
}
165+
Ok(())
137166
}
138167
}

src/permission/checker.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ impl PermissionChecker {
6060
("find_files", &config.find_files),
6161
("list_dir", &config.list_dir),
6262
("write_todo_list", &config.write_todo_list),
63+
("apply_patch", &config.apply_patch),
64+
("lsp", &config.lsp),
65+
("question", &config.question),
6366
] {
6467
let Some(tp) = tool_perm else { continue };
6568
let mut entries = Vec::new();

src/permission/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ pub struct PermissionConfig {
3232
pub find_files: Option<ToolPerm>,
3333
pub list_dir: Option<ToolPerm>,
3434
pub write_todo_list: Option<ToolPerm>,
35+
/// `apply_patch` — bulk multi-file patch tool. Mutates the
36+
/// filesystem like `write`/`edit`; deserves per-pattern rules.
37+
pub apply_patch: Option<ToolPerm>,
38+
/// `lsp` — language-server queries (definition, references,
39+
/// hover, etc.). Reads project files via the language server.
40+
pub lsp: Option<ToolPerm>,
41+
/// `question` — interactive user-input solicitation tool. Per-
42+
/// pattern rules let users restrict which kinds of questions
43+
/// the agent can ask.
44+
pub question: Option<ToolPerm>,
3545
pub external_directory: Option<HashMap<String, Action>>,
3646
pub doom_loop: Option<Action>,
3747
}

src/session/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,10 @@ impl Session {
446446
entry_id: &CompactString,
447447
label: Option<String>,
448448
) -> Result<(), String> {
449-
self.ensure_tree_initialized();
449+
// Mirror the other mutation methods — keep tree + store in
450+
// lockstep even though set_label only touches the tree, in
451+
// case a future label-aware code path inspects the store.
452+
self.ensure_back_compat_initialized();
450453
let node = self
451454
.tree
452455
.entries

src/session/storage.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,22 @@ pub fn save_session(session: &Session) -> anyhow::Result<()> {
5858
std::fs::create_dir_all(&dir)?;
5959
let path = dir.join(format!("{}.json", session.id));
6060
let json = serde_json::to_string_pretty(session)?;
61-
std::fs::write(path, json)?;
61+
// Atomic write: write to a sibling temp file, fsync, then rename
62+
// over the target. A crash mid-write leaves the temp behind but
63+
// never a truncated `.json`. The rename is atomic on every OS we
64+
// target. Use the same parent dir so rename stays on one filesystem.
65+
let tmp = dir.join(format!(".{}.json.tmp", session.id));
66+
{
67+
use std::io::Write;
68+
let mut f = std::fs::File::create(&tmp)?;
69+
f.write_all(json.as_bytes())?;
70+
// Best-effort fsync; non-fatal if the platform doesn't support it.
71+
let _ = f.sync_all();
72+
}
73+
if let Err(e) = std::fs::rename(&tmp, &path) {
74+
let _ = std::fs::remove_file(&tmp);
75+
return Err(e.into());
76+
}
6277
Ok(())
6378
}
6479

src/ui/markdown.rs

Lines changed: 168 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,129 @@ fn bullet_prefix(in_blockquote: bool) -> &'static str {
6464
if in_blockquote { " ┊ " } else { " • " }
6565
}
6666

67+
/// Render a markdown table as `| col | col |` rows with a separator
68+
/// line below the header. Columns are padded so the right borders
69+
/// align. Caps each cell's display at the available width so a
70+
/// long cell doesn't break alignment. No-ops when both header and
71+
/// rows are empty.
72+
fn render_table(
73+
header: &[String],
74+
rows: &[Vec<String>],
75+
max_width: usize,
76+
out: &mut Vec<LineEntry>,
77+
) {
78+
if header.is_empty() && rows.is_empty() {
79+
return;
80+
}
81+
// Compute per-column max char width.
82+
let ncols = header
83+
.len()
84+
.max(rows.iter().map(|r| r.len()).max().unwrap_or(0));
85+
if ncols == 0 {
86+
return;
87+
}
88+
let mut widths = vec![0usize; ncols];
89+
for (i, cell) in header.iter().enumerate() {
90+
widths[i] = widths[i].max(cell.chars().count());
91+
}
92+
for row in rows {
93+
for (i, cell) in row.iter().enumerate() {
94+
widths[i] = widths[i].max(cell.chars().count());
95+
}
96+
}
97+
// Cap any single column to avoid one runaway cell blowing the
98+
// line width. Distribute available width: target inner width =
99+
// max_width - 4 (for outer `| ` + ` |`), minus 3*(ncols-1) for
100+
// ` | ` separators. Cells get clipped to fit.
101+
let inner = max_width.saturating_sub(2 * 2);
102+
let sep_overhead = if ncols > 1 { 3 * (ncols - 1) } else { 0 };
103+
let cell_budget = inner.saturating_sub(sep_overhead);
104+
let per_col = if ncols > 0 { cell_budget / ncols } else { 0 };
105+
for w in widths.iter_mut() {
106+
if per_col > 0 && *w > per_col {
107+
*w = per_col;
108+
}
109+
}
110+
111+
let fit = |cell: &str, w: usize| -> String {
112+
let chars: Vec<char> = cell.chars().collect();
113+
if chars.len() <= w {
114+
let mut s: String = chars.iter().collect();
115+
for _ in chars.len()..w {
116+
s.push(' ');
117+
}
118+
s
119+
} else if w <= 1 {
120+
chars.iter().take(w).collect()
121+
} else {
122+
let mut s: String = chars.iter().take(w - 1).collect();
123+
s.push('…');
124+
s
125+
}
126+
};
127+
128+
let render_row = |row: &[String], widths: &[usize]| -> String {
129+
let mut s = String::with_capacity(max_width);
130+
s.push_str("│ ");
131+
for i in 0..widths.len() {
132+
if i > 0 {
133+
s.push_str(" │ ");
134+
}
135+
let cell = row.get(i).map(String::as_str).unwrap_or("");
136+
s.push_str(&fit(cell, widths[i]));
137+
}
138+
s.push_str(" │");
139+
s
140+
};
141+
142+
let sep = {
143+
let mut s = String::with_capacity(max_width);
144+
s.push('├');
145+
for (i, w) in widths.iter().enumerate() {
146+
if i > 0 {
147+
s.push('┼');
148+
}
149+
for _ in 0..(w + 2) {
150+
s.push('─');
151+
}
152+
}
153+
s.push('┤');
154+
s
155+
};
156+
157+
if !header.is_empty() {
158+
out.push(LineEntry {
159+
text: CompactString::new(&render_row(header, &widths)),
160+
color: crate::ui::theme::header(),
161+
});
162+
out.push(LineEntry {
163+
text: CompactString::new(&sep),
164+
color: crate::ui::theme::dim(),
165+
});
166+
}
167+
for row in rows {
168+
out.push(LineEntry {
169+
text: CompactString::new(&render_row(row, &widths)),
170+
color: crate::ui::theme::agent(),
171+
});
172+
}
173+
out.push(LineEntry {
174+
text: CompactString::new(""),
175+
color: crate::ui::theme::agent(),
176+
});
177+
}
178+
67179
pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
68180
if text.is_empty() {
69181
return Vec::new();
70182
}
71183

72-
let parser = pulldown_cmark::Parser::new(text);
184+
// Enable GFM tables so `Tag::Table*` events actually fire.
185+
// Without this, table syntax falls back to plain paragraphs and
186+
// the table never reaches `render_table`.
187+
let mut opts = pulldown_cmark::Options::empty();
188+
opts.insert(pulldown_cmark::Options::ENABLE_TABLES);
189+
let parser = pulldown_cmark::Parser::new_ext(text, opts);
73190
let mut result = Vec::new();
74191
let mut acc = String::new();
75192

@@ -78,6 +195,17 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
78195
let mut in_blockquote = false;
79196
let mut ordered_list = false;
80197
let mut list_item_count: u64 = 0;
198+
// Table accumulation: pulldown_cmark emits TableHead → (Row × N
199+
// cells) for the header row, then more TableRow blocks for body.
200+
// We collect cells into `current_cell`, rows into `current_row`,
201+
// and the whole table into `table_header` + `table_rows`, then
202+
// render with column-aligned padding when the table ends.
203+
let mut in_table = false;
204+
let mut in_table_head = false;
205+
let mut current_cell = String::new();
206+
let mut current_row: Vec<String> = Vec::new();
207+
let mut table_header: Vec<String> = Vec::new();
208+
let mut table_rows: Vec<Vec<String>> = Vec::new();
81209

82210
for event in parser {
83211
match event {
@@ -108,10 +236,23 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
108236
list_item_count += 1;
109237
}
110238
Tag::FootnoteDefinition(_) => {}
111-
Tag::Table(_) => {}
112-
Tag::TableHead => {}
113-
Tag::TableRow => {}
114-
Tag::TableCell => {}
239+
Tag::Table(_) => {
240+
flush_acc(&acc, crate::ui::theme::agent(), max_width, &mut result);
241+
acc.clear();
242+
in_table = true;
243+
table_header.clear();
244+
table_rows.clear();
245+
}
246+
Tag::TableHead => {
247+
in_table_head = true;
248+
current_row.clear();
249+
}
250+
Tag::TableRow => {
251+
current_row.clear();
252+
}
253+
Tag::TableCell => {
254+
current_cell.clear();
255+
}
115256
_ => {}
116257
},
117258
Event::End(tag_end) => match tag_end {
@@ -226,21 +367,37 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
226367
});
227368
}
228369
TagEnd::FootnoteDefinition => {}
229-
TagEnd::Table => {}
230-
TagEnd::TableHead => {}
231-
TagEnd::TableRow => {}
232-
TagEnd::TableCell => {}
370+
TagEnd::Table => {
371+
render_table(&table_header, &table_rows, max_width, &mut result);
372+
in_table = false;
373+
}
374+
TagEnd::TableHead => {
375+
table_header = std::mem::take(&mut current_row);
376+
in_table_head = false;
377+
}
378+
TagEnd::TableRow => {
379+
if !in_table_head {
380+
table_rows.push(std::mem::take(&mut current_row));
381+
}
382+
}
383+
TagEnd::TableCell => {
384+
current_row.push(std::mem::take(&mut current_cell));
385+
}
233386
_ => {}
234387
},
235388
Event::Text(t) => {
236-
if in_code_block {
389+
if in_table {
390+
current_cell.push_str(&t);
391+
} else if in_code_block {
237392
acc.push_str(&t);
238393
} else {
239394
acc.push_str(&t);
240395
}
241396
}
242397
Event::Code(t) => {
243-
if in_code_block {
398+
if in_table {
399+
current_cell.push_str(&t);
400+
} else if in_code_block {
244401
acc.push_str(&t);
245402
} else {
246403
acc.push_str(&t);

0 commit comments

Comments
 (0)