fix(extension-host): gate dsh_settings through BrokerContext::act and add changelog (CW-7983)

Route dsh/settings reads and writes through BrokerContext::act so every broker call checks invocation budget and emits sealed broker_acts receipts on the tool result:

- Describe records a BrokerEffect::Read act against the plugin's authority name.
- update/replace/mutate validate caller posture (write_refusal) and reviewed roots/keys/values (validate_change) BEFORE calling BrokerContext::act(..., BrokerEffect::Write, ...) so invalid or out-of-scope edits fail cleanly without opening a permission gate prompt.
- Re-check write_refusal(call.context.tool()) after act() returns to close any live-posture TOCTOU window (switching to ApprovalMode::Never or Auto while the gate card is pending refuses the disk write).
- Wire BrokerActTool::all() into Fixture::new and add settings_acts_pass_the_gate_and_record_receipts covering read recording, pre-gate root rejection, allowed write receipts, gate denial ((error_code::DENIED, "denied")), and post-act live-posture revocation.
- Add an honest 0.10.3 note in CHANGELOG.md and sync crates/tui/CHANGELOG.md.

Checks (rebased on b7249e5e58):
- Boat xlarge nextest (20261114T010800Z-dsh09-test-78979b.log): 234 passed, 0 failed, 7046 skipped
- Boat xlarge clippy (20261114T010815Z-dsh09-clippy-c570dc.log): exit 0
- npm --prefix crates/engine/extension-host run typecheck: exit 0
- node --test crates/engine/extension-host/test/settings.test.mjs: 3 passed, 0 failed
- sh scripts/preflight.sh --check: preflight: OK
- Dual pro reviews: Correctness (edf327db) LAND, Bypass (7475b8a1) LAND
This commit is contained in:
CodeWhale Bot committed 2026-11-13 17:55:14 -08:00
1 parent 1dc5bb71cb
commit 01259e18ca
3 files changed
+201 -26

No files matched your search

+1
View File
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Agent `cwd` in sibling worktrees:** spawning an agent with `cwd` pointing to an existing linked worktree (or a subdirectory inside it) under the repository's `.codewhale-worktrees/<repo>/` root is now accepted when both share the same repository common git directory; outside directories and foreign or plain directories under `.codewhale-worktrees/` stay refused. (CW-8215)
- **DSH market npm installs and extension catalog service:** DSH market `npm` entries with an exact semantic version (and optional `https` registry) now resolve to reviewed registry-tarball install plans (`npm:<pkg>@<ver>` or exact `.tgz` URL) without executing `npm` or bypassing plugin trust review. Inside the extension host, the `dsh/catalog` broker arm now plans reviewed installs without writing to disk, returns only verified approved provider routes scoped to the calling plugin, and serves account balance read-only while withholding credentials and account mutations (CW-7988).
- **Plugin settings service backend (0.10.3):** adds the internal `settings` host-service handler (`describe`, `update`, `replace`, `mutate`) and extension-host adapter helper so plugins can inspect and atomically edit only their own reviewed `[plugins."<name>".config]` keys in `config.toml` through the session permission gate and live write posture, leaving comments, unrelated tables and other plugins' configuration untouched (writes return `applies: "reload"` and take effect at the next reload). This backend is not yet wired into live host-service dispatch or plugin grants, so no active plugin exposes it yet. (CW-7983)
- **Removed (crate API):** the unwired `codewhale-hooks` dispatcher and its stdout, JSONL and Unix-socket sinks; `codewhale-core`'s `session`, `journal` and `ids` modules and the `hooks` argument of `Runtime::new`; `codewhale-runtime::resource_telemetry`; ten unreferenced public functions; and the duplicate `codewhale-agent` crate. `hook_sinks.unix_socket_path` still parses and is ignored. CLI, TUI and app-server behavior is unchanged.
- **First-run setup:** installer downloads retry up to three times and remove partial files after a failed attempt. Folder trust uses 1 to trust, 2 to continue untrusted, and 3 or Esc to quit; letters do nothing. Provider pickers consistently say "needs key", and the unconfigured custom placeholder says "needs endpoint" while configured-route errors remain visible.
- **Quota-window reset text:** a rate-limited turn now gives the reset time in plain words such as "19h29m" instead of Rust debug output like `Some(70168s)`, and leaves the reset clause out when the provider gives no reset time. An OpenCode Go message points at that provider's own key with `codewhale auth set --provider opencode-go`, without repeating a hint the message already carries. (CW-8209)
+199 -26
View File
@@ -12,7 +12,9 @@ use serde::Deserialize;
use serde_json::{Map, Value, json};
use tokio_util::sync::CancellationToken;
use super::dsh_broker::{BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
use super::dsh_broker::{
BrokerCall, BrokerEffect, BrokerFuture, BrokerMethods, BrokerRefusal, decode,
};
use super::plugin_config::{self, PluginConfig};
use super::protocol::DshService;
use crate::tools::spec::ToolContext;
@@ -122,23 +124,46 @@ pub(super) fn serve_with(call: BrokerCall, authority: Option<SettingsAuthority>)
if !fits(&call.input, &mut { MAX_INPUT_BYTES }) {
return Err(invalid("the settings input is too large"));
}
let (change, expected) = match decode::<SettingsMethod>(&call)? {
let (method_name, change, expected) = match decode::<SettingsMethod>(&call)? {
SettingsMethod::Describe(NoInput {}) => {
let writable = write_refusal(call.context.tool()).is_none();
let plugin = call.owner.plugin_id.clone();
let cancel = call.context.cancel().clone();
call.context
.act("describe", BrokerEffect::Read, Some(&authority.plugin_name))
.await?;
return describe(plugin, cancel, authority, source, writable).await;
}
SettingsMethod::Update(input) => (Change::Update(input.patch), input.expected_revision),
SettingsMethod::Replace(input) => {
(Change::Replace(input.value), input.expected_revision)
SettingsMethod::Update(input) => (
"update",
Change::Update(input.patch),
input.expected_revision,
),
SettingsMethod::Replace(input) => (
"replace",
Change::Replace(input.value),
input.expected_revision,
),
SettingsMethod::Mutate(input) => {
("mutate", Change::Mutate(input.ops), input.expected_revision)
}
SettingsMethod::Mutate(input) => (Change::Mutate(input.ops), input.expected_revision),
};
if let Some(reason) = write_refusal(call.context.tool()) {
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, reason, "settings write refused by the session");
return Err(refused("settings cannot be changed in this session"));
}
validate_change(&authority.roots, &change).map_err(failure_refusal)?;
call.context
.act(
method_name,
BrokerEffect::Write,
Some(&authority.plugin_name),
)
.await?;
if let Some(reason) = write_refusal(call.context.tool()) {
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, reason, "settings write refused by the session");
return Err(refused("settings cannot be changed in this session"));
}
let turn = Arc::clone(&TURN).lock_owned().await;
if call.context.cancel().is_cancelled() {
return Err(refused("the settings call was withdrawn"));
@@ -160,11 +185,8 @@ pub(super) fn serve_with(call: BrokerCall, authority: Option<SettingsAuthority>)
"applies": APPLIES,
}))
}
Ok(Err(Failure::Invalid(message))) => Err(invalid(message)),
Ok(Err(Failure::Outside)) => Err(refused(
"that setting is outside this plugin's reviewed settings",
)),
Ok(Err(Failure::Store)) | Err(_) => Err(refused("the settings could not be saved")),
Ok(Err(failure)) => Err(failure_refusal(failure)),
Err(_) => Err(refused("the settings could not be saved")),
}
})
}
@@ -228,9 +250,17 @@ fn refused(message: &str) -> BrokerRefusal {
}
}
fn failure_refusal(failure: Failure) -> BrokerRefusal {
match failure {
Failure::Invalid(message) => invalid(message),
Failure::Outside => refused("that setting is outside this plugin's reviewed settings"),
Failure::Store => refused("the settings could not be saved"),
}
}
fn write_refusal(context: &ToolContext) -> Option<&'static str> {
let mut tool = context.clone();
tool.refresh_live_posture();
let _ = tool.refresh_live_posture();
if tool.child_host.is_some() || tool.owner_agent_id.is_some() {
return Some("the caller is a child agent");
}
@@ -353,35 +383,71 @@ fn unset_at(section: &mut Map<String, Value>, path: &[String]) {
}
}
fn validate_change(roots: &BTreeSet<String>, change: &Change) -> Result<(), Failure> {
let valid_map = |map: &Map<String, Value>| {
reviewed(roots, map.keys())?;
if map
.iter()
.all(|(key, value)| key_ok(key) && storable(value))
{
Ok(())
} else {
Err(Failure::Invalid(
"settings hold strings, finite numbers, booleans, arrays and tables under plain keys",
))
}
};
match change {
Change::Update(patch) => valid_map(patch),
Change::Replace(section) => valid_map(section),
Change::Mutate(ops) => {
if ops.len() > MAX_OPS {
return Err(Failure::Invalid("too many settings edits in one call"));
}
for op in ops {
let (PathOp::Set { path, .. } | PathOp::Unset { path }) = op;
if path.is_empty() || path.len() > MAX_PATH_DEPTH {
return Err(Failure::Invalid("a settings path names 1 to 16 keys"));
}
reviewed(roots, path.iter().take(1))?;
if !path.iter().all(|key| key_ok(key)) {
return Err(Failure::Invalid(
"settings hold strings, finite numbers, booleans, arrays and tables under plain keys",
));
}
if let PathOp::Set { value, .. } = op
&& !storable(value)
{
return Err(Failure::Invalid(
"settings hold strings, finite numbers, booleans, arrays and tables under plain keys",
));
}
}
Ok(())
}
}
}
fn next_config(
current: &Value,
roots: &BTreeSet<String>,
change: Change,
) -> Result<Map<String, Value>, Failure> {
validate_change(roots, &change)?;
let mut next = current.as_object().cloned().unwrap_or_default();
match change {
Change::Update(patch) => {
reviewed(roots, patch.keys())?;
for (key, value) in patch {
let merged = merge(next.remove(&key), value);
next.insert(key, merged);
}
}
Change::Replace(section) => {
reviewed(roots, section.keys())?;
next.retain(|key, _| !roots.contains(key));
next.extend(section);
}
Change::Mutate(ops) => {
if ops.len() > MAX_OPS {
return Err(Failure::Invalid("too many settings edits in one call"));
}
for op in ops {
let (PathOp::Set { path, .. } | PathOp::Unset { path }) = &op;
if path.is_empty() || path.len() > MAX_PATH_DEPTH {
return Err(Failure::Invalid("a settings path names 1 to 16 keys"));
}
reviewed(roots, path.iter().take(1))?;
match op {
PathOp::Set { path, value } => set_at(&mut next, &path, value)?,
PathOp::Unset { path } => unset_at(&mut next, &path),
@@ -556,7 +622,7 @@ mod tests {
use codewhale_runtime::config::read_plugin_settings;
use super::super::core_call::InvocationGuard;
use super::super::dsh_broker::{Arm, TABLE, TestGrants};
use super::super::dsh_broker::{Arm, BrokerActTool, TABLE, TestGrants};
use super::super::plugin_config::{PluginConfigs, activation_hash};
use super::super::protocol::{DshBrokerParams, RpcErrorWire, error_code};
use super::super::supervisor::HostRequestContext;
@@ -565,9 +631,12 @@ mod tests {
use super::super::{ExtensionHostManager, ExtensionHostOptions};
use super::*;
use crate::core::engine::LivePosture;
use crate::tools::codemode::{ExtensionCaller, NestedCallGate};
use crate::tools::codemode::{
ExtensionCaller, NestedCallGate, NestedCallVerdict, NestedDecision,
};
use crate::tools::spec::{
ToolAuthorityEnvelope, ToolMutationAuthority, ToolShellAuthority, ToolVerificationAuthority,
ToolAuthorityEnvelope, ToolError, ToolMutationAuthority, ToolShellAuthority,
ToolVerificationAuthority,
};
const NAME: &str = "fixture.plugin";
@@ -637,7 +706,7 @@ model = "theirs"
tool: "a_tool".to_string(),
scope: "ext:a@hash".to_string(),
},
Vec::new(),
BrokerActTool::all().to_vec(),
),
posture,
dir,
@@ -1189,4 +1258,108 @@ model = "theirs"
"a first edit creates only the plugin's own table"
);
}
#[tokio::test]
async fn settings_acts_pass_the_gate_and_record_receipts() {
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
let mut f = Fixture::new();
let asked = Arc::new(AtomicUsize::new(0));
let deny = Arc::new(AtomicBool::new(false));
let switch_to_never = Arc::new(AtomicBool::new(false));
let asked_in_gate = Arc::clone(&asked);
let deny_in_gate = Arc::clone(&deny);
let switch_in_gate = Arc::clone(&switch_to_never);
let posture_in_gate = f.posture.clone();
f.gate = NestedCallGate::answering_for_test(move |name, input| {
asked_in_gate.fetch_add(1, Ordering::SeqCst);
if deny_in_gate.load(Ordering::SeqCst) {
return NestedCallVerdict::Refused {
error: ToolError::permission_denied("denied by the person"),
decision: NestedDecision::Denied,
};
}
if switch_in_gate.load(Ordering::SeqCst) {
posture_in_gate.switch_for_tests(ApprovalMode::Never);
}
NestedCallVerdict::Run {
name: name.to_string(),
input: input.clone(),
supports_parallel: false,
decision: NestedDecision::Auto,
hook_context: None,
}
})
.for_extension(
ExtensionCaller {
origin: "extension:a".to_string(),
tool: "a_tool".to_string(),
scope: "ext:a@hash".to_string(),
},
BrokerActTool::all().to_vec(),
);
let guard = f.begin();
let settings = ticket(&guard, "settings");
let _ = f.call(&settings, "describe", json!({})).await.unwrap();
assert_eq!(asked.load(Ordering::SeqCst), 0);
let outside = f
.call(&settings, "update", json!({"patch": {"other": 1}}))
.await;
assert_eq!(refusal(&outside), policy_refused());
assert_eq!(
asked.load(Ordering::SeqCst),
0,
"out-of-scope edits are rejected before opening a gate prompt"
);
let ok = f
.call(&settings, "update", json!({"patch": {"model": "gated"}}))
.await
.unwrap();
assert_eq!(ok["written"], true);
assert_eq!(asked.load(Ordering::SeqCst), 1);
let after_ok = f.text();
deny.store(true, Ordering::SeqCst);
let denied = f
.call(&settings, "update", json!({"patch": {"model": "blocked"}}))
.await;
assert_eq!(refusal(&denied), (error_code::DENIED, "denied".into()));
assert_eq!(asked.load(Ordering::SeqCst), 2);
assert_eq!(f.text(), after_ok);
deny.store(false, Ordering::SeqCst);
switch_to_never.store(true, Ordering::SeqCst);
let raced = f
.call(&settings, "update", json!({"patch": {"model": "never"}}))
.await;
assert_eq!(refusal(&raced), policy_refused());
assert_eq!(asked.load(Ordering::SeqCst), 3);
assert_eq!(f.text(), after_ok);
let receipts = guard.receipts().expect("broker acts are recorded");
assert_eq!(receipts["broker_acts"]["total"], 4, "{receipts}");
assert_eq!(
receipts["broker_acts"]["acts"]
.as_array()
.unwrap()
.iter()
.map(|act| (
act["service"].as_str().unwrap(),
act["method"].as_str().unwrap(),
act["outcome"].as_str().unwrap(),
act["target"].as_str().unwrap(),
))
.collect::<Vec<_>>(),
vec![
("settings", "describe", "read", NAME),
("settings", "update", "allowed", NAME),
("settings", "update", "denied", NAME),
("settings", "update", "allowed", NAME),
]
);
}
}
+1
View File
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Agent `cwd` in sibling worktrees:** spawning an agent with `cwd` pointing to an existing linked worktree (or a subdirectory inside it) under the repository's `.codewhale-worktrees/<repo>/` root is now accepted when both share the same repository common git directory; outside directories and foreign or plain directories under `.codewhale-worktrees/` stay refused. (CW-8215)
- **DSH market npm installs and extension catalog service:** DSH market `npm` entries with an exact semantic version (and optional `https` registry) now resolve to reviewed registry-tarball install plans (`npm:<pkg>@<ver>` or exact `.tgz` URL) without executing `npm` or bypassing plugin trust review. Inside the extension host, the `dsh/catalog` broker arm now plans reviewed installs without writing to disk, returns only verified approved provider routes scoped to the calling plugin, and serves account balance read-only while withholding credentials and account mutations (CW-7988).
- **Plugin settings service backend (0.10.3):** adds the internal `settings` host-service handler (`describe`, `update`, `replace`, `mutate`) and extension-host adapter helper so plugins can inspect and atomically edit only their own reviewed `[plugins."<name>".config]` keys in `config.toml` through the session permission gate and live write posture, leaving comments, unrelated tables and other plugins' configuration untouched (writes return `applies: "reload"` and take effect at the next reload). This backend is not yet wired into live host-service dispatch or plugin grants, so no active plugin exposes it yet. (CW-7983)
- **Removed (crate API):** the unwired `codewhale-hooks` dispatcher and its stdout, JSONL and Unix-socket sinks; `codewhale-core`'s `session`, `journal` and `ids` modules and the `hooks` argument of `Runtime::new`; `codewhale-runtime::resource_telemetry`; ten unreferenced public functions; and the duplicate `codewhale-agent` crate. `hook_sinks.unix_socket_path` still parses and is ignored. CLI, TUI and app-server behavior is unchanged.
- **First-run setup:** installer downloads retry up to three times and remove partial files after a failed attempt. Folder trust uses 1 to trust, 2 to continue untrusted, and 3 or Esc to quit; letters do nothing. Provider pickers consistently say "needs key", and the unconfigured custom placeholder says "needs endpoint" while configured-route errors remain visible.
- **Quota-window reset text:** a rate-limited turn now gives the reset time in plain words such as "19h29m" instead of Rust debug output like `Some(70168s)`, and leaves the reset clause out when the provider gives no reset time. An OpenCode Go message points at that provider's own key with `codewhale auth set --provider opencode-go`, without repeating a hint the message already carries. (CW-8209)