Skip to content

fix(security): perm bypass in grep/glob/find/semantic; URL scheme case-insensitive; tool defaults match docs - #120

Merged
yogthos merged 1 commit into
mainfrom
fix/security-perm-bypass
May 21, 2026
Merged

fix(security): perm bypass in grep/glob/find/semantic; URL scheme case-insensitive; tool defaults match docs#120
yogthos merged 1 commit into
mainfrom
fix/security-perm-bypass

Conversation

@yogthos

@yogthos yogthos commented May 21, 2026

Copy link
Copy Markdown
Collaborator

Six verified security-critical findings. C1: grep/glob/find_files skipped external_directory permission rules (only checked pattern, not path). C2: 3 of 5 semantic tools had the same path-arg bypass. C3: scheme check was case-sensitive (HTTP://169.254.169.254/ slipped past both scheme + SSRF defenses). C6: tools.websearch/webfetch defaulted to false despite docs saying true. C5: --tools CLI flag was dead code. 1 new regression test, 813 pass. Architectural items (C4 unregistered-tool allow, C7 LSP didClose, H1/H3/H4/H5/H6/H8/H9/H10) deferred to a config/cleanup PR.

…ve URL scheme; tool defaults match docs

Six verified security-critical findings from a code review.

## C1: grep / glob / find_files bypassed external_directory rules

Each tool called `check_perm(name, pattern)` but the actual
filesystem path being walked (`args.path`) was never permission-
checked. `check_perm_path` is what consults `ext_dir_rules`
+ Accept-mode working-dir gating; the bare `check_perm` skips
both. An LLM doing `grep("keyword", "/etc")` or
`find_files("*", "~/.ssh")` walked external dirs without
asking — even under restrictive rules.

Added `check_perm_path` after the existing `check_perm` call
in each of the three tools. The pattern matches `read.rs` /
`list_dir.rs` which already did this.

## C2: semantic tools didn't use path-aware perm check

`find_callees`, `get_symbol_body`, `list_symbols` all
accepted a `path` arg and passed it to `check_perm` — same
class of bypass as C1. Switched the three to `check_perm_path`.
The other two semantic tools (`find_callers`, `find_definition`)
operate over symbol names only and continue to use `check_perm`.

## C3: case-sensitive scheme check enabled SSRF bypass

`starts_with("http://")` only matched lowercase. URL schemes
are case-insensitive per RFC 3986, so `HTTP://169.254.169.254/`
slipped past both the scheme guard AND the SSRF host blocker
(which also did case-sensitive `strip_prefix`). Refactored to
a shared `has_http_scheme` helper using `eq_ignore_ascii_case`,
and rewrote the host-extraction to use case-insensitive length-
matching to strip the scheme.

## C6: tools.websearch / tools.webfetch default mismatched docs

CONFIG.md states "both `bool`, default `true`". Code had
`.unwrap_or(false)` so both tools were disabled by default,
contradicting the documented behavior. Flipped to `unwrap_or(true)`.
Explicit `false` in config still disables; absent or `true`
enables (websearch additionally requires the runtime EXA_API_KEY
check to pass, unchanged).

## C5: removed dead --tools / -t CLI flag

The `tools: Vec<String>` flag was defined in clap but never read
anywhere — pure dead code. Not documented in README or CONFIG.md.
Removed the clap arg entirely. Users invoking `-t bash` will
now get a clear clap error instead of silent acceptance.

## Tests

1 new regression: `scheme_matching_is_case_insensitive` —
verifies `HTTP://`, `HTTPS://`, mixed case all pass the
scheme check AND that SSRF defense still triggers for case-
variant schemes targeting metadata IPs.

813 all-features tests pass (was 812). All build profiles clean.

## Deferred to next PR (config + cleanup batch)

- C4: unregistered tool names default to Allow. Architectural —
  needs a design decision (explicit tool whitelist vs default
  allow). Will tackle separately.
- C7: missing didClose in LSP. Needs lifecycle design — when to
  fire (on file delete? on session end?).
- H1: unbounded edit/apply_patch file reads. Trivial cap; in
  next batch.
- H3: blocking std::fs in apply_patch + list_dir. In next batch.
- H4 / H5: missing PermissionConfig fields for MCP / 8 other
  tools. Schema change; in next batch.
- H6: MCP connect errors only at tracing::warn. Tiny fix;
  in next batch.
- H8: LSP config extensions field ignored. In next batch.
- H9 / H10: dead acp_servers / acp_host / acp_port config
  fields. In next batch.

## Verified false positives

- H2 (empty bash): `quote_aware_split` already filters empty
  segments. No bug.
- H7 (LSP spawn silent): the `broken` set + backoff IS the
  user-feedback mechanism for repeated failures; logging at
  warn is intentional non-fatal degradation.
- H11 (plugin dedup): summary IS emitted when a different error
  arrives.
- H12 (token counter field): `estimate_tokens` on the
  accumulated partial string is correct.
@yogthos
yogthos merged commit 7f1f675 into main May 21, 2026
1 check passed
@yogthos
yogthos deleted the fix/security-perm-bypass branch May 21, 2026 21:05
yogthos added a commit that referenced this pull request May 21, 2026
…ig (#121)

Continues from PR #120 (security-critical). Six deferred items
shipped.

## H1: edit + apply_patch unbounded file reads

`edit.rs` and `apply_patch.rs` (apply_update) called
`tokio::fs::read` / `std::fs::read_to_string` with no size
guard. An LLM pointing them at a multi-GB log/binary would OOM
the agent process.

Added 100 MiB pre-flight metadata check in both. The cap is
generous (every realistic source file fits) but rejects the
pathological case fast with a clear error pointing the agent
at `bash + sed/awk` for huge files.

## H3: apply_patch blocking std::fs in async

All four `apply_*` helpers (create / update / delete / rename)
used synchronous `std::fs` calls inside an async `call`
method. On the tokio runtime with limited worker threads, slow
I/O can starve the event loop.

Switched each to `tokio::fs` and made them async. Tests
updated from `#[test]` to `#[tokio::test]` (8 tests touched).

## H4 + H5: PermissionConfig fields for missing tools

`PermissionConfig` only had fields for 11 tools (bash, read,
write, edit, grep, find_files, list_dir, write_todo_list,
apply_patch, lsp, question). 12 other tools the agent can call
had no way to be individually gated — they always fell through
to the global default action.

Added 12 fields covering the missing surface:

- Web: `webfetch`, `websearch`
- Subagent / state: `task`, `memory`, `skill`
- Semantic (tree-sitter): `list_symbols`, `get_symbol_body`,
  `find_definition`, `find_callers`, `find_callees`
- MCP umbrella: `mcp_tool` — pattern rules match against the
  full `mcp_tool:<server>:<tool>` key, so
  `{"mcp_tool:filesystem:*": "deny"}` blocks every tool
  from a filesystem MCP server.

`PermissionChecker::new` now loads rules for all 23 tool
names. `is_path_tool_name` extended to mark the path-bearing
semantic tools (`list_symbols`, `get_symbol_body`,
`find_callees`) plus `grep`/`find_files`/`glob` whose
new path-side perm check (PR #120) uses path-glob semantics.

The `/allow` slash command's whitelist grew to match — typos
on the new tool names still get caught.

## H6: MCP connect / list_tools errors visible to user

`McpClientManager::connect_all` and `collect_tools` logged
failures at `tracing::warn!` only. Default-log users (no
`RUST_LOG` / `--verbose`) saw nothing — configured MCP
servers just silently failed to register tools.

Added an `eprintln!` alongside each warn so the failure
surfaces to stderr unconditionally. Message names the server
and tells the user the tools won't be available this session.

## H9 + H10: removed dead acp_host / acp_port config keys

Both keys were parsed by serde but never consumed anywhere.
ACP transport is stdio-only by design. Removed from
`config::Config`. CONFIG.md already (PR #112) documents the
removal; no further docs change needed.

`acp_servers` kept — it has a CONFIG.md entry, holds future
TCP / Unix-socket config, and isn't actively misleading users.

## CONFIG.md

Rewrote the Permission tool-keys list from a single sentence
into a categorized list covering all 23 tools + the
`mcp_tool:<server>:<tool>` pattern syntax.

## Tests

8 `apply_patch` tests converted to `#[tokio::test]` (no new
assertions — the conversion was mechanical so the existing
coverage still applies). 813 all-features / 730 plugin / 604
default pass — same as PR #120 plus one new test there.

## Not in this PR (still deferred)

- **C4**: unregistered-tool default-allow. This PR ADDS fields
  for the 12 missing tools, so the original "unregistered"
  set is now mostly covered. The remaining gap (future MCP
  tools, third-party plugins) is by design — `mcp_tool` is
  the umbrella key.
- **C7**: LSP didClose. Needs lifecycle design (when to fire?
  on file delete? session end?). Will tackle as a focused PR.
- **H8**: LSP `extensions` config field ignored. Real gap
  but bigger plumbing change; separate PR.

## Verified false positives (re-confirmed from earlier review)

- H2 empty bash: `quote_aware_split` filters empty segments.
- H7 LSP spawn silent: broken-set + backoff IS the feedback.
- H11 plugin dedup: summary IS emitted.
- H12 token counter: estimate_tokens on partial is correct.

Co-authored-by: Yogthos <yogthos@gmail.com>
yogthos added a commit that referenced this pull request May 21, 2026
…122)

Two LSP gaps from the audit, both real.

## C7: missing didClose → server-side state leak

LSP servers retain parse trees + diagnostic caches keyed on
every `textDocument/didOpen`. dirge never sent
`textDocument/didClose`, so a long session editing dozens of
files left rust-analyzer / pyright / etc. holding all that
state for the lifetime of the server process.

Added two methods on `LspClient`:

- `notify_close(path)`: send didClose for one file, drop the
  associated FileState / push + pull diagnostics / last-push
  timestamp from local tracking. Returns Ok on never-opened
  files (a stray didClose is harmless per the spec).
- `close_all()`: fan didClose across every tracked file.
  Used at session shutdown.

`LspManager::close_all_files` fans across every attached
client. Called from main.rs after the interactive loop
returns. Best-effort: an already-dead server isn't re-contacted
and individual notify_close failures are swallowed.

The `lsp_manager` Arc is cloned before being moved into
`ui::run_interactive` so the shutdown call site still has a
handle.

Two new regression tests pin the behavior:
- `notify_close_emits_did_close_and_clears_state` — checks
  that subsequent `notify_open` after close emits a fresh
  didOpen (version 0) rather than a didChange.
- `close_all_emits_did_close_for_every_open_file` — two open
  files → two didClose frames sent.

## H8: LspServerConfig.extensions ignored

`LspServerConfig` carries an `extensions: Option<Vec<String>>`
field, parsed by serde, but `LspManager` always used the
hardcoded `builtin_servers()` registry. User config like
`{ "lsp": { "rust": { "extensions": ["rs", "rlib"] } } }`
was silently dropped.

Two changes:

1. `ServerInfo.extensions` changed from `&'static [&'static str]`
   to `Vec<String>` so it can be mutated post-construction.
   `builtin_servers()` now produces owned vecs; this is a
   one-time cost paid at session startup.

2. New `apply_extension_overrides` function in `lsp::server`
   walks the user's per-server config and replaces builtin
   extension lists (or removes the server entirely if
   `disabled: true`). Lowercases + strips leading dots to
   match how `servers_for_extension` looks up by extension.

3. `LspManager::with_servers` constructor accepts the
   modified server list. The existing `new` delegates with
   the unmodified builtin list.

4. `main.rs::build_channels` applies overrides between
   `builtin_servers()` and `LspManager::with_servers(...)`.

The `compile_lsp_commands` docstring previously called the
extensions field a follow-up; updated to point at the new
override path.

A trait shim (`AsExtensionOverride`) keeps the `lsp::server`
module from depending directly on `config::LspServerConfig` —
keeps the dep graph clean and lets the tests use a stub.

4 new regression tests:
- Extensions override replaces builtin list.
- `disabled: true` removes server from the active set.
- Unknown server id silently ignored (no panic, no spurious
  entry added).
- User-supplied extensions are normalized (leading-dot strip +
  lowercase).

## Status

736 plugin / 610 default pass (was 730 / 604). All build
profiles + fmt clean.

This closes the last two real items in the original audit's
deferred list. PR #120 → C1/C2/C3/C5/C6. PR #121 → H1/H3/H4/H5/
H6/H9/H10. This PR → C7/H8. All verified false positives (H2,
H7, H11, H12) remain unchanged.

Co-authored-by: Yogthos <yogthos@gmail.com>
yogthos added a commit that referenced this pull request May 21, 2026
…WEBFETCH_ENABLED symmetry (#123)

## Steering the model toward the question tool

opencode's `question.txt` description gets the model to actually
reach for the tool by enumerating concrete use cases + usage
notes. dirge's prior description was a single sentence; ported
opencode's structure:

- "Use this when you need to: (1) gather preferences (2)
  clarify ambiguity (3) get decisions (4) offer choices"
- Usage notes covering the `custom` option, multi_select,
  and the "(Recommended)" first-option convention
- Explicit "prefer asking over guessing when genuinely
  ambiguous, but don't over-ask" guidance

System-prompt nudge: replaced the generic "ask the user
directly" line with one that names the `question` tool, calls
for concrete options with "(Recommended)" markers, and
balances against over-asking. Matches opencode's intent without
their anti-asking "beast mode" override.

## C2 remnant — find_callers optional path

The earlier C2 fix (PR #120) covered 3 of 5 semantic tools.
`find_callers` was missed: it accepts an optional `path` arg
that scopes the search, but only the symbol name went through
permission. An LLM doing
`find_callers(name="foo", path="/etc")` walked /etc with
no external_directory check.

Added `check_perm_path` after the existing name-side
`check_perm`. Defaults to "." when no path arg supplied (so
the check runs against the working dir).

`find_definition` remains untouched — it has no path arg
(operates over the whole project index), so check_perm on the
symbol name is the right granularity.

## C4 remnant — task_status missing

`task_status` (the companion to `task` for querying
background subagent state) had no `PermissionConfig` field and
wasn't in the `PermissionChecker::new` loop. It always fell
through to the `*` default, contradicting the "every tool
gated explicitly" model.

Added `task_status: Option<ToolPerm>` to PermissionConfig +
the matching entry in the checker's load loop. `/allow`'s
KNOWN_PERM_TOOLS whitelist and CONFIG.md's category list both
get the new key. Read-only tool — users mostly leave it as
default Allow, but the option to deny it independently exists
(e.g. force background-only by allowing task but denying
task_status polling).

## WEBFETCH_ENABLED env-var symmetry

websearch had a `WEBSEARCH_ENABLED` env-var escape hatch
(set to "true"/"1" to force-enable). webfetch had no
equivalent — users had to edit config.json. Added
`WEBFETCH_ENABLED` with identical semantics + a tiny
`env_true` closure so the two web-tool checks share the
parsing.

## Tests

No new tests added — these are surface-area + steering
changes. The existing 10 question-tool tests still pass and
cover the JSON contract; the new path-side check on
`find_callers` is exercised whenever permission tests run.

736 plugin / 610 default pass. All build profiles + fmt clean.

Co-authored-by: Yogthos <yogthos@gmail.com>
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
…ve URL scheme; tool defaults match docs (dirge-code#120)

Six verified security-critical findings from a code review.

## C1: grep / glob / find_files bypassed external_directory rules

Each tool called `check_perm(name, pattern)` but the actual
filesystem path being walked (`args.path`) was never permission-
checked. `check_perm_path` is what consults `ext_dir_rules`
+ Accept-mode working-dir gating; the bare `check_perm` skips
both. An LLM doing `grep("keyword", "/etc")` or
`find_files("*", "~/.ssh")` walked external dirs without
asking — even under restrictive rules.

Added `check_perm_path` after the existing `check_perm` call
in each of the three tools. The pattern matches `read.rs` /
`list_dir.rs` which already did this.

## C2: semantic tools didn't use path-aware perm check

`find_callees`, `get_symbol_body`, `list_symbols` all
accepted a `path` arg and passed it to `check_perm` — same
class of bypass as C1. Switched the three to `check_perm_path`.
The other two semantic tools (`find_callers`, `find_definition`)
operate over symbol names only and continue to use `check_perm`.

## C3: case-sensitive scheme check enabled SSRF bypass

`starts_with("http://")` only matched lowercase. URL schemes
are case-insensitive per RFC 3986, so `HTTP://169.254.169.254/`
slipped past both the scheme guard AND the SSRF host blocker
(which also did case-sensitive `strip_prefix`). Refactored to
a shared `has_http_scheme` helper using `eq_ignore_ascii_case`,
and rewrote the host-extraction to use case-insensitive length-
matching to strip the scheme.

## C6: tools.websearch / tools.webfetch default mismatched docs

CONFIG.md states "both `bool`, default `true`". Code had
`.unwrap_or(false)` so both tools were disabled by default,
contradicting the documented behavior. Flipped to `unwrap_or(true)`.
Explicit `false` in config still disables; absent or `true`
enables (websearch additionally requires the runtime EXA_API_KEY
check to pass, unchanged).

## C5: removed dead --tools / -t CLI flag

The `tools: Vec<String>` flag was defined in clap but never read
anywhere — pure dead code. Not documented in README or CONFIG.md.
Removed the clap arg entirely. Users invoking `-t bash` will
now get a clear clap error instead of silent acceptance.

## Tests

1 new regression: `scheme_matching_is_case_insensitive` —
verifies `HTTP://`, `HTTPS://`, mixed case all pass the
scheme check AND that SSRF defense still triggers for case-
variant schemes targeting metadata IPs.

813 all-features tests pass (was 812). All build profiles clean.

## Deferred to next PR (config + cleanup batch)

- C4: unregistered tool names default to Allow. Architectural —
  needs a design decision (explicit tool whitelist vs default
  allow). Will tackle separately.
- C7: missing didClose in LSP. Needs lifecycle design — when to
  fire (on file delete? on session end?).
- H1: unbounded edit/apply_patch file reads. Trivial cap; in
  next batch.
- H3: blocking std::fs in apply_patch + list_dir. In next batch.
- H4 / H5: missing PermissionConfig fields for MCP / 8 other
  tools. Schema change; in next batch.
- H6: MCP connect errors only at tracing::warn. Tiny fix;
  in next batch.
- H8: LSP config extensions field ignored. In next batch.
- H9 / H10: dead acp_servers / acp_host / acp_port config
  fields. In next batch.

## Verified false positives

- H2 (empty bash): `quote_aware_split` already filters empty
  segments. No bug.
- H7 (LSP spawn silent): the `broken` set + backoff IS the
  user-feedback mechanism for repeated failures; logging at
  warn is intentional non-fatal degradation.
- H11 (plugin dedup): summary IS emitted when a different error
  arrives.
- H12 (token counter field): `estimate_tokens` on the
  accumulated partial string is correct.

Co-authored-by: Yogthos <yogthos@gmail.com>
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
…ig (dirge-code#121)

Continues from PR dirge-code#120 (security-critical). Six deferred items
shipped.

## H1: edit + apply_patch unbounded file reads

`edit.rs` and `apply_patch.rs` (apply_update) called
`tokio::fs::read` / `std::fs::read_to_string` with no size
guard. An LLM pointing them at a multi-GB log/binary would OOM
the agent process.

Added 100 MiB pre-flight metadata check in both. The cap is
generous (every realistic source file fits) but rejects the
pathological case fast with a clear error pointing the agent
at `bash + sed/awk` for huge files.

## H3: apply_patch blocking std::fs in async

All four `apply_*` helpers (create / update / delete / rename)
used synchronous `std::fs` calls inside an async `call`
method. On the tokio runtime with limited worker threads, slow
I/O can starve the event loop.

Switched each to `tokio::fs` and made them async. Tests
updated from `#[test]` to `#[tokio::test]` (8 tests touched).

## H4 + H5: PermissionConfig fields for missing tools

`PermissionConfig` only had fields for 11 tools (bash, read,
write, edit, grep, find_files, list_dir, write_todo_list,
apply_patch, lsp, question). 12 other tools the agent can call
had no way to be individually gated — they always fell through
to the global default action.

Added 12 fields covering the missing surface:

- Web: `webfetch`, `websearch`
- Subagent / state: `task`, `memory`, `skill`
- Semantic (tree-sitter): `list_symbols`, `get_symbol_body`,
  `find_definition`, `find_callers`, `find_callees`
- MCP umbrella: `mcp_tool` — pattern rules match against the
  full `mcp_tool:<server>:<tool>` key, so
  `{"mcp_tool:filesystem:*": "deny"}` blocks every tool
  from a filesystem MCP server.

`PermissionChecker::new` now loads rules for all 23 tool
names. `is_path_tool_name` extended to mark the path-bearing
semantic tools (`list_symbols`, `get_symbol_body`,
`find_callees`) plus `grep`/`find_files`/`glob` whose
new path-side perm check (PR dirge-code#120) uses path-glob semantics.

The `/allow` slash command's whitelist grew to match — typos
on the new tool names still get caught.

## H6: MCP connect / list_tools errors visible to user

`McpClientManager::connect_all` and `collect_tools` logged
failures at `tracing::warn!` only. Default-log users (no
`RUST_LOG` / `--verbose`) saw nothing — configured MCP
servers just silently failed to register tools.

Added an `eprintln!` alongside each warn so the failure
surfaces to stderr unconditionally. Message names the server
and tells the user the tools won't be available this session.

## H9 + H10: removed dead acp_host / acp_port config keys

Both keys were parsed by serde but never consumed anywhere.
ACP transport is stdio-only by design. Removed from
`config::Config`. CONFIG.md already (PR dirge-code#112) documents the
removal; no further docs change needed.

`acp_servers` kept — it has a CONFIG.md entry, holds future
TCP / Unix-socket config, and isn't actively misleading users.

## CONFIG.md

Rewrote the Permission tool-keys list from a single sentence
into a categorized list covering all 23 tools + the
`mcp_tool:<server>:<tool>` pattern syntax.

## Tests

8 `apply_patch` tests converted to `#[tokio::test]` (no new
assertions — the conversion was mechanical so the existing
coverage still applies). 813 all-features / 730 plugin / 604
default pass — same as PR dirge-code#120 plus one new test there.

## Not in this PR (still deferred)

- **C4**: unregistered-tool default-allow. This PR ADDS fields
  for the 12 missing tools, so the original "unregistered"
  set is now mostly covered. The remaining gap (future MCP
  tools, third-party plugins) is by design — `mcp_tool` is
  the umbrella key.
- **C7**: LSP didClose. Needs lifecycle design (when to fire?
  on file delete? session end?). Will tackle as a focused PR.
- **H8**: LSP `extensions` config field ignored. Real gap
  but bigger plumbing change; separate PR.

## Verified false positives (re-confirmed from earlier review)

- H2 empty bash: `quote_aware_split` filters empty segments.
- H7 LSP spawn silent: broken-set + backoff IS the feedback.
- H11 plugin dedup: summary IS emitted.
- H12 token counter: estimate_tokens on partial is correct.

Co-authored-by: Yogthos <yogthos@gmail.com>
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
…irge-code#122)

Two LSP gaps from the audit, both real.

## C7: missing didClose → server-side state leak

LSP servers retain parse trees + diagnostic caches keyed on
every `textDocument/didOpen`. dirge never sent
`textDocument/didClose`, so a long session editing dozens of
files left rust-analyzer / pyright / etc. holding all that
state for the lifetime of the server process.

Added two methods on `LspClient`:

- `notify_close(path)`: send didClose for one file, drop the
  associated FileState / push + pull diagnostics / last-push
  timestamp from local tracking. Returns Ok on never-opened
  files (a stray didClose is harmless per the spec).
- `close_all()`: fan didClose across every tracked file.
  Used at session shutdown.

`LspManager::close_all_files` fans across every attached
client. Called from main.rs after the interactive loop
returns. Best-effort: an already-dead server isn't re-contacted
and individual notify_close failures are swallowed.

The `lsp_manager` Arc is cloned before being moved into
`ui::run_interactive` so the shutdown call site still has a
handle.

Two new regression tests pin the behavior:
- `notify_close_emits_did_close_and_clears_state` — checks
  that subsequent `notify_open` after close emits a fresh
  didOpen (version 0) rather than a didChange.
- `close_all_emits_did_close_for_every_open_file` — two open
  files → two didClose frames sent.

## H8: LspServerConfig.extensions ignored

`LspServerConfig` carries an `extensions: Option<Vec<String>>`
field, parsed by serde, but `LspManager` always used the
hardcoded `builtin_servers()` registry. User config like
`{ "lsp": { "rust": { "extensions": ["rs", "rlib"] } } }`
was silently dropped.

Two changes:

1. `ServerInfo.extensions` changed from `&'static [&'static str]`
   to `Vec<String>` so it can be mutated post-construction.
   `builtin_servers()` now produces owned vecs; this is a
   one-time cost paid at session startup.

2. New `apply_extension_overrides` function in `lsp::server`
   walks the user's per-server config and replaces builtin
   extension lists (or removes the server entirely if
   `disabled: true`). Lowercases + strips leading dots to
   match how `servers_for_extension` looks up by extension.

3. `LspManager::with_servers` constructor accepts the
   modified server list. The existing `new` delegates with
   the unmodified builtin list.

4. `main.rs::build_channels` applies overrides between
   `builtin_servers()` and `LspManager::with_servers(...)`.

The `compile_lsp_commands` docstring previously called the
extensions field a follow-up; updated to point at the new
override path.

A trait shim (`AsExtensionOverride`) keeps the `lsp::server`
module from depending directly on `config::LspServerConfig` —
keeps the dep graph clean and lets the tests use a stub.

4 new regression tests:
- Extensions override replaces builtin list.
- `disabled: true` removes server from the active set.
- Unknown server id silently ignored (no panic, no spurious
  entry added).
- User-supplied extensions are normalized (leading-dot strip +
  lowercase).

## Status

736 plugin / 610 default pass (was 730 / 604). All build
profiles + fmt clean.

This closes the last two real items in the original audit's
deferred list. PR dirge-code#120 → C1/C2/C3/C5/C6. PR dirge-code#121 → H1/H3/H4/H5/
H6/H9/H10. This PR → C7/H8. All verified false positives (H2,
H7, H11, H12) remain unchanged.

Co-authored-by: Yogthos <yogthos@gmail.com>
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
…WEBFETCH_ENABLED symmetry (dirge-code#123)

## Steering the model toward the question tool

opencode's `question.txt` description gets the model to actually
reach for the tool by enumerating concrete use cases + usage
notes. dirge's prior description was a single sentence; ported
opencode's structure:

- "Use this when you need to: (1) gather preferences (2)
  clarify ambiguity (3) get decisions (4) offer choices"
- Usage notes covering the `custom` option, multi_select,
  and the "(Recommended)" first-option convention
- Explicit "prefer asking over guessing when genuinely
  ambiguous, but don't over-ask" guidance

System-prompt nudge: replaced the generic "ask the user
directly" line with one that names the `question` tool, calls
for concrete options with "(Recommended)" markers, and
balances against over-asking. Matches opencode's intent without
their anti-asking "beast mode" override.

## C2 remnant — find_callers optional path

The earlier C2 fix (PR dirge-code#120) covered 3 of 5 semantic tools.
`find_callers` was missed: it accepts an optional `path` arg
that scopes the search, but only the symbol name went through
permission. An LLM doing
`find_callers(name="foo", path="/etc")` walked /etc with
no external_directory check.

Added `check_perm_path` after the existing name-side
`check_perm`. Defaults to "." when no path arg supplied (so
the check runs against the working dir).

`find_definition` remains untouched — it has no path arg
(operates over the whole project index), so check_perm on the
symbol name is the right granularity.

## C4 remnant — task_status missing

`task_status` (the companion to `task` for querying
background subagent state) had no `PermissionConfig` field and
wasn't in the `PermissionChecker::new` loop. It always fell
through to the `*` default, contradicting the "every tool
gated explicitly" model.

Added `task_status: Option<ToolPerm>` to PermissionConfig +
the matching entry in the checker's load loop. `/allow`'s
KNOWN_PERM_TOOLS whitelist and CONFIG.md's category list both
get the new key. Read-only tool — users mostly leave it as
default Allow, but the option to deny it independently exists
(e.g. force background-only by allowing task but denying
task_status polling).

## WEBFETCH_ENABLED env-var symmetry

websearch had a `WEBSEARCH_ENABLED` env-var escape hatch
(set to "true"/"1" to force-enable). webfetch had no
equivalent — users had to edit config.json. Added
`WEBFETCH_ENABLED` with identical semantics + a tiny
`env_true` closure so the two web-tool checks share the
parsing.

## Tests

No new tests added — these are surface-area + steering
changes. The existing 10 question-tool tests still pass and
cover the JSON contract; the new path-side check on
`find_callers` is exercised whenever permission tests run.

736 plugin / 610 default pass. All build profiles + fmt clean.

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