mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-13 18:17:39 -10:00
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:
1 parent
1dc5bb71cb
commit
01259e18ca
3 files changed
+201
-26
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user