Compare commits

...
Author SHA1 Message Date
clawdbot 50dcd06c65 fix(cli): preserve trailing block separators in streamed markdown
When markdown is streamed chunk-by-chunk, block-level elements (paragraphs,
headings, code blocks, lists) need trailing newlines to prevent consecutive
chunks from running together on the same line.

This fix:
- Refactors render_markdown() to use a new render_markdown_raw() helper
- Adds markdown_to_ansi_stream() for stream-aware rendering that preserves
  trailing newlines from block elements
- Updates MarkdownStreamState::push() to use markdown_to_ansi_stream()
  instead of markdown_to_ansi()
- Adds a test verifying that streamed blocks remain visually separated

Fixes #3257
2026-10-12 22:09:34 +00:00
黄云龙 86428abce6 refactor(runtime): make the permission privilege ladder an explicit rank (#3303)
* fix(runtime): prompt permission mode never invokes the prompter

PermissionMode derives Ord from declaration order, placing Prompt (and Allow)
above the real privilege ladder (ReadOnly < WorkspaceWrite < DangerFullAccess).
In PermissionPolicy::authorize_with_context the ladder check
'current_mode >= required_mode' therefore evaluates true for every tool when the
active mode is Prompt (3 >= {0,1,2}), returning Allow and making the subsequent
'if current_mode == PermissionMode::Prompt' branch that routes to prompt_or_deny
unreachable.

Effect: a session configured with the 'prompt' permission mode silently
auto-allows every tool and never calls the prompter (reached via
ConversationRuntime::run_turn with an interactive prompter), defeating the mode's
entire purpose. The sibling PermissionEnforcer already special-cases Prompt
before its own 'active_mode >= required_mode' check, confirming Prompt is not
meant to be part of the ordered ladder.

Fix: exclude Prompt from the ordered-ladder comparison so Prompt mode falls
through to the interactive prompt path. Allow remains handled by its explicit
equality check. The same comparison appears twice in authorize_with_context —
once in the shared tail and once inside the 'Some(PermissionOverride::Allow)'
arm, which is reached when a hook emits permissionDecision "allow" — so both
copies carry the guard; guarding only the tail still left a hook able to
auto-allow every tool in Prompt mode without ever consulting the prompter.

Adds regression tests covering Prompt mode with a prompter, without a prompter,
and with a hook allow override.

* refactor(runtime): make the permission privilege ladder an explicit rank

`PermissionMode` derived `PartialOrd`/`Ord`, so the ladder deciding whether a
tool runs without asking was declaration order. A variant inserted in the middle
of the enum re-ranked it silently, and the difference between "this tool may
run" and "this tool may run after a prompt" was a property of where an `enum`
line happened to sit. That is how `Prompt` came to outrank `DangerFullAccess`,
satisfy `current_mode >= required_mode` for every tool, and skip the prompter.

Replace the derive with `privilege_rank`, an exhaustive `match` naming every
variant, so inserting one is a build failure rather than a silent re-ranking.
`Ord` is deliberately not derived, so a stray `>=` on two modes no longer
compiles; the two comparisons outside this module now say which rank they read
(`PermissionEnforcer::check_with_required_mode` and the `claw doctor` permission
check). Both keep their previous answers, since `privilege_rank` reproduces the
old declaration order exactly, including `Prompt` and `Allow` above the tiers.

`Prompt` and `Allow` are modes of operation, not privilege tiers, so they are
never ranked *against* a tool requirement. The new `covers` predicate states
that by name rather than by number: `Allow` always covers, `Prompt` never does
(it asks), and only the three tiers compare ranks.

That also collapses the duplicated ladder in `authorize_with_context`. The
`Some(PermissionOverride::Allow)` arm carried a second, identical copy of the
ask-rule check and the ladder; it now falls through to the single copy below,
so a hook `allow` is an override rather than a bypass and the two copies can no
longer drift apart.

Behaviour is unchanged for every (mode, requirement, rule, override)
combination. The tests pin the `Prompt` routes — plain and hook-`allow` — across
all three tiers, plus the rank ordering itself.
2026-10-13 07:04:44 +09:00
gaebal-gajae 1b1fba48fc Merge pull request #3269 from nankingjing/docs/correct-rust-crate-count
docs: correct rust workspace crate count to 11

This updates the documentation to reflect the actual number of crates in the
rust workspace, which is 11, not 9. The workspace contains:
- api
- claw-analog
- claw-rag-service
- commands
- compat-harness
- mock-anthropic-service
- plugins
- runtime
- rusty-claude-cli
- telemetry
- tools
2026-10-12 22:03:21 +00:00
gaebal-gajae 794254597c Fix untrusted project hooks in read-only mode 2026-10-12 22:02:05 +00:00
黄云龙 8613b7065a docs: correct rust workspace crate count to 11 2026-07-10 22:47:51 +08:00
14 changed files with 482 additions and 90 deletions

No files matched your search

@@ -0,0 +1 @@
{"schema_version":1,"session_id":"5e2dd1e8-ea4b-4af1-b56b-89ab7cca4299","state":"running","ready_for_input":false,"updated_at":"2026-10-12T22:09:30.659Z","current_turn_id":null,"last_turn_id":null,"live":true,"reason":null,"source":"agent_session_event","event":"turn_start","cwd":"/home/bellman/Data/Workspace/gc-task-cc-issue-3257-lane","workdir":"/home/bellman/Data/Workspace/gc-task-cc-issue-3257-lane","branch":null,"session_file":"/home/bellman/.gajaeway/broker/agent/sessions/v2-6fji37qf7ld523ivvoiohlcpmz3nu45m2apxyrkusn5nxczjmouq/2026-10-12T22-04-33-362Z_5e2dd1e8-ea4b-4af1-b56b-89ab7cca4299.jsonl","activity":{"seq":115,"last_activity_at":"2026-10-12T22:09:34.144Z","tool":"bash","phase":"started","outcome":null,"elapsed_ms":null,"active_tool_count":1,"active_tools":[{"tool":"bash","started_at":"2026-10-12T22:09:34.144Z"}],"in_flight":[{"digest":"e603a70efb03b6b2b78c94b704197d26fc8a35113543291a21f68b3b08ca11a9","tool":"bash","started_at":"2026-10-12T22:09:34.144Z"}]}}
@@ -0,0 +1 @@
{"pid":1,"start_time":"unknown","token":"1a2e8a33-76f2-4175-ac23-1521458f12a2","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
@@ -0,0 +1 @@
{"pid":1,"start_time":"unknown","token":"a1342032-af80-4a49-a783-291bf73bf027","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
Whitespace-only changes.
@@ -0,0 +1,12 @@
{
"version": 1,
"sessionId": "5e2dd1e8-ea4b-4af1-b56b-89ab7cca4299",
"pid": 487360,
"host": "127.0.0.1",
"port": 37541,
"url": "ws://127.0.0.1:37541",
"token": "LlIfu_aHH57jfys2Ii0ORMrnu56NLAa1",
"startedAt": 1791842673967,
"updatedAt": 1791842673967,
"stale": false
}
@@ -0,0 +1 @@
{"effectMarker":"f441857a-f591-474b-b34a-6d89b2bf9feb","incarnation":"linux:159099652","pid":487360}
@@ -0,0 +1 @@
{"effectMarker":"f441857a-f591-474b-b34a-6d89b2bf9feb","incarnation":"linux:159099652","pid":487360}
+1 -1
View File
@@ -221,7 +221,7 @@ rust/
## Stats
- **~20K lines** of Rust
- **9 crates** in workspace
- **11 crates** in workspace
- **Binary name:** `claw`
- **Default model:** `claude-opus-4-7`
- **Default permissions:** `workspace-write`
+133 -53
View File
@@ -466,31 +466,39 @@ impl ConfigLoader {
let mut merged = BTreeMap::new();
let mut loaded_entries = Vec::new();
let mut mcp = McpConfigCollection::default();
let mut all_warnings = Vec::new();
let mut all_warnings: Vec<String> = Vec::new();
for entry in self.discover() {
crate::config_validate::check_unsupported_format(&entry.path)?;
let OptionalConfigFile::Loaded(parsed) = read_optional_json_object(&entry.path)? else {
continue;
};
let validation = crate::config_validate::validate_config_file(
&parsed.object,
&parsed.source,
&entry.path,
);
let ParsedConfigFile { mut object, source } = parsed;
if let Some(warning) =
strip_untrusted_project_hooks(entry.source, &mut object, &entry.path)
{
all_warnings.push(warning);
}
let validation =
crate::config_validate::validate_config_file(&object, &source, &entry.path);
if !validation.is_ok() {
let first_error = &validation.errors[0];
return Err(ConfigError::Parse(first_error.to_string()));
}
all_warnings.extend(validation.warnings);
validate_optional_hooks_config(&parsed.object, &entry.path)?;
merge_mcp_servers(&mut mcp, entry.source, &parsed.object, &entry.path)?;
deep_merge_objects(&mut merged, &parsed.object);
all_warnings.extend(
validation
.warnings
.into_iter()
.map(|warning| warning.to_string()),
);
validate_optional_hooks_config(&object, &entry.path)?;
merge_mcp_servers(&mut mcp, entry.source, &object, &entry.path)?;
deep_merge_objects(&mut merged, &object);
loaded_entries.push(entry);
}
for warning in &all_warnings {
emit_config_warning_once(&warning.to_string());
emit_config_warning_once(warning);
}
build_runtime_config(merged, loaded_entries, mcp)
@@ -512,19 +520,22 @@ impl ConfigLoader {
let OptionalConfigFile::Loaded(parsed) = read_optional_json_object(&entry.path)? else {
continue;
};
let validation = crate::config_validate::validate_config_file(
&parsed.object,
&parsed.source,
&entry.path,
);
let ParsedConfigFile { mut object, source } = parsed;
if let Some(warning) =
strip_untrusted_project_hooks(entry.source, &mut object, &entry.path)
{
all_warnings.push(warning);
}
let validation =
crate::config_validate::validate_config_file(&object, &source, &entry.path);
if !validation.is_ok() {
let first_error = &validation.errors[0];
return Err(ConfigError::Parse(first_error.to_string()));
}
all_warnings.extend(validation.warnings.iter().map(|w| w.to_string()));
validate_optional_hooks_config(&parsed.object, &entry.path)?;
merge_mcp_servers(&mut mcp, entry.source, &parsed.object, &entry.path)?;
deep_merge_objects(&mut merged, &parsed.object);
validate_optional_hooks_config(&object, &entry.path)?;
merge_mcp_servers(&mut mcp, entry.source, &object, &entry.path)?;
deep_merge_objects(&mut merged, &object);
loaded_entries.push(entry);
}
@@ -589,11 +600,14 @@ impl ConfigLoader {
}
};
let validation = crate::config_validate::validate_config_file(
&parsed.object,
&parsed.source,
&entry.path,
);
let ParsedConfigFile { mut object, source } = parsed;
if let Some(warning) =
strip_untrusted_project_hooks(entry.source, &mut object, &entry.path)
{
warnings.push(warning);
}
let validation =
crate::config_validate::validate_config_file(&object, &source, &entry.path);
if !validation.is_ok() {
let detail = validation.errors[0].to_string();
load_error.get_or_insert_with(|| detail.clone());
@@ -612,7 +626,7 @@ impl ConfigLoader {
.map(|warning| warning.to_string()),
);
if let Err(error) = validate_optional_hooks_config(&parsed.object, &entry.path) {
if let Err(error) = validate_optional_hooks_config(&object, &entry.path) {
let detail = error.to_string();
load_error.get_or_insert_with(|| detail.clone());
files.push(ConfigFileReport::load_error(
@@ -624,9 +638,7 @@ impl ConfigLoader {
continue;
}
if let Err(error) =
merge_mcp_servers(&mut mcp, entry.source, &parsed.object, &entry.path)
{
if let Err(error) = merge_mcp_servers(&mut mcp, entry.source, &object, &entry.path) {
let detail = error.to_string();
load_error.get_or_insert_with(|| detail.clone());
files.push(ConfigFileReport::load_error(
@@ -638,8 +650,8 @@ impl ConfigLoader {
continue;
}
let key_paths = collect_config_key_paths(&parsed.object);
deep_merge_objects(&mut merged, &parsed.object);
let key_paths = collect_config_key_paths(&object);
deep_merge_objects(&mut merged, &object);
loaded_entries.push(entry.clone());
files.push(ConfigFileReport::loaded(entry, precedence_rank, key_paths));
}
@@ -663,6 +675,23 @@ impl ConfigLoader {
}
}
fn strip_untrusted_project_hooks(
source: ConfigSource,
object: &mut BTreeMap<String, JsonValue>,
path: &Path,
) -> Option<String> {
// Project settings come from the repository and there is no project-hook
// trust approval flow yet. User and local settings remain eligible.
if source == ConfigSource::Project && object.remove("hooks").is_some() {
Some(format!(
"Ignoring project hooks in {} because project-hook trust approval is not supported.",
path.display()
))
} else {
None
}
}
impl ConfigFileReport {
fn loaded(entry: ConfigEntry, precedence_rank: usize, key_paths: Vec<String>) -> Self {
Self {
@@ -2590,6 +2619,7 @@ mod tests {
};
use crate::json::JsonValue;
use crate::sandbox::FilesystemIsolationMode;
use crate::HookRunner;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -2695,17 +2725,14 @@ mod tests {
.and_then(JsonValue::as_object)
.expect("hooks object")
.contains_key("PreToolUse"));
assert!(loaded
assert!(!loaded
.get("hooks")
.and_then(JsonValue::as_object)
.expect("hooks object")
.contains_key("PostToolUse"));
assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
assert_eq!(loaded.hooks().post_tool_use(), &["project".to_string()]);
assert_eq!(
loaded.hooks().post_tool_use_failure(),
&["project-failure".to_string()]
);
assert!(loaded.hooks().post_tool_use().is_empty());
assert!(loaded.hooks().post_tool_use_failure().is_empty());
assert_eq!(loaded.permission_rules().allow(), &["Read".to_string()]);
assert_eq!(
loaded.permission_rules().deny(),
@@ -2718,6 +2745,73 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn project_config_hooks_cannot_execute_commands() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
let project_settings = cwd.join(".claw").join("settings.json");
let project_legacy_settings = cwd.join(".claw.json");
let marker = root.join("project-hook-executed");
fs::create_dir_all(project_settings.parent().expect("project config dir"))
.expect("create project config dir");
fs::create_dir_all(&home).expect("home config dir");
let command = format!("echo compromised >> {}", marker.display());
fs::write(
&project_legacy_settings,
format!(r#"{{"hooks":{{"PreToolUse":["{command}"]}}}}"#),
)
.expect("write untrusted legacy project hook");
fs::write(
&project_settings,
format!(r#"{{"hooks":{{"PreToolUse":["{command}"]}}}}"#),
)
.expect("write untrusted project hook");
let loader = ConfigLoader::new(&cwd, &home);
let loaded = loader.load().expect("config should load");
let _ =
HookRunner::from_feature_config(loaded.feature_config()).run_pre_tool_use("Read", "{}");
let hook_executed = marker.exists();
if hook_executed {
fs::remove_file(&marker).expect("remove hook marker");
}
assert!(
!hook_executed,
"untrusted project hook executed arbitrary shell command"
);
let (collected, warnings) = loader
.load_collecting_warnings()
.expect("collecting config should load");
assert!(collected.hooks().pre_tool_use().is_empty());
assert_eq!(
warnings
.iter()
.filter(|warning| warning.contains("Ignoring project hooks"))
.count(),
2
);
let inspected = loader.inspect_collecting_warnings();
assert_eq!(
inspected
.warnings
.iter()
.filter(|warning| warning.contains("Ignoring project hooks"))
.count(),
2
);
assert!(inspected
.runtime_config
.expect("inspection should produce runtime config")
.hooks()
.pre_tool_use()
.is_empty());
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn parses_object_style_hook_entries_with_matchers() {
let root = temp_dir();
@@ -3523,7 +3617,7 @@ mod tests {
}
#[test]
fn loads_valid_hook_entries_and_records_invalid_siblings_441() {
fn ignores_project_hook_entries_and_keeps_user_hooks_441() {
// given
let root = temp_dir();
let cwd = root.join("project");
@@ -3547,22 +3641,8 @@ mod tests {
.load()
.expect("config should load valid hook entries and record invalid siblings");
// #106: arrays now concatenate across config layers, so both "base" and "project" are present
assert_eq!(
loaded.hooks().pre_tool_use(),
&["base".to_string(), "project".to_string()]
);
assert_eq!(loaded.hooks().invalid_count(), 1);
assert_eq!(loaded.hooks().invalid_hooks()[0].event, "PreToolUse");
assert_eq!(
loaded.hooks().invalid_hooks()[0].kind,
"invalid_hooks_config"
);
// #106: invalid entry at index 2 after array concatenation
assert_eq!(loaded.hooks().invalid_hooks()[0].index, Some(2));
assert!(loaded.hooks().invalid_hooks()[0]
.reason
.contains("must be a string or hook object"));
assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
assert!(loaded.hooks().invalid_hooks().is_empty());
fs::remove_dir_all(root).expect("cleanup temp dir");
}
+83 -2
View File
@@ -10,7 +10,7 @@ use crate::compact::{
use crate::config::RuntimeFeatureConfig;
use crate::hooks::{HookAbortSignal, HookProgressReporter, HookRunResult, HookRunner};
use crate::permissions::{
PermissionContext, PermissionOutcome, PermissionPolicy, PermissionPrompter,
PermissionContext, PermissionMode, PermissionOutcome, PermissionPolicy, PermissionPrompter,
};
use crate::session::{ContentBlock, ConversationMessage, Session};
use crate::usage::{TokenUsage, UsageTracker};
@@ -176,6 +176,11 @@ where
feature_config: &RuntimeFeatureConfig,
) -> Self {
let usage_tracker = UsageTracker::from_session(&session);
let hook_runner = if permission_policy.active_mode() == PermissionMode::ReadOnly {
HookRunner::default()
} else {
HookRunner::from_feature_config(feature_config)
};
Self {
session,
api_client,
@@ -184,7 +189,7 @@ where
system_prompt,
max_iterations: usize::MAX,
usage_tracker,
hook_runner: HookRunner::from_feature_config(feature_config),
hook_runner,
auto_compaction_input_tokens_threshold: auto_compaction_threshold_from_env(),
hook_abort_signal: HookAbortSignal::default(),
hook_progress_reporter: None,
@@ -991,6 +996,82 @@ mod tests {
));
}
#[test]
fn read_only_mode_does_not_execute_hooks() {
struct SingleCallApiClient {
calls: usize,
}
impl ApiClient for SingleCallApiClient {
fn stream(
&mut self,
_request: ApiRequest,
) -> Result<Vec<AssistantEvent>, RuntimeError> {
self.calls += 1;
if self.calls == 1 {
Ok(vec![
AssistantEvent::ToolUse {
id: "tool-1".to_string(),
name: "Read".to_string(),
input: "{}".to_string(),
},
AssistantEvent::ToolUse {
id: "tool-2".to_string(),
name: "ReadError".to_string(),
input: "{}".to_string(),
},
AssistantEvent::MessageStop,
])
} else {
Ok(vec![
AssistantEvent::TextDelta("done".to_string()),
AssistantEvent::MessageStop,
])
}
}
}
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after epoch")
.as_nanos();
let marker = std::env::temp_dir().join(format!(
"claw-read-only-hook-{}-{nanos}",
std::process::id()
));
let hook_command = format!("echo compromised >> \"{}\"", marker.display());
let features = RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
vec![shell_snippet(&hook_command)],
vec![shell_snippet(&hook_command)],
vec![shell_snippet(&hook_command)],
));
let mut runtime = ConversationRuntime::new_with_features(
Session::new(),
SingleCallApiClient { calls: 0 },
StaticToolExecutor::new()
.register("Read", |_input| Ok("read result".to_string()))
.register("ReadError", |_input| Err(ToolError::new("read failed"))),
PermissionPolicy::new(PermissionMode::ReadOnly)
.with_tool_requirement("Read", PermissionMode::ReadOnly)
.with_tool_requirement("ReadError", PermissionMode::ReadOnly),
vec!["system".to_string()],
&features,
);
runtime
.run_turn("read a file", None)
.expect("read-only tool call should succeed");
let hook_executed = marker.exists();
if hook_executed {
fs::remove_file(&marker).expect("remove hook marker");
}
assert!(
!hook_executed,
"read-only mode must not execute pre-, post-, or failure hooks"
);
}
#[test]
fn records_runtime_session_trace_events() {
let sink = Arc::new(MemoryTelemetrySink::default());
@@ -81,8 +81,10 @@ impl PermissionEnforcer {
let active_mode = self.policy.active_mode();
// Check if active mode meets the dynamically determined required mode
if active_mode >= required_mode {
// Check if active mode meets the dynamically determined required mode.
// Ranked explicitly: `PermissionMode` no longer derives `Ord`, so the
// ladder is a named function rather than declaration order.
if active_mode.privilege_rank() >= required_mode.privilege_rank() {
return EnforcementResult::Allowed;
}
+187 -28
View File
@@ -5,7 +5,10 @@ use serde_json::Value;
use crate::config::RuntimePermissionRuleConfig;
/// Permission level assigned to a tool invocation or runtime session.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
///
/// Deliberately **not** `PartialOrd`/`Ord`: see [`PermissionMode::privilege_rank`]
/// for why ordering is a named function here rather than declaration order.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionMode {
ReadOnly,
WorkspaceWrite,
@@ -25,6 +28,53 @@ impl PermissionMode {
Self::Allow => "allow",
}
}
/// Position on the privilege ladder used by [`PermissionPolicy`].
///
/// This is the only ranking, and `Ord` is intentionally not derived, so a
/// stray `active_mode >= required_mode` no longer compiles. Ranking through
/// declaration order meant a variant inserted in the middle of the enum
/// silently re-ranked the ladder, and the difference between "this tool may
/// run" and "this tool may run after a prompt" was therefore a property of
/// where an `enum` line happened to sit. Every variant is named here, so an
/// insertion is a build failure instead of a silent re-ranking; the two
/// comparisons outside this module that do read a rank — the
/// `PermissionEnforcer`'s `check_with_required_mode` and the `claw doctor`
/// permission check — now say so at the call site.
///
/// `Prompt` and `Allow` are *modes of operation*, not privilege tiers. They
/// rank above every tier so those two callers keep the answers they had, but
/// they are never ranked *against* a tier: see [`Self::covers`].
#[must_use]
pub const fn privilege_rank(self) -> u8 {
match self {
Self::ReadOnly => 0,
Self::WorkspaceWrite => 1,
Self::DangerFullAccess => 2,
Self::Prompt => 3,
Self::Allow => 4,
}
}
/// Whether a session running in `self` may use a tool that requires
/// `required` without asking.
///
/// Only tiers answer this by rank. `Allow` never asks and `Prompt` always
/// asks — it is the exact opposite of a silent allow — so neither is a
/// statement about how much of the workspace the session may touch, and
/// comparing either to `required` is comparing a mode to a tier. That
/// comparison is what let `Prompt`, which outranks `DangerFullAccess`, make
/// the ladder return `Allow` for every tool and skip the prompter entirely.
#[must_use]
pub const fn covers(self, required: Self) -> bool {
match self {
Self::Allow => true,
Self::Prompt => false,
Self::ReadOnly | Self::WorkspaceWrite | Self::DangerFullAccess => {
self.privilege_rank() >= required.privilege_rank()
}
}
}
}
/// Hook-provided override applied before standard permission evaluation.
@@ -236,29 +286,12 @@ impl PermissionPolicy {
prompter,
);
}
Some(PermissionOverride::Allow) => {
if let Some(rule) = ask_rule {
let reason = format!(
"tool '{tool_name}' requires approval due to ask rule '{}'",
rule.raw
);
return Self::prompt_or_deny(
tool_name,
input,
current_mode,
required_mode,
Some(reason),
prompter,
);
}
if allow_rule.is_some()
|| current_mode == PermissionMode::Allow
|| current_mode >= required_mode
{
return PermissionOutcome::Allow;
}
}
None => {}
// A hook `allow` is an override, not a bypass: the ask-rule check
// and the privilege ladder below still run for it. Both used to be
// spelled out a second time inside this arm, which is why the ladder
// existed in two copies that had to be corrected in step — and the
// second copy is exactly what a fix for one of them is easy to miss.
Some(PermissionOverride::Allow) | None => {}
}
if let Some(rule) = ask_rule {
@@ -276,10 +309,10 @@ impl PermissionPolicy {
);
}
if allow_rule.is_some()
|| current_mode == PermissionMode::Allow
|| current_mode >= required_mode
{
// The ladder, in one place. An `allow` rule is an explicit grant for
// this tool and outranks the mode; otherwise the mode decides through
// [`PermissionMode::covers`].
if allow_rule.is_some() || current_mode.covers(required_mode) {
return PermissionOutcome::Allow;
}
@@ -587,6 +620,132 @@ mod tests {
));
}
#[test]
fn prompt_mode_routes_to_prompter_instead_of_auto_allowing() {
// Regression: `Prompt` sorts above `DangerFullAccess` in the
// `PermissionMode` `Ord` derivation, so the `current_mode >= required_mode`
// ladder check used to auto-allow every tool in `Prompt` mode and never
// invoke the prompter — defeating the entire purpose of the mode.
let policy = PermissionPolicy::new(PermissionMode::Prompt)
.with_tool_requirement("read_file", PermissionMode::ReadOnly);
let mut prompter = RecordingPrompter {
seen: Vec::new(),
allow: true,
};
let outcome = policy.authorize("read_file", "{}", Some(&mut prompter));
assert_eq!(outcome, PermissionOutcome::Allow);
assert_eq!(prompter.seen.len(), 1);
assert_eq!(prompter.seen[0].tool_name, "read_file");
assert_eq!(prompter.seen[0].current_mode, PermissionMode::Prompt);
}
#[test]
fn prompt_mode_denies_when_no_prompter_is_available() {
let policy = PermissionPolicy::new(PermissionMode::Prompt)
.with_tool_requirement("read_file", PermissionMode::ReadOnly);
assert!(matches!(
policy.authorize("read_file", "{}", None),
PermissionOutcome::Deny { .. }
));
}
#[test]
fn prompt_mode_prompts_for_every_privilege_tier() {
// The ladder used to exist twice — once on the plain path and once inside
// the `Some(PermissionOverride::Allow)` arm — so a hook emitting
// `permissionDecision: "allow"` (see `hooks.rs`) had its own route to the
// auto-allow. Sweeping the tiers against both routes keeps them honest now
// that they are one predicate.
for required in [
PermissionMode::ReadOnly,
PermissionMode::WorkspaceWrite,
PermissionMode::DangerFullAccess,
] {
for override_decision in [None, Some(PermissionOverride::Allow)] {
let policy = PermissionPolicy::new(PermissionMode::Prompt)
.with_tool_requirement("read_file", required);
let context = PermissionContext::new(
override_decision,
override_decision.map(|_| "hook approved".to_string()),
);
let mut prompter = RecordingPrompter {
seen: Vec::new(),
allow: true,
};
let outcome =
policy.authorize_with_context("read_file", "{}", &context, Some(&mut prompter));
assert_eq!(
outcome,
PermissionOutcome::Allow,
"an approving prompter must still pass a {required:?} tool"
);
assert_eq!(
prompter.seen.len(),
1,
"prompt mode skipped the prompter for a {required:?} tool \
(override = {override_decision:?})"
);
assert_eq!(prompter.seen[0].tool_name, "read_file");
assert_eq!(prompter.seen[0].current_mode, PermissionMode::Prompt);
assert_eq!(prompter.seen[0].required_mode, required);
}
}
}
#[test]
fn privilege_rank_orders_the_privilege_tiers() {
assert!(
PermissionMode::ReadOnly.privilege_rank()
< PermissionMode::WorkspaceWrite.privilege_rank()
);
assert!(
PermissionMode::WorkspaceWrite.privilege_rank()
< PermissionMode::DangerFullAccess.privilege_rank()
);
}
#[test]
fn privilege_rank_places_the_operating_modes_above_every_tier() {
// `Prompt` and `Allow` are modes of operation rather than tiers, so
// `covers` answers for them by name and never consults this number. They
// still have to rank above every tier, because two callers outside this
// module — the `PermissionEnforcer`'s `check_with_required_mode` and the
// `claw doctor` permission check — compare ranks, and a `Prompt`/`Allow`
// session must keep the result it had under the old derived `Ord`.
for mode in [PermissionMode::Prompt, PermissionMode::Allow] {
assert!(
mode.privilege_rank() > PermissionMode::DangerFullAccess.privilege_rank(),
"{mode:?} must outrank danger-full-access"
);
assert!(
mode.privilege_rank() > PermissionMode::WorkspaceWrite.privilege_rank(),
"{mode:?} must outrank workspace-write"
);
}
}
#[test]
fn covers_never_compares_an_operating_mode_against_a_tier() {
for required in [
PermissionMode::ReadOnly,
PermissionMode::WorkspaceWrite,
PermissionMode::DangerFullAccess,
] {
assert!(
!PermissionMode::Prompt.covers(required),
"prompt mode must ask before running a {required:?} tool"
);
}
assert!(PermissionMode::Allow.covers(PermissionMode::DangerFullAccess));
assert!(PermissionMode::DangerFullAccess.covers(PermissionMode::WorkspaceWrite));
assert!(!PermissionMode::WorkspaceWrite.covers(PermissionMode::DangerFullAccess));
}
#[test]
fn applies_rule_based_denials_and_allows() {
let rules = RuntimePermissionRuleConfig::new(
+5 -2
View File
@@ -4222,15 +4222,18 @@ fn check_permission_health(permission_mode: PermissionModeProvenance) -> Diagnos
|| source.to_string(),
|env_var| format!("{source}:{env_var}"),
);
// Ranks, not declaration order: `PermissionMode` deliberately does not
// derive `Ord`, so the ladder is named at every comparison site.
let specs = mvp_tool_specs();
let active_rank = permission_mode.mode.privilege_rank();
let tools_satisfied = specs
.iter()
.filter(|spec| permission_mode.mode >= spec.required_permission)
.filter(|spec| active_rank >= spec.required_permission.privilege_rank())
.map(|spec| spec.name)
.collect::<Vec<_>>();
let tools_gated = specs
.iter()
.filter(|spec| permission_mode.mode < spec.required_permission)
.filter(|spec| active_rank < spec.required_permission.privilege_rank())
.map(|spec| spec.name)
.collect::<Vec<_>>();
+52 -2
View File
@@ -249,6 +249,16 @@ impl TerminalRenderer {
#[must_use]
pub fn render_markdown(&self, markdown: &str) -> String {
self.render_markdown_raw(markdown).trim_end().to_string()
}
/// Render markdown to ANSI without trimming trailing whitespace.
///
/// Block-level events (paragraphs, headings, lists, code blocks) emit their
/// trailing newline separators here. `render_markdown` trims them for
/// one-shot output, while streaming keeps them so that successive chunks
/// stay on their own lines instead of running together.
fn render_markdown_raw(&self, markdown: &str) -> String {
let normalized = normalize_nested_fences(markdown);
let mut output = String::new();
let mut state = RenderState::default();
@@ -267,7 +277,7 @@ impl TerminalRenderer {
);
}
output.trim_end().to_string()
output
}
#[must_use]
@@ -275,6 +285,18 @@ impl TerminalRenderer {
self.render_markdown(markdown)
}
/// Stream-friendly variant of `markdown_to_ansi` that preserves trailing
/// block separators (such as a paragraph's closing newlines).
///
/// `MarkdownStreamState::push` writes each rendered chunk directly to the
/// terminal with no separator of its own, so the trailing newlines must
/// survive here to keep a paragraph from sticking to the block that follows
/// it. The final `flush` still uses `markdown_to_ansi` to trim the tail.
#[must_use]
pub fn markdown_to_ansi_stream(&self, markdown: &str) -> String {
self.render_markdown_raw(markdown)
}
#[allow(clippy::too_many_lines)]
fn render_event(
&self,
@@ -609,7 +631,7 @@ impl MarkdownStreamState {
let split = find_stream_safe_boundary(&self.pending)?;
let ready = self.pending[..split].to_string();
self.pending.drain(..split);
Some(renderer.markdown_to_ansi(&ready))
Some(renderer.markdown_to_ansi_stream(&ready))
}
#[must_use]
@@ -1052,6 +1074,34 @@ mod tests {
assert!(plain_text.contains("fn nested()"));
}
#[test]
fn stream_rendering_preserves_block_separators() {
let renderer = TerminalRenderer::new();
// The stream variant keeps the paragraph-ending newlines that the
// one-shot variant trims, so consecutive streamed chunks do not run
// into each other when written back-to-back to the terminal.
let streamed = renderer.markdown_to_ansi_stream("Intro paragraph.\n\n");
assert!(streamed.ends_with('\n'));
assert!(!renderer
.markdown_to_ansi("Intro paragraph.\n\n")
.ends_with('\n'));
let mut state = MarkdownStreamState::default();
let first = state
.push(&renderer, "Intro paragraph.\n\n")
.expect("blank line closes the paragraph block");
let second = state
.push(&renderer, "1. first item\n\n")
.expect("blank line closes the list block");
let combined = strip_ansi(&format!("{first}{second}"));
assert!(combined.contains("Intro paragraph."));
assert!(combined.contains("1. first item"));
// The two blocks stay on separate lines instead of sticking together.
assert!(!combined.contains("Intro paragraph.1."));
}
#[test]
fn spinner_advances_frames() {
let terminal_renderer = TerminalRenderer::new();