Skip to content

Commit bf5e98d

Browse files
yogthosYogthos
andauthored
chore: audit fixes (5 rounds — CRITICAL/HIGH/MEDIUM/LOW) (#54)
* fix(audit r1): path traversal, compress crashes, question permission Round 1 of the audit fixes — security + crashes. ## Path traversal in session storage (CRITICAL) `save_session` / `load_session` / `delete_session` interpolated `session.id` directly into the `{id}.json` filename. Session ids are normally UUIDs but they round-trip through JSON on disk; a tampered-with file with `id: "../../etc/passwd"` could escape the sessions directory on save or read arbitrary files on load. New `validate_session_id` gate: accepts `[A-Za-z0-9._-]+` only, explicitly rejects `.`, `..`, slashes, backslashes. Tested with the usual escape attempts. ## Compress bounds + leaf-tracking (HIGH) `Session::compress(_, first_kept_index, _)` did `messages.drain(..first_kept_index)` with no bounds check — an out-of-range index from a buggy caller panicked. Now clamped to `messages.len()` so misuse degrades to "summarize everything" instead of crashing the agent. Branched-session compaction also had a latent leaf-tracking bug: if `tree.leaf_id` pointed at a branch leaf that was in the dropped set (e.g. user forked, then compressed the alternate branch), the leaf id was left dangling. New post-prune check re-anchors the leaf to the first kept message (or the summary if everything was dropped). ## QuestionTool routes through permission (HIGH) `QuestionTool::call` injected user input into the LLM's tool result without any permission check, unlike `TaskTool` / `WriteTool` / `BashTool` etc. Added `permission: Option<PermCheck>` + `ask_tx: Option<AskSender>` to the struct, new `.with_permission()` builder, and a `check_perm(&self.permission, &self.ask_tx, "question", &summary)` at the top of `call`. Wired through `builder.rs` so production paths get the gate; existing tests construct without permission and continue to pass. ## Test plan - [x] 2 new tests in `session::storage::tests` (UUID accept, traversal reject). - [x] `cargo test --features plugin` -> 606 pass, 0 fail. - [x] Both build profiles -> 0 warnings. * fix(audit r2): on-complete dispatch, turn-hook slots, harness-response, warn unknown ops Round 2 — silent failures. ## on-complete now fires (CRITICAL) `on-complete` was in `HOOK_NAMES` (so plugins defining it got auto-aliased on load) but no host site dispatched. Plugins that defined `on-complete` ran forever without ever seeing the event. Now fires from `AgentEvent::Done` right after `on-response` + the pending-prompt + store_response sequence, so plugins observe a fully-complete turn (response stored, pending prompts processed). ## Turn-hook slot reset (HIGH) `on-turn-start` / `on-turn-end` bypassed `dispatch_tool_hook`'s slot-clear step, so a plugin calling `(harness/block ...)` from inside a turn hook would leave the slot set and spuriously block the *first* tool of the next turn. Both turn hooks now reset `harness-block` / `harness-mutate-input` / `harness-replace-result` explicitly after the dispatch returns. ## harness-response cleared after store (HIGH) `mgr.store_response()` wrote `harness-response` and left it set indefinitely. Plugins reading the var in a later hook saw stale text from previous turns. Now cleared with `(set harness-response nil)` immediately after store, matching the pattern other slots already use. ## drain_tree_ops warns on unknown op verbs (MEDIUM) `parse_tree_op_line` silently dropped lines it didn't recognize (forward-compat — good), but with no diagnostic a typo'd op verb in a plugin would fail with no trace. Added a `tracing::warn!` at `dirge::plugin` target so confused plugin authors can spot the typo in the log. ## Test plan - [x] `cargo test --features plugin` -> 606 pass, 0 fail. - [x] Both build profiles -> 0 warnings. * fix(audit r3): markdown theme sweep, cursor flicker, avatar idle, atomic init Round 3 — theme + UI polish. ## Markdown theme sweep (CRITICAL) `src/ui/markdown.rs` had 13 hardcoded color literals (`Color::Cyan`, `Color::DarkYellow`, `Color::DarkGrey`) used for headings, code blocks, blockquotes, and bullets. Under the phosphor theme these forced cyan + yellow accents that broke palette coherence. Swept to `theme::header()`, `theme::tool()`, and `theme::dim()` so palette swaps cascade through markdown rendering. `bullet_prefix(col: Color)` matched on `Color::DarkGrey` as a sentinel for "this is a blockquote." With the dim color now themed away from DarkGrey, the sentinel was broken. Refactored to `bullet_prefix(in_blockquote: bool)` — explicit, theme-safe. ## Cursor flicker on the right (HIGH) `draw_bottom` called `draw_panel` while the cursor was visible — the panel's MoveTo loop walked the hardware cursor across the right-hand panel one cell at a time, visibly flickering. Now hides the cursor BEFORE panel + avatar paints, places it at the final input position, then re-shows. ## Avatar resets to Idle on user submit (MEDIUM) After a turn completed the avatar stuck on Done forever — the next user prompt didn't visually "wake" it. Now all three user-message commit sites set the avatar to Idle so the brief moment between submit and first agent token shows a neutral face that transitions to Thinking/Speaking naturally. ## Atomic back-compat init (MEDIUM) `ensure_message_store_initialized()` and `ensure_tree_initialized()` were called as a pair in every mutation method; a panic between them could leave the session half-initialized (tree rebuilt but store empty, or vice versa). New `ensure_back_compat_initialized()` runs both in one call. All five mutation sites (add_message, pop_last_message, switch_to_leaf, fork_at, compress) updated. ## Test plan - [x] `cargo test --features plugin` -> 606 pass, 0 fail. - [x] Both build profiles -> 0 warnings. * fix(audit r4): permission gates, schema corrections, single-compaction record Round 4 — schema + permission hygiene. ## TaskStatusTool routes through permission `task_status` was the only background-task tool that didn't call `check_perm`. The wait=true path can hold the parent turn open for up to 10 minutes; both wait=true and wait=false leak subagent state to the caller LLM. Now gated identically to `task`. ## PlanEnterTool / PlanExitTool stay un-gated (documented) These tools surface a user-confirmation dialog via `plan_tx` / `PlanSwitchResponse::{Accepted,Rejected}` — that IS the gate. A second `check_perm` call would double-ask the user. Added a comment in both `call` methods so future readers don't try to "fix" the missing check. ## WebFetch max_chars: number -> integer Schema declared `"type": "number"` for what's a `usize` in code. LLM-emitted floats would round-trip as 0 (when fractional) or panic in `.chars().take(n)`. Now declared as integer with a minimum of 1. ## LSP workspaceSymbol query: schema now documents the contract Schema marks `query` as a regular optional field, but the call path errors when query is missing AND operation == workspaceSymbol. Added that constraint to the parameter description so the LLM knows when to pass it (the runtime check stays as the source of truth). ## Compress now replaces the compactions list instead of appending `Compaction::first_kept_index` is meaningful only for the *latest* compaction record — keeping a list of records from earlier compresses left stale `first_kept_index` values that no longer matched the post-drain message indices. The LLM context already folds older summaries into the new summary via `previous_summary` (captured in `slash.rs:109` before compress runs), so dropping the historical list is lossless. Fixes the multi-compaction accounting drift flagged in the audit. ## Test plan - [x] `cargo test --features plugin` -> 606 pass, 0 fail. - [x] Both build profiles -> 0 warnings. * fix(audit r5): glob cache, fork docs, switch-session diagnostics, pop fallback Round 5 — low hygiene. ## GlobTool gets the dual-constructor pattern Other read tools (Read/Grep/FindFiles/ListDir) follow a `new(perm, ask_tx) / with_cache(perm, ask_tx, cache)` pattern. `GlobTool` had only `new`, so glob calls re-walked the filesystem every invocation even when bash/write/edit cache-clears explicitly invalidated other tools' caches. Added `with_cache(perm, ask_tx, cache)` and wired it through `builder.rs`. Reuses the same `ToolCache` so a `bash`/`write` mutation clears glob results too. ## pop_last_message: tree-corruption fallback If `tree.entries` somehow lacks the popped message's id (data corruption, external mutation), the old code wiped `tree.leaf_id` to None, leaving the tree dangling on branched sessions. New fallback uses the previous message's id from the linear cache so the leaf stays anchored to a real node. ## fork_at: documented root behaviour `fork_at` at the conversation root clears `messages` and sets `leaf_id = None` — useful but surprising. Docstring now spells this out, including the note that sibling branches survive. ## switch-session: ambiguity error lists candidate ids `prefix 'ab' matches 5 sessions` was un-debuggable. Now lists the first 3 matching session ids (short form) so the plugin author / user can pick a longer prefix immediately. ## Test plan - [x] `cargo test --features plugin` -> 606 pass, 0 fail. - [x] Both build profiles -> 0 warnings. --------- Co-authored-by: Yogthos <yogthos@gmail.com>
1 parent b25d58a commit bf5e98d

14 files changed

Lines changed: 340 additions & 62 deletions

File tree

src/agent/builder.rs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,11 @@ pub async fn build_agent_inner<M: CompletionModel + 'static>(
194194
ask_tx.clone(),
195195
cache.clone(),
196196
)),
197-
Box::new(tools::GlobTool::new(permission.clone(), ask_tx.clone())),
197+
Box::new(tools::GlobTool::with_cache(
198+
permission.clone(),
199+
ask_tx.clone(),
200+
cache.clone(),
201+
)),
198202
Box::new(tools::ListDirTool::with_cache(
199203
permission.clone(),
200204
ask_tx.clone(),
@@ -217,8 +221,11 @@ pub async fn build_agent_inner<M: CompletionModel + 'static>(
217221
)),
218222
];
219223

220-
let question_tool = question_tx
221-
.map(|tx| Box::new(tools::QuestionTool::new(tx)) as Box<dyn rig::tool::ToolDyn>);
224+
let question_tool = question_tx.map(|tx| {
225+
Box::new(
226+
tools::QuestionTool::new(tx).with_permission(permission.clone(), ask_tx.clone()),
227+
) as Box<dyn rig::tool::ToolDyn>
228+
});
222229

223230
let plan_tools = plan_tx.map(|tx| {
224231
let enter =
@@ -279,8 +286,10 @@ pub async fn build_agent_inner<M: CompletionModel + 'static>(
279286
pm,
280287
store.clone(),
281288
));
282-
let status_tool =
283-
Box::new(tools::TaskStatusTool::new(store)) as Box<dyn rig::tool::ToolDyn>;
289+
let status_tool = Box::new(
290+
tools::TaskStatusTool::new(store)
291+
.with_permission(permission.clone(), ask_tx.clone()),
292+
) as Box<dyn rig::tool::ToolDyn>;
284293
builder = builder.tools(hookify(vec![task_tool, status_tool]));
285294
}
286295

src/agent/tools/glob.rs

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,42 @@ use serde::Deserialize;
55
use std::path::Path;
66

77
use crate::agent::tools::MAX_FIND_RESULTS;
8+
use crate::agent::tools::cache::ToolCache;
89
use crate::agent::tools::{AskSender, PermCheck, ToolError, check_perm};
910

1011
pub struct GlobTool {
1112
pub permission: Option<PermCheck>,
1213
pub ask_tx: Option<AskSender>,
14+
pub cache: Option<ToolCache>,
1315
}
1416

1517
impl GlobTool {
18+
/// Construct without a cache. Retained for parity with other
19+
/// tools (Read/Grep/FindFiles/ListDir) and exercised by unit
20+
/// tests; production paths use `with_cache`.
21+
#[allow(dead_code)]
1622
pub fn new(permission: Option<PermCheck>, ask_tx: Option<AskSender>) -> Self {
17-
Self { permission, ask_tx }
23+
Self {
24+
permission,
25+
ask_tx,
26+
cache: None,
27+
}
28+
}
29+
30+
/// Builder that matches the dual-constructor pattern used by
31+
/// Read/Grep/FindFiles/ListDir. Same `ToolCache` is shared
32+
/// across tools so a `bash`/`write` that mutates the filesystem
33+
/// invalidates glob results too via `cache.clear()`.
34+
pub fn with_cache(
35+
permission: Option<PermCheck>,
36+
ask_tx: Option<AskSender>,
37+
cache: ToolCache,
38+
) -> Self {
39+
Self {
40+
permission,
41+
ask_tx,
42+
cache: Some(cache),
43+
}
1844
}
1945
}
2046

@@ -95,6 +121,17 @@ impl Tool for GlobTool {
95121
)
96122
.await?;
97123

124+
let cache_key = format!(
125+
"glob:{}:{}",
126+
args.pattern,
127+
args.path.as_deref().unwrap_or("."),
128+
);
129+
if let Some(ref cache) = self.cache
130+
&& let Some(cached) = cache.get(&cache_key)
131+
{
132+
return Ok(cached);
133+
}
134+
98135
let re = glob_to_regex(&args.pattern).map_err(|e| ToolError::Msg(e))?;
99136

100137
let root = args
@@ -150,11 +187,15 @@ impl Tool for GlobTool {
150187
});
151188

152189
let results: Vec<String> = matches.into_iter().map(|(rel, _)| rel).collect();
153-
if results.is_empty() {
154-
Ok(String::new())
190+
let out = if results.is_empty() {
191+
String::new()
155192
} else {
156-
Ok(results.join("\n"))
193+
results.join("\n")
194+
};
195+
if let Some(ref cache) = self.cache {
196+
cache.set(&cache_key, out.clone());
157197
}
198+
Ok(out)
158199
}
159200
}
160201

src/agent/tools/lsp.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ impl Tool for LspTool {
176176
},
177177
"query": {
178178
"type": "string",
179-
"description": "Search string for workspaceSymbol. Empty string returns all symbols."
179+
"description": "Search string for workspaceSymbol — REQUIRED when operation is 'workspaceSymbol' (pass empty string to list all symbols). Ignored for other operations."
180180
}
181181
},
182182
"required": ["operation", "file_path"]

src/agent/tools/plan.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ impl Tool for PlanEnterTool {
6262
}
6363

6464
async fn call(&self, _args: PlanEnterArgs) -> Result<String, ToolError> {
65+
// Note: this tool doesn't go through `check_perm` because the
66+
// plan_tx channel itself surfaces a confirmation dialog to the
67+
// user — the user has to explicitly Accept or Reject the mode
68+
// switch via `PlanSwitchResponse`. Routing through `check_perm`
69+
// would double-ask. This matches how `harness/confirm` works
70+
// in the plugin layer.
6571
let (reply_tx, reply_rx) = oneshot::channel();
6672

6773
self.plan_tx

src/agent/tools/question.rs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use rig::tool::Tool;
33
use serde::Deserialize;
44
use tokio::sync::{mpsc, oneshot};
55

6-
use crate::agent::tools::ToolError;
6+
use crate::agent::tools::{AskSender, PermCheck, ToolError, check_perm};
77

88
pub type QuestionSender = mpsc::Sender<QuestionRequest>;
99
pub type QuestionReceiver = mpsc::Receiver<QuestionRequest>;
@@ -48,11 +48,30 @@ pub struct QuestionOption {
4848

4949
pub struct QuestionTool {
5050
pub question_tx: QuestionSender,
51+
pub permission: Option<PermCheck>,
52+
pub ask_tx: Option<AskSender>,
5153
}
5254

5355
impl QuestionTool {
5456
pub fn new(question_tx: QuestionSender) -> Self {
55-
Self { question_tx }
57+
Self {
58+
question_tx,
59+
permission: None,
60+
ask_tx: None,
61+
}
62+
}
63+
64+
/// Builder for the production path: wires the permission checker
65+
/// + ask channel so `question` invocations go through the same
66+
/// allow/ask/deny rules as every other behaviour-altering tool.
67+
pub fn with_permission(
68+
mut self,
69+
permission: Option<PermCheck>,
70+
ask_tx: Option<AskSender>,
71+
) -> Self {
72+
self.permission = permission;
73+
self.ask_tx = ask_tx;
74+
self
5675
}
5776
}
5877

@@ -116,6 +135,17 @@ impl Tool for QuestionTool {
116135
}
117136

118137
async fn call(&self, args: QuestionArgs) -> Result<String, ToolError> {
138+
// Route through the same permission system as task / write /
139+
// bash. `question` rewrites what the LLM sees by injecting
140+
// user input — that's behavior-altering and shouldn't bypass
141+
// user rules.
142+
let summary = args
143+
.questions
144+
.first()
145+
.map(|q| q.question.clone())
146+
.unwrap_or_default();
147+
check_perm(&self.permission, &self.ask_tx, "question", &summary).await?;
148+
119149
let (reply_tx, reply_rx) = oneshot::channel();
120150

121151
self.question_tx

src/agent/tools/task_status.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,32 @@ use rig::tool::Tool;
33
use serde::Deserialize;
44
use std::time::Duration;
55

6-
use crate::agent::tools::ToolError;
76
use crate::agent::tools::background::{BackgroundStore, TaskState};
7+
use crate::agent::tools::{AskSender, PermCheck, ToolError, check_perm};
88

99
pub struct TaskStatusTool {
1010
bg_store: BackgroundStore,
11+
permission: Option<PermCheck>,
12+
ask_tx: Option<AskSender>,
1113
}
1214

1315
impl TaskStatusTool {
1416
pub fn new(bg_store: BackgroundStore) -> Self {
15-
Self { bg_store }
17+
Self {
18+
bg_store,
19+
permission: None,
20+
ask_tx: None,
21+
}
22+
}
23+
24+
pub fn with_permission(
25+
mut self,
26+
permission: Option<PermCheck>,
27+
ask_tx: Option<AskSender>,
28+
) -> Self {
29+
self.permission = permission;
30+
self.ask_tx = ask_tx;
31+
self
1632
}
1733
}
1834

@@ -52,6 +68,10 @@ impl Tool for TaskStatusTool {
5268
}
5369

5470
async fn call(&self, args: TaskStatusArgs) -> Result<String, ToolError> {
71+
// Same permission gate as `task` — status polling can side-
72+
// channel sensitive subagent results, and the wait=true path
73+
// can hold the parent turn open for up to 10 minutes.
74+
check_perm(&self.permission, &self.ask_tx, "task_status", &args.task_id).await?;
5575
let wait = args.wait.unwrap_or(false);
5676

5777
if wait {

src/agent/tools/webfetch.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ impl Tool for WebFetchTool {
9191
"description": "URLs to fetch (may be comma-separated)"
9292
},
9393
"max_chars": {
94-
"type": "number",
94+
"type": "integer",
95+
"minimum": 1,
9596
"description": "Maximum characters to return per URL (default: 3000)"
9697
}
9798
},

src/plugin/mod.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2063,7 +2063,14 @@ fn parse_tree_op_line(line: &str) -> Option<TreeOp> {
20632063
Some(TreeOp::SwitchSession { id_prefix: arg1 })
20642064
}
20652065
}
2066-
_ => None,
2066+
// Forward compat: a future plugin shipping an op verb we
2067+
// don't know yet shouldn't poison the rest of the drain. Log
2068+
// at WARN so a confused plugin author can spot the typo
2069+
// instead of silently failing.
2070+
other => {
2071+
tracing::warn!(target: "dirge::plugin", op = other, "drain_tree_ops: unknown op verb (skipped)");
2072+
None
2073+
}
20672074
}
20682075
}
20692076

0 commit comments

Comments
 (0)