Skip to content

chore: audit fixes (5 rounds — CRITICAL/HIGH/MEDIUM/LOW) - #54

Merged
yogthos merged 5 commits into
mainfrom
chore/audit-fixes
May 20, 2026
Merged

chore: audit fixes (5 rounds — CRITICAL/HIGH/MEDIUM/LOW)#54
yogthos merged 5 commits into
mainfrom
chore/audit-fixes

Conversation

@yogthos

@yogthos yogthos commented May 20, 2026

Copy link
Copy Markdown
Collaborator

Five rounds of audit fixes from the codebase audit.

Round 1 — Security + crashes (CRITICAL/HIGH)

  • Session storage path-traversal validation (`save_session` / `load_session` / `delete_session` now reject ids containing `..`, slashes, etc.)
  • Compress bounds clamp + branched-session leaf-tracking fix
  • QuestionTool routes through permission system

Round 2 — Silent failures (HIGH/MEDIUM)

  • `on-complete` hook now actually dispatches (was in HOOK_NAMES but never fired)
  • Turn-hook slot reset (block/mutate/replace no longer leak from turn boundaries into tool calls)
  • harness-response cleared after store_response
  • drain_tree_ops warns on unknown op verbs

Round 3 — Theme + UI polish (CRITICAL/HIGH/MEDIUM)

  • 13 hardcoded color literals in markdown.rs swept to theme accessors
  • Cursor flicker fixed (Hide before draw_panel)
  • Avatar resets to Idle on user submit
  • ensure_back_compat_initialized atomic helper

Round 4 — Schema + permission hygiene (MEDIUM)

  • TaskStatusTool routes through permission
  • PlanEnter/PlanExit kept un-gated (have their own confirm flow) — documented
  • WebFetch max_chars: number → integer
  • LSP workspaceSymbol query constraint documented in schema
  • Compress now replaces compactions list instead of appending stale records

Round 5 — Low hygiene (LOW)

  • GlobTool dual-constructor with cache support
  • pop_last_message tree-corruption fallback
  • fork_at root-node behaviour documented
  • switch-session ambiguity error lists candidate session ids

Totals

  • 606 tests pass, 0 fail
  • Both build profiles -> 0 warnings
  • 22 of 24 audit findings resolved; remaining 2 deferred (workflow.janet example coverage, line-only selection — both pure feature work)

Yogthos added 5 commits May 20, 2026 18:46
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.
…e, 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.
…mic 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.
…n 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.
… 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.
@yogthos
yogthos merged commit bf5e98d into main May 20, 2026
1 check passed
@yogthos
yogthos deleted the chore/audit-fixes branch May 20, 2026 23:05
yogthos added a commit that referenced this pull request May 21, 2026
…ts rebuild, MCP hardening (#68)

TDD-driven fixes for the verified real bugs from audit round 9.

## Round A — verified real bugs (CRITICAL/HIGH)

### `/cd   /tmp` no longer cds to home
`splitn(3, ' ')` produces empty middle elements for consecutive
spaces — `/cd   /tmp` parsed as `["/cd", "", "/tmp"]`, and the
empty `parts[1]` collapsed to "cd to home". Now derives the
target by stripping the `/cd` prefix and trimming, so any
amount of whitespace between the command and the path resolves
correctly.

### Empty-input "allow always" no longer pins literal placeholder
PR #67 made `suggest_pattern("bash", "")` return the literal
`"<edit this pattern>"` placeholder so an accidental "(a) allow
always" wouldn't pin a catch-all `"* *"`. But the ask-dialog
fed that placeholder straight into `UserDecision::AllowAlways`,
storing the literal text as a real pattern in
`permission_allowlist`. The dialog now detects placeholders via
the new `is_placeholder_pattern` predicate and falls back to
`AllowOnce` with a dim "can't derive a useful pattern from empty
input; allowing once only" message.

### `/regen-prompts` rebuilds the agent
Regenerating overwrote the on-disk prompt content + reloaded
`context.prompts`, but the LIVE agent kept the old preamble in
memory. Users had to `/prompt <name>` to actually see the new
content. Now also re-binds `context.current_prompt` to the
freshly-loaded body for the currently-active name and rebuilds
the agent so the new system prompt takes effect immediately.

### `/toggle` added to `/help`
Slipped through PR #54 — the feature exists and is in the
README but the in-app `/help` text didn't list it.

### Anthropic `overloaded_error` classified as RateLimit
The error classifier matched "rate limit" / "too many requests"
/ "429" but not Anthropic's `overloaded_error` (structurally a
rate-limit signal). Falls through to `Other` and no retry
fires — user saw a one-shot failure on transient backend
pressure. Now any error string containing "overloaded" routes
to `ErrorKind::RateLimit` and triggers the exponential-backoff
retry.

## Round B — MCP hardening

### MCP server init timeout (10s)
`serve_client((), transport).await` had no upper bound. A
command-based MCP server that hung on `initialize` (waiting for
stdin / wedged binary) would pin dirge's startup indefinitely.
Now wrapped in `tokio::time::timeout(MCP_INIT_TIMEOUT)`; past
10s we abort, log the failure, and continue with the other
servers.

### Empty `EXA_API_KEY` skips Exa default registration
A user with `EXA_API_KEY=""` (explicit empty, e.g. from a
`.envrc` that intentionally clears it) used to register the
Exa server anyway, then every web-search call failed with 401
at first use. Now treats empty key the same as unset — Exa
default skipped, no broken server in the list.

### MCP tool `inputSchema` null fallback to `{}`
Servers that omit `inputSchema` had it serialized as JSON
`null`, which rig's tool registration treats as an invalid
parameters block. The tool became unusable. Now substitute an
empty object: the tool stays callable; the LLM sees "no params"
correctly.

## Tests

3 new tests, written failing first:

- `classify_anthropic_overloaded_error_as_retryable` (2 cases)
- `placeholder_pattern_is_detectable`

(`/cd` whitespace fix is observable in the existing /cd tests if
present; verified by manual trace through the parsing path.)

Total: 635 pass (was 633), 0 fail across all build profiles
(`--features plugin`, `--all-features`, `--no-default-features`).

## Deferred from this audit (bigger scope)

- **Compression prunes sibling branches** — needs a tree-walk
  during `compress` to preserve sibling subtrees whose parents
  get dropped. Real bug, real risk on branched sessions, but
  the fix touches the compress data flow significantly.
- **Subagent permission/sandbox/hooks** — `task` tool's
  `btw_query` is a bare LLM call with no permission/sandbox
  inheritance and no plugin hook dispatch. By design today, but
  the safety properties are not what users expect.
- **`convert_history` loses tool call structure** — pre-existing
  finding; resuming a session shows the LLM text-only traces of
  prior tool calls, losing the structured tool_use markers.
- **README "events are buffered" claim** — implementation streams
  live; doc says buffered. Tokens are NOT re-emitted on retry
  but the user already saw the partial.
- **Multi-plugin harness-block last-write-wins** — design
  question (queue vs. first-wins) deferred for discussion.
- **`/clear` no confirmation** — design choice; standard for CLI.
- **`/quit` save-before-break** — needs verification; the outer
  loop may already save on Interrupted.

## Test plan

- [x] `cargo test --features plugin` -> 635 pass, 0 fail.
- [x] `cargo build --all-features` -> compiles, no warnings.
- [x] `cargo build --no-default-features` -> compiles.

Co-authored-by: Yogthos <yogthos@gmail.com>
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
* 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>
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
…ts rebuild, MCP hardening (dirge-code#68)

TDD-driven fixes for the verified real bugs from audit round 9.

## Round A — verified real bugs (CRITICAL/HIGH)

### `/cd   /tmp` no longer cds to home
`splitn(3, ' ')` produces empty middle elements for consecutive
spaces — `/cd   /tmp` parsed as `["/cd", "", "/tmp"]`, and the
empty `parts[1]` collapsed to "cd to home". Now derives the
target by stripping the `/cd` prefix and trimming, so any
amount of whitespace between the command and the path resolves
correctly.

### Empty-input "allow always" no longer pins literal placeholder
PR dirge-code#67 made `suggest_pattern("bash", "")` return the literal
`"<edit this pattern>"` placeholder so an accidental "(a) allow
always" wouldn't pin a catch-all `"* *"`. But the ask-dialog
fed that placeholder straight into `UserDecision::AllowAlways`,
storing the literal text as a real pattern in
`permission_allowlist`. The dialog now detects placeholders via
the new `is_placeholder_pattern` predicate and falls back to
`AllowOnce` with a dim "can't derive a useful pattern from empty
input; allowing once only" message.

### `/regen-prompts` rebuilds the agent
Regenerating overwrote the on-disk prompt content + reloaded
`context.prompts`, but the LIVE agent kept the old preamble in
memory. Users had to `/prompt <name>` to actually see the new
content. Now also re-binds `context.current_prompt` to the
freshly-loaded body for the currently-active name and rebuilds
the agent so the new system prompt takes effect immediately.

### `/toggle` added to `/help`
Slipped through PR dirge-code#54 — the feature exists and is in the
README but the in-app `/help` text didn't list it.

### Anthropic `overloaded_error` classified as RateLimit
The error classifier matched "rate limit" / "too many requests"
/ "429" but not Anthropic's `overloaded_error` (structurally a
rate-limit signal). Falls through to `Other` and no retry
fires — user saw a one-shot failure on transient backend
pressure. Now any error string containing "overloaded" routes
to `ErrorKind::RateLimit` and triggers the exponential-backoff
retry.

## Round B — MCP hardening

### MCP server init timeout (10s)
`serve_client((), transport).await` had no upper bound. A
command-based MCP server that hung on `initialize` (waiting for
stdin / wedged binary) would pin dirge's startup indefinitely.
Now wrapped in `tokio::time::timeout(MCP_INIT_TIMEOUT)`; past
10s we abort, log the failure, and continue with the other
servers.

### Empty `EXA_API_KEY` skips Exa default registration
A user with `EXA_API_KEY=""` (explicit empty, e.g. from a
`.envrc` that intentionally clears it) used to register the
Exa server anyway, then every web-search call failed with 401
at first use. Now treats empty key the same as unset — Exa
default skipped, no broken server in the list.

### MCP tool `inputSchema` null fallback to `{}`
Servers that omit `inputSchema` had it serialized as JSON
`null`, which rig's tool registration treats as an invalid
parameters block. The tool became unusable. Now substitute an
empty object: the tool stays callable; the LLM sees "no params"
correctly.

## Tests

3 new tests, written failing first:

- `classify_anthropic_overloaded_error_as_retryable` (2 cases)
- `placeholder_pattern_is_detectable`

(`/cd` whitespace fix is observable in the existing /cd tests if
present; verified by manual trace through the parsing path.)

Total: 635 pass (was 633), 0 fail across all build profiles
(`--features plugin`, `--all-features`, `--no-default-features`).

## Deferred from this audit (bigger scope)

- **Compression prunes sibling branches** — needs a tree-walk
  during `compress` to preserve sibling subtrees whose parents
  get dropped. Real bug, real risk on branched sessions, but
  the fix touches the compress data flow significantly.
- **Subagent permission/sandbox/hooks** — `task` tool's
  `btw_query` is a bare LLM call with no permission/sandbox
  inheritance and no plugin hook dispatch. By design today, but
  the safety properties are not what users expect.
- **`convert_history` loses tool call structure** — pre-existing
  finding; resuming a session shows the LLM text-only traces of
  prior tool calls, losing the structured tool_use markers.
- **README "events are buffered" claim** — implementation streams
  live; doc says buffered. Tokens are NOT re-emitted on retry
  but the user already saw the partial.
- **Multi-plugin harness-block last-write-wins** — design
  question (queue vs. first-wins) deferred for discussion.
- **`/clear` no confirmation** — design choice; standard for CLI.
- **`/quit` save-before-break** — needs verification; the outer
  loop may already save on Interrupted.

## Test plan

- [x] `cargo test --features plugin` -> 635 pass, 0 fail.
- [x] `cargo build --all-features` -> compiles, no warnings.
- [x] `cargo build --no-default-features` -> compiles.

Co-authored-by: Yogthos <yogthos@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant