Skip to content

Commit 79555dc

Browse files
yogthosYogthos
andauthored
fix(audit r3): /retry handles system msgs, table breaks, apply_patch path-tool, unique save nonce, ragged tables (#57)
Round of follow-up fixes after auditing the recent PRs (granular selection, markdown tables, CRLF apply_patch, atomic save, bold streaming). ## CRITICAL fixes ### /retry now handles system messages The original /retry used `undo_last` which only pops Assistant/User pairs. If a system message (compress summary, error note) landed between the user prompt and the retry trigger, it stayed in the session and the agent saw it as context. Replaced with a tight loop that pops messages until the last user message has been removed too. Works regardless of how many system/assistant messages were sandwiched between. ### Markdown table cells handle SoftBreak / HardBreak `Event::SoftBreak` and `Event::HardBreak` pushed `\n` into `acc` unconditionally. When inside a table cell, this smeared the cell across multiple lines and broke alignment. Now substitutes a single space for the break when `in_table`, so the cell's visible content stays on one row. ### apply_patch / lsp now in `is_path_tool_name` The tool-rule map registered `apply_patch` and `lsp`, but the external-directory consultation check used a narrower whitelist (`read | write | edit | list_dir`). When a config had `external_directory: { "/tmp/**": "deny" }` and an agent ran `apply_patch` against `/tmp/...`, the rule was silently ignored. Both `is_path_tool_name` and the inner `is_path_tool` now use the same canonical list (DRY: inner calls the public predicate). ## HIGH fix ### Atomic save tmp filename includes per-call nonce Tmp file was `dir/.{id}.json.tmp` — deterministic. Two concurrent saves of the same session id (rare, but possible across processes or via plugin tree-ops) raced on the same tmp file. Now the tmp name is `dir/.{id}.{pid}-{ns}.json.tmp` so each save writes its own tmp. Rename collisions remain harmless (last writer wins on the target; each tmp was complete before rename). ## LOW fix ### Ragged table rows get a minimum column width When `ncols` was the max across header + rows but some body row had fewer cells, the missing columns kept `widths[i] = 0`, leaving the separator line wider than the cells. Each column now gets a minimum width of 1 char so the right border aligns. ## Skipped (false positives from the audit) - Bold "leak past colored span" — `ResetColor` does reset attributes on the terminals we care about; `NormalIntensity` is belt-and-braces. No actual leak in practice. - Multi-byte UTF-8 grapheme cluster slicing — Rust's `chars()` iterator guarantees char-boundary safety; the code is correct. - `buffer_pos_at` clamp comment — accurate; just terminology nitpick. - Auto-compact alert "may scroll out of view" — the framed alert already stops the eye; further hardening would require a modal overlay (bigger UX change). ## Test plan - [x] `cargo test --features plugin` -> 612 pass, 0 fail. - [x] Both build profiles -> 0 warnings. Co-authored-by: Yogthos <yogthos@gmail.com>
1 parent 2559208 commit 79555dc

6 files changed

Lines changed: 92 additions & 16 deletions

File tree

CONFIG.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,27 @@ Accepted top-level keys:
9090
Permission actions are lowercase strings: `allow`, `ask`, or `deny`. Each tool
9191
rule can be a single action or an object mapping glob-like patterns to actions.
9292
Supported permission tool keys are `bash`, `read`, `write`, `edit`, `grep`,
93-
`find_files`, `list_dir`, and `write_todo_list`. MCP-backed tools are
94-
checked under `mcp_tool:{server_name}:{tool_name}`. Use `"*"` for the
95-
default action, `external_directory` for absolute-path rules outside the
96-
working directory, and `doom_loop` for repeated identical tool calls
97-
(default: `ask`). If `bash` is omitted, dirge installs its built-in
98-
safe bash allow/deny rules.
93+
`find_files`, `list_dir`, `write_todo_list`, `apply_patch`, `lsp`, and
94+
`question`. MCP-backed tools are checked under
95+
`mcp_tool:{server_name}:{tool_name}`. Use `"*"` for the default action,
96+
`external_directory` for absolute-path rules outside the working directory,
97+
and `doom_loop` for repeated identical tool calls (default: `ask`). If
98+
`bash` is omitted, dirge installs its built-in safe bash allow/deny rules.
99+
100+
### Mode semantics
101+
102+
- **`standard`** (default): every rule in `permission` is consulted; tools without
103+
matching rules fall back to `*` (default `allow`).
104+
- **`restrictive`**: like `standard`, but any tool whose rule resolves to `allow`
105+
via the `*` fallback (no explicit allow rule matched) is converted to `ask`.
106+
Explicit `allow` rules still allow. Explicit `deny` rules still deny.
107+
- **`accept`** (equivalent to `--accept-all`): auto-allows tools whose targets
108+
resolve inside the working directory; tools touching paths outside still
109+
consult `external_directory` rules.
110+
- **`yolo`** (equivalent to `--yolo`): bypasses every check. Use with caution.
111+
112+
CLI precedence (high → low): `--yolo` > `--accept-all` > `--restrictive` >
113+
`default_permission_mode` config > `standard`.
99114

100115
When compiled with MCP support, `mcp_servers` accepts command-based and URL-based
101116
servers:

src/permission/checker.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ pub struct PermissionChecker {
2929
/// classic glob semantics (one segment, doesn't cross `/`). Everything else
3030
/// is treated as shell/text where `*` means "any chars including /".
3131
pub(crate) fn is_path_tool_name(tool: &str) -> bool {
32-
matches!(tool, "read" | "write" | "edit" | "list_dir")
32+
matches!(
33+
tool,
34+
"read" | "write" | "edit" | "list_dir" | "apply_patch" | "lsp"
35+
)
3336
}
3437

3538
/// Build a Pattern with the right `*` semantics for the given tool.
@@ -291,7 +294,12 @@ impl PermissionChecker {
291294
}
292295

293296
fn is_path_tool(&self, tool: &str) -> bool {
294-
matches!(tool, "read" | "write" | "edit" | "list_dir")
297+
// Must match `is_path_tool_name` — these are the tools that
298+
// take a filesystem path as their permission input and need
299+
// `external_directory` rule consultation. `apply_patch` and
300+
// `lsp` are included because both route filesystem-path
301+
// strings through `check_perm_path`.
302+
is_path_tool_name(tool)
295303
}
296304

297305
fn is_external_path(&self, path_str: &str) -> bool {

src/permission/mod.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,28 @@ pub struct PermissionConfig {
4646
pub doom_loop: Option<Action>,
4747
}
4848

49+
/// Per-session security mode. Selected via `--yolo` / `--accept-all` /
50+
/// `--restrictive` CLI flags or the `default_permission_mode` config
51+
/// key. Mode precedence (high to low): `Yolo > Accept > Restrictive >
52+
/// Standard`.
4953
#[derive(Debug, Clone, Copy, PartialEq)]
5054
pub enum SecurityMode {
55+
/// Every rule in `PermissionConfig` is consulted; tools with no
56+
/// matching rule fall back to the `*` default action.
5157
Standard,
58+
/// Like `Standard`, but any tool whose rule resolves to `Allow`
59+
/// *via the `*` fallback* (no explicit allow rule matched) gets
60+
/// upgraded to `Ask`. Explicit allow rules still allow; explicit
61+
/// deny rules still deny. The semantic difference from
62+
/// `Standard`: "if nothing explicitly approved this, ask the
63+
/// user." It does NOT flip every Allow to Ask.
5264
Restrictive,
65+
/// Auto-allows tools whose targets resolve inside the working
66+
/// directory; tools touching paths outside `cwd` still consult
67+
/// `external_directory` rules. Useful for fast iteration on a
68+
/// trusted project.
5369
Accept,
70+
/// Bypasses every check. Use with caution.
5471
Yolo,
5572
}
5673

src/session/storage.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,21 @@ pub fn save_session(session: &Session) -> anyhow::Result<()> {
6262
// over the target. A crash mid-write leaves the temp behind but
6363
// never a truncated `.json`. The rename is atomic on every OS we
6464
// target. Use the same parent dir so rename stays on one filesystem.
65-
let tmp = dir.join(format!(".{}.json.tmp", session.id));
65+
//
66+
// The tmp filename includes a per-call nonce (pid + nanos) so two
67+
// concurrent saves of the same session id don't collide on the
68+
// tmp file. Each thread/process writes to its own tmp; the rename
69+
// race is harmless (last-writer wins on the target, but neither
70+
// tmp is partial because each was fully written before rename).
71+
let nonce = format!(
72+
"{}-{}",
73+
std::process::id(),
74+
std::time::SystemTime::now()
75+
.duration_since(std::time::UNIX_EPOCH)
76+
.map(|d| d.as_nanos())
77+
.unwrap_or(0),
78+
);
79+
let tmp = dir.join(format!(".{}.{}.json.tmp", session.id, nonce));
6680
{
6781
use std::io::Write;
6882
let mut f = std::fs::File::create(&tmp)?;

src/ui/markdown.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,13 @@ fn render_table(
9494
widths[i] = widths[i].max(cell.chars().count());
9595
}
9696
}
97+
// Minimum column width — ragged rows (one row has fewer cells
98+
// than `ncols`) would otherwise leave `widths[i] = 0` for the
99+
// missing columns, breaking the separator line + right-border
100+
// alignment. Guarantee at least 1 char per column.
101+
for w in widths.iter_mut() {
102+
*w = (*w).max(1);
103+
}
97104
// Cap any single column to avoid one runaway cell blowing the
98105
// line width. Distribute available width: target inner width =
99106
// max_width - 4 (for outer `| ` + ` |`), minus 3*(ncols-1) for
@@ -404,8 +411,13 @@ pub fn markdown_to_styled(text: &str, max_width: usize) -> Vec<LineEntry> {
404411
}
405412
}
406413
Event::SoftBreak | Event::HardBreak => {
407-
if in_code_block {
408-
acc.push('\n');
414+
if in_table {
415+
// Break inside a table cell would smear the cell
416+
// across multiple lines and misalign the row.
417+
// Markdown spec doesn't allow real newlines in
418+
// table cells — substitute a space so the visible
419+
// content stays on one line.
420+
current_cell.push(' ');
409421
} else {
410422
acc.push('\n');
411423
}

src/ui/slash.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,11 +1050,21 @@ pub async fn handle_slash(
10501050
.cloned();
10511051
match last_user {
10521052
Some(msg) => {
1053-
// Pop the trailing assistant response (if any) so a
1054-
// /retry doesn't leave the failed reply in the
1055-
// session — the agent would otherwise see its own
1056-
// bad answer as context on the retry.
1057-
let _ = undo_last(session);
1053+
// Pop messages until we've removed the last user
1054+
// message itself — covers assistant replies AND
1055+
// system messages (compress summaries, error
1056+
// notices) that landed between the user prompt
1057+
// and the current state. `undo_last` only handles
1058+
// the Assistant/User pair pattern; using it here
1059+
// would leave a trailing System message in the
1060+
// session and the agent would see it on retry.
1061+
while let Some(last) = session.messages.last() {
1062+
let was_user = last.role == MessageRole::User;
1063+
session.pop_last_message();
1064+
if was_user {
1065+
break;
1066+
}
1067+
}
10581068
input.buffer = msg.content.clone();
10591069
input.cursor = msg.content.len();
10601070
render_session(renderer, session, cli, cfg, context)?;

0 commit comments

Comments
 (0)