fix(security): tighten approval, execution and endpoint boundaries for 0.10.1

Hardening across the Engine, the bundled Computer Use plugin and the chat
bridges. Each item reuses the existing authority instead of adding a second one.

- Computer control: a consent or script call runs only on an exact decision a
  person gave on its own approval card; rules, autonomous postures, child
  agents and code-mode programs cannot produce one. The bundled plugin carries
  the matching consent, script, helper and SSH argument checks and known-host
  routing.
- Git status and review: automatic status, worktree and log reads go through
  the sanitized review command, refuse unsupported runtime configuration
  overrides, and stay pinned to the executable that was checked.
- Redaction: persisted config backups, config dumps, MCP listings and
  notification payloads share one sensitive-key vocabulary in codewhale-sanitize.
- Recursive RLM: each round of Python a child model writes is admitted by the
  turn serving the direct `rlm` call, through the same planning and approval
  as an inline repl block, and runs only as admitted; `rlm` is not available
  inside execute_tools programs. Python kernels are discarded when the applied
  posture narrows.
- Gate commands and the cargo test runner start inside the session's shell
  sandbox with the sanitized environment.
- Chat bridges decide an approval only for the human who started that turn, on
  the Runtime's current pending approval, and consume a stored action only
  after delivery. Upgrade note: approvals for turns started before the upgrade
  are decided from the TUI; WeCom `/allow` no longer takes `remember`.
- Mutable session artifacts are written through the pinned session-relative
  replace; the owned-artifact pager reads through the pinned open with a
  64 MiB display cap.
- The stdio bridge's Runtime child binds an ephemeral loopback port itself and
  reports it; the parent validates that report, never follows redirects with
  the bearer token, and reaps the child on a bad or late report.

Known limits: Python kernels are approval-gated local subprocesses, not
OS-sandboxed; where no enforcing sandbox is configured (Linux without
bubblewrap, Windows) a workspace-write policy runs runners unsandboxed, as it
already does for exec_shell; an approval wait counts against an RLM turn's
deadline; verifier, plugin, hook and lane child processes are unchanged.

Local macOS evidence on this exact tree: CI-policy Clippy on Rust 1.99, whole
workspace, all targets: exit 0. Focused Rust selection: app-server 7 passed /
0 failed, config 7/0, sanitize 2/0, codewhale-tui 1346/0. Bridges: core 37/0,
feishu 19/0, telegram 39/0, wecom 17/0, weixin 13/0. Computer Use plugin: 401
passed / 0 failed / 16 skipped. npm test: 75/0, 19/0, 63/0 with 1 skipped;
check:web passes. With each new guard disabled in turn, its regression tests
fail and pass again on the restored source. A native run confirmed the stdio
parent reaches a real Runtime child through the endpoint the child reported.
Hosted three-OS, shared-process and release-candidate results are still
required for this head; no Linux or Windows native run is claimed.

Co-authored-by: CodeWhaleBot <[email protected]>
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale BotandCodeWhaleBot committed 2026-10-01 07:25:45 -07:00
1 parent eb162047e5
commit e86d67711f
82 files changed
+4402 -386

No files matched your search

Generated
+1
View File
@@ -1251,6 +1251,7 @@ dependencies = [
"ratatui",
"regex",
"reqwest",
"ring",
"rio-vt",
"rmcp",
"rquickjs",
+116 -13
View File
@@ -1609,6 +1609,7 @@ where
/// the turn registry across the request.
async fn interrupt_turn_request(turn: &InFlightTurn) -> std::result::Result<bool, JsonRpcError> {
let mut request = codewhale_release::platform_http_client_builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(10))
.build()
.map_err(|err| JsonRpcError::internal(err.to_string()))?
@@ -1681,25 +1682,35 @@ async fn invalidate_runtime_bridge(state: &AppState) {
impl RuntimeBridge {
async fn start(config_path: Option<&Path>) -> Result<Self> {
install_rustls_crypto_provider();
let port = reserve_runtime_port()?;
let auth_token = format!("cwrt_{}", Uuid::new_v4().simple());
let child = Self::runtime_command(config_path, port, &auth_token)?
// The bearer token only ever goes to the endpoint the child itself
// reports, and never follows a redirect away from it.
let client = codewhale_release::platform_http_client_builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.context("failed to build runtime API client")?;
let mut child = Self::runtime_command(config_path, &auth_token)?
.spawn()
.context("failed to start runtime API bridge")?;
let stdout = child.stdout.take();
// Owned by the bridge from here on, so every failure below reaps it.
let mut bridge = Self {
base_url: format!("http://127.0.0.1:{port}"),
client: codewhale_release::platform_http_client_builder()
.build()
.context("failed to build runtime API client")?,
base_url: String::new(),
client,
auth_token: Some(auth_token),
child: Some(child),
last_seq_by_thread: HashMap::new(),
};
let stdout = stdout.context("runtime API bridge has no stdout to report its endpoint")?;
let endpoint = runtime_child_endpoint(stdout, Duration::from_secs(15)).await?;
bridge.base_url = format!("http://{endpoint}");
bridge.wait_until_ready().await?;
Ok(bridge)
}
fn runtime_command(config_path: Option<&Path>, port: u16, auth_token: &str) -> Result<Command> {
/// The child binds an ephemeral loopback port itself (`--port 0`) and
/// reports it on stdout; the parent never reserves a port for it to race.
fn runtime_command(config_path: Option<&Path>, auth_token: &str) -> Result<Command> {
let current_exe = std::env::current_exe().ok();
let mut command = if let Some(path) = current_exe {
Command::new(path)
@@ -1716,11 +1727,11 @@ impl RuntimeBridge {
.arg("--host")
.arg("127.0.0.1")
.arg("--port")
.arg(port.to_string())
.arg("0")
.env("CODEWHALE_RUNTIME_TOKEN", auth_token)
.env("DEEPSEEK_RUNTIME_TOKEN", auth_token)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
if let Some(config_path) = config_path {
command.arg("--config").arg(config_path);
@@ -2225,9 +2236,68 @@ impl Drop for RuntimeBridge {
}
}
fn reserve_runtime_port() -> Result<u16> {
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
Ok(listener.local_addr()?.port())
/// The line the Runtime prints once it holds its listener (the Runtime's
/// `RUNTIME_LISTENING_PREFIX`; this crate does not depend on that one).
const RUNTIME_LISTENING_PREFIX: &str = "Runtime API listening on http://";
/// The endpoint line is short and comes first; anything larger is not it.
const RUNTIME_READY_MAX_BYTES: usize = 1024;
/// The endpoint a Runtime child reports for itself: exactly a nonzero port on
/// `127.0.0.1`, the host the parent asked it to bind.
fn parse_runtime_endpoint(line: &str) -> Result<std::net::SocketAddr> {
let address = line
.trim_end()
.strip_prefix(RUNTIME_LISTENING_PREFIX)
.context("runtime API bridge did not report its endpoint")?;
let endpoint: std::net::SocketAddr = address
.parse()
.context("runtime API bridge reported an invalid endpoint")?;
if endpoint.ip() != std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST) || endpoint.port() == 0
{
bail!("runtime API bridge reported an endpoint that is not a loopback port");
}
Ok(endpoint)
}
/// Read the first stdout line, bounded.
fn read_runtime_ready_line(stdout: &mut impl std::io::Read) -> Result<String> {
let mut line = Vec::new();
let mut byte = [0u8; 1];
loop {
if stdout.read(&mut byte)? == 0 {
bail!("runtime API bridge closed stdout before reporting its endpoint");
}
if byte[0] == b'\n' {
break;
}
if line.len() >= RUNTIME_READY_MAX_BYTES {
bail!("runtime API bridge sent an oversized readiness line");
}
line.push(byte[0]);
}
String::from_utf8(line).context("runtime API bridge readiness line is not UTF-8")
}
/// Wait for the child to report the endpoint it bound. The reader thread then
/// keeps draining stdout for the child's lifetime, so the child's later
/// prints neither block on a full pipe nor fail on a closed one. On error the
/// caller drops the bridge, which kills and reaps the child.
async fn runtime_child_endpoint(
mut stdout: std::process::ChildStdout,
wait: Duration,
) -> Result<std::net::SocketAddr> {
let (ready, endpoint) = tokio::sync::oneshot::channel();
std::thread::spawn(move || {
let _ = ready.send(
read_runtime_ready_line(&mut stdout).and_then(|line| parse_runtime_endpoint(&line)),
);
let _ = std::io::copy(&mut stdout, &mut std::io::sink());
});
match tokio::time::timeout(wait, endpoint).await {
Ok(Ok(endpoint)) => endpoint,
Ok(Err(_)) => bail!("runtime API bridge readiness reader ended unexpectedly"),
Err(_) => bail!("timed out waiting for the runtime API bridge to report its endpoint"),
}
}
fn install_rustls_crypto_provider() {
@@ -5628,16 +5698,49 @@ mod tests {
assert!(token.unwrap().starts_with("cwapp_"));
}
#[test]
fn runtime_child_endpoint_must_be_a_reported_loopback_port() {
let ok = parse_runtime_endpoint("Runtime API listening on http://127.0.0.1:49152\r\n")
.expect("loopback endpoint");
assert_eq!(ok.port(), 49152);
for bad in [
"Runtime API listening on http://127.0.0.1:0",
"Runtime API listening on http://10.0.0.5:7878",
"Runtime API listening on http://[::1]:7878",
"Runtime API listening on http://example.com:80",
"Runtime API listening on http://127.0.0.1:80/redirect",
"listening on http://127.0.0.1:7878",
"",
] {
assert!(parse_runtime_endpoint(bad).is_err(), "{bad:?}");
}
let mut first_line_only: &[u8] =
b"Runtime API listening on http://127.0.0.1:5000\nRuntime API listening on http://127.0.0.1:6000\n";
assert_eq!(
read_runtime_ready_line(&mut first_line_only).unwrap(),
"Runtime API listening on http://127.0.0.1:5000"
);
let mut closed: &[u8] = b"Runtime API listening on http://127.0.0.1:5000";
assert!(read_runtime_ready_line(&mut closed).is_err(), "no newline");
let oversized = vec![b'a'; RUNTIME_READY_MAX_BYTES + 1];
assert!(read_runtime_ready_line(&mut oversized.as_slice()).is_err());
}
#[test]
fn runtime_bridge_command_keeps_auth_token_out_of_argv() {
// FR001-C001: runtime auth token must not appear on the child argv
// (visible via local `ps`); pass it via env instead.
let token = "cwrt_unit_test_secret_token_not_for_argv";
let cmd = RuntimeBridge::runtime_command(None, 18787, token).expect("command");
let cmd = RuntimeBridge::runtime_command(None, token).expect("command");
let argv: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(
argv.windows(2).any(|pair| pair == ["--port", "0"]),
"the child picks and reports its own port: {argv:?}"
);
assert!(
!argv
.iter()
+61 -45
View File
@@ -5890,6 +5890,10 @@ fn config_toml_without_plaintext_api_keys(raw: &str) -> Result<String> {
)
})?;
remove_plaintext_api_keys_recursive(document.as_table_mut());
scrub_backup_decor(document.as_table_mut().decor_mut());
if let Some(trailing) = document.trailing().as_str().map(scrub_backup_comments) {
document.set_trailing(trailing);
}
Ok(document.to_string())
}
@@ -5900,17 +5904,27 @@ fn config_toml_without_plaintext_api_keys(raw: &str) -> Result<String> {
fn remove_plaintext_api_keys_recursive(table: &mut dyn toml_edit::TableLike) {
let sensitive: Vec<String> = table
.iter()
.filter(|(key, _)| is_sensitive_config_key(key))
.filter(|(key, item)| {
is_sensitive_config_key(key) || item.as_value().is_some_and(backup_value_carries_secret)
})
.map(|(key, _)| key.to_owned())
.collect();
for key in sensitive {
// Keep a comment written above the key (often the file header).
config_document::remove_key_preserving_leading_decor(table, &key);
}
for (_, item) in table.iter_mut() {
for (mut key, item) in table.iter_mut() {
scrub_backup_decor(key.leaf_decor_mut());
if let toml_edit::Item::Table(nested) = item {
scrub_backup_decor(nested.decor_mut());
}
if let toml_edit::Item::Value(value) = item {
scrub_backup_decor(value.decor_mut());
}
match item {
toml_edit::Item::ArrayOfTables(tables) => {
for nested in tables.iter_mut() {
scrub_backup_decor(nested.decor_mut());
remove_plaintext_api_keys_recursive(nested);
}
}
@@ -5927,7 +5941,9 @@ fn remove_plaintext_api_keys_recursive(table: &mut dyn toml_edit::TableLike) {
}
fn remove_plaintext_api_keys_in_array(array: &mut toml_edit::Array) {
array.retain(|value| !backup_value_carries_secret(value));
for value in array.iter_mut() {
scrub_backup_decor(value.decor_mut());
match value {
toml_edit::Value::InlineTable(table) => remove_plaintext_api_keys_recursive(table),
toml_edit::Value::Array(nested) => remove_plaintext_api_keys_in_array(nested),
@@ -5936,6 +5952,46 @@ fn remove_plaintext_api_keys_in_array(array: &mut toml_edit::Array) {
}
}
fn backup_value_carries_secret(value: &toml_edit::Value) -> bool {
value
.as_str()
.is_some_and(codewhale_secrets::sanitize::contains_secret)
}
fn scrub_backup_decor(decor: &mut toml_edit::Decor) {
let prefix = decor
.prefix()
.and_then(|text| text.as_str())
.map(scrub_backup_comments);
let suffix = decor
.suffix()
.and_then(|text| text.as_str())
.map(scrub_backup_comments);
if let Some(prefix) = prefix {
decor.set_prefix(prefix);
}
if let Some(suffix) = suffix {
decor.set_suffix(suffix);
}
}
fn scrub_backup_comments(text: &str) -> String {
text.split_inclusive('\n')
.filter(|line| {
!line
.trim_start()
.strip_prefix('#')
.map(str::trim)
.is_some_and(|comment| {
comment
.split_once('=')
.is_some_and(|(key, _)| is_sensitive_config_key(key))
|| codewhale_secrets::sanitize::contains_secret(comment)
})
})
.collect()
}
/// Merge comments and formatting from an original TOML file into a
/// freshly serialized document so user annotations (comments, whitespace,
/// disabled keys) survive config rewrites.
@@ -7072,49 +7128,9 @@ fn redact_secret(secret: &str) -> String {
#[must_use]
pub fn is_sensitive_config_key(key: &str) -> bool {
let Some(segment) = key.rsplit('.').next() else {
return false;
};
let normalized = segment
.trim()
.trim_matches('"')
.replace('-', "_")
.to_ascii_lowercase();
matches!(
normalized.as_str(),
"api_key"
| "apikey"
| "api_keys"
| "authorization"
| "bearer"
| "client_secret"
| "credential"
| "credentials"
| "id_token"
| "password"
| "passwords"
| "passwd"
| "proxy_authorization"
| "refresh_token"
| "secret"
| "secrets"
| "token"
| "tokens"
| "cookie"
| "set_cookie"
| "sas"
) || normalized.ends_with("_authorization")
|| normalized.ends_with("_cookie")
|| normalized.ends_with("_password")
|| normalized.ends_with("_secret")
|| normalized.ends_with("_token")
// `*_key` covers `api_key`, `secret_key`, `access_key`,
// `private_key` and header spellings such as
// `Ocp-Apim-Subscription-Key`. Only names known to hold no secret
// are exempt.
|| (normalized.ends_with("_key")
&& !matches!(normalized.as_str(), "public_key" | "endpoint_key"))
key.rsplit('.')
.next()
.is_some_and(codewhale_secrets::sanitize::is_sensitive_key_name)
}
/// Resolve dotted paths without treating a dotted key as a top-level literal.
+109
View File
@@ -10799,3 +10799,112 @@ fn stream_settings_are_typed_nested_and_fail_without_mutation() {
Some(0)
);
}
#[test]
fn config_backup_shared_vocabulary_preserves_safe_values_and_comments_on_disk() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(CONFIG_FILE_NAME);
let canonical = "model = \"current-model\"\n";
fs::write(&path, canonical).expect("canonical config");
let raw = r#"# ordinary header stays
# privateKey = "comment-s10-synthetic"
model = "fixture-model" # clientSecret=inline-s10-synthetic
max_tokens = 4096 # ordinary inline stays
public_key = "public-value"
endpoint_key = "endpoint-label"
base_url = "https://api.example.com"
# ordinary URL https://api.example.com
[providers.fixture] # cookie=table-inline-s10-synthetic
privateKey = "private-s10-synthetic"
clientSecret = "client-s10-synthetic"
api_key_env = "SYNTHETIC_KEY_ENV"
auth_mode = "api_key"
headers = { "Set-Cookie" = "cookie-s10-synthetic", "X-Trace" = "kept-trace" }
[future_section]
profiles = [{ accessToken = "access-s10-synthetic", label = "kept-profile" }]
args = ["--flag", "clientSecret=array-s10-synthetic", "https://api.example.com"]
webhook_url = "https://hooks.example.com/?sas=url-s10-synthetic"
# trailing ordinary comment stays
"#;
let backup_path = config_backup_path(&path);
fs::write(&backup_path, raw).expect("seed historical backup");
scrub_plaintext_api_keys_from_config_backup(&path).expect("scrub persisted backup");
let backup = fs::read_to_string(&backup_path).expect("persisted backup");
for marker in [
"comment-s10-synthetic",
"inline-s10-synthetic",
"table-inline-s10-synthetic",
"private-s10-synthetic",
"client-s10-synthetic",
"cookie-s10-synthetic",
"access-s10-synthetic",
"array-s10-synthetic",
"url-s10-synthetic",
] {
assert!(
!backup.contains(marker),
"credential survived in persisted backup"
);
}
for kept in [
"ordinary header stays",
"ordinary inline stays",
"trailing ordinary comment stays",
"max_tokens = 4096",
"public-value",
"endpoint-label",
"https://api.example.com",
"ordinary URL",
"SYNTHETIC_KEY_ENV",
"auth_mode",
"kept-trace",
"kept-profile",
"--flag",
] {
assert!(backup.contains(kept), "ordinary backup data lost: {kept}");
}
backup
.parse::<toml_edit::DocumentMut>()
.expect("still valid TOML");
assert_eq!(fs::read_to_string(&path).unwrap(), canonical);
}
#[test]
fn config_dump_shared_vocabulary_redacts_camel_case() {
for key in [
"accessToken",
"clientSecret",
"privateKey",
"refreshToken",
"Set-Cookie",
"sas",
"Ocp-Apim-Subscription-Key",
] {
assert!(is_sensitive_config_key(key), "{key}");
assert!(
is_sensitive_config_key(&format!("providers.fixture.{key}")),
"{key}"
);
}
for key in [
"max_tokens",
"token_budget",
"api_key_source",
"authMode",
"publicKey",
"endpoint_key",
] {
assert!(!is_sensitive_config_key(key), "{key}");
}
let value: toml::Value = toml::from_str(
r#"privateKey = "private-s10-synthetic"
clientSecret = "client-s10-synthetic"
model = "fixture-model"
"#,
)
.unwrap();
let shown = redact_toml_value_for_display("providers.fixture", &value);
assert!(!shown.contains("private-s10-synthetic"));
assert!(!shown.contains("client-s10-synthetic"));
assert!(shown.contains("fixture-model"));
}
+1 -1
View File
@@ -376,7 +376,7 @@ fn key_is_sensitive(raw: &str) -> bool {
/// Punctuation and case transitions become `_`, so `oauth.token`,
/// `accessToken`, and `APIKey` share the same matching surface as
/// `oauth_token`, `access_token`, and `api_key`.
fn normalize_sensitive_key(raw: &str) -> String {
pub(crate) fn normalize_sensitive_key(raw: &str) -> String {
let mut normalized = String::with_capacity(raw.len());
let mut chars = raw.chars().peekable();
let mut previous = None;
+163 -46
View File
@@ -173,27 +173,7 @@ pub fn redact_url_for_display(url: &str) -> String {
}
fn is_sensitive_url_query_key(key: &str) -> bool {
let normalized = key.trim().replace(['-', '.'], "_").to_ascii_lowercase();
matches!(
normalized.as_str(),
"api_key"
| "apikey"
| "access_token"
| "auth_token"
| "authorization"
| "bearer"
| "client_secret"
| "credential"
| "id_token"
| "password"
| "refresh_token"
| "secret"
| "token"
) || normalized.ends_with("_api_key")
|| normalized.ends_with("_authorization")
|| normalized.ends_with("_password")
|| normalized.ends_with("_secret")
|| normalized.ends_with("_token")
is_sensitive_key_name(key)
}
/// True when `role` names an internal, non-user-visible message role.
@@ -204,32 +184,84 @@ pub fn is_internal_role(role: &str) -> bool {
)
}
/// True when a JSON/assignment key names a credential-bearing value.
///
/// Classification normalizes separators and quotes so obfuscated variants are
/// still caught; the vocabulary is shared with `/structcopy`.
/// Credential names shared by config, URL and JSON output checks.
const SENSITIVE_KEY_NAMES: &[&str] = &[
"access_key",
"access_token",
"api_key",
"api_keys",
"apikey",
"auth_token",
"authorization",
"bearer",
"client_secret",
"cookie",
"credential",
"credentials",
"id_token",
"passwd",
"password",
"passwords",
"private_key",
"proxy_authorization",
"refresh_token",
"secret",
"secrets",
"session_key",
"set_cookie",
"sas",
"token",
"tokens",
];
/// Use the existing text redactor's identifier normalization, including
/// camelCase and acronym boundaries, instead of maintaining another parser.
#[must_use]
pub fn normalize_key(key: &str) -> String {
crate::redact::normalize_sensitive_key(key)
}
fn key_spellings(key: &str) -> [String; 2] {
[normalize_key(key), normalize_key(&key.to_ascii_lowercase())]
}
/// Precise credential check for settings, headers and flags. Usage settings
/// such as max_tokens and token_budget remain visible.
#[must_use]
pub fn is_sensitive_key_name(key: &str) -> bool {
key_spellings(key).iter().any(|normalized| {
SENSITIVE_KEY_NAMES.contains(&normalized.as_str())
|| ["_authorization", "_cookie", "_passwd", "_password", "_secret", "_token"]
.iter().any(|suffix| normalized.ends_with(suffix))
// Retain config's subscription-key and custom key protection.
|| (normalized.ends_with("_key")
&& !matches!(normalized.as_str(), "public_key" | "endpoint_key"))
})
}
/// Broad export check; model-bound text keeps its existing credential-shaped
/// policy in redact.rs. One shared normalization handles every spelling.
#[must_use]
pub fn is_sensitive_key(key: &str) -> bool {
let normalized = key
.trim()
.trim_matches(['\'', '"'])
.replace(['-', '.', ' '], "_")
.to_ascii_lowercase();
[
"api_key",
"apikey",
"secret",
"token",
"password",
"passwd",
"authorization",
"access_key",
"client_secret",
"private_key",
"cookie",
"session_key",
]
.iter()
.any(|hint| normalized.contains(hint))
is_sensitive_key_name(key)
|| key_spellings(key).iter().any(|normalized| {
[
"api_key",
"apikey",
"secret",
"token",
"password",
"passwd",
"authorization",
"access_key",
"client_secret",
"private_key",
"cookie",
"session_key",
]
.iter()
.any(|hint| normalized.contains(hint))
})
}
/// Sanitize arbitrary text for safe export output.
@@ -254,6 +286,29 @@ pub fn sanitize_text(input: &str) -> String {
redact_secrets(&urls)
}
/// Whether an arbitrary value carries material the shared redactor masks.
/// Formatting-only changes (URL canonicalization, newlines, ANSI) do not make
/// a harmless config value a credential.
#[must_use]
pub fn contains_secret(input: &str) -> bool {
let mut visible = String::with_capacity(input.len());
strip_ansi_into(input, &mut visible);
private_key_regex().is_match(&visible)
|| bearer_regex().is_match(&visible)
|| jwt_regex().is_match(&visible)
|| redact_secrets(&visible) != visible
|| url_regex().find_iter(&visible).any(|matched| {
let raw = matched.as_str().trim_end_matches(['.', ',', ';', '!']);
url::Url::parse(raw).is_ok_and(|url| {
!url.username().is_empty()
|| url.password().is_some()
|| url
.query_pairs()
.any(|(key, _)| is_sensitive_url_query_key(&key))
})
})
}
/// Recursively redact a JSON value.
///
/// A value under a sensitive key is replaced wholesale; other strings pass
@@ -331,6 +386,68 @@ fn url_regex() -> &'static Regex {
mod tests {
use super::*;
#[test]
fn shared_vocabulary_redacts_camel_case_and_keeps_safe_key_names() {
for key in [
"accessToken",
"clientSecret",
"privateKey",
"refreshToken",
"APIKey",
"X-API-Key",
"sessionKey",
"oauth2Token",
"aUtHoRiZaTiOn",
"Ocp-Apim-Subscription-Key",
"Set-Cookie",
"sas",
] {
assert!(is_sensitive_key_name(key), "{key}");
let mut value = serde_json::json!({key: "s10-synthetic-value"});
redact_json(&mut value, None);
assert!(!value.to_string().contains("s10-synthetic-value"), "{key}");
}
for key in [
"max_tokens",
"token_budget",
"maxTokens",
"api_key_source",
"authMode",
"publicKey",
"endpoint_key",
"monkey",
] {
assert!(!is_sensitive_key_name(key), "{key}");
}
assert_eq!(normalize_key("APIKey"), "api_key");
}
#[test]
fn shared_secret_detection_preserves_formatting_only_config_values() {
for safe in [
"https://api.example.com",
"https://api.example.com/v1?team=core",
"https://api.example.com/?tokenBudget=5",
"normal text",
"max tokens = 8192",
"tokenCount = 4",
] {
assert!(!contains_secret(safe), "safe value classified as secret");
}
for secret in [
"clientSecret=opaque-value",
"https://alice:[email protected]",
"https://example.com/?privateKey=opaque-value",
"https://example.com/?sas=opaque-value",
] {
assert!(contains_secret(secret), "credential not classified");
}
assert_ne!(
sanitize_text("https://api.example.com"),
"https://api.example.com"
);
}
#[test]
fn strip_ansi_removes_control_sequences_but_keeps_text() {
let mut out = String::new();
+2
View File
@@ -134,6 +134,8 @@ tar = "0.4"
liblzma = { version = "0.4", features = ["static"] }
flate2 = "1.1"
sha2.workspace = true
# Reuse the locked cryptographic implementation for exact-call user decisions.
ring = "0.17"
semver.workspace = true
codewhale-localization = { path = "../localization", version = "0.10.1" }
shell-words = "1.1.1"
+116 -7
View File
@@ -6,6 +6,7 @@
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { isDeepStrictEqual } from "node:util";
import * as registry from "../src/registry.mjs";
import * as consent from "../src/consent.mjs";
import { backendFor, installRemoteAgent, executorFor, closeAppSession, routeFingerprint, closeSshChannel, SESSION_ID } from "../src/transport.mjs";
@@ -87,6 +88,89 @@ function assertLease(name) {
// batched decision would pass as one the user just made.
const CONSENT_DECISIONS = new Set(["consent_allow", "consent_deny", "consent_revoke"]);
const isConsentDecision = (tool, args) => CONSENT_DECISIONS.has(tool) || (tool === "consent" && args?.action !== "status");
// ---------- the user's own decisions ----------
// Widening the ledger (allow, revoke, an irreversible-action confirm), running
// an app_script, and registering or spawning a computer are the user's calls,
// never the model's. Each needs one of:
// - a decision the host attests: Codewhale sends a per-connection key as the
// first stdin message (codewhale/host_keys) and attaches
// _meta["codewhale/user_decision"] = {nonce, args_json, mac} only after the
// user approved that exact call on its card;
// - or, under another host, an MCP elicitation the client shows its user.
// Otherwise the call is refused with consent_needs_user.
const NEEDS_USER_DECISION = new Set(["consent_allow", "consent_revoke", "app_script", "computer_register", "computer_spawn"]);
const wireName = (tool, args) => { try { return resolveTool(tool, args ?? {}).name; } catch { return tool; } };
const needsUserDecision = (tool, args) => NEEDS_USER_DECISION.has(wireName(tool, args));
let hostDecisionKey = null;
let sawFirstMessage = false;
let clientElicitation = false;
const seenDecisionNonces = new Set();
/** First-message key delivery from the host (never from later messages). */
function acceptHostKeys(params) {
const decision = typeof params?.decision_key === "string" && /^[0-9a-f]{64}$/i.test(params.decision_key) ? Buffer.from(params.decision_key, "hex") : null;
if (!decision) return;
hostDecisionKey = decision;
if (typeof params?.ledger_key === "string" && /^[0-9a-f]{64}$/i.test(params.ledger_key)) consent.setLedgerKey(Buffer.from(params.ledger_key, "hex"));
}
/** Whether `meta` carries a fresh host attestation for exactly this call. */
function verifyUserDecision(tool, args, meta) {
const d = meta?.["codewhale/user_decision"];
if (!hostDecisionKey || !d || typeof d.nonce !== "string" || typeof d.mac !== "string" || typeof d.args_json !== "string") return false;
if (!/^[0-9a-f]{16,128}$/i.test(d.nonce) || seenDecisionNonces.has(d.nonce)) return false;
const expected = crypto.createHmac("sha256", hostDecisionKey)
.update(Buffer.concat([Buffer.from(String(tool)), Buffer.from([0]), Buffer.from(d.args_json), Buffer.from([0]), Buffer.from(d.nonce)]))
.digest();
const given = /^[0-9a-f]+$/i.test(d.mac) ? Buffer.from(d.mac, "hex") : Buffer.alloc(0);
if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) return false;
let attested;
try { attested = JSON.parse(d.args_json); } catch { return false; }
if (!isDeepStrictEqual(attested, args ?? {})) return false;
seenDecisionNonces.add(d.nonce);
return true;
}
// Requests this server sends its client (elicitation), keyed by id.
const serverRequests = new Map();
let serverRequestSeq = 0;
function clientRequest(method, params) {
const signal = currentSignal();
throwIfAborted(signal);
const id = `codewhale-cu-${++serverRequestSeq}`;
return new Promise((resolve, reject) => {
const finish = (settle, value) => {
serverRequests.delete(id);
signal?.removeEventListener("abort", abort);
settle(value);
};
const abort = () => {
finish(reject, new ServerError("cancelled", "the request ended before the user decided"));
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: id } }) + "\n");
};
serverRequests.set(id, {
resolve: value => finish(resolve, value),
reject: error => finish(reject, error),
});
signal?.addEventListener("abort", abort, { once: true });
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
});
}
/** Resolve with nothing when the user decided this call; throw otherwise. */
async function requireUserDecision(params, name, args) {
if (verifyUserDecision(params.name, params.arguments ?? {}, params._meta)) return;
if (clientElicitation) {
const answer = await clientRequest("elicitation/create", {
message: `Computer Use asks for your decision: ${name} ${JSON.stringify(args).slice(0, 400)}`,
requestedSchema: { type: "object", properties: {} },
});
if (answer?.action === "accept") return;
throw new ServerError("consent_declined", "the user did not approve this — do not retry it; continue without it or ask them");
}
throw new ServerError("consent_needs_user", `${name} is the user's own decision: it runs only when the host shows them the exact call and they approve it. A model tool call cannot make it. Ask the user.`);
}
const mayDeliverInput = (requestName) => requestName === "run_actions" || requestName === "trajectory_replay"
|| LEASE_GATED_TOOLS.has(requestName) || (MERGED_EXPANSION[requestName] ?? []).some((wire) => LEASE_GATED_TOOLS.has(wire));
const cancelledCode = () => (controlStopped ? "control_stopped" : leasePreempted.has(currentSignal()) ? HUMAN_DRIVING : "cancelled");
@@ -771,6 +855,14 @@ async function callTool(params) {
return { content: [{ type: "text", text: JSON.stringify(fail(null, "bad_args", `${requested} requires "${field}"`)) }], isError: true };
}
}
if (NEEDS_USER_DECISION.has(name)) {
try {
await requireUserDecision(params, name, args);
throwIfAborted();
} catch (err) {
return { content: [{ type: "text", text: JSON.stringify(fail(null, err.code ?? "consent_needs_user", err.message ?? String(err), { tool: requested })) }], isError: true };
}
}
if (name === "stop_computer_control") {
controlStopped = true;
@@ -827,7 +919,7 @@ async function callTool(params) {
if (controlStopped && !READ_ONLY_TOOLS.has(call.tool)) { results.push({ tool: call.tool, ok: false, code: "control_stopped" }); break; }
// A redacted step carries a placeholder, not what was entered —
// replaying it would type "[redacted]" into the app.
if (call.replayable === false || call.redacted === true || isConsentDecision(call.tool, call.args)) { results.push({ tool: call.tool, ok: false, code: "not_replayable" }); break; }
if (call.replayable === false || call.redacted === true || isConsentDecision(call.tool, call.args) || needsUserDecision(call.tool, call.args)) { results.push({ tool: call.tool, ok: false, code: "not_replayable" }); break; }
let body = null;
try {
const r = await callTool({ name: call.tool, arguments: call.args ?? {} });
@@ -843,7 +935,7 @@ async function callTool(params) {
} finally { replaying = false; }
}
const failed = results.filter((r) => r.ok === false).length;
return { content: [{ type: "text", text: JSON.stringify(receipt(null, { ok: true, tool: "trajectory_replay", trajectory: path.basename(file), dry_run: dryRun, turns_in_file: calls.length, replayed: results.length, failed, ...(dryRun ? { plan: calls.map((c) => c.tool), not_replayable: calls.flatMap((c, i) => (c.replayable === false || c.redacted === true || isConsentDecision(c.tool, c.args)) ? [i] : []) } : { results }), note: dryRun ? "Nothing was executed. Run again without dry_run:true to replay through the normal gates." : "Replay re-entered the normal pipeline; grants, permissions and the kill switch still apply." })) }] };
return { content: [{ type: "text", text: JSON.stringify(receipt(null, { ok: true, tool: "trajectory_replay", trajectory: path.basename(file), dry_run: dryRun, turns_in_file: calls.length, replayed: results.length, failed, ...(dryRun ? { plan: calls.map((c) => c.tool), not_replayable: calls.flatMap((c, i) => (c.replayable === false || c.redacted === true || isConsentDecision(c.tool, c.args) || needsUserDecision(c.tool, c.args)) ? [i] : []) } : { results }), note: dryRun ? "Nothing was executed. Run again without dry_run:true to replay through the normal gates." : "Replay re-entered the normal pipeline; grants, permissions and the kill switch still apply." })) }] };
}
if (name === "computer_list") {
@@ -859,19 +951,18 @@ async function callTool(params) {
if (name === "computer_register") {
try {
assertNotOwnedElsewhere(args.computer);
const entry = registry.register({ id: args.computer, transport: args.transport, label: args.label, host: args.host, port: args.port, user: args.user, target: args.target });
await bindComputer(entry);
const entry = registry.register({ id: args.computer, transport: args.transport, label: args.label, host: args.host, port: args.port, user: args.user, knownHosts: args.knownHosts, target: args.target }); await bindComputer(entry);
let installed = null;
if (entry.transport === "ssh" && args.installAgent !== false) {
installed = await installRemoteAgent(entry);
registry.register({ id: entry.id, transport: "ssh", host: entry.host, port: entry.port, user: entry.user, platformHint: installed.remotePlatform, agentPath: installed.agentPath });
registry.register({ id: entry.id, transport: "ssh", host: entry.host, port: entry.port, user: entry.user, knownHosts: entry.knownHosts, platformHint: installed.remotePlatform, agentPath: installed.agentPath });
}
if (entry.transport === "ssh" && args.installAgent === false && !entry.platformHint) {
// Probe cheaply through the agent; if it is missing, registration still succeeds.
try {
const ex = await executorFor(entry);
const reply = await ex.remote({ tool: "platform" });
registry.register({ id: entry.id, transport: "ssh", host: entry.host, port: entry.port, user: entry.user, platformHint: reply.platform });
registry.register({ id: entry.id, transport: "ssh", host: entry.host, port: entry.port, user: entry.user, knownHosts: entry.knownHosts, platformHint: reply.platform });
} catch {}
}
const fresh = registry.get(entry.id);
@@ -1018,6 +1109,7 @@ async function callTool(params) {
if (!step || typeof step.tool !== "string") throw new ServerError("bad_args", `step ${i} needs a tool name`);
if (step.tool === "run_actions") throw new ServerError("bad_args", "run_actions cannot nest");
if (isConsentDecision(step.tool, step.arguments)) throw new ServerError("bad_args", "consent decisions cannot be a run_actions step — record each one as its own consent call after the user answers");
if (needsUserDecision(step.tool, step.arguments)) throw new ServerError("consent_needs_user", `${step.tool} needs the user's own approval as its own call, not a run_actions step`);
if (!TOOL_NAMES.has(step.tool)) throw new ServerError("unknown_tool", `unknown tool "${step.tool}"`);
const result = await callTool({ name: step.tool, arguments: { ...(step.arguments ?? {}), computer: computer.id } });
const body = JSON.parse(result.content[0].text);
@@ -1428,6 +1520,7 @@ async function callToolRecorded(params) {
const HANDLERS = {
initialize(params) {
clientElicitation = params?.capabilities?.elicitation != null;
return {
protocolVersion: params?.protocolVersion ?? "2025-06-18",
capabilities: {
@@ -1581,7 +1674,23 @@ async function handleLine(line) {
const msg = tryJson(line, null);
if (!msg || typeof msg !== "object") return;
const { id, method, params } = msg;
if (!method) return; // response to a server request — we never issue any
const first = !sawFirstMessage;
sawFirstMessage = true;
if (method === "codewhale/host_keys") {
// Only the host that spawned us writes the first line of our stdin.
if (first && id == null) acceptHostKeys(params);
return;
}
if (!method) {
// A response to a request this server sent (elicitation).
const pending = serverRequests.get(id);
if (pending) {
serverRequests.delete(id);
if (msg.error) pending.reject(Object.assign(new Error(msg.error.message ?? "client refused"), { code: "consent_declined" }));
else pending.resolve(msg.result);
}
return;
}
const handler = HANDLERS[method];
if (!handler) {
if (id != null) respondError(id, -32601, `method not found: ${method}`);
@@ -83,7 +83,11 @@ user's. Remote computers are covered by their transport's trust, not this
ledger. `app_script` goes through the ledger too: every app a script names
(`tell application "X"`, `Application("X")`, and System Events plus each
`process "X"` it drives) needs the user's decision first, and macOS
Automation prompts come on top of that.
Automation prompts come on top of that. A script that names no app runs as
osascript itself and needs consent for `com.apple.osascript`. Recording a
decision (`consent` allow or revoke, a `confirm`), running `app_script`, and
registering or spawning a computer are the user's own calls: the host shows
them the exact call, and a model call alone returns `consent_needs_user`.
Only in explicitly authorized foreground mode, where a shared surface is taken
— a front lease for window-record
@@ -333,7 +337,10 @@ with stderr, and `script_timeout` means the script — or a consent dialog
`doShellScript`, `do script`/`doScript` (terminals), the Objective-C
bridge (`ObjC`, `$`, `use framework`), `run script`, `eval`, raw
`«event …»` codes, System Events `keystroke`/`key code`/`click at`, and
terminal or script-runner apps as targets all refuse `script_refused`.
terminal or script-runner apps as targets all refuse `script_refused`,
as do StandardAdditions file access, `open location`, `mount volume`,
`system attribute`, and opening files or other apps through an app
(`open POSIX file …`, `.open(`, `.launch(`, `Path(`).
So does any app the script does not name with a literal: write
`tell application "Mail"` / `Application("Mail")`, `process "Safari"` /
`processes.byName("Safari")`, and in JXA use `.at(i)` or `.byName("x")`
@@ -39,6 +39,8 @@ Never retry a refusal unchanged — re-observe, re-target, or change route.
| `not_granted` | the session's capability grant (`CODEWHALE_CU_GRANT`) does not include this tool | work inside the grant; the host narrowed it deliberately |
| `consent_required` | no user decision exists for this app on the local computer | ask the user, then record it: `consent {action:"allow"\|"deny", app:"…"}` |
| `app_denied` | the user denied this app — the deny covers every spelling of it | do not work around it; only they can `consent {action:"revoke"}` |
| `consent_needs_user` | a consent allow/revoke, a `confirm`, `app_script`, or computer register/spawn was called without the user's own decision | ask the user; the host shows them the exact call — never retry it as a model call or a `run_actions` step |
| `consent_declined` | the user declined the host's prompt for that call | do not retry; continue without it or ask them |
| `foreground_consent_required` | `activate:true` needs the separate foreground decision | ask, then `consent {action:"allow"\|"deny", scope:"foreground"}` — or keep working background (`activate:false`) |
| `confirmation_required` | the click or press would activate a pay/buy/order/send/transfer/delete control | stop and show the user exactly what will happen; only on their approval, `consent {action:"allow", confirm:"<token>"}` and repeat the identical call |
| `confirmation_unknown` | the confirmation token is unknown, used, or expired | repeat the original call for a fresh token and ask the user again |
@@ -5,6 +5,8 @@
import url from "node:url";
import { exec } from "./remote-runtime.mjs";
import { withSignal, throwIfAborted } from "./exec.mjs";
import { checkAppScript } from "./app-script-policy.mjs";
import * as consent from "./consent.mjs";
export const ALLOWED = new Set([
"preview", "platform", "probe", "list_displays", "switch_display", "list_apps", "list_sessions", "list_windows",
@@ -151,6 +153,37 @@ export function summarizeSessions() {
};
}
/** App identities a request names directly, for the consent ledger. */
function namedApps(tool, args) {
if (tool !== "open_application" && tool !== "kill_app") return [];
const ref = {};
if (typeof args.bundle_id === "string" && args.bundle_id.trim()) ref.bundle_id = args.bundle_id.trim();
if (typeof args.name === "string" && args.name.trim()) ref.name = args.name.trim();
if (Number.isInteger(args.pid) && args.pid > 0) ref.pid = args.pid;
return Object.keys(ref).length ? [ref] : [];
}
/**
* The runner's own policy, applied whoever sent the request: the MCP server,
* or any other process that reaches this socket. A script must pass the
* app_script policy, and no request may touch an app the user denied.
* (Allows are the MCP server's to check; a deny needs no signature.)
*/
function policyRefusal(tool, args, computerId) {
let targets = namedApps(tool, args);
if (tool === "app_script") {
const policy = checkAppScript(args.script, args.language);
if (policy.refused) return { code: "script_refused", message: `app_script refused: ${policy.refused}` };
targets = policy.targets;
}
for (const ref of targets) {
if (consent.decisionFor(computerId, consent.appKeys(ref)).state === "denied") {
return { code: "app_denied", message: `the user denied access to ${ref.name ?? ref.bundle_id ?? `pid ${ref.pid}`} on this computer` };
}
}
return null;
}
/**
* Execute one {tool, args} request on this machine's backend. Never throws:
* every outcome is a receipt object with `ok`.
@@ -164,6 +197,8 @@ export async function handle(req, { computerId = "local", sessionId = "direct",
return { ok: false, error: { code: "unsupported_on_transport", message: `"${tool}" runs on the local computer only — a remote agent stays a computer-use channel, never a shell` } };
}
if (tool === "platform") return { ok: true, platform: process.platform };
const refusal = policyRefusal(tool, req.args ?? {}, computerId);
if (refusal) return { ok: false, platform: process.platform, tool, error: refusal };
if (controlMode !== "ready") return { ok: false, error: { code: `control_${controlMode}`, message: `Computer Use is ${controlMode} by the user. Wait for them to resume it in the menu bar.` } };
const generation = controlGeneration;
const key = `${computerId}:${sessionId}`;
@@ -67,6 +67,17 @@ const AS_DENY = [
// tools, which carry the per-app gates.
[/\b(keystroke|key\s+code)\b/i, "System Events keystrokes go to the frontmost app, not the named one — use the type or key tool"],
[/\bclick\s+at\b/i, "coordinate clicks through System Events go to whatever is on screen — use the click tool"],
// StandardAdditions run in osascript itself, whatever app a script names:
// file I/O, opening URLs, mounting volumes and reading the environment are
// not app scripting.
[/\bopen\s+for\s+access\b/i, "StandardAdditions file access (`open for access`) is not app scripting"],
[/\b(read|write)\b[^\n]*\b(POSIX\s+file|file|alias)\b/i, "StandardAdditions file reads and writes are not app scripting"],
[/\bopen\s+location\b/i, "`open location` opens a URL outside any consented app"],
[/\bmount\s+volume\b/i, "`mount volume` is not app scripting"],
[/\bsystem\s+attribute\b/i, "`system attribute` reads the environment"],
// A consented app must not become a launcher for another app or a
// runnable file (Finder, `launch`, `reopen`).
[/\b(open|launch|reopen)\b[^\n]*\b(POSIX\s+file|file|alias|application\s+file|disk\s+item)\b/i, "opening files or applications through another app is refused — use open_application, which asks for consent"],
];
function checkAppleScript(script) {
@@ -123,6 +134,9 @@ const JXA_DENY = [
[/\b(eval|Function|Library|Ref|require|importScripts|constructor|prototype|__proto__|Reflect|Proxy)\b/, "dynamic code loading, evaluation and reflection are refused"],
[/\bObject\s*\.\s*(getOwnProperty\w*|defineProperty|defineProperties|entries|values|assign|getPrototypeOf|setPrototypeOf)\b/, "reflection over objects is refused"],
[/\bosascript\b/i, "nested osascript is refused"],
[/\.\s*(open|launch|reopen)\s*\(/, "opening files or applications through another app is refused — use open_application, which asks for consent"],
[/\bPath\s*\(/, "file paths (Path(…)) are not app scripting"],
[/\.\s*(openLocation|mountVolume|systemAttribute|openForAccess|read|write)\s*\(/, "StandardAdditions calls are not app scripting"],
];
function checkJxa(script) {
@@ -177,10 +191,16 @@ const shellHost = (ref) => SHELL_HOSTS.has(String(ref.bundle_id ?? ref.name ?? "
* Check one app_script call. Returns {refused: string|null, targets: ref[]}
* where each ref is {name} or {bundle_id} for the consent ledger.
*/
/** The consent identity of a script that names no application: osascript itself. */
export const OSASCRIPT_TARGET = Object.freeze({ bundle_id: "com.apple.osascript" });
export function checkAppScript(script, language = "applescript", env = process.env) {
const mode = appScriptMode(env);
if (mode === "off") return refuse("app_script is turned off on this computer (CODEWHALE_CU_APP_SCRIPT=off)");
const checked = language === "javascript" ? checkJxa(String(script)) : checkAppleScript(String(script));
// A script that names no app still runs, as osascript: it is consented to
// under that identity, in every mode.
if (!checked.targets.length) checked.targets.push({ ...OSASCRIPT_TARGET });
if (mode === "unrestricted") return { refused: null, targets: checked.targets };
if (checked.refused) return checked;
const host = checked.targets.find(shellHost);
@@ -64,11 +64,23 @@ export function runInfoPath() { return path.join(stateDir(), "app-run.json"); }
export function readRegistration() {
try {
const reg = JSON.parse(fs.readFileSync(registrationPath(), "utf8"));
if (!Array.isArray(reg?.launch) || reg.launch.length === 0 || typeof reg.launch[0] !== "string") return null;
if (!launchArgv(reg)) return null;
return reg;
} catch { return null; }
}
/**
* The argv that starts the registered app: always the bundle's own launcher,
* derived from its path. app.json is a file any process of this user can
* write, so a `launch` argv stored there is never run.
*/
export function launchArgv(reg, platform = process.platform) {
const bundle = reg?.path;
if (typeof bundle !== "string" || !path.isAbsolute(bundle) || bundle.includes("\0")) return null;
if (platform === "darwin" && !/\.app\/?$/i.test(bundle)) return null;
return defaultLaunch(bundle, platform);
}
export function writeRegistration(reg) {
fs.mkdirSync(stateDir(), { recursive: true });
fs.writeFileSync(registrationPath(), JSON.stringify({ ...reg, registeredAt: new Date().toISOString() }, null, 2) + "\n");
@@ -182,7 +194,9 @@ export function defaultLaunch(bundlePath, platform = process.platform) {
/** Start the registered app detached (LaunchServices on macOS so TCC attributes it to the app). */
export function launchApp(reg) {
const [cmd, ...args] = reg.launch;
const argv = launchArgv(reg);
if (!argv) return null;
const [cmd, ...args] = argv;
const child = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true });
child.on("error", () => {});
child.unref();
+45 -14
View File
@@ -18,8 +18,32 @@
// actually enforce on this surface.
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { stateDir } from "./registry.mjs";
// Persisted allows are signed. consent.json is a file any process of this
// user can write, so an allow counts only when it carries a MAC under the
// ledger key the host keeps in its secret store (sent over the host channel,
// see server.mjs). Denies need no signature: they only narrow. Without a
// ledger key, allows live for the session only.
let ledgerKey = null;
export function setLedgerKey(key) { ledgerKey = Buffer.isBuffer(key) && key.length >= 32 ? key : null; }
function entryMac(computerId, key, entry) {
return crypto.createHmac("sha256", ledgerKey).update(`${computerId}\0${key}\0${entry.decision}\0${entry.at ?? ""}`).digest("hex");
}
function signed(computerId, key, entry) {
return entry.decision === "allow" && ledgerKey ? { ...entry, mac: entryMac(computerId, key, entry) } : entry;
}
/** A persisted entry that may be honored: any deny, or an allow whose MAC verifies. */
function honored(computerId, key, entry) {
if (!entry || typeof entry !== "object") return false;
if (entry.decision === "deny") return true;
if (entry.decision !== "allow" || !ledgerKey || typeof entry.mac !== "string") return false;
const expected = Buffer.from(entryMac(computerId, key, entry), "hex");
const given = /^[0-9a-f]+$/i.test(entry.mac) ? Buffer.from(entry.mac, "hex") : Buffer.alloc(0);
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
const ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
const consentPath = () => path.join(stateDir(), "consent.json");
@@ -86,7 +110,8 @@ export function parseAppArg({ app, name, bundle_id, pid } = {}) {
}
function persistedEntries(computerId) {
return load().computers[computerId]?.apps ?? {};
const apps = load().computers[computerId]?.apps ?? {};
return Object.fromEntries(Object.entries(apps).filter(([key, entry]) => honored(computerId, key, entry)));
}
/**
@@ -120,18 +145,20 @@ export function record(computerId, keys, decision, { remember = false, name = nu
if (decision !== "allow" && decision !== "deny") throw Object.assign(new Error(`decision must be "allow" or "deny"`), { code: "bad_args" });
const at = new Date().toISOString();
const entry = { decision, at, ...(name ? { name } : {}) };
// pid keys never persist — see appKeys.
// pid keys never persist — see appKeys. An allow persists only when it
// can be signed.
const persistable = keys.filter((k) => !k.startsWith("pid:"));
if (remember && persistable.length) {
const persist = remember && persistable.length > 0 && (decision === "deny" || ledgerKey != null);
if (persist) {
const data = load();
const apps = (data.computers[computerId] ??= { apps: {} }).apps ??= {};
for (const key of persistable) apps[key] = { ...entry };
for (const key of persistable) apps[key] = signed(computerId, key, { ...entry });
save(data);
}
const sm = sessionMap(computerId);
const backed = new Set(remember ? persistable : []);
const backed = new Set(persist ? persistable : []);
for (const key of keys) sm.set(key, { ...entry, ...(backed.has(key) ? { persisted: true } : {}) });
return { keys, decision, persisted: remember && persistable.length > 0 };
return { keys, decision, persisted: persist };
}
/**
@@ -146,14 +173,16 @@ export function alias(computerId, keys, { persisted = false, name = null } = {})
const sm = sessionMap(computerId);
const entry = { decision: "allow", at, ...(name ? { name } : {}) };
let durableKeys = new Set();
if (persisted) {
if (persisted && ledgerKey) {
const data = load();
const apps = (data.computers[computerId] ??= { apps: {} }).apps ??= {};
for (const key of keys.filter((k) => !k.startsWith("pid:"))) apps[key] ??= { ...entry };
for (const key of keys.filter((k) => !k.startsWith("pid:"))) {
if (!honored(computerId, key, apps[key])) apps[key] = signed(computerId, key, { ...entry });
}
save(data);
// A key already persisted as deny stays deny on disk — the marker only
// goes on keys whose disk entry is actually this allow.
durableKeys = new Set(keys.filter((k) => apps[k]?.decision === "allow"));
durableKeys = new Set(keys.filter((k) => apps[k]?.decision === "allow" && honored(computerId, k, apps[k])));
}
for (const key of keys) sm.set(key, { ...entry, ...(durableKeys.has(key) ? { persisted: true } : {}) });
}
@@ -174,7 +203,8 @@ export function revoke(computerId, keys) {
export function foregroundDecision(computerId) {
const s = session.get(computerId)?.get("scope:foreground");
const p = load().computers[computerId]?.foreground;
const stored = load().computers[computerId]?.foreground;
const p = honored(computerId, "scope:foreground", stored) ? stored : null;
const pick = [s && { ...s, persisted: s.persisted === true }, p && { ...p, persisted: true }]
.filter(Boolean)
.sort((a, b) => String(b.at).localeCompare(String(a.at)))[0];
@@ -184,13 +214,14 @@ export function foregroundDecision(computerId) {
export function recordForeground(computerId, decision, { remember = false } = {}) {
const at = new Date().toISOString();
sessionMap(computerId).set("scope:foreground", { decision, at, ...(remember ? { persisted: true } : {}) });
if (remember) {
const persist = remember && (decision === "deny" || ledgerKey != null);
sessionMap(computerId).set("scope:foreground", { decision, at, ...(persist ? { persisted: true } : {}) });
if (persist) {
const data = load();
(data.computers[computerId] ??= {}).foreground = { decision, at };
(data.computers[computerId] ??= {}).foreground = signed(computerId, "scope:foreground", { decision, at });
save(data);
}
return { scope: "foreground", decision, persisted: remember };
return { scope: "foreground", decision, persisted: persist };
}
export function revokeForeground(computerId) {
@@ -4,6 +4,7 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { validateSshTarget } from "./ssh-args.mjs";
export class RegistryError extends Error {
constructor(code, message) { super(message); this.name = "RegistryError"; this.code = code; }
@@ -29,10 +30,22 @@ function localEntry() {
};
}
/** An entry read from computers.json that could not have been registered. */
function unusableEntry(entry) {
if (!entry || typeof entry !== "object") return true;
if (entry.transport !== "ssh") return false;
try { validateSshTarget(entry); return false; } catch { return true; }
}
export function load() {
try {
const raw = JSON.parse(fs.readFileSync(registryPath(), "utf8"));
if (!raw || typeof raw !== "object" || !raw.computers) throw new Error("bad shape");
// The file is state, not authority: an entry that registration would
// refuse (an ssh host or user that reads as an option) is dropped.
for (const [id, entry] of Object.entries(raw.computers)) {
if (unusableEntry(entry)) delete raw.computers[id];
}
return raw;
} catch {
const fresh = { version: 1, active: "local", computers: { local: localEntry() } };
@@ -84,14 +97,10 @@ export function register({ id, transport, label, ...rest }) {
throw new RegistryError("reserved_id", '"local" is reserved for this machine');
}
if (transport === "ssh") {
if (!rest.host || !/^[A-Za-z0-9._-]+$/.test(rest.host)) {
throw new RegistryError("invalid_host", "ssh computers need a valid host (letters, digits, dot, dash, underscore)");
}
if (rest.port != null && (!Number.isInteger(rest.port) || rest.port < 1 || rest.port > 65535)) {
throw new RegistryError("invalid_port", "port must be an integer in 1..65535");
}
if (rest.user != null && !/^[a-zA-Z0-9._-]+$/.test(rest.user)) {
throw new RegistryError("invalid_user", "user must be a plain name");
try {
validateSshTarget(rest);
} catch (e) {
throw new RegistryError(e.code ?? "invalid_host", e.message);
}
}
if (transport === "hdc") {
@@ -0,0 +1,56 @@
// One set of ssh rules for every ssh this plugin starts (the remote agent,
// its persistent channel, and the agent install copy). Codewhale's Fleet SSH
// host applies the same rules; tests/fixtures/ssh-destinations.json holds the
// vectors both implementations are tested against.
// - host keys must already be known: StrictHostKeyChecking=yes, no updates;
// a computer may name its own known-hosts file, which is then the only one
// - a host or user is a destination, never an option: neither may start
// with "-", and "--" ends the options before the destination
export const SSH_HOST_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
export const SSH_USER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
export class SshTargetError extends Error {
constructor(code, message) { super(message); this.name = "SshTargetError"; this.code = code; }
}
/** Throw when an ssh computer entry cannot be used as a destination. */
export function validateSshTarget({ host, user, port, knownHosts } = {}) {
if (typeof host !== "string" || !SSH_HOST_RE.test(host)) {
throw new SshTargetError("invalid_host", "ssh computers need a valid host (letters, digits, dot, dash, underscore; not starting with '-')");
}
if (user != null && (typeof user !== "string" || !SSH_USER_RE.test(user))) {
throw new SshTargetError("invalid_user", "user must be a plain name (not starting with '-')");
}
if (port != null && (!Number.isInteger(port) || port < 1 || port > 65535)) {
throw new SshTargetError("invalid_port", "port must be an integer in 1..65535");
}
if (knownHosts != null && (typeof knownHosts !== "string" || !knownHosts.startsWith("/") || /[\s"\\]/.test(knownHosts))) {
throw new SshTargetError("invalid_known_hosts", "knownHosts must be an absolute path without spaces or quotes");
}
}
/** ssh options (no destination) for a computer entry. */
export function sshOptions(computer, { portFlag = "-p" } = {}) {
const options = [
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=8",
"-o", "StrictHostKeyChecking=yes",
"-o", "UpdateHostKeys=no",
];
if (computer.knownHosts) {
options.push("-o", `UserKnownHostsFile=${computer.knownHosts}`, "-o", "GlobalKnownHostsFile=/dev/null");
}
if (computer.port) options.push(portFlag, String(computer.port));
return options;
}
export function sshDestination(computer) {
return computer.user ? `${computer.user}@${computer.host}` : computer.host;
}
/** Full ssh argv prefix: options, "--", destination. The remote command follows. */
export function sshArgv(computer) {
validateSshTarget(computer);
return [...sshOptions(computer), "--", sshDestination(computer)];
}
@@ -45,8 +45,8 @@ export const TOOLS = [
{ name: "preview", description: "macOS: show or hide the nonactivating app preview with the drawn agent cursor. On by default while an app is bound — each action updates the captured window and cursor without moving the real pointer. Set enabled:false to mute it for the session.", inputSchema: { type: "object", properties: { enabled: { type: "boolean" }, computer: computerParam }, additionalProperties: false } },
// ---- computers (switching is a default) ----
{
name: "computer", description: "The computer registry. action list | switch | register | spawn | remove. switch/register/spawn/remove take `id`; register also takes transport (local|ssh|hdc) plus host/port/user/target/installAgent; spawn takes transport (docker) plus optional image/label and creates a task-owned disposable desktop that remove or session end destroys. Prefer a spawned computer for work that does not need the user's own session. Every other tool also accepts `computer` to switch stickily on use.",
inputSchema: { type: "object", required: ["action"], properties: { action: { enum: ["list", "switch", "register", "spawn", "remove"] }, id: { type: "string", description: "Short id for the registered computer (letters, digits, dot, dash)" }, transport: { enum: ["local", "ssh", "hdc", "docker"] }, label: { type: "string" }, image: { type: "string", description: "spawn/docker: image to run (default the plugin's Linux desktop image)" }, host: { type: "string", description: "ssh: hostname" }, port: { type: "integer", description: "ssh: port (default 22)" }, user: { type: "string", description: "ssh: user" }, target: { type: "string", description: "hdc: target key (omit for the only connected device)" }, installAgent: { type: "boolean", description: "ssh: push the remote agent before first use (default true)" } }, additionalProperties: false },
name: "computer", description: "The computer registry. action list | switch | register | spawn | remove. switch/register/spawn/remove take `id`; register also takes transport (local|ssh|hdc) plus host/port/user/knownHosts/target/installAgent; spawn takes transport (docker) plus optional image/label and creates a task-owned disposable desktop that remove or session end destroys. Prefer a spawned computer for work that does not need the user's own session. Every other tool also accepts `computer` to switch stickily on use.",
inputSchema: { type: "object", required: ["action"], properties: { action: { enum: ["list", "switch", "register", "spawn", "remove"] }, id: { type: "string", description: "Short id for the registered computer (letters, digits, dot, dash)" }, transport: { enum: ["local", "ssh", "hdc", "docker"] }, label: { type: "string" }, image: { type: "string", description: "spawn/docker: image to run (default the plugin's Linux desktop image)" }, host: { type: "string", description: "ssh: hostname" }, port: { type: "integer", description: "ssh: port (default 22)" }, user: { type: "string", description: "ssh: user" }, knownHosts: { type: "string", description: "ssh: absolute path to the existing trusted host-key file" }, target: { type: "string", description: "hdc: target key (omit for the only connected device)" }, installAgent: { type: "boolean", description: "ssh: push the remote agent before first use (default true)" } }, additionalProperties: false },
},
{
name: "computer_list",
@@ -71,6 +71,7 @@ export const TOOLS = [
host: { type: "string", description: "ssh: hostname" },
port: { type: "integer", description: "ssh: port (default 22)" },
user: { type: "string", description: "ssh: user" },
knownHosts: { type: "string", description: "ssh: absolute path to the existing trusted host-key file" },
target: { type: "string", description: "hdc: target key (omit for the only connected device)" },
installAgent: { type: "boolean", description: "ssh: push the remote agent before first use (default true)" },
},
@@ -6,6 +6,7 @@
// - hdc: HarmonyOS device over `hdc` shell / file push-pull
import { run, runOk, runInputLease, ExecError, currentSignal } from "./exec.mjs";
import { ensureApp, appSessionRequest } from "./app-socket.mjs";
import { sshArgv, sshDestination, sshOptions, validateSshTarget } from "./ssh-args.mjs";
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
@@ -201,10 +202,9 @@ function attachPersistentChannel(ex, binding, serveArgv) {
/** ssh executor: speaks to the remote agent installed by installRemoteAgent(). */
export function sshExec(computer, binding) {
const userHost = computer.user ? `${computer.user}@${computer.host}` : computer.host;
const portArgs = computer.port ? ["-p", String(computer.port)] : [];
const userHost = sshDestination(computer);
const remoteAgent = safeRemotePath(computer.agentPath ?? ".codewhale-cu/agent/agent.mjs");
const base = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "StrictHostKeyChecking=accept-new", ...portArgs, userHost];
const base = sshArgv(computer);
const ex = {
kind: "ssh",
base,
@@ -286,7 +286,8 @@ export async function installRemoteAgent(computer) {
for (const rel of rels) {
const localPath = rel === "agent.mjs" ? path.join(PLUGIN_ROOT, "agent.mjs") : path.join(srcDir, rel.slice(4));
const dest = safeRemotePath(`${marker}/${rel}`);
r = await run("scp", [...(computer.port ? ["-P", String(computer.port)] : []), localPath, `${ex.userHost}:${dest}`], { timeoutMs: 30_000 });
validateSshTarget(computer);
r = await run("scp", [...sshOptions(computer, { portFlag: "-P" }), "--", localPath, `${ex.userHost}:${dest}`], { timeoutMs: 30_000 });
if (r.code !== 0) throw new ExecError(`scp ${rel} failed: ${r.stderr.trim().slice(0, 300)}`, r);
}
// Probe remote platform via the agent itself.
@@ -372,7 +373,7 @@ export function routeFingerprint(computer) {
if (computer.transport === "docker") route.push(computer.container || null);
if (computer.transport === "hdc") route.push(computer.target || null);
if (computer.transport === "ssh") route.push(computer.host, computer.user || null,
computer.port || null, computer.agentPath ?? ".codewhale-cu/agent/agent.mjs");
computer.port || null, computer.knownHosts || null, computer.agentPath ?? ".codewhale-cu/agent/agent.mjs");
return JSON.stringify(route);
}
@@ -2,6 +2,7 @@
// dictionary. Local-computer only — a remote channel must never become a
// shell, so ssh/hdc computers refuse before dispatch and the remote agent
// refuses again at its own handler boundary.
import { hostKeysLine, attest, attestParams } from "./fixtures/host-decision.mjs";
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
@@ -20,6 +21,7 @@ async function boot(t, env = {}) {
env: { ...process.env, CODEWHALE_CU_STATE_DIR: stateDir, CODEWHALE_CU_RECORDINGS_DIR: recDir, CODEWHALE_CU_APP: "off", ...env },
stdio: ["pipe", "pipe", "pipe"],
});
child.stdin.write(hostKeysLine());
t.after(() => { try { child.stdin.end(); } catch {} child.kill("SIGTERM"); fs.rmSync(stateDir, { recursive: true, force: true }); fs.rmSync(recDir, { recursive: true, force: true }); });
let buf = "";
const pending = new Map();
@@ -40,7 +42,7 @@ async function boot(t, env = {}) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, 20_000);
pending.set(id, (msg) => { clearTimeout(timer); resolve(msg); });
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params: attestParams(method, params) }) + "\n");
});
};
const tool = async (name, args = {}) => JSON.parse((await rpc("tools/call", { name, arguments: args })).result.content[0].text);
@@ -58,6 +60,10 @@ test("tools/list advertises app_script with a required script", async (t) => {
test("app_script runs AppleScript and JXA on the local computer", { skip: process.platform !== "darwin" }, async (t) => {
const s = await boot(t);
// A script that names no app runs as osascript and needs that consent.
const unconsented = await s.tool("app_script", { script: 'return "whole computer"' });
assert.equal(unconsented.error?.code, "consent_required");
assert.equal((await s.tool("consent", { action: "allow", bundle_id: "com.apple.osascript" })).ok, true);
const as = await s.tool("app_script", { script: 'return "whole computer"' });
assert.equal(as.ok, true);
assert.equal(as.result, "whole computer");
@@ -69,6 +75,7 @@ test("app_script runs AppleScript and JXA on the local computer", { skip: proces
test("app_script failures are typed, never opaque", { skip: process.platform !== "darwin" }, async (t) => {
const s = await boot(t);
assert.equal((await s.tool("consent", { action: "allow", bundle_id: "com.apple.osascript" })).ok, true);
const bad = await s.tool("app_script", { script: "this is not applescript at all" });
assert.equal(bad.ok, false);
assert.equal(bad.error.code, "script_error");
@@ -115,9 +122,10 @@ test("a read-only grant never advertises or calls app_script", async (t) => {
});
test("a named grant admits app_script exactly", { skip: process.platform !== "darwin" }, async (t) => {
const s = await boot(t, { CODEWHALE_CU_GRANT: "app_script" });
const s = await boot(t, { CODEWHALE_CU_GRANT: "app_script,consent" });
const names = (await s.rpc("tools/list", {})).result.tools.map((x) => x.name);
assert.ok(names.includes("app_script"));
assert.equal((await s.tool("consent", { action: "allow", bundle_id: "com.apple.osascript" })).ok, true);
assert.equal((await s.tool("app_script", { script: "return 42" })).result, "42");
});
@@ -128,3 +136,12 @@ test("the kill switch stops scripting too", async (t) => {
assert.equal(r.ok, false);
assert.equal(r.error.code, "control_stopped");
});
test("unrestricted mode still honors a deny for a named target", async (t) => {
const s = await boot(t, { CODEWHALE_CU_APP_SCRIPT: "unrestricted" });
assert.equal((await s.tool("consent", { action: "deny", app: "Mail" })).ok, true);
const r = await s.tool("app_script", { script: 'tell application "Mail" to do shell script "id"' });
assert.equal(r.error?.code, "app_denied");
const bare = await s.tool("app_script", { script: "return 1" });
assert.equal(bare.error?.code, "consent_required", "a no-app script needs osascript consent in every mode");
});
@@ -1,6 +1,7 @@
// The human/agent control lease on a shared Codewhale Computer: input tools
// refuse with computer_busy_human_driving while a person drives, observation
// keeps working, and hand-back restores input without a restart.
import { hostKeysLine, attest, attestParams } from "./fixtures/host-decision.mjs";
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
@@ -77,6 +78,7 @@ function startServer() {
const env = { ...process.env, CODEWHALE_CU_LEASE_FILE: LEASE, CODEWHALE_CU_APP: "off", CODEWHALE_CU_APP_WARM: "off",
CODEWHALE_CU_STATE_DIR: fs.mkdtempSync(path.join(dir, "state-")), CODEWHALE_CU_RECORDINGS_DIR: dir };
const child = spawn(process.execPath, [path.join(ROOT, "mcp/server.mjs")], { env, stdio: ["pipe", "pipe", "ignore"] });
child.stdin.write(hostKeysLine());
let buf = "";
const pending = new Map();
let nextId = 1;
@@ -92,7 +94,7 @@ function startServer() {
const rpc = (method, params) => new Promise((resolve) => {
const id = nextId++;
pending.set(id, resolve);
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params: attestParams(method, params) })}\n`);
});
const tool = async (name, args = {}) => JSON.parse((await rpc("tools/call", { name, arguments: args })).result.content[0].text);
return { child, rpc, tool };
@@ -2,6 +2,7 @@
// computer. Unit tests pin the ledger; server tests prove the gate refuses
// before backend dispatch, cannot be sidestepped by re-spelling the app, and
// that foreground control is a separate consent from app access.
import { hostKeysLine, attest, attestParams, TEST_LEDGER_KEY, ledgerMac } from "./fixtures/host-decision.mjs";
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
@@ -22,6 +23,9 @@ function freshDir() {
process.env.CODEWHALE_CU_STATE_DIR = dir;
return dir;
}
// The unit tests run as the host would configure the plugin: remembered
// allows are signed with the ledger key.
consent.setLedgerKey(Buffer.from(TEST_LEDGER_KEY, "hex"));
// Every test gets a clean store and a clean session map key space.
let cidSeq = 0;
const cid = () => `c${cidSeq++}`;
@@ -132,13 +136,14 @@ test("status merges persisted and session entries and labels their source", () =
// ---------- wire: the gate, over the real server ----------
async function boot(t, env = {}) {
async function boot(t, env = {}, { hostKeys = true, attested = true, elicitation = null } = {}) {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-consent-srv-"));
const recDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-consent-rec-"));
const child = spawn("node", [path.join(ROOT, "mcp", "server.mjs")], {
env: { ...process.env, CODEWHALE_CU_STATE_DIR: stateDir, CODEWHALE_CU_RECORDINGS_DIR: recDir, CODEWHALE_CU_APP: "off", CODEWHALE_CU_TEST_BACKEND: path.join(ROOT, "tests", "fixtures", "fake-backend.mjs"), ...env },
stdio: ["pipe", "pipe", "pipe"],
});
if (hostKeys) child.stdin.write(hostKeysLine());
t.after(() => { try { child.stdin.end(); } catch {} child.kill("SIGTERM"); fs.rmSync(stateDir, { recursive: true, force: true }); fs.rmSync(recDir, { recursive: true, force: true }); });
let buf = "";
const pending = new Map();
@@ -151,6 +156,11 @@ async function boot(t, env = {}) {
buf = buf.slice(i + 1);
if (!line) continue;
const msg = JSON.parse(line);
if (msg.method === "elicitation/create" && elicitation) {
if (typeof elicitation === "function") elicitation(msg);
else child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { action: elicitation } }) + "\n");
continue;
}
if (msg.id != null && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); }
}
});
@@ -159,11 +169,13 @@ async function boot(t, env = {}) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, timeoutMs);
pending.set(id, (msg) => { clearTimeout(timer); resolve(msg); });
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params: attested ? attestParams(method, params) : params }) + "\n");
});
};
const tool = async (name, args = {}, timeoutMs) => JSON.parse((await rpc("tools/call", { name, arguments: args }, timeoutMs)).result.content[0].text);
return { rpc, tool, stateDir };
const send = (params) => rpc("tools/call", params).then((r) => JSON.parse(r.result.content[0].text));
const sendWire = message => child.stdin.write(JSON.stringify(message) + "\n");
return { rpc, tool, send, sendWire, stateDir };
}
test("first app contact refuses consent_required before any backend work", async (t) => {
@@ -259,3 +271,106 @@ test("spawned computers are task-owned — the app ledger never gates them", { s
const st = await s.tool("consent", { action: "status", computer: id });
assert.equal(st.ok, true);
});
// ---------- the user's own decision ----------
test("consent allow without a host decision is refused", async (t) => {
const s = await boot(t, {}, { attested: false });
const r = await s.tool("consent", { action: "allow", app: "FakeApp" });
assert.equal(r.ok, false);
assert.equal(r.error.code, "consent_needs_user");
for (const [name, args] of [["consent_allow", { app: "FakeApp" }], ["consent", { action: "revoke", app: "FakeApp" }], ["app_script", { script: "return 1" }], ["computer_register", { computer: "box", transport: "local" }]]) {
const refused = await s.tool(name, args);
assert.equal(refused.error?.code, "consent_needs_user", name);
}
// Narrowing needs no decision.
assert.equal((await s.tool("consent", { action: "deny", app: "OtherApp" })).ok, true);
const opened = await s.tool("open_application", { name: "FakeApp" });
assert.equal(opened.error?.code, "consent_required");
});
test("consent allow with a valid decision MAC succeeds; replayed nonce is refused", async (t) => {
const s = await boot(t, {}, { attested: false });
const params = attest({ name: "consent", arguments: { action: "allow", app: "FakeApp" } });
assert.equal((await s.send(params)).ok, true);
const replayed = await s.send(params);
assert.equal(replayed.error?.code, "consent_needs_user");
// A MAC for other arguments does not cover these.
const forged = attest({ name: "consent", arguments: { action: "allow", app: "OtherApp" } });
forged.arguments = { action: "allow", app: "Terminal" };
assert.equal((await s.send(forged)).error?.code, "consent_needs_user");
// A MAC under another key does not verify.
const wrongKey = attest({ name: "consent", arguments: { action: "allow", app: "Terminal" } }, "33".repeat(32));
assert.equal((await s.send(wrongKey)).error?.code, "consent_needs_user");
});
test("a decision is refused when the host sent no key, and keys are read only from the first message", async (t) => {
const s = await boot(t, {}, { hostKeys: false });
// Keys arriving after the first message are ignored.
await s.rpc("ping", {});
const late = await s.rpc("codewhale/host_keys", { decision_key: "11".repeat(32) }, 300).catch(() => null);
void late;
const r = await s.tool("consent", { action: "allow", app: "FakeApp" });
assert.equal(r.error?.code, "consent_needs_user");
});
test("a client with elicitation decides for other hosts", async (t) => {
for (const [answer, ok] of [["accept", true], ["decline", false]]) {
const s = await boot(t, {}, { hostKeys: false, attested: false, elicitation: answer });
await s.rpc("initialize", { protocolVersion: "2025-06-18", capabilities: { elicitation: {} } });
const r = await s.tool("consent", { action: "allow", app: "FakeApp" });
assert.equal(r.ok, ok, `${answer}: ${JSON.stringify(r)}`);
if (!ok) assert.equal(r.error.code, "consent_declined");
}
});
test("planted consent.json allow entry without MAC is ignored; deny is honored", () => {
const dir = freshDir();
const id = cid();
const at = new Date().toISOString();
const good = { decision: "allow", at };
fs.writeFileSync(path.join(dir, "consent.json"), JSON.stringify({ version: 1, computers: { [id]: {
apps: {
"name:planted": { decision: "allow", at },
"name:forged": { decision: "allow", at, mac: "00".repeat(32) },
"name:signed": { ...good, mac: ledgerMac(id, "name:signed", good) },
"name:blocked": { decision: "deny", at },
},
foreground: { decision: "allow", at },
} } }));
assert.equal(consent.decisionFor(id, ["name:planted"]).state, "undecided");
assert.equal(consent.decisionFor(id, ["name:forged"]).state, "undecided");
assert.equal(consent.decisionFor(id, ["name:signed"]).state, "allowed");
assert.equal(consent.decisionFor(id, ["name:blocked"]).state, "denied");
assert.equal(consent.foregroundDecision(id).state, "undecided");
});
test("confirm token requires a host decision", async (t) => {
const s = await boot(t, {}, { attested: false });
const r = await s.tool("consent", { action: "allow", confirm: "confirm-000000000000000000" });
assert.equal(r.error?.code, "consent_needs_user");
});
test("run_actions app_script step needs a host decision", async (t) => {
const s = await boot(t);
const r = await s.tool("run_actions", { steps: [{ tool: "app_script", arguments: { script: "return 1" } }] });
assert.equal(r.ok, false);
assert.equal(r.error.code, "consent_needs_user");
});
test("cancelled user elicitation releases dispatch and cannot grant consent later", async (t) => {
let sawPrompt;
const prompt = new Promise(resolve => { sawPrompt = resolve; });
const s = await boot(t, {}, { hostKeys: false, attested: false, elicitation: sawPrompt });
await s.rpc("initialize", { protocolVersion: "2025-06-18", capabilities: { elicitation: {} } });
s.sendWire({ jsonrpc: "2.0", id: "cancelled-consent", method: "tools/call", params: { name: "consent", arguments: { action: "allow", app: "FakeApp" } } });
const request = await Promise.race([prompt, new Promise((_, reject) => setTimeout(() => reject(new Error("elicitation was not shown")), 2_000))]);
s.sendWire({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: "cancelled-consent" } });
// The unanswered card no longer holds the serialized tool queue.
const before = await s.tool("consent", { action: "status" }, 1_000);
assert.deepEqual(before.apps, {});
s.sendWire({ jsonrpc: "2.0", id: request.id, result: { action: "accept" } });
const after = await s.tool("consent", { action: "status" }, 1_000);
assert.deepEqual(after.apps, {}, "a late accept cannot grant the cancelled request");
});
@@ -0,0 +1,35 @@
// Test host: what Codewhale does for the plugin it spawns. The first stdin
// line carries the per-connection decision key (and the ledger key), and a
// tools/call the user approved carries an attested decision in _meta.
import crypto from "node:crypto";
export const TEST_DECISION_KEY = "11".repeat(32);
export const TEST_LEDGER_KEY = "22".repeat(32);
export function hostKeysLine({ ledger = true } = {}) {
return JSON.stringify({
jsonrpc: "2.0",
method: "codewhale/host_keys",
params: { decision_key: TEST_DECISION_KEY, ...(ledger ? { ledger_key: TEST_LEDGER_KEY } : {}) },
}) + "\n";
}
/** tools/call params with a decision attested under the test key. */
export function attest(params, key = TEST_DECISION_KEY) {
if (!params || typeof params.name !== "string") return params;
const argsJson = JSON.stringify(params.arguments ?? {});
const nonce = crypto.randomBytes(16).toString("hex");
const mac = crypto.createHmac("sha256", Buffer.from(key, "hex"))
.update(Buffer.concat([Buffer.from(params.name), Buffer.from([0]), Buffer.from(argsJson), Buffer.from([0]), Buffer.from(nonce)]))
.digest("hex");
return { ...params, _meta: { ...(params._meta ?? {}), "codewhale/user_decision": { nonce, args_json: argsJson, mac } } };
}
export const attestParams = (method, params) => (method === "tools/call" ? attest(params) : params);
/** Sign a persisted allow the way the plugin does under TEST_LEDGER_KEY. */
export function ledgerMac(computerId, key, entry) {
return crypto.createHmac("sha256", Buffer.from(TEST_LEDGER_KEY, "hex"))
.update(`${computerId}\0${key}\0${entry.decision}\0${entry.at ?? ""}`)
.digest("hex");
}
@@ -0,0 +1,16 @@
{
"comment": "Shared ssh destination vectors: Codewhale Fleet SSH hosts (crates/tui/src/fleet/host.rs) and Computer Use ssh computers (src/ssh-args.mjs) must both accept `valid` and refuse `invalid`.",
"valid": [
{ "host": "builder.example.test" },
{ "host": "10.0.0.7", "user": "fleet" },
{ "host": "build_box-2", "user": "ci.bot" }
],
"invalid": [
{ "host": "-oProxyCommand=sh" },
{ "host": "-Fx" },
{ "host": "builder example" },
{ "host": "builder.example.test", "user": "-oProxyCommand=sh" },
{ "host": "builder.example.test", "user": "a@b" },
{ "host": "builder.example.test", "user": "a b" }
]
}
@@ -1,6 +1,7 @@
// Capability grants: CODEWHALE_CU_GRANT narrows the advertised and callable
// surface for the whole server process, fixed at launch; the daemon enforces
// the same set independently (covered in session-lifecycle.test.mjs).
import { hostKeysLine, attest, attestParams } from "./fixtures/host-decision.mjs";
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
@@ -18,6 +19,7 @@ async function boot(t, grant) {
env: { ...process.env, CODEWHALE_CU_STATE_DIR: stateDir, CODEWHALE_CU_RECORDINGS_DIR: recDir, CODEWHALE_CU_APP: "off", CODEWHALE_CU_GRANT: grant },
stdio: ["pipe", "pipe", "pipe"],
});
child.stdin.write(hostKeysLine());
t.after(() => { try { child.stdin.end(); } catch {} child.kill("SIGTERM"); fs.rmSync(stateDir, { recursive: true, force: true }); fs.rmSync(recDir, { recursive: true, force: true }); });
let buf = "";
const pending = new Map();
@@ -38,7 +40,7 @@ async function boot(t, grant) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, 20_000);
pending.set(id, (msg) => { clearTimeout(timer); resolve(msg); });
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params: attestParams(method, params) }) + "\n");
});
};
const tool = async (name, args = {}) => JSON.parse((await rpc("tools/call", { name, arguments: args })).result.content[0].text);
@@ -4,6 +4,7 @@
// consent ledger (System Events and its processes included);
// - irreversible-action confirmation: pay/buy/order/send/transfer/delete
// controls need a per-call user confirmation that no app grant covers.
import { hostKeysLine, attest, attestParams } from "./fixtures/host-decision.mjs";
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
@@ -38,7 +39,7 @@ function rpc(method, params) {
return new Promise((resolve, reject) => {
const t = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, 30_000);
pending.set(id, (msg) => { clearTimeout(t); resolve(msg); });
server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params: attestParams(method, params) }) + "\n");
});
}
async function tool(name, args = {}) {
@@ -66,6 +67,7 @@ before(async () => {
},
stdio: ["pipe", "pipe", "pipe"],
});
server.stdin.write(hostKeysLine());
server.stdout.setEncoding("utf8");
server.stdout.on("data", (d) => {
buf += d;
@@ -115,7 +117,7 @@ test("app_script policy refuses shell escapes in AppleScript and JXA", () => {
});
test("app_script policy names every target app and refuses targets it cannot read", () => {
assert.deepEqual(checkAppScript('return "whole computer"').targets, []);
assert.deepEqual(checkAppScript('return "whole computer"').targets, [{ bundle_id: "com.apple.osascript" }]);
assert.deepEqual(checkAppScript('tell application "Finder" to get name of every window').targets, [{ name: "Finder" }]);
assert.deepEqual(checkAppScript('tell application id "com.apple.Safari" to get URL of front document').targets, [{ bundle_id: "com.apple.Safari" }]);
const se = checkAppScript('tell application "System Events" to tell process "Safari" to click button 1 of window 1');
@@ -135,6 +137,29 @@ test("app_script policy names every target app and refuses targets it cannot rea
]) assert.ok(checkAppScript(script, language).refused, `must refuse: ${script}`);
});
test("app_script with no named app is refused or needs osascript consent", () => {
assert.ok(checkAppScript('read POSIX file "/etc/hosts"').refused);
for (const script of ['set f to open for access POSIX file "/tmp/x" with write permission', 'write "x" to file "Macintosh HD:tmp:x"', 'open location "https://example.com"', 'mount volume "smb://host/share"', 'system attribute "HOME"']) {
assert.ok(checkAppScript(script).refused, script);
}
const bare = checkAppScript("return (current date) as string");
assert.equal(bare.refused, null);
assert.deepEqual(bare.targets, [{ bundle_id: "com.apple.osascript" }]);
assert.deepEqual(checkAppScript('"ok".toUpperCase()', "javascript").targets, [{ bundle_id: "com.apple.osascript" }]);
});
test("Finder open of a denied app is refused", () => {
for (const [script, language] of [
['tell application "Finder" to open POSIX file "/Applications/Terminal.app"', "applescript"],
['tell application "Finder" to open file "run.command" of desktop', "applescript"],
['tell application "Finder" to open application file "Terminal.app" of folder "Applications" of startup disk', "applescript"],
['Application("Finder").open(Path("/Applications/Terminal.app"))', "javascript"],
['Application("Finder").launch()', "javascript"],
]) {
assert.match(checkAppScript(script, language).refused ?? "", /opening files or applications|file paths/, script);
}
});
test("app_script policy modes: off refuses everything, unknown fails closed, unrestricted keeps targets", () => {
assert.equal(appScriptMode({}), "apps");
assert.equal(appScriptMode({ CODEWHALE_CU_APP_SCRIPT: "nonsense" }), "off");
@@ -0,0 +1,48 @@
// The desktop helper applies its own policy to every request that reaches its
// socket, and never runs a launch command read from its state files.
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import url from "node:url";
const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..");
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-helper-policy-"));
process.env.CODEWHALE_CU_STATE_DIR = stateDir;
process.env.CODEWHALE_CU_TEST_BACKEND = path.join(ROOT, "tests", "fixtures", "fake-backend.mjs");
const { handle } = await import("../src/app-handler.mjs");
const { launchArgv, readRegistration, writeRegistration } = await import("../src/app-socket.mjs");
test("app.json launch without MAC is ignored", () => {
const planted = { id: "net.codewhale.computer-use", path: "/Applications/Codewhale Computer Use.app", launch: ["/bin/sh", "-c", "touch /tmp/planted"] };
assert.deepEqual(launchArgv(planted, "darwin"), ["open", "-g", "-a", "/Applications/Codewhale Computer Use.app"]);
for (const bad of [{ launch: ["/bin/sh"] }, { path: "relative.app" }, { path: "/tmp/not-a-bundle" }]) {
assert.equal(launchArgv(bad, "darwin"), null, JSON.stringify(bad));
}
writeRegistration({ id: "x", launch: ["/bin/sh", "-c", "true"] });
assert.equal(readRegistration(), null, "a registration without a bundle path is not launchable");
});
test("the helper refuses scripts its policy refuses, whoever connects", async () => {
const r = await handle({ tool: "app_script", args: { script: 'do shell script "id"' } }, { computerId: "local", sessionId: "raw" });
assert.equal(r.ok, false);
assert.equal(r.error.code, "script_refused");
});
test("the helper honors a recorded deny, whoever connects", async () => {
fs.writeFileSync(path.join(stateDir, "consent.json"), JSON.stringify({ version: 1, computers: { local: { apps: {
"name:terminal": { decision: "deny", at: new Date().toISOString() },
} } } }));
for (const req of [
{ tool: "open_application", args: { name: "Terminal" } },
{ tool: "kill_app", args: { name: "Terminal" } },
{ tool: "app_script", args: { script: 'tell application "Terminal" to activate' } },
]) {
const r = await handle(req, { computerId: "local", sessionId: "raw" });
assert.equal(r.error?.code, req.tool === "app_script" ? "script_refused" : "app_denied", JSON.stringify(req));
}
const other = await handle({ tool: "app_script", args: { script: 'tell application "Notes" to activate' } }, { computerId: "local", sessionId: "raw" });
assert.notEqual(other.error?.code, "app_denied");
});
@@ -56,3 +56,32 @@ test("remove falls back to local when removing the active computer", () => {
assert.throws(() => registry.remove("local"), (e) => e.code === "reserved_id");
assert.throws(() => registry.remove("pad"), (e) => e.code === "unknown_computer");
});
test("ssh hosts or users that read as options are refused at registration and ignored on load", async () => {
const fsMod = await import("node:fs");
const os = await import("node:os");
const pathMod = await import("node:path");
const dir = fsMod.mkdtempSync(pathMod.join(os.tmpdir(), "cu-registry-"));
const previous = process.env.CODEWHALE_CU_STATE_DIR;
process.env.CODEWHALE_CU_STATE_DIR = dir;
try {
const registry = await import("../src/registry.mjs");
assert.throws(() => registry.register({ id: "bad", transport: "ssh", host: "-Fx" }), { code: "invalid_host" });
assert.throws(() => registry.register({ id: "bad2", transport: "ssh", host: "ok.example", user: "-oProxyCommand=x" }), { code: "invalid_user" });
fsMod.writeFileSync(pathMod.join(dir, "computers.json"), JSON.stringify({
version: 1,
active: "planted",
computers: {
planted: { id: "planted", transport: "ssh", host: "-Fx" },
fine: { id: "fine", transport: "ssh", host: "ok.example" },
},
}));
const listed = registry.list();
assert.equal(listed.computers.planted, undefined);
assert.ok(listed.computers.fine);
assert.equal(listed.active, "local");
} finally {
if (previous === undefined) delete process.env.CODEWHALE_CU_STATE_DIR;
else process.env.CODEWHALE_CU_STATE_DIR = previous;
}
});
@@ -1,6 +1,7 @@
// Server protocol tests: real MCP server process over stdio, isolated state.
// The ssh/scp shims stand in for a remote machine, proving the full remote
// agent loop (install -> platform probe -> tool dispatch) without real ssh.
import { hostKeysLine, attest, attestParams } from "./fixtures/host-decision.mjs";
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
@@ -46,7 +47,7 @@ function rpc(method, params, timeoutMs = 90_000) {
return new Promise((resolve, reject) => {
const t = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, timeoutMs);
pending.set(id, (msg) => { clearTimeout(t); resolve(msg); });
server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params: attestParams(method, params) }) + "\n");
});
}
@@ -69,6 +70,7 @@ before(async () => {
},
stdio: ["pipe", "pipe", "pipe"],
});
server.stdin.write(hostKeysLine());
server.stderr.on("data", (d) => process.stderr.write(`[server] ${d}`));
server.stdout.setEncoding("utf8");
server.stdout.on("data", (d) => {
@@ -1,5 +1,6 @@
// Real MCP + real transports, with HDC/SSH executables or local backends replaced.
// Every catalog, downloaded byte and command log belongs to this fixture.
import { hostKeysLine, attest, attestParams } from "./fixtures/host-decision.mjs";
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
@@ -73,6 +74,7 @@ if (args[0] === 'file' && args[1] === 'recv') {
fs.writeFileSync(env.CODEWHALE_CU_TEST_BACKEND, backendSource);
}
const child = spawn(process.execPath, [path.join(ROOT, "mcp/server.mjs")], { env, stdio: ["pipe", "pipe", "pipe"] });
child.stdin.write(hostKeysLine());
let nextId = 0;
const pending = new Map();
const lines = createInterface({ input: child.stdout });
@@ -100,7 +102,7 @@ if (args[0] === 'file' && args[1] === 'recv') {
const response = await new Promise((resolve, reject) => {
timer = setTimeout(() => reject(new Error(`${name} timed out: ${stderr}`)), 8_000);
pending.set(id, resolve);
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method: "tools/call", params: { name, arguments: args } }) + "\n");
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method: "tools/call", params: attest({ name, arguments: args }) }) + "\n");
});
assert.ok(response.result, JSON.stringify(response));
return JSON.parse(response.result.content[0].text);
@@ -411,3 +413,32 @@ test("register and spawn never replace a desktop another session spawned", async
assert.deepEqual(JSON.parse(fs.readFileSync(file)).computers.desk, owned, `${name} must leave the owning session's entry intact`);
}
});
test("SSH registration retains its trusted host-key file after platform discovery", async t => {
const f = fixture(t, null, `
const fs = require('node:fs');
const args = process.argv.slice(2);
fs.appendFileSync(process.env.ROUTE_LOG, JSON.stringify({args}) + '\\n');
console.log(JSON.stringify({ok:true,platform:'linux'}));
`);
const knownHosts = path.join(f.dir, "trusted-hosts");
const result = await f.tool("computer_register", {
computer: "pad", transport: "ssh", host: "fixture.test",
knownHosts, installAgent: false,
});
assert.equal(result.ok, true, JSON.stringify(result));
assert.equal(result.registered.knownHosts, knownHosts);
assert.equal(result.registered.platformHint, "linux");
assert.equal(JSON.parse(fs.readFileSync(path.join(f.dir, "computers.json"))).computers.pad.knownHosts, knownHosts);
const calls = f.calls();
assert.equal(calls.length, 1);
assert.ok(calls[0].args.includes(`UserKnownHostsFile=${knownHosts}`));
assert.ok(calls[0].args.includes("GlobalKnownHostsFile=/dev/null"));
assert.ok(calls[0].args.includes("StrictHostKeyChecking=yes"));
});
test("changing the trusted SSH host-key file changes route identity", () => {
const original = {transport: "ssh", host: "fixture.test", knownHosts: "/private/trusted-a"};
assert.notEqual(routeFingerprint(original), routeFingerprint({...original, knownHosts: "/private/trusted-b"}));
assert.notEqual(routeFingerprint(original), routeFingerprint({...original, knownHosts: undefined}));
});
@@ -1,6 +1,7 @@
// Target-pipeline tests: real MCP server over stdio with an injected fake
// backend (CODEWHALE_CU_TEST_BACKEND) so raster math and element
// revalidation can be asserted against the exact args the backend receives.
import { hostKeysLine, attest, attestParams } from "./fixtures/host-decision.mjs";
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
@@ -27,14 +28,14 @@ function rpc(method, params, timeoutMs = 30_000) {
return new Promise((resolve, reject) => {
const t = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, timeoutMs);
pending.set(id, (msg) => { clearTimeout(t); resolve(msg); });
server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params: attestParams(method, params) }) + "\n");
});
}
function rpcId(method, params) {
const id = nextId++;
const p = new Promise((resolve) => pending.set(id, resolve));
server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params: attestParams(method, params) }) + "\n");
return { id, p };
}
@@ -71,6 +72,7 @@ before(async () => {
},
stdio: ["pipe", "pipe", "pipe"],
});
server.stdin.write(hostKeysLine());
server.stderr.on("data", (d) => process.stderr.write(`[server] ${d}`));
server.stdout.setEncoding("utf8");
server.stdout.on("data", (d) => {
@@ -4,6 +4,7 @@
// in-process route. These tests drive the real server over stdio with that
// route forced (CODEWHALE_CU_TEST_REMOTE) and assert that a coordinate target
// reaches the backend as screen points, with the raster's refusals intact.
import { hostKeysLine, attest, attestParams } from "./fixtures/host-decision.mjs";
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
@@ -29,7 +30,7 @@ function rpc(method, params, timeoutMs = 30_000) {
return new Promise((resolve, reject) => {
const t = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, timeoutMs);
pending.set(id, (msg) => { clearTimeout(t); resolve(msg); });
server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params: attestParams(method, params) }) + "\n");
});
}
@@ -56,6 +57,7 @@ before(async () => {
},
stdio: ["pipe", "pipe", "pipe"],
});
server.stdin.write(hostKeysLine());
server.stderr.on("data", (d) => process.stderr.write(`[server] ${d}`));
server.stdout.setEncoding("utf8");
server.stdout.on("data", (d) => {
@@ -1,5 +1,6 @@
// Exercise the actual helper socket and MCP lifecycle with recording backends.
// Child-process cancellation must prevent delayed input, not just hide replies.
import { hostKeysLine, attest, attestParams } from "./fixtures/host-decision.mjs";
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
@@ -41,6 +42,7 @@ async function until(check, timeoutMs = 3000) {
function mcp() {
const child = spawn(process.execPath, [path.join(ROOT, "mcp/server.mjs")], { env, stdio: ["pipe", "pipe", "pipe"] });
child.stdin.write(hostKeysLine());
hosts.add(child);
const replies = new Map();
let buf = "";
@@ -55,7 +57,7 @@ function mcp() {
replies.set(msg.id, msg);
}
});
const send = (method, params, requestId) => child.stdin.write(JSON.stringify({ jsonrpc: "2.0", ...(requestId === undefined ? {} : { id: requestId }), method, params }) + "\n");
const send = (method, params, requestId) => child.stdin.write(JSON.stringify({ jsonrpc: "2.0", ...(requestId === undefined ? {} : { id: requestId }), method, params: attestParams(method, params) }) + "\n");
const start = (name, args = {}) => { const requestId = ++id; send("tools/call", { name, arguments: args }, requestId); return requestId; };
const response = async (requestId) => {
await until(() => replies.has(requestId));
@@ -1,6 +1,7 @@
// Spawned computers: registry shape, executor wiring, docker lifecycle, and
// the MCP spawn/remove path. Docker tests are integration tests — they run
// real containers when a daemon is present and skip otherwise.
import { hostKeysLine, attest, attestParams } from "./fixtures/host-decision.mjs";
import { test, after } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
@@ -127,7 +128,7 @@ function rpc(method, params) {
return new Promise((resolve, reject) => {
const t = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, 90_000);
pending.set(id, (msg) => { clearTimeout(t); resolve(msg); });
server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params: attestParams(method, params) }) + "\n");
});
}
const call = async (name, args = {}) => JSON.parse((await rpc("tools/call", { name, arguments: args })).result.content[0].text);
@@ -137,6 +138,7 @@ test("computer spawn registers an owned docker computer, acts on it, and remove
env: { ...process.env, CODEWHALE_CU_STATE_DIR: tmp },
stdio: ["pipe", "pipe", "pipe"],
});
server.stdin.write(hostKeysLine());
server.stdout.on("data", (c) => {
buf += c.toString();
let i;
@@ -0,0 +1,28 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import url from "node:url";
import { sshArgv, sshOptions, validateSshTarget } from "../src/ssh-args.mjs";
const here = path.dirname(url.fileURLToPath(import.meta.url));
const vectors = JSON.parse(fs.readFileSync(path.join(here, "fixtures", "ssh-destinations.json"), "utf8"));
test("transport ssh argv pins known hosts and ends options", () => {
const argv = sshArgv({ host: "builder.example.test", user: "fleet", port: 2222, knownHosts: "/etc/cu_known_hosts" });
for (const option of ["StrictHostKeyChecking=yes", "UpdateHostKeys=no", "UserKnownHostsFile=/etc/cu_known_hosts", "GlobalKnownHostsFile=/dev/null", "BatchMode=yes"]) {
const at = argv.indexOf(option);
assert.ok(at > 0, `missing ${option}: ${argv.join(" ")}`);
assert.equal(argv[at - 1], "-o");
}
assert.ok(!argv.some((arg) => arg.includes("accept-new")));
const end = argv.indexOf("--");
assert.equal(argv[end + 1], "[email protected]");
assert.equal(end + 2, argv.length, "the destination is last; the remote command follows it");
assert.deepEqual(sshOptions({ host: "h", port: 2200 }, { portFlag: "-P" }).slice(-2), ["-P", "2200"]);
});
test("ssh destinations follow the shared vectors", () => {
for (const entry of vectors.valid) assert.doesNotThrow(() => validateSshTarget(entry), JSON.stringify(entry));
for (const entry of vectors.invalid) assert.throws(() => validateSshTarget(entry), JSON.stringify(entry));
});
@@ -1,6 +1,7 @@
// Slice D: the advertised surface is merged; the wire names stay callable as
// aliases. Unit tests pin the expansion rules; a spawned server proves the
// advertised list and that both merged and alias calls route identically.
import { hostKeysLine, attest, attestParams } from "./fixtures/host-decision.mjs";
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
@@ -132,7 +133,7 @@ function rpc(method, params) {
return new Promise((resolve, reject) => {
const t = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, 20_000);
pending.set(id, (msg) => { clearTimeout(t); resolve(msg); });
server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params: attestParams(method, params) }) + "\n");
});
}
@@ -146,6 +147,7 @@ before(() => {
env: { ...process.env, CODEWHALE_CU_STATE_DIR: stateDir, CODEWHALE_CU_RECORDINGS_DIR: path.join(stateDir, "rec") },
stdio: ["pipe", "pipe", "pipe"],
});
server.stdin.write(hostKeysLine());
server.stdout.on("data", (c) => {
buf += c.toString();
let i;
@@ -1,6 +1,7 @@
// Trajectory recording and replay over the real server: files land in an
// isolated recordings dir; replay re-enters the normal tool pipeline and the
// recorder never records itself.
import { hostKeysLine, attest, attestParams } from "./fixtures/host-decision.mjs";
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
@@ -22,7 +23,7 @@ function rpc(method, params) {
return new Promise((resolve, reject) => {
const t = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, 20_000);
pending.set(id, (msg) => { clearTimeout(t); resolve(msg); });
server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params: attestParams(method, params) }) + "\n");
});
}
async function tool(name, args = {}) {
@@ -35,6 +36,7 @@ before(() => {
env: { ...process.env, CODEWHALE_CU_STATE_DIR: stateDir, CODEWHALE_CU_RECORDINGS_DIR: recDir, CODEWHALE_CU_APP: "off" },
stdio: ["pipe", "pipe", "pipe"],
});
server.stdin.write(hostKeysLine());
server.stdout.on("data", (c) => {
buf += c.toString();
let i;
@@ -126,6 +128,19 @@ test("E4: entered text is redacted, the file is 0600 in a 0700 dir, and redacted
assert.deepEqual(replay.results, [{ tool: "set_value", ok: false, code: "not_replayable" }]);
});
test("trajectory_replay skips app_script", async () => {
const started = await tool("trajectory", { action: "start" });
assert.equal(started.recording, true);
// The recorded attempt (whatever its outcome) is enough: a script is the
// user's decision each time and never runs from a recording.
await tool("app_script", { script: "return 1" });
const stopped = await tool("trajectory", { action: "stop" });
const dry = await tool("trajectory", { action: "replay", id: path.basename(stopped.file), dry_run: true });
assert.deepEqual(dry.not_replayable, [0]);
const replay = await tool("trajectory", { action: "replay", id: path.basename(stopped.file) });
assert.deepEqual(replay.results, [{ tool: "app_script", ok: false, code: "not_replayable" }]);
});
test("replay refuses escaping ids; the kill switch gates replay but not status", async () => {
const bad = await tool("trajectory", { action: "replay", id: "../escape.jsonl" });
assert.equal(bad.error?.code, "bad_args");
@@ -1,6 +1,7 @@
// wait_for polling, element-targeted type/key, and the persistent agent
// channel: real MCP server over stdio with the injected fake backend, plus
// the remote agent's --serve mode driven as a local child process.
import { hostKeysLine, attest, attestParams } from "./fixtures/host-decision.mjs";
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
@@ -29,7 +30,7 @@ function rpc(method, params, timeoutMs = 30_000) {
return new Promise((resolve, reject) => {
const t = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, timeoutMs);
pending.set(id, (msg) => { clearTimeout(t); resolve(msg); });
server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params: attestParams(method, params) }) + "\n");
});
}
@@ -62,6 +63,7 @@ before(async () => {
},
stdio: ["pipe", "pipe", "pipe"],
});
server.stdin.write(hostKeysLine());
server.stderr.on("data", (d) => process.stderr.write(`[server] ${d}`));
server.stdout.setEncoding("utf8");
server.stdout.on("data", (d) => {
+105 -8
View File
@@ -148,10 +148,7 @@ pub fn write_session_artifact(
"could not resolve session artifact path (missing home directory)",
)
})?;
if let Some(parent) = absolute_path.parent() {
std::fs::create_dir_all(parent)?;
}
crate::utils::write_atomic(&absolute_path, content.as_bytes())?;
open_session_relative(session_id, &relative_path, true)?.replace(content.as_bytes())?;
Ok((absolute_path, relative_path))
}
@@ -237,10 +234,7 @@ pub fn write_session_artifact_bytes(
"could not resolve session artifact path (missing home directory)",
)
})?;
if let Some(parent) = absolute_path.parent() {
std::fs::create_dir_all(parent)?;
}
crate::utils::write_atomic(&absolute_path, content)?;
open_session_relative(session_id, &relative_path, true)?.replace(content)?;
Ok((absolute_path, relative_path))
}
@@ -431,4 +425,107 @@ mod tests {
assert!(write_session_relative_immutable("session-a", &relative, b"payload").is_err());
assert!(!sessions.join("session-a/artifacts/art_failed.txt").exists());
}
#[cfg(unix)]
fn check_mutable_parent_links(binary: bool) {
let _guard = TEST_ARTIFACT_SESSIONS_GUARD
.lock()
.unwrap_or_else(|err| err.into_inner());
for linked_session in [true, false] {
let temp = tempfile::tempdir().unwrap();
let sessions = temp.path().join("sessions");
let _root = set_test_sessions_root(sessions.clone());
std::fs::create_dir_all(&sessions).unwrap();
let outside = temp.path().join("outside");
let outside_artifacts = if linked_session {
outside.join("artifacts")
} else {
outside.clone()
};
std::fs::create_dir_all(&outside_artifacts).unwrap();
let extension = if binary { "bin" } else { "txt" };
let canary = outside_artifacts.join(format!("mutable.{extension}"));
std::fs::write(&canary, b"old-exact-artifact-canary").unwrap();
if linked_session {
std::os::unix::fs::symlink(&outside, sessions.join("session-a")).unwrap();
} else {
std::fs::create_dir(sessions.join("session-a")).unwrap();
std::os::unix::fs::symlink(&outside, sessions.join("session-a/artifacts")).unwrap();
}
let result = if binary {
write_session_artifact_bytes("session-a", "mutable", extension, b"new-byte-payload")
} else {
write_session_artifact("session-a", "mutable", "new-text-payload")
};
assert_eq!(
std::fs::read(&canary).unwrap(),
b"old-exact-artifact-canary",
"mutable artifact overwrote a linked outside destination"
);
assert!(result.is_err(), "linked mutable artifact parent accepted");
}
}
#[cfg(unix)]
#[test]
fn mutable_text_artifact_refuses_linked_session_and_artifacts_parents() {
check_mutable_parent_links(false);
}
#[cfg(unix)]
#[test]
fn mutable_byte_artifact_refuses_linked_session_and_artifacts_parents() {
check_mutable_parent_links(true);
}
#[cfg(unix)]
#[test]
fn mutable_artifacts_replace_exact_bytes_without_following_leaf_links() {
use std::os::unix::fs::PermissionsExt;
let _guard = TEST_ARTIFACT_SESSIONS_GUARD
.lock()
.unwrap_or_else(|err| err.into_inner());
let temp = tempfile::tempdir().unwrap();
let sessions = temp.path().join("sessions");
let _root = set_test_sessions_root(sessions.clone());
let outside = temp.path().join("outside.txt");
std::fs::write(&outside, b"outside-leaf-canary").unwrap();
for binary in [false, true] {
let extension = if binary { "bin" } else { "txt" };
let relative = PathBuf::from(format!("artifacts/mutable.{extension}"));
let directory = sessions.join("session-a/artifacts");
std::fs::create_dir_all(&directory).unwrap();
let path = sessions.join("session-a").join(&relative);
std::os::unix::fs::symlink(&outside, &path).unwrap();
let (absolute, recorded) = if binary {
write_session_artifact_bytes(
"session-a",
"mutable",
extension,
b"\0exact-byte-payload",
)
} else {
write_session_artifact("session-a", "mutable", "exact-text-payload")
}
.unwrap();
assert_eq!(absolute, path);
assert_eq!(recorded, relative);
let expected: &[u8] = if binary {
b"\0exact-byte-payload"
} else {
b"exact-text-payload"
};
assert_eq!(std::fs::read(&absolute).unwrap(), expected);
assert!(
!std::fs::symlink_metadata(&absolute)
.unwrap()
.file_type()
.is_symlink()
);
assert_eq!(
std::fs::metadata(&absolute).unwrap().permissions().mode() & 0o777,
0o600
);
assert_eq!(std::fs::read(&outside).unwrap(), b"outside-leaf-canary");
}
}
}
+2
View File
@@ -146,6 +146,8 @@ impl McpDispatchUnderTest for McpPoolDispatch {
model_name,
input,
&[],
// Conformance probes cannot supply a person's card decision.
None,
) => result,
}
}
+29
View File
@@ -2221,7 +2221,9 @@ impl Engine {
auto_approve,
approval_mode,
);
let prior = self.applied_runtime_authority();
self.apply_runtime_mode_policy(&authority);
self.discard_kernels_if_narrowed(&prior).await;
start_permit.send(Event::TurnStarted {
turn_id: turn_id.clone(),
@@ -2500,8 +2502,10 @@ impl Engine {
!= (authority.auto_approve || effective_approval == ApprovalMode::Bypass)
|| self.session.approval_mode != effective_approval
|| self.api_config.sandbox_mode != configured_sandbox_mode;
let prior = self.applied_runtime_authority();
self.api_config.sandbox_mode = configured_sandbox_mode;
self.apply_runtime_mode_policy(&authority);
self.discard_kernels_if_narrowed(&prior).await;
if !changed {
return false;
}
@@ -2600,6 +2604,28 @@ impl Engine {
}
}
/// A Python kernel keeps whatever it imported, opened or started under the
/// posture it ran in. Once the posture grants less than `prior`, drop the
/// session kernel and every persistent RLM kernel so later code starts in
/// a fresh interpreter. Nothing takes a kernel out and puts it back, so a
/// round already in flight cannot restore one.
///
/// Known limitation: these interpreters are approval-gated local
/// subprocesses, not OS-sandboxed; discarding them bounds reuse, it does
/// not undo effects of code that already ran.
async fn discard_kernels_if_narrowed(&mut self, prior: &LiveRuntimeAuthority) {
if !self.applied_runtime_authority().narrows(prior) {
return;
}
self.repl_kernel = None;
self.config
.runtime_services
.rlm_sessions
.lock()
.await
.clear();
}
fn apply_runtime_mode_policy(&mut self, authority: &TurnAuthority) {
// Prompt composition is mode-agnostic. Keep the hash-guarded refresh
// because embedders may still derive custom prompt bytes from session
@@ -5683,7 +5709,9 @@ impl Engine {
// Track the complete effective mode policy so mid-turn metadata, `/edit`,
// idle worker resumptions, and approval gates cannot read a stale policy
// after the UI changed modes (#3568).
let prior = self.applied_runtime_authority();
self.apply_runtime_mode_policy(&input_policy);
self.discard_kernels_if_narrowed(&prior).await;
// Create turn context first so start event includes a stable turn id.
// An active goal gets the host's goal allowance (#5994); turns with
@@ -8998,6 +9026,7 @@ mod tool_setup;
pub(crate) mod turn_budget;
pub(crate) mod turn_heartbeat;
pub(crate) mod turn_loop;
pub(crate) use approval::HumanDecision;
pub(crate) use dispatch::{
FLEET_FINAL_REPORT_NOTICE, FLEET_NO_PROGRESS_STOP, FLEET_STRATEGY_SWITCH_NOTICE,
FleetDenialAction, FleetDenialBatch, FleetDenialGuard, content_without_approval_note,
+39 -3
View File
@@ -75,11 +75,47 @@ pub(super) enum UserInputDecision {
},
}
/// A person pressed Allow on an approval card for this call.
///
/// Only the engine's card resolver can build one; auto-approval, Full
/// Access, Auto-Review and session grants never do. Tools that act on a
/// person's behalf (the Computer Use consent and script calls) forward it to
/// the plugin as an attested decision.
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct HumanDecision {
tool_name: String,
arguments: serde_json::Value,
}
impl HumanDecision {
pub(super) fn from_card_allow(tool_name: &str, arguments: &serde_json::Value) -> Self {
Self {
tool_name: tool_name.to_string(),
arguments: arguments.clone(),
}
}
pub(crate) fn authorizes(&self, tool_name: &str, arguments: &serde_json::Value) -> bool {
self.tool_name == tool_name && self.arguments == *arguments
}
#[cfg(test)]
pub(crate) fn for_test(tool_name: &str, arguments: &serde_json::Value) -> Self {
Self::from_card_allow(tool_name, arguments)
}
}
impl std::fmt::Debug for HumanDecision {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("HumanDecision(card allow)")
}
}
/// Result of awaiting tool approval from the user.
#[derive(Debug)]
pub(super) enum ApprovalResult {
/// User approved the tool execution.
Approved,
Approved(ApprovalDecider),
/// User denied the tool execution.
Denied,
/// The approval card expired unanswered. Nobody refused the call, so it
@@ -245,7 +281,7 @@ impl Engine {
match decision {
ApprovalDecision::Approved { id, by } if id == tool_id => {
self.commit_approval_outcome(tool_id, ApprovalOutcome::ApprovedOnce, Some(by)).await?;
return Ok(ApprovalResult::Approved);
return Ok(ApprovalResult::Approved(by));
}
ApprovalDecision::Denied { id, by } if id == tool_id => {
self.commit_approval_outcome(tool_id, ApprovalOutcome::Denied, Some(by)).await?;
@@ -1762,7 +1798,7 @@ mod tests {
let result = task.await.expect("approval task");
match expected {
ApprovalOutcome::ApprovedOnce => {
assert!(matches!(result, Ok(ApprovalResult::Approved)));
assert!(matches!(result, Ok(ApprovalResult::Approved(_))));
}
ApprovalOutcome::Denied => {
assert!(matches!(result, Ok(ApprovalResult::Denied)));
+11
View File
@@ -854,6 +854,17 @@ pub(super) fn mcp_tool_approval_description(name: &str, input: &serde_json::Valu
&script_sha256[..16]
);
}
Some(ComputerUseUserGate::Computer {
action,
transport,
destination,
}) => {
let transport = transport.as_deref().unwrap_or("default");
let destination = destination.as_deref().unwrap_or("<not given>");
return format!(
"Computer Use: {action} a computer the model will then drive (transport {transport}, {destination}). Approve only if you want this machine controlled."
);
}
None => {}
}
match crate::mcp::mcp_tool_approval_hint(name) {
+364 -1
View File
@@ -3,7 +3,7 @@ use super::*;
use super::context::COMPACTION_SUMMARY_MARKER;
use super::streaming::{TOOL_CALL_END_MARKERS, TOOL_CALL_MARKER_PAIRS};
use super::turn_loop::{
auto_review_block_tool_error, initial_stream_error_user_message,
auto_review_block_tool_error, call_forces_prompt, initial_stream_error_user_message,
preview_request_error_user_message, registered_tool_approval_required,
registered_tool_forces_prompt, replace_runtime_mcp_tools, repo_law_must_block_without_prompt,
requested_sandbox_escalation, sandbox_escalation_denial, workspace_write_carve_out_applies,
@@ -8538,6 +8538,182 @@ async fn normal_repl_kernel_persists_across_user_turns() {
);
}
/// A recursive `rlm_query` makes a child model write Python. That round runs
/// only after the turn serving the `rlm` call admits it like `code_execution`:
/// outside Full Access it waits on a card of its own, beyond the approval the
/// direct `rlm` call already took. Denied, nothing runs; approved, it runs.
#[tokio::test]
async fn recursive_rlm_round_waits_on_the_code_execution_gate() {
use crate::llm_client::mock::{MockLlmClient, canned};
use codewhale_models::{ContentBlock, Message};
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
for approve in [false, true] {
let workspace = tempdir().expect("tempdir");
let marker = workspace.path().join("nested-round-ran");
let nested = format!(
"```repl\nopen({:?}, 'w').write('x')\nFINAL('nested done')\n```",
marker.display().to_string()
);
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "nested-model-response", "object": "chat.completion",
"model": "deepseek-v4-flash",
"choices": [{"index": 0, "message": {"role": "assistant", "content": nested},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 7, "completion_tokens": 9, "total_tokens": 16}
})))
.mount(&server)
.await;
let child_config = Config::default()
.with_legacy_root(Some("loopback-fixture-key".into()), Some(server.uri()));
let child_client = crate::client::CodewhaleClient::new(&child_config).expect("client");
let rlm = crate::tools::rlm::RLM_TOOL_NAME;
let mock = std::sync::Arc::new(MockLlmClient::new(vec![
canned::tool_call_turn(
"rlm-open",
rlm,
r#"{"action":"open","name":"ctx","content":"fixture context"}"#,
),
canned::tool_call_turn(
"rlm-eval",
rlm,
r#"{"action":"eval","name":"ctx","code":"print(rlm_query('nested context'))"}"#,
),
canned::simple_text_turn("Done."),
]));
let client: crate::core::model_client::SharedModelClient = mock.clone();
let (mut engine, handle) = Engine::new_with_model_client(
deterministic_engine_config(workspace.path()),
&Config::default(),
client,
);
engine.session.auto_approve = false;
engine.session.approval_mode = ApprovalMode::Suggest;
engine.session.add_message(Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: "Recurse.".to_string(),
cache_control: None,
}],
});
let registry = crate::tools::ToolRegistryBuilder::new()
.with_rlm_tool(Some(child_client), "deepseek-v4-flash".to_string())
.build(engine.build_tool_context(engine.current_mode, engine.session.auto_approve));
let policy = test_tool_surface(
&engine,
registry,
Some(vec![
catalog_tool(rlm),
catalog_tool(CODE_EXECUTION_TOOL_NAME),
]),
AppMode::Agent,
);
let task = tokio::spawn(async move {
let mut turn = crate::core::turn::TurnContext::new(6);
engine.run_turn(&mut turn, policy, None, None).await
});
let events = handle.rx_event.clone();
let nested_card = tokio::time::timeout(Duration::from_secs(30), async {
let mut rx = events.write().await;
while let Some(event) = rx.recv().await {
if let Event::ApprovalRequired { id, tool_name, .. } = event {
if tool_name == CODE_EXECUTION_TOOL_NAME {
return id;
}
// The direct `rlm` call's own approval.
handle.approve_tool_call(&id).await.expect("approve rlm");
}
}
panic!("event stream closed before the nested round asked for approval");
})
.await
.expect("the nested round must wait on its own approval card");
assert!(
nested_card.ends_with(".1"),
"the round is the rlm call's first nested request: {nested_card}"
);
assert!(!marker.exists(), "nothing runs before the decision");
if approve {
handle
.approve_tool_call(&nested_card)
.await
.expect("approve");
} else {
handle.deny_tool_call(&nested_card).await.expect("deny");
}
let (status, error) = tokio::time::timeout(Duration::from_secs(60), task)
.await
.expect("turn deadline")
.expect("turn task");
assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
assert_eq!(marker.exists(), approve, "approve={approve}");
}
}
/// A kernel keeps what it loaded under the posture it ran in, so a narrower
/// posture starts later code in a fresh interpreter; an unchanged or broader
/// posture keeps the working kernel.
#[tokio::test]
async fn narrowing_the_posture_discards_python_kernels() {
use crate::llm_client::mock::MockLlmClient;
let workspace = tempdir().expect("tempdir");
let client: crate::core::model_client::SharedModelClient =
std::sync::Arc::new(MockLlmClient::new(Vec::new()));
let (mut engine, _handle) = Engine::new_with_model_client(
deterministic_engine_config(workspace.path()),
&Config::default(),
client,
);
// Without a Python interpreter there is no kernel to discard.
let Ok(kernel) = crate::repl::runtime::PythonRuntime::new().await else {
return;
};
engine.repl_kernel = Some(kernel);
let posture = engine.applied_runtime_authority();
// Re-applying the same posture is not a narrowing.
engine
.apply_change_mode(
posture.mode,
posture.allow_shell,
posture.trust_mode,
posture.auto_approve,
posture.approval_mode,
posture.configured_sandbox_mode.clone(),
)
.await;
assert!(
engine.repl_kernel.is_some(),
"an unchanged posture keeps the kernel"
);
engine
.apply_change_mode(
posture.mode,
posture.allow_shell,
posture.trust_mode,
posture.auto_approve,
posture.approval_mode,
Some("read-only".to_string()),
)
.await;
assert!(
engine.applied_runtime_authority().narrows(&posture),
"fixture must actually narrow"
);
assert!(
engine.repl_kernel.is_none(),
"a narrower posture drops the kernel"
);
}
/// A turn dropped mid-round leaves its kernel broken, and a broken kernel
/// refuses every later round. The next turn starts a fresh kernel instead of
/// failing once on the stale one.
@@ -11771,6 +11947,44 @@ fn rlm_eval_required_approval_is_auto_approved_in_full_access() {
));
}
/// A remembered grant for a Computer Use consent must not answer a later,
/// identical call: the prompt is forced, so only a card decides it.
#[test]
fn computer_use_decisions_always_force_the_card() {
let consent = serde_json::json!({"app": "Safari", "bundle_id": "com.apple.Safari"});
for name in [
"mcp_codewhale-cu_consent_allow",
"mcp_codewhale-cu_consent_revoke",
] {
assert!(
call_forces_prompt(name, &consent, ApprovalRequirement::Required),
"{name}"
);
}
assert!(call_forces_prompt(
"mcp_codewhale-cu_app_script",
&serde_json::json!({"script": "tell application \"Finder\" to activate"}),
ApprovalRequirement::Required,
));
assert!(!call_forces_prompt(
"mcp_codewhale-cu_consent_status",
&serde_json::json!({}),
ApprovalRequirement::Required,
));
let ask = crate::core::authority::TurnAuthority::from_effective_fields(
AppMode::Agent,
true,
false,
false,
ApprovalMode::Suggest,
);
assert_eq!(
crate::core::authority::resolve_approval_request_disposition(&ask, true, false, true),
crate::core::authority::ApprovalRequestDisposition::Prompt,
"a session grant must not pre-answer a forced prompt"
);
}
#[test]
fn non_bypassable_registered_tools_auto_approve_in_full_access() {
// #3866 reversed (owner decision, 2026-08-10): Full Access already grants
@@ -29821,3 +30035,152 @@ async fn mcp_server_instructions_reach_the_request_labelled_once_and_only_for_vi
.is_some_and(|text| text.contains("no longer applies"))
);
}
async fn run_computer_use_live_card_case(
posture: ApprovalMode,
decider: crate::approval_log::ApprovalDecider,
expected_prompt: bool,
expected_allowed: bool,
) {
use crate::llm_client::mock::{MockLlmClient, canned};
let (root, plugins, mut pool, server) = crate::mcp::computer_use_test_fixture();
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path());
let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
pool.get_or_connect(&server)
.await
.expect("connect real isolated CU plugin");
let name = pool
.to_api_tools()
.into_iter()
.find(|tool| tool.name.ends_with("_consent"))
.unwrap()
.name;
let mock = Arc::new(MockLlmClient::new(vec![
canned::tool_call_turn(
"call-consent",
&name,
r#"{"action":"allow","scope":"foreground","remember":false}"#,
),
canned::simple_text_turn("Recorded the result."),
]));
let config = Config::default();
let mut cfg = deterministic_engine_config(&root.path().join("workspace"));
cfg.plugin_registry = Some(plugins);
cfg.mcp_config_path = root.path().join("mcp.json");
let (mut engine, handle) = Engine::new_with_model_client(cfg, &config, mock);
engine.mcp_pool = Some(Arc::new(tokio::sync::Mutex::new(pool)));
let task = tokio::spawn(engine.run());
let mut op = external_user_message_op("Decide foreground consent.", AppMode::Agent, &config);
if let Op::SendMessage(turn) = &mut op {
turn.approval_mode = posture;
turn.auto_approve = posture == ApprovalMode::Bypass;
turn.trust_mode = posture == ApprovalMode::Bypass;
}
handle.send(op).await.unwrap();
let mut prompts = 0;
let mut completed = None;
let mut rx = handle.rx_event.write().await;
loop {
match tokio::time::timeout(model_turn_event_timeout(), rx.recv())
.await
.expect("bounded engine event")
.expect("engine event")
{
Event::ApprovalRequired {
id,
tool_name,
approval_force_prompt,
..
} if tool_name == name => {
assert!(approval_force_prompt, "this must be an exact forced card");
prompts += 1;
handle.approve_tool_call_by(id, decider).await.unwrap();
}
Event::ToolCallComplete {
name: tool, result, ..
} if tool == name => completed = Some(result),
Event::TurnComplete { .. } => break,
_ => {}
}
}
drop(rx);
assert_eq!(
prompts > 0,
expected_prompt,
"posture={posture:?}, decider={decider:?}"
);
let result = completed.expect("consent call has a paired completion");
if posture != ApprovalMode::Suggest {
assert!(
result.is_err(),
"autonomous posture must block before plugin execution: {result:?}"
);
}
let allowed = result.as_ref().is_ok_and(|result| {
let envelope: serde_json::Value =
serde_json::from_str(content_without_approval_note(result)).expect("MCP envelope");
let outcome: serde_json::Value = if result.success {
serde_json::from_str(
envelope["content"][0]["text"]
.as_str()
.expect("plugin result text"),
)
.expect("plugin result JSON")
} else {
// The shared MCP adapter preserves failure text verbatim.
assert_eq!(envelope["error"]["code"], "consent_needs_user");
envelope
};
result.success
&& outcome["ok"] == true
&& outcome["scope"] == "foreground"
&& outcome["decision"] == "allow"
});
assert_eq!(
allowed, expected_allowed,
"posture={posture:?}, decider={decider:?}: {result:?}"
);
handle.send(Op::Shutdown).await.unwrap();
task.await.unwrap();
}
#[tokio::test]
async fn computer_use_human_card_allows_the_exact_live_call() {
let _env = lock_test_env();
run_computer_use_live_card_case(
ApprovalMode::Suggest,
crate::approval_log::ApprovalDecider::User,
true,
true,
)
.await;
}
#[tokio::test]
async fn computer_use_session_rule_and_posture_cannot_mint_a_human_decision() {
let _env = lock_test_env();
for decider in [
crate::approval_log::ApprovalDecider::SessionRule,
crate::approval_log::ApprovalDecider::Posture,
] {
run_computer_use_live_card_case(ApprovalMode::Suggest, decider, true, false).await;
}
}
#[tokio::test]
async fn computer_use_autonomous_postures_block_before_the_plugin() {
let _env = lock_test_env();
for posture in [
ApprovalMode::Bypass,
ApprovalMode::Auto,
ApprovalMode::Never,
] {
run_computer_use_live_card_case(
posture,
crate::approval_log::ApprovalDecider::User,
false,
false,
)
.await;
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ use crate::regex_cache::compile_user_regex;
pub(crate) const MULTI_TOOL_PARALLEL_NAME: &str = "multi_tool_use.parallel";
pub(crate) const REQUEST_USER_INPUT_NAME: &str = "request_user_input";
pub(super) const CODE_EXECUTION_TOOL_NAME: &str = "code_execution";
pub(crate) const CODE_EXECUTION_TOOL_NAME: &str = "code_execution";
const CODE_EXECUTION_TOOL_TYPE: &str = "code_execution_20250825";
const CODE_EXECUTION_DESCRIPTION: &str = "Execute Python code with the local Python interpreter in the workspace and return stdout/stderr/return_code as JSON.";
pub(super) use crate::tools::codemode::EXECUTE_TOOLS_TOOL_NAME;
+8 -1
View File
@@ -337,6 +337,7 @@ impl Engine {
name: &str,
input: serde_json::Value,
disallowed_tools: &[String],
decision: Option<&super::HumanDecision>,
) -> Result<RichToolResult, ToolError> {
McpPool::authorize_call(disallowed_tools, name, &input)
.map_err(|error| ToolError::not_available(error.to_string()))?;
@@ -391,7 +392,7 @@ impl Engine {
let result = pool
.lock()
.await
.call_tool_with_disallowed(name, input, disallowed_tools)
.call_tool_with_disallowed(name, input, disallowed_tools, decision)
.await;
match result {
Ok(result) => {
@@ -604,12 +605,18 @@ impl Engine {
.or_else(|| registry.map(|registry| registry.context()))
.map(|context| context.disallowed_tools.as_slice())
.unwrap_or_default();
// Only a per-call override can carry a person's decision; the
// registry's shared context never does.
let decision = context_override
.as_ref()
.and_then(|context| context.human_decision.as_ref());
Engine::execute_mcp_tool_with_pool(
pool,
&tx_event,
&tool_name,
tool_input,
disallowed_tools,
decision,
)
.await
} else {
+184 -19
View File
@@ -293,6 +293,19 @@ pub(super) fn registered_tool_forces_prompt(
&& registered_tool_requires_non_bypassable_approval(tool_name)
}
/// A Computer Use consent, script or computer registration call carries the
/// person's decision to the plugin, so only an approval card may answer it:
/// the prompt is forced, and no session grant, remembered rule or runtime
/// grant pre-answers it.
pub(super) fn call_forces_prompt(
tool_name: &str,
input: &serde_json::Value,
requirement: ApprovalRequirement,
) -> bool {
registered_tool_forces_prompt(tool_name, requirement)
|| crate::tools::approval_cache::computer_use_user_gate(tool_name, input).is_some()
}
/// Repo-law `ask` rules require a human decision. Only Ask posture can open
/// that decision; every autonomous or no-prompt posture must fail closed.
pub(super) fn repo_law_must_block_without_prompt(
@@ -549,6 +562,21 @@ pub(super) fn replace_runtime_mcp_tools(
*tool_catalog != catalog_before || *active_tool_names != active_before
}
/// Whether model-written Python may run on this turn at all: `code_execution`
/// is on the turn's surface and neither Plan mode nor the allow/deny lists
/// withhold it. Inline fences and nested RLM rounds share this rule.
fn code_execution_offered(
mode: AppMode,
tool_catalog: &[codewhale_models::Tool],
tool_policy: &ToolSurfacePolicy,
) -> bool {
let name = super::tool_catalog::CODE_EXECUTION_TOOL_NAME;
mode != AppMode::Plan
&& tool_catalog.iter().any(|tool| tool.name == name)
&& tool_policy.passes_allow_list(name)
&& !tool_policy.denies_tool(name)
}
impl Engine {
/// Inline ```repl blocks run model-written Python in the session kernel,
/// so they are admitted exactly like a `code_execution` call carrying
@@ -561,6 +589,9 @@ impl Engine {
async fn repl_fence_blocked_reason(
&mut self,
blocks: &[crate::repl::ReplBlock],
// What the approval card says would run, e.g. "the reply's ```repl
// block(s) in the session REPL kernel".
what_runs: &str,
approval_id: &str,
client: &dyn crate::core::model_client::ModelClient,
turn: &mut TurnContext,
@@ -633,8 +664,7 @@ impl Engine {
approval_grouping_key: approval_grouping_key.0,
input: plan.input,
description: format!(
"Run the reply's ```repl block(s) in the session REPL kernel (a local \
subprocess, not OS-sandboxed): {}",
"Run {what_runs} (a local subprocess, not OS-sandboxed): {}",
plan.approval_description
),
intent_summary: None,
@@ -648,14 +678,14 @@ impl Engine {
"tool_id": approval_id,
"tool_name": tool_name,
"decision": match decision {
Ok(ApprovalResult::Approved) => "approved",
Ok(ApprovalResult::Approved(_)) => "approved",
Ok(ApprovalResult::TimedOut) => "timeout",
_ => "denied",
},
"caller": "repl_fence",
}));
let refusal = match decision {
Ok(ApprovalResult::Approved) => None,
Ok(ApprovalResult::Approved(_)) => None,
Ok(ApprovalResult::Denied) => Some("not approved".to_string()),
// An expired card is not the user's denial (#6601).
Ok(ApprovalResult::TimedOut) => {
@@ -2506,12 +2536,7 @@ impl Engine {
// on this turn's surface, and only after the same approval.
let repl_fence_present = has_sendable_assistant_content
&& crate::repl::sandbox::has_repl_block(&current_text_visible);
let repl_fence_offered = mode != AppMode::Plan
&& tool_catalog
.iter()
.any(|tool| tool.name == super::tool_catalog::CODE_EXECUTION_TOOL_NAME)
&& tool_policy.passes_allow_list(super::tool_catalog::CODE_EXECUTION_TOOL_NAME)
&& !tool_policy.denies_tool(super::tool_catalog::CODE_EXECUTION_TOOL_NAME);
let repl_fence_offered = code_execution_offered(mode, &tool_catalog, &tool_policy);
let mut repl_fence_skip_reason = (repl_fence_present && !repl_fence_offered)
.then(|| "code execution is not available on this turn".to_string());
let repl_blocks = if repl_fence_present && repl_fence_offered {
@@ -2524,6 +2549,7 @@ impl Engine {
repl_fence_skip_reason = self
.repl_fence_blocked_reason(
&repl_blocks,
"the reply's ```repl block(s) in the session REPL kernel",
&approval_id,
client.as_ref(),
turn,
@@ -3593,7 +3619,7 @@ impl Engine {
if let Some(prepared) = prepared_policy {
let registered_non_bypassable =
registered_tool_forces_prompt(&tool_name, prepared.call.approval);
call_forces_prompt(&tool_name, &prepared.call.input, prepared.call.approval);
approval_required = registered_tool_approval_required(
&tool_name,
prepared.call.approval,
@@ -3939,6 +3965,24 @@ impl Engine {
}
}
// Consent is an exact human decision, never a standing grant or
// autonomous approval. Keep existing hard blocks authoritative.
if blocked_error.is_none()
&& crate::tools::approval_cache::computer_use_user_gate(&tool_name, &tool_input)
.is_some()
{
if batch_approval_mode == ApprovalMode::Suggest {
approval_required = true;
approval_force_prompt = true;
} else {
approval_required = false;
approval_force_prompt = false;
blocked_error = Some(ToolError::permission_denied(
"Computer Use consent, scripting and registration require the user's exact approval in Ask posture.".to_string()
));
}
}
// An ordinary approval does not change the sandbox. Say that
// on the gate itself; an explicit sandbox_permissions request
// takes the separate exact-call path above. Scoped to shell —
@@ -4579,7 +4623,7 @@ impl Engine {
.request_tool_approval(&tool_id, &tool_name, approval_event)
.await
{
Ok(ApprovalResult::Approved) => {
Ok(ApprovalResult::Approved(by)) => {
let decision = if model_requested_policy.is_some() {
"approved_with_requested_policy"
} else {
@@ -4605,6 +4649,31 @@ impl Engine {
elevated_context,
Some(ToolApprovalStamp::ApprovedWithPolicy),
)
} else if by == crate::approval_log::ApprovalDecider::User
&& plan.approval_force_prompt
&& crate::tools::approval_cache::computer_use_user_gate(
&tool_name,
&tool_input,
)
.is_some()
{
// The person just approved this exact
// Computer Use call on its card: that
// decision travels with the call.
let decided_context =
batch_tool_context.clone().map(|context| {
context.with_human_decision(
super::approval::HumanDecision::from_card_allow(
&tool_name,
&tool_input,
),
)
});
(
None,
decided_context,
Some(ToolApprovalStamp::ApprovedByUser),
)
} else {
(None, None, Some(ToolApprovalStamp::ApprovedByUser))
}
@@ -4770,11 +4839,13 @@ impl Engine {
result_override
{
(result_override.map(RichToolResult::plain), false)
} else if tool_name == EXECUTE_TOOLS_TOOL_NAME
} else if (tool_name == EXECUTE_TOOLS_TOOL_NAME
|| tool_name == crate::tools::rlm::RLM_TOOL_NAME)
&& let Some(context) = call_context.clone()
{
self.execute_tools_with_nested_gate(
nested_gate_env,
&tool_name,
&tool_id,
tool_input.clone(),
tool_exec_lock.clone(),
@@ -4910,7 +4981,9 @@ impl Engine {
(outcomes, authority_changed)
}
/// Run one `execute_tools` call while serving its nested-call gate.
/// Run one `execute_tools` (or `rlm`) call while serving its nested-call
/// gate. An `rlm` call's nested requests are the code rounds of its
/// recursive sub-turns, decided by [`Self::gate_rlm_round`].
///
/// The program runs on the ordinary executor; each nested call it makes
/// arrives here and is planned by `plan_tool_calls` (source: code mode)
@@ -4921,6 +4994,7 @@ impl Engine {
async fn execute_tools_with_nested_gate(
&mut self,
nested_gate_env: &mut NestedGateEnv<'_>,
tool_name: &str,
tool_id: &str,
tool_input: serde_json::Value,
tool_exec_lock: Arc<RwLock<()>>,
@@ -4944,7 +5018,7 @@ impl Engine {
false,
self.tx_event.clone(),
Some(cancel.clone()),
EXECUTE_TOOLS_TOOL_NAME.to_string(),
tool_name.to_string(),
Some(tool_id.to_string()),
tool_input,
self.session.workspace.clone(),
@@ -4963,8 +5037,8 @@ impl Engine {
result = &mut run => return (result, false),
Some(request) = requests.recv() => {
seq += 1;
let verdict = self
.gate_nested_call(
let verdict = if tool_name == crate::tools::rlm::RLM_TOOL_NAME {
self.gate_rlm_round(
nested_gate_env,
tool_id,
seq,
@@ -4975,7 +5049,21 @@ impl Engine {
tool_registry,
mode,
)
.await;
.await
} else {
self.gate_nested_call(
nested_gate_env,
tool_id,
seq,
request.name,
request.input,
tool_catalog,
active_tool_names,
tool_registry,
mode,
)
.await
};
let _ = request.reply.send(verdict);
}
}
@@ -4992,6 +5080,83 @@ impl Engine {
.max(Duration::from_secs(1))
}
/// Decide one code round of an `rlm` call's recursive sub-turn. The round
/// is model-written Python, so it is admitted exactly like an inline
/// ```repl block carrying the same code; the admitted input is returned
/// unchanged and the sub-turn runs nothing else.
#[allow(clippy::too_many_arguments)]
async fn gate_rlm_round(
&mut self,
nested_gate_env: &mut NestedGateEnv<'_>,
parent_id: &str,
seq: usize,
name: String,
input: serde_json::Value,
tool_catalog: &[codewhale_models::Tool],
active_tool_names: &mut std::collections::HashSet<String>,
tool_registry: Option<&crate::tools::ToolRegistry>,
mode: AppMode,
) -> crate::tools::codemode::NestedCallVerdict {
use crate::tools::codemode::{NestedCallVerdict, NestedDecision};
let refused = |reason: String| NestedCallVerdict::Refused {
error: ToolError::permission_denied(reason),
decision: NestedDecision::Refused,
};
// Same rule as a nested program call: once the posture the `rlm`
// call started under has changed, no later round runs on it.
if !nested_gate_env.authority_changed && self.apply_pending_runtime_authority().await {
nested_gate_env.authority_changed = true;
}
if nested_gate_env.authority_changed {
return refused(
"permissions changed while this rlm call was running; retry it with the current permissions".to_string(),
);
}
let code = match input.get("code").and_then(Value::as_str) {
Some(code) if name == super::tool_catalog::CODE_EXECUTION_TOOL_NAME => code.to_string(),
_ => return refused("an rlm call may only ask to run a code round".to_string()),
};
if !code_execution_offered(mode, tool_catalog, nested_gate_env.tool_policy) {
return refused("code execution is not available on this turn".to_string());
}
let block = crate::repl::ReplBlock {
code,
start_offset: 0,
end_offset: 0,
};
let posture_before = self.applied_runtime_authority();
let reason = self
.repl_fence_blocked_reason(
std::slice::from_ref(&block),
"a recursive RLM round's Python in a child kernel",
&format!("{parent_id}.{seq}"),
nested_gate_env.client,
nested_gate_env.turn,
nested_gate_env.tool_policy,
tool_catalog,
tool_registry,
active_tool_names,
nested_gate_env.tool_call_budget,
mode,
nested_gate_env.fleet_denial_guard,
)
.await;
if self.applied_runtime_authority() != posture_before {
nested_gate_env.authority_changed = true;
}
match reason {
Some(reason) => refused(reason),
None => NestedCallVerdict::Run {
name,
input,
supports_parallel: false,
decision: NestedDecision::Auto,
hook_context: None,
},
}
}
/// Decide one nested `execute_tools` call through the direct-call gate.
#[allow(clippy::too_many_arguments)]
async fn gate_nested_call(
@@ -5115,7 +5280,7 @@ impl Engine {
.request_tool_approval(&nested_id, &plan.name, approval_event)
.await;
let (decision, refusal) = match answer {
Ok(ApprovalResult::Approved) => (NestedDecision::Approved, None),
Ok(ApprovalResult::Approved(_)) => (NestedDecision::Approved, None),
Ok(ApprovalResult::Denied) => (
NestedDecision::Denied,
Some(ToolError::permission_denied(format!(
+41 -1
View File
@@ -79,7 +79,10 @@ impl CredentialSource {
pub(crate) fn label(&self) -> Cow<'static, str> {
match self {
Self::AuthModeNone => Cow::Borrowed("auth_mode = \"none\""),
Self::KeylessRoute { base_url } => Cow::Owned(format!("keyless route {base_url}")),
Self::KeylessRoute { base_url } => Cow::Owned(format!(
"keyless route {}",
crate::doctor::structural_url_authority(base_url)
)),
Self::CliOverride => Cow::Borrowed("--api-key"),
Self::ProviderConfigApiKey { table } => Cow::Owned(format!("[{table}] api_key")),
Self::ProviderConfigEnv { var } => Cow::Owned(format!("api_key_env {var}")),
@@ -147,3 +150,40 @@ impl CredentialResolution {
.find_map(|probe| probe.fix.as_deref())
}
}
#[cfg(test)]
mod private_label_tests {
use super::CredentialSource;
#[test]
fn keyless_label_omits_credentials_and_still_names_the_endpoint() {
for url in [
"https://user:[email protected]/v1",
"https://[email protected]/v1",
"https://gateway.invalid/key/label-s10-synthetic/v1",
"https://gateway.invalid/v1?token=label-s10-synthetic",
"https://gateway.invalid/v1#label-s10-synthetic",
] {
let label = CredentialSource::KeylessRoute {
base_url: url.into(),
}
.label()
.into_owned();
assert_eq!(label, "keyless route https://gateway.invalid");
}
let label = CredentialSource::KeylessRoute {
base_url: "http://localhost:11434/v1".into(),
}
.label()
.into_owned();
assert_eq!(label, "keyless route http://localhost:11434");
for url in ["not a url", "file:///etc/passwd", ""] {
let label = CredentialSource::KeylessRoute {
base_url: url.into(),
}
.label()
.into_owned();
assert!(label.contains("configured value omitted"));
assert!(!label.contains("passwd"));
}
}
}
+238 -8
View File
@@ -984,6 +984,34 @@ impl Git {
use anyhow::Context;
let mut command = Self::command().context("git not found on PATH")?;
// Runtime config pairs are required to disable filter names containing
// '=' without changing which key Git sees. Older Git ignores them.
// Probe once for this process's cached executable, before any read.
// A random value prevents repository config from spoofing support.
static REVIEW_CONFIG_SUPPORTED: OnceLock<bool> = OnceLock::new();
if !*REVIEW_CONFIG_SUPPORTED.get_or_init(|| {
let Some(mut probe) = Self::command() else {
return false;
};
let value = uuid::Uuid::new_v4().to_string();
probe
.current_dir(workspace)
.stdin(std::process::Stdio::null())
.env_remove("GIT_CONFIG")
.env_remove("GIT_CONFIG_PARAMETERS")
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "codewhale.reviewConfigCapability")
.env("GIT_CONFIG_VALUE_0", &value)
.args(["config", "--get", "codewhale.reviewConfigCapability"]);
matches!(probe.output(), Ok(output)
if output.status.success() && output.stdout == format!("{value}\n").as_bytes())
}) {
anyhow::bail!(
"Cannot safely inspect Git review configuration: Git runtime configuration overrides are unavailable; upgrade Git (2.31 or newer)"
);
}
command
.current_dir(workspace)
.stdin(std::process::Stdio::null())
@@ -1087,7 +1115,8 @@ impl ExternalTool for Git {
/// run a program the workspace chose, so no git child gets the parent's
/// credentials. The guards below are applied after the scrub.
fn command() -> Option<Command> {
let mut cmd = command_for_spec(&Self::resolve()?);
let mut cmd = Command::new(Self::resolve()?);
crate::utils::suppress_console_window(&mut cmd);
crate::child_env::apply_to_git_command(&mut cmd);
cmd.env("GIT_OPTIONAL_LOCKS", "0");
apply_git_noninteractive_env(&mut cmd);
@@ -1104,13 +1133,12 @@ impl ExternalTool for Git {
static CACHE: OnceLock<Option<String>> = OnceLock::new();
CACHE
.get_or_init(|| {
for candidate in Self::candidates() {
if probe_executable(candidate) {
tracing::info!(target: "tool_dependencies", "Resolved git binary");
return Some((*candidate).to_string());
}
}
None
// The review capability cache must follow the executable
// checked here even if a later child changes PATH or cwd.
let path = resolve_executable_path(Self::candidates().first()?, "--version")?;
let path = std::path::absolute(path).ok()?;
tracing::info!(target: "tool_dependencies", "Resolved git binary");
Some(path.to_string_lossy().into_owned())
})
.clone()
}
@@ -1972,6 +2000,208 @@ mod tests {
}
}
#[test]
fn git_review_accepts_supported_runtime_config() {
let dir = tempfile::tempdir().unwrap();
let output = Git::review_command(dir.path())
.expect("native Git supports runtime overrides")
.args(["config", "--get", "core.fsmonitor"])
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(output.stdout, b"false\n");
}
/// The child has a fresh executable/capability cache. Its wrapper ignores
/// runtime config as older Git would; it is not an old-Git installation.
#[cfg(unix)]
#[test]
fn git_review_refuses_unsupported_runtime_config() {
use std::os::unix::fs::PermissionsExt;
const CHILD: &str = "CODEWHALE_TEST_UNSUPPORTED_GIT_REVIEW";
if let Some(repo) = std::env::var_os(CHILD) {
let repo = PathBuf::from(repo);
if std::env::var_os("CODEWHALE_TEST_MISSING_GIT_REVIEW").is_some() {
let error = Git::review_command(&repo).expect_err("Git is absent");
assert_eq!(error.to_string(), "git not found on PATH");
return;
}
match Git::review_command(&repo) {
Err(error) => assert!(
error
.to_string()
.contains("runtime configuration overrides are unavailable"),
"{error:#}"
),
Ok(mut command) => {
let output = command.args(["status", "--porcelain"]).output().unwrap();
let witness = std::fs::read_to_string(repo.join("helper-seen"))
.unwrap_or_else(|_| "no helper witness".into());
panic!(
"unsupported Git reached repository read: status={:?}; {witness}",
output.status
);
}
}
assert!(!repo.join("helper-seen").exists());
return;
}
let _lock = crate::test_support::lock_test_env();
let real_git = resolve_executable_path("git", "--version").expect("absolute native Git");
let repo = tempfile::tempdir().unwrap();
let wrapper_dir = tempfile::tempdir().unwrap();
let helper = repo.path().join("fsmonitor.sh");
let marker = repo.path().join("helper-seen");
std::fs::write(
&helper,
format!(
"#!/bin/sh\nprintf 'unsupported-runtime-config-helper\\n' >> '{}'\nexit 1\n",
marker.display()
),
)
.unwrap();
std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o755)).unwrap();
for args in [
vec!["init", "-q"],
vec!["config", "core.fsmonitor", helper.to_str().unwrap()],
// A static capability marker would falsely accept this repository.
vec!["config", "codewhale.reviewConfigCapability", "supported"],
] {
let output = Git::output(&args, repo.path()).unwrap();
assert!(output.status.success(), "{output:?}");
}
let wrapper = wrapper_dir.path().join("git");
let quoted_git = real_git.replace('\'', "'\\''");
std::fs::write(
&wrapper,
format!("#!/bin/sh\nunset GIT_CONFIG_COUNT\nexec '{quoted_git}' \"$@\"\n"),
)
.unwrap();
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let output = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"dependencies::tests::git_review_refuses_unsupported_runtime_config",
"--nocapture",
])
.env(CHILD, repo.path())
.env("PATH", wrapper_dir.path())
.output()
.unwrap();
assert!(
output.status.success(),
"emulated unsupported Git refusal failed:\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(!marker.exists());
let missing_path = tempfile::tempdir().unwrap();
let missing = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"dependencies::tests::git_review_refuses_unsupported_runtime_config",
"--nocapture",
])
.env(CHILD, repo.path())
.env("CODEWHALE_TEST_MISSING_GIT_REVIEW", "1")
.env("PATH", missing_path.path())
.output()
.unwrap();
assert!(
missing.status.success(),
"missing Git diagnostic failed: {}\n{}",
String::from_utf8_lossy(&missing.stdout),
String::from_utf8_lossy(&missing.stderr)
);
}
#[cfg(unix)]
#[test]
fn git_review_keeps_checked_executable_after_path_changes() {
use std::os::unix::fs::PermissionsExt;
const CHILD: &str = "CODEWHALE_TEST_GIT_REVIEW_PATH_CHANGE";
if let Some(repo) = std::env::var_os(CHILD) {
let _lock = crate::test_support::lock_test_env();
let repo = PathBuf::from(repo);
let initial = Git::review_command(&repo)
.unwrap()
.args(["config", "--get", "core.fsmonitor"])
.output()
.unwrap();
assert!(initial.status.success());
assert_eq!(initial.stdout, b"false\n");
let changed = std::env::var_os("CODEWHALE_TEST_CHANGED_GIT_PATH").unwrap();
let _path = crate::test_support::EnvVarGuard::set("PATH", changed);
let output = Git::review_command(&repo)
.unwrap()
.args(["status", "--porcelain"])
.output()
.unwrap();
assert!(output.status.success(), "{output:?}");
let marker = repo.join("path-change-helper-seen");
assert!(
!marker.exists(),
"cached review switched to unchecked Git: {}",
std::fs::read_to_string(marker).unwrap_or_default()
);
return;
}
let _lock = crate::test_support::lock_test_env();
let real_git = resolve_executable_path("git", "--version").expect("absolute native Git");
let quoted_git = real_git.replace('\'', "'\\''");
let repo = tempfile::tempdir().unwrap();
let original_path = tempfile::tempdir().unwrap();
let supported_git_dir = original_path.path().join("Git path with spaces");
std::fs::create_dir(&supported_git_dir).unwrap();
let changed_path = tempfile::tempdir().unwrap();
for (dir, prefix) in [
(supported_git_dir.as_path(), ""),
(changed_path.path(), "unset GIT_CONFIG_COUNT\n"),
] {
let script = dir.join("git");
std::fs::write(
&script,
format!("#!/bin/sh\n{prefix}exec '{quoted_git}' \"$@\"\n"),
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let helper = repo.path().join("fsmonitor.sh");
std::fs::write(
&helper,
format!(
"#!/bin/sh\nprintf 'path-change-fixture-helper\\n' >> '{}'\nexit 1\n",
repo.path().join("path-change-helper-seen").display()
),
)
.unwrap();
std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o755)).unwrap();
for args in [
vec!["init", "-q"],
vec!["config", "core.fsmonitor", helper.to_str().unwrap()],
] {
assert!(Git::output(&args, repo.path()).unwrap().status.success());
}
let output = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"dependencies::tests::git_review_keeps_checked_executable_after_path_changes",
"--nocapture",
])
.env(CHILD, repo.path())
.env("CODEWHALE_TEST_CHANGED_GIT_PATH", changed_path.path())
.env("PATH", &supported_git_dir)
.output()
.unwrap();
assert!(
output.status.success(),
"native PATH-switch review fixture failed:\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(!repo.path().join("path-change-helper-seen").exists());
}
#[test]
fn git_output_version_succeeds() {
// Only run when git is actually installed.
+75 -12
View File
@@ -10699,6 +10699,42 @@ fn duplicate_computer_use_warning(name: &str) -> String {
)
}
/// Credential-safe launch summary used by the actual `mcp list` path.
fn mcp_server_listing(command: Option<&str>, args: &[String], url: Option<&str>) -> String {
use codewhale_secrets::sanitize::{
is_sensitive_key_name, redact_url_for_display, sanitize_text,
};
let flag_name = |arg: &str| arg.trim_start_matches('-').to_string();
let mut shown = Vec::with_capacity(args.len());
let mut mask_next = false;
for arg in args {
if mask_next {
shown.push("***".to_string());
mask_next = false;
continue;
}
if arg.starts_with('-') {
if let Some((flag, _)) = arg.split_once('=') {
if is_sensitive_key_name(&flag_name(flag)) {
shown.push(format!("{flag}=***"));
continue;
}
} else if is_sensitive_key_name(&flag_name(arg)) {
mask_next = true;
}
}
// Each argument on its own, so a masked value never swallows the
// arguments after it.
shown.push(sanitize_text(arg));
}
match (command, url) {
(Some(command), _) if shown.is_empty() => sanitize_text(command),
(Some(command), _) => format!("{} {}", sanitize_text(command), shown.join(" ")),
(None, Some(url)) => sanitize_text(&redact_url_for_display(url)),
(None, None) => "unknown".to_string(),
}
}
async fn run_mcp_command(
config: &Config,
workspace: &Path,
@@ -10768,18 +10804,11 @@ async fn run_mcp_command(
.replace(' ', "-")
)
};
let args = if server.args.is_empty() {
"".to_string()
} else {
format!(" {}", server.args.join(" "))
};
let cmd_str = if let Some(cmd) = server.command {
format!("{cmd}{args}")
} else if let Some(url) = server.url {
url
} else {
"unknown".to_string()
};
let cmd_str = mcp_server_listing(
server.command.as_deref(),
&server.args,
server.url.as_deref(),
);
let required = if server.required { " required" } else { "" };
println!(" - {name} [{status}{required}{auth}] {cmd_str}");
}
@@ -22321,3 +22350,37 @@ mod telemetry_surface_tests;
#[cfg(test)]
#[path = "tests/telemetry_counters.rs"]
mod telemetry_counter_tests;
#[cfg(test)]
mod private_listing_tests {
use super::mcp_server_listing;
#[test]
fn mcp_listing_shared_vocabulary_never_echoes_flag_or_url_credentials() {
let args = [
"server.js",
"--privateKey",
"private-s10-synthetic",
"--clientSecret=client-s10-synthetic",
"--token-budget",
"4096",
"--port",
"8080",
]
.map(str::to_string);
let listing = mcp_server_listing(Some("node"), &args, None);
assert!(!listing.contains("private-s10-synthetic"));
assert!(!listing.contains("client-s10-synthetic"));
assert!(listing.contains("--port 8080"));
assert!(listing.contains("--token-budget 4096"));
let url = mcp_server_listing(
None,
&[],
Some(
"https://user:[email protected]/sse?privateKey=query-s10-synthetic&team=core",
),
);
assert!(!url.contains("url-s10-synthetic"));
assert!(!url.contains("query-s10-synthetic"));
assert!(url.contains("team=core"));
}
}
+175 -12
View File
@@ -1694,6 +1694,9 @@ pub struct McpConnection {
/// Pool catalog generation that created/last authorized this connection.
/// Directly constructed test connections use zero until inserted.
catalog_generation: u64,
/// Key this host shares with the built-in Computer Use plugin over the
/// connection itself, to attest a person's card decision on a call.
decision_key: Option<[u8; 32]>,
}
struct PendingAuthorityWatch {
@@ -1982,8 +1985,34 @@ impl McpConnection {
authority_revocation_reason,
authority_watch,
catalog_generation: 0,
decision_key: None,
};
// The built-in Computer Use plugin accepts consent and script calls
// only with a decision attested by its host. Its keys travel as the
// first message on the plugin's own stdin, never in its environment.
if conn.config.url.is_none()
&& conn.config.command.is_some()
&& conn
.config
.reviewed_plugin
.as_ref()
.is_some_and(|source| source.plugin_name() == COMPUTER_USE_PLUGIN_NAME)
{
let decision_key = random_key()?;
let ledger_key = computer_use_ledger_key().await;
conn.send(serde_json::json!({
"jsonrpc": "2.0",
"method": COMPUTER_USE_HOST_KEYS_METHOD,
"params": {
"decision_key": hex_encode(&decision_key),
"ledger_key": ledger_key,
}
}))
.await?;
conn.decision_key = Some(decision_key);
}
// Initialize with timeout
tokio::time::timeout(Duration::from_secs(connect_timeout_secs), conn.initialize())
.await
@@ -2375,21 +2404,38 @@ impl McpConnection {
}
/// Call a tool on this MCP server
#[cfg(test)]
pub async fn call_tool(
&mut self,
tool_name: &str,
arguments: serde_json::Value,
timeout_secs: u64,
) -> Result<serde_json::Value> {
self.call_method(
"tools/call",
serde_json::json!({
"name": tool_name,
"arguments": arguments
}),
timeout_secs,
)
.await
self.call_tool_decided(tool_name, arguments, timeout_secs, None)
.await
}
/// Call a tool, attaching an attested person's decision when there is
/// one and this server shares a decision key with the host.
async fn call_tool_decided(
&mut self,
tool_name: &str,
arguments: serde_json::Value,
timeout_secs: u64,
decision: Option<&crate::core::engine::HumanDecision>,
) -> Result<serde_json::Value> {
let mut params = serde_json::json!({
"name": tool_name,
"arguments": arguments
});
if decision.is_some()
&& let Some(key) = self.decision_key.as_ref()
{
params["_meta"] = serde_json::json!({
COMPUTER_USE_DECISION_META: attest_decision(key, tool_name, &params["arguments"])?,
});
}
self.call_method("tools/call", params, timeout_secs).await
}
/// Read a resource from this MCP server
@@ -4573,6 +4619,7 @@ impl McpPool {
authority_revocation_reason: Arc::new(std::sync::Mutex::new(None)),
authority_watch: None,
catalog_generation: 0,
decision_key: None,
};
self.connections.insert(server.to_string(), conn);
}
@@ -5187,6 +5234,7 @@ impl McpPool {
name: &str,
input: serde_json::Value,
rules: &[String],
decision: Option<&crate::core::engine::HumanDecision>,
) -> Result<serde_json::Value> {
Self::authorize_call(&self.disallowed_tools, name, &input)?;
Self::authorize_call(rules, name, &input)?;
@@ -5241,7 +5289,7 @@ impl McpPool {
return Ok(serde_json::json!({ field: items }));
}
let synthetic_auth = self.authenticate_tool_target(name).is_some();
let mut result = self.call_tool(name, input).await?;
let mut result = self.call_tool_with_decision(name, input, decision).await?;
if synthetic_auth {
Self::filter_authenticate_result(&mut result, rules);
}
@@ -5262,12 +5310,27 @@ impl McpPool {
}
/// Call a tool by its prefixed name (mcp_{server}_{tool})
#[cfg(test)]
pub async fn call_tool(
&mut self,
prefixed_name: &str,
arguments: serde_json::Value,
) -> Result<serde_json::Value> {
self.call_tool_with_decision(prefixed_name, arguments, None)
.await
}
/// Call a tool, carrying a person's card decision for this exact call.
pub(crate) async fn call_tool_with_decision(
&mut self,
prefixed_name: &str,
arguments: serde_json::Value,
decision: Option<&crate::core::engine::HumanDecision>,
) -> Result<serde_json::Value> {
Self::authorize_call(&self.disallowed_tools, prefixed_name, &arguments)?;
if decision.is_some_and(|decision| !decision.authorizes(prefixed_name, &arguments)) {
anyhow::bail!("Human approval does not authorize this exact MCP call");
}
if prefixed_name == "list_mcp_resources" {
let server = arguments
.get("server")
@@ -5365,7 +5428,10 @@ impl McpPool {
anyhow::bail!("MCP tool '{tool_name}' is disabled for server '{server_name}'");
}
let timeout = conn.config().effective_execute_timeout(&global_timeouts);
let result = match conn.call_tool(&tool_name, arguments.clone(), timeout).await {
let result = match conn
.call_tool_decided(&tool_name, arguments.clone(), timeout, decision)
.await
{
Ok(result) => Ok(result),
// A rejected credential is not a stale session: reconnecting
// replays the same rejection, so it takes the auth-required
@@ -5403,7 +5469,8 @@ impl McpPool {
))
} else {
let timeout = conn.config().effective_execute_timeout(&global_timeouts);
conn.call_tool(&tool_name, arguments, timeout).await
conn.call_tool_decided(&tool_name, arguments, timeout, decision)
.await
}
}
// A reconnect that fails must not swallow the call error
@@ -6059,6 +6126,100 @@ pub fn resolve_server_scope(global_path: &Path, workspace: &Path, name: &str) ->
/// Plugin name of the built-in Computer Use bundle.
const COMPUTER_USE_PLUGIN_NAME: &str = "computer-use";
/// First message the host sends the built-in Computer Use plugin: its
/// per-connection decision key and the persisted-ledger key.
const COMPUTER_USE_HOST_KEYS_METHOD: &str = "codewhale/host_keys";
/// `_meta` key carrying an attested person's decision on a `tools/call`.
const COMPUTER_USE_DECISION_META: &str = "codewhale/user_decision";
/// Secret-store slot of the key that signs remembered Computer Use grants.
const COMPUTER_USE_LEDGER_KEY_SLOT: &str = "codewhale_cu_ledger_key";
fn random_key() -> Result<[u8; 32]> {
use ring::rand::SecureRandom as _;
let mut key = [0_u8; 32];
ring::rand::SystemRandom::new()
.fill(&mut key)
.map_err(|_| {
anyhow::anyhow!("System randomness is unavailable for Computer Use approval")
})?;
Ok(key)
}
fn hex_encode(bytes: &[u8]) -> String {
use std::fmt::Write as _;
bytes
.iter()
.fold(String::with_capacity(bytes.len() * 2), |mut out, byte| {
let _ = write!(out, "{byte:02x}");
out
})
}
/// `{nonce, args_json, mac}` for one call: `mac` is HMAC-SHA256 over
/// `tool \0 args_json \0 nonce`, and the plugin checks that `args_json`
/// parses to the arguments it received.
fn attest_decision(
key: &[u8; 32],
tool_name: &str,
arguments: &serde_json::Value,
) -> Result<serde_json::Value> {
let nonce = hex_encode(&random_key()?[..16]);
let args_json = serde_json::to_string(arguments)?;
let message = [
tool_name.as_bytes(),
b"\0",
args_json.as_bytes(),
b"\0",
nonce.as_bytes(),
]
.concat();
let tag = ring::hmac::sign(
&ring::hmac::Key::new(ring::hmac::HMAC_SHA256, key),
&message,
);
Ok(serde_json::json!({
"nonce": nonce,
"args_json": args_json,
"mac": hex_encode(tag.as_ref()),
}))
}
/// The key that signs remembered Computer Use grants, created once in the
/// secret store. `None` when the store is unavailable: remembered grants
/// then do not survive the session.
async fn computer_use_ledger_key() -> Option<String> {
if cfg!(test) {
return None;
}
tokio::task::spawn_blocking(|| {
let secrets = codewhale_secrets::Secrets::auto_detect();
// Concurrent connection starts must use the same persisted key;
// reading and replacing it share the secret store's entry authority.
secrets
.with_entry_transaction(COMPUTER_USE_LEDGER_KEY_SLOT, |stored| {
if let Some(key) = stored.as_ref()
&& key.len() == 64
&& key.bytes().all(|b| b.is_ascii_hexdigit())
{
return Ok(Some(key.clone()));
}
let Ok(bytes) = random_key() else {
return Ok(None);
};
let key = hex_encode(&bytes);
*stored = Some(key.clone());
Ok(Some(key))
})
.ok()
.flatten()
})
.await
.ok()
.flatten()
}
/// User-configured servers that launch the same Computer Use plugin as the
/// enabled built-in `computer-use` bundle, with the argument that gave each
/// one away. Two copies advertise every Computer Use schema twice (about
@@ -7091,3 +7252,5 @@ mod qualified_plugin_server_name_tests {
#[cfg(test)]
mod tests;
#[cfg(test)]
pub(crate) use tests::computer_use_test_fixture;
+244 -8
View File
@@ -3027,6 +3027,7 @@ fn test_connection(transport: Box<dyn McpTransport>) -> McpConnection {
authority_revocation_reason: Arc::new(std::sync::Mutex::new(None)),
authority_watch: None,
catalog_generation: 0,
decision_key: None,
}
}
@@ -8804,13 +8805,13 @@ async fn mcp_ceiling_child_scoped_meta_calls_do_not_widen_or_mutate_sibling_poli
("list_mcp_resource_templates", "templates"),
] {
let child = pool
.call_tool_with_disallowed(method, serde_json::json!({}), &child_rules)
.call_tool_with_disallowed(method, serde_json::json!({}), &child_rules, None)
.await
.unwrap();
assert_eq!(child[field].as_array().unwrap().len(), 1);
assert_eq!(child[field][0]["server"], "public");
let sibling = pool
.call_tool_with_disallowed(method, serde_json::json!({}), &[])
.call_tool_with_disallowed(method, serde_json::json!({}), &[], None)
.await
.unwrap();
assert_eq!(sibling[field].as_array().unwrap().len(), 2);
@@ -8819,19 +8820,25 @@ async fn mcp_ceiling_child_scoped_meta_calls_do_not_widen_or_mutate_sibling_poli
pool.call_tool_with_disallowed(
"read_mcp_resource",
serde_json::json!({"server":"private_a","uri":"memory://one"}),
&child_rules
&child_rules,
None
)
.await
.is_err()
);
assert!(
pool.call_tool_with_disallowed("mcp_private_a_read", serde_json::json!({}), &child_rules)
.await
.is_err()
pool.call_tool_with_disallowed(
"mcp_private_a_read",
serde_json::json!({}),
&child_rules,
None
)
.await
.is_err()
);
assert!(sent.lock().unwrap().is_empty());
assert_eq!(
pool.call_tool_with_disallowed("mcp_private_a_read", serde_json::json!({}), &[])
pool.call_tool_with_disallowed("mcp_private_a_read", serde_json::json!({}), &[], None)
.await
.unwrap(),
serde_json::json!({"ok":true})
@@ -8906,7 +8913,8 @@ async fn mcp_ceiling_preserves_ordinary_tool_result_tools_field() {
pool.call_tool_with_disallowed(
"mcp_public_read",
serde_json::json!({}),
&["mcp_private_*".to_string()]
&["mcp_private_*".to_string()],
None
)
.await
.unwrap(),
@@ -10172,3 +10180,231 @@ async fn mcp_server_instructions_exclude_servers_whose_tools_are_all_denied() {
vec![("guided".to_string(), "Use search.".to_string())]
);
}
#[tokio::test]
async fn computer_use_call_carries_an_attested_decision_only_when_a_person_approved() {
let respond = |id: u64| {
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": id, "result": {"ok": true}}))
};
let sent = Arc::new(Mutex::new(Vec::new()));
let mut connection = test_connection(Box::new(ScriptedValueTransport {
sent: Arc::clone(&sent),
responses: VecDeque::from([respond(1), respond(2), respond(3)]),
}));
let key = [7_u8; 32];
connection.decision_key = Some(key);
let args = serde_json::json!({"action": "allow", "app": "Safari", "remember": 1.0});
let decision = crate::core::engine::HumanDecision::for_test("mcp_fixture_consent", &args);
connection
.call_tool_decided("consent", args.clone(), 5, Some(&decision))
.await
.expect("decided call");
connection
.call_tool_decided("consent", args.clone(), 5, None)
.await
.expect("plain call");
connection.decision_key = None;
connection
.call_tool_decided("consent", args.clone(), 5, Some(&decision))
.await
.expect("call without a shared key");
let sent = sent.lock().unwrap().clone();
let attested = &sent[0]["params"]["_meta"][COMPUTER_USE_DECISION_META];
let nonce = attested["nonce"].as_str().expect("nonce");
let args_json = attested["args_json"].as_str().expect("args_json");
assert_eq!(
serde_json::from_str::<serde_json::Value>(args_json).unwrap(),
sent[0]["params"]["arguments"]
);
let message = [
b"consent".as_slice(),
b"\0",
args_json.as_bytes(),
b"\0",
nonce.as_bytes(),
]
.concat();
let expected = ring::hmac::sign(
&ring::hmac::Key::new(ring::hmac::HMAC_SHA256, &key),
&message,
);
assert_eq!(attested["mac"], hex_encode(expected.as_ref()));
assert!(sent[1]["params"].get("_meta").is_none(), "{}", sent[1]);
assert!(sent[2]["params"].get("_meta").is_none(), "{}", sent[2]);
}
#[tokio::test]
async fn copied_human_decision_cannot_authorize_another_mcp_call() {
let input = serde_json::json!({"action": "allow", "app": "Safari"});
let decision = crate::core::engine::HumanDecision::for_test("mcp_fixture_consent", &input);
let mut pool = McpPool::new(McpConfig::default());
for (name, arguments) in [
(
"mcp_fixture_consent",
serde_json::json!({"action": "allow", "app": "Terminal"}),
),
("mcp_other_consent", input.clone()),
] {
let error = pool
.call_tool_with_decision(name, arguments, Some(&decision))
.await
.unwrap_err();
assert!(
error
.to_string()
.contains("does not authorize this exact MCP call"),
"{error:#}"
);
}
assert!(decision.authorizes("mcp_fixture_consent", &input));
}
/// Actual shipped plugin, reviewed and staged by the normal plugin authority.
/// Every backend and state path is isolated; this fixture never drives a real app.
pub(crate) fn computer_use_test_fixture() -> (
tempfile::TempDir,
Arc<crate::plugins::PluginRegistry>,
McpPool,
String,
) {
let root = tempfile::tempdir().expect("private fixture root");
let workspace = root.path().join("workspace");
fs::create_dir(&workspace).unwrap();
let plugin_base = root.path().join("plugins/computer-use");
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("plugins/computer-use");
let mut pending = vec![(source, plugin_base.clone())];
while let Some((from, to)) = pending.pop() {
fs::create_dir_all(&to).unwrap();
for entry in fs::read_dir(from).unwrap() {
let entry = entry.unwrap();
let destination = to.join(entry.file_name());
if entry.file_type().unwrap().is_dir() {
pending.push((entry.path(), destination));
} else {
fs::copy(entry.path(), destination).unwrap();
}
}
}
let state = root.path().join("cu-state");
let recordings = root.path().join("recordings");
let fake = plugin_base.join("tests/fixtures/fake-backend.mjs");
fs::write(
plugin_base.join("plugin.toml"),
format!(
"schema_version = 1\n[plugin]\nname = \"computer-use\"\nversion = \"1.0.0\"\n\
[mcp_servers.local]\ncommand = \"node\"\nargs = [\"mcp/server.mjs\"]\nconnect_timeout = 5\n\
[mcp_servers.local.env]\nCODEWHALE_CU_APP = \"off\"\n\
CODEWHALE_CU_STATE_DIR = {}\nCODEWHALE_CU_RECORDINGS_DIR = {}\nCODEWHALE_CU_TEST_BACKEND = {}\n",
serde_json::to_string(&state.to_string_lossy()).unwrap(),
serde_json::to_string(&recordings.to_string_lossy()).unwrap(),
serde_json::to_string(&fake.to_string_lossy()).unwrap(),
),
)
.unwrap();
let discovery = crate::plugins::discovery::DiscoveryConfig {
workspace,
user_plugins_dir: root.path().join("plugins"),
workspace_plugins_dir: root.path().join("unused-workspace-plugins"),
builtin_plugin_dirs: Vec::new(),
state_path: root.path().join("plugin-state/state.json"),
};
let mut registry = crate::plugins::discovery::discover_with_config(&discovery);
registry.trust("computer-use").unwrap();
registry.enable("computer-use").unwrap();
let plugin = registry.get("computer-use").unwrap().clone();
let authority = registry.authority_for("computer-use").unwrap();
let config = merge_plugin_mcp_servers_from_plugins(
McpConfig::default(),
vec![("computer-use".to_string(), plugin, authority)],
)
.unwrap();
let server = config
.servers
.keys()
.next()
.expect("plugin MCP server")
.clone();
(root, Arc::new(registry), McpPool::new(config), server)
}
#[tokio::test]
async fn computer_use_real_plugin_host_handshake_rejects_tamper_replay_and_late_keys() {
let _env = crate::test_support::lock_test_env();
let (root, _registry, mut pool, server) = computer_use_test_fixture();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path());
let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
let connection = pool
.get_or_connect(&server)
.await
.expect("actual reviewed Node plugin");
let key = connection
.decision_key
.expect("first-message key from the host");
let args = serde_json::json!({"scope":"foreground", "remember":false});
let decision = crate::core::engine::HumanDecision::for_test("mcp_fixture_consent_allow", &args);
let decode = |result: serde_json::Value| {
serde_json::from_str::<serde_json::Value>(result["content"][0]["text"].as_str().unwrap())
.unwrap()
};
let unsigned = decode(
connection
.call_tool_decided("consent_allow", args.clone(), 5, None)
.await
.unwrap(),
);
assert_eq!(
unsigned["error"]["code"], "consent_needs_user",
"{unsigned}"
);
let approved = decode(
connection
.call_tool_decided("consent_allow", args.clone(), 5, Some(&decision))
.await
.unwrap(),
);
assert_eq!(approved["ok"], true, "{approved}");
// A later key notification must not replace the first connection key.
let wrong_key = [8_u8; 32];
connection.send(serde_json::json!({"jsonrpc":"2.0","method":COMPUTER_USE_HOST_KEYS_METHOD,"params":{"decision_key":hex_encode(&wrong_key)}})).await.unwrap();
let fresh = attest_decision(&key, "consent_revoke", &args).unwrap();
for (tool, arguments, tag) in [
(
"consent_revoke",
args.clone(),
attest_decision(&wrong_key, "consent_revoke", &args).unwrap(),
),
(
"consent_revoke",
serde_json::json!({"scope":"foreground","remember":true}),
fresh.clone(),
),
("consent_allow", args.clone(), fresh.clone()),
] {
let rejected = decode(connection.call_method("tools/call", serde_json::json!({"name":tool,"arguments":arguments,"_meta":{COMPUTER_USE_DECISION_META:tag}}), 5).await.unwrap());
assert_eq!(
rejected["error"]["code"], "consent_needs_user",
"{rejected}"
);
}
let exact = serde_json::json!({"name":"consent_revoke","arguments":args,"_meta":{COMPUTER_USE_DECISION_META:fresh}});
let allowed = decode(
connection
.call_method("tools/call", exact.clone(), 5)
.await
.unwrap(),
);
assert_eq!(
allowed["ok"], true,
"the original key remains authoritative: {allowed}"
);
let replay = decode(
connection
.call_method("tools/call", exact, 5)
.await
.unwrap(),
);
assert_eq!(replay["error"]["code"], "consent_needs_user", "{replay}");
pool.shutdown_all().await;
}
+22
View File
@@ -425,6 +425,10 @@ fn redact_absolute_paths(text: &str) -> String {
let res = regex_cache(
&PATTERNS,
[
// Windows UNC and extended paths reveal server/share names.
r"(^|[^A-Za-z0-9_\\])(\\\\(?:\?\\)?[^\\/\s]+\\[^\\/\s]+(?:\\[^\\/\s]*)*)",
// Leave the double slash after a URL scheme alone.
r"(^|[^A-Za-z0-9_:/])(//[^/\s]+/[^/\s]+(?:/[^/\s]*)*)",
// POSIX: at least two components so a bare `/tmp` or a lone
// slash in prose is not mangled.
r"(^|[^A-Za-z0-9_:/\\])((?:/[A-Za-z0-9._~%+@\-]+){2,}/?)",
@@ -690,4 +694,22 @@ mod tests {
let payload = NotificationPayload::turn_complete(" \n ");
assert_eq!(payload.headline(), FALLBACK_HEADLINE);
}
#[test]
fn notification_payload_masks_unc_server_share_and_directories() {
for path in [
r"\\fileserver\share\clients\case.txt",
r"\\?\C:\Users\fixture\clients\case.txt",
"//fileserver/share/clients/case.txt",
] {
let payload =
NotificationPayload::turn_complete("Turn complete").with_preview(Some(path));
let preview = payload.preview().expect("preview");
assert_eq!(preview, "…/case.txt");
assert!(!preview.contains("fileserver"));
assert!(!preview.contains("clients"));
}
let payload = NotificationPayload::turn_complete("Turn complete")
.with_preview(Some("see https://example.com/docs/page"));
assert_eq!(payload.preview(), Some("see https://example.com/docs/page"));
}
}
+1
View File
@@ -103,6 +103,7 @@ const COMPUTER_USE_FILES: &[(&str, &[u8])] = &[
bundle_file!("src/registry.mjs"),
bundle_file!("src/remote-runtime.mjs"),
bundle_file!("src/sprite-task.mjs"),
bundle_file!("src/ssh-args.mjs"),
bundle_file!("src/tools.mjs"),
bundle_file!("src/trajectory.mjs"),
bundle_file!("src/transport.mjs"),
+19 -1
View File
@@ -330,6 +330,10 @@ pub struct RlmBridge {
/// best-effort stream (#6511).
events: Option<tokio::sync::mpsc::Sender<crate::core::events::Event>>,
deadline: tokio::time::Instant,
/// The serving turn's permission gate. A nested RLM turn runs each round
/// of model-written Python only after this gate admits that exact code;
/// without one the nested turn runs no code at all.
gate: Option<crate::tools::codemode::NestedCallGate>,
}
impl RlmBridge {
@@ -359,9 +363,20 @@ impl RlmBridge {
usage,
events: None,
deadline: tokio::time::Instant::now() + crate::tools::subagent::DEFAULT_CHILD_WALL_TIME,
gate: None,
}
}
/// Admit nested RLM code rounds through the serving turn's gate.
#[must_use]
pub(crate) fn with_gate(
mut self,
gate: Option<crate::tools::codemode::NestedCallGate>,
) -> Self {
self.gate = gate;
self
}
/// Clamp recursive work to the already-spent parent budget. An unbounded
/// parent keeps the existing child safety cap; no recursive call resets it.
pub(crate) fn with_deadline(mut self, deadline: Option<tokio::time::Instant>) -> Self {
@@ -586,6 +601,7 @@ impl RlmBridge {
self.depth_remaining.saturating_sub(1),
self.usage.clone(),
self.deadline,
self.gate.clone(),
)
.await;
@@ -751,7 +767,9 @@ mod tests {
fn bridge_for(mock: Arc<MockLlmClient>, depth_remaining: u32) -> RlmBridge {
let client: Arc<dyn RlmLlmClient> = mock;
RlmBridge::new(client, "child-model".to_string(), depth_remaining)
RlmBridge::new(client, "child-model".to_string(), depth_remaining).with_gate(Some(
crate::tools::codemode::NestedCallGate::admitting_for_test(),
))
}
#[tokio::test]
+149 -2
View File
@@ -144,6 +144,7 @@ pub(crate) fn run_rlm_turn_inner(
max_depth,
RlmUsageAccumulator::new(),
tokio::time::Instant::now() + crate::tools::subagent::DEFAULT_CHILD_WALL_TIME,
Some(crate::tools::codemode::NestedCallGate::admitting_for_test()),
)
}
@@ -159,6 +160,7 @@ pub(crate) fn run_rlm_turn_inner_with_usage(
max_depth: u32,
usage: RlmUsageAccumulator,
deadline: tokio::time::Instant,
gate: Option<crate::tools::codemode::NestedCallGate>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = RlmTurnResult> + Send>> {
Box::pin(async move {
let mut result = run_rlm_turn_impl(
@@ -171,6 +173,7 @@ pub(crate) fn run_rlm_turn_inner_with_usage(
max_depth,
usage.clone(),
deadline,
gate,
)
.await;
let snapshot = usage.snapshot().await;
@@ -186,6 +189,37 @@ pub(crate) fn run_rlm_turn_inner_with_usage(
// Implementation
// ---------------------------------------------------------------------------
/// Why one round of model-written Python may not run, or `None` when the
/// serving turn admitted exactly `code`.
///
/// Known limitation: time spent waiting on an approval counts against the
/// RLM turn's absolute deadline.
async fn round_refusal(
gate: Option<&crate::tools::codemode::NestedCallGate>,
code: &str,
) -> Option<String> {
use crate::tools::codemode::NestedCallVerdict;
let tool_name = crate::core::engine::tool_catalog::CODE_EXECUTION_TOOL_NAME;
let Some(gate) = gate else {
return Some("no permission gate is serving this RLM turn".to_string());
};
match gate
.ask(tool_name.to_string(), serde_json::json!({ "code": code }))
.await
{
NestedCallVerdict::Run { name, input, .. }
if name == tool_name
&& input.get("code").and_then(serde_json::Value::as_str) == Some(code) =>
{
None
}
NestedCallVerdict::Run { .. } | NestedCallVerdict::Answered { .. } => {
Some("the admitted call was not this code".to_string())
}
NestedCallVerdict::Refused { error, .. } => Some(error.to_string()),
}
}
/// Record at the producing loop, before forwarding. Recording at each bridge
/// would duplicate deeper events as they pass through their ancestors.
struct RlmEventSender {
@@ -228,6 +262,7 @@ async fn run_rlm_turn_impl(
max_depth: u32,
routed_usage: RlmUsageAccumulator,
deadline: tokio::time::Instant,
gate: Option<crate::tools::codemode::NestedCallGate>,
) -> RlmTurnResult {
let start = Instant::now();
let tx_event = RlmEventSender {
@@ -296,7 +331,8 @@ async fn run_rlm_turn_impl(
routed_usage.clone(),
)
.with_events(tx_event.sender.clone())
.with_deadline(Some(deadline));
.with_deadline(Some(deadline))
.with_gate(gate.clone());
tx_event
.send(Event::status(format!(
@@ -531,7 +567,33 @@ async fn run_rlm_turn_impl(
})
.await;
// 4d. Execute the code in the REPL with the bridge servicing
// 4d. Model-written Python runs only after the serving turn
// admits this exact code, like a `code_execution` call
// carrying it. No gate, a refusal, or an answer for any
// other input ends the turn with nothing run.
if let Some(reason) = round_refusal(gate.as_ref(), &code_to_run).await {
tx_event
.send(Event::status(format!(
"RLM round {} not run: {reason}",
iteration + 1
)))
.await;
break 'turn RlmTurnResult {
answer: String::new(),
iterations: iteration + 1,
duration: start.elapsed(),
error: Some(format!("RLM code was not run: {reason}")),
usage: total_usage.clone(),
routed_usage: Vec::new(),
routed_usage_drop_records: Vec::new(),
routed_usage_dropped_records: 0,
termination: RlmTermination::Error,
trace: trace.clone(),
total_rpcs,
};
}
// 4e. Execute the code in the REPL with the bridge servicing
// llm_query / rlm_query callbacks.
let round = match repl.run(&code_to_run, Some(&bridge)).await {
Ok(r) => r,
@@ -1048,6 +1110,89 @@ mod tests {
use crate::llm_client::mock::MockLlmClient;
use codewhale_models::MessageResponse;
/// One model round whose code writes a marker file, run under `gate`.
async fn marker_round(
gate: Option<crate::tools::codemode::NestedCallGate>,
) -> (RlmTurnResult, bool) {
let workspace = tempfile::tempdir().expect("tempdir");
let marker = workspace.path().join("rlm-round-executed.txt");
let marker_literal = serde_json::to_string(&marker.to_string_lossy()).expect("literal");
let mock = Arc::new(MockLlmClient::new(Vec::new()));
mock.push_message_response(MessageResponse {
id: "mock_gated_rlm".to_string(),
r#type: "message".to_string(),
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: format!(
"```repl\nfrom pathlib import Path\nPath({marker_literal}).write_text('executed')\nFINAL('ran')\n```"
),
cache_control: None,
}],
model: "mock-model".to_string(),
stop_reason: Some("end_turn".to_string()),
stop_sequence: None,
container: None,
usage: Usage::default(),
});
let client: Arc<dyn RlmLlmClient> = mock;
let (tx, _rx) = mpsc::channel(8);
let result = run_rlm_turn_inner_with_usage(
client,
"root-model".to_string(),
"long context".to_string(),
None,
"child-model".to_string(),
tx,
0,
RlmUsageAccumulator::new(),
tokio::time::Instant::now() + Duration::from_secs(60),
gate,
)
.await;
(result, marker.exists())
}
#[tokio::test]
async fn code_round_runs_only_when_the_gate_admits_exactly_that_code() {
use crate::tools::codemode::{NestedCallGate, NestedCallVerdict, NestedDecision};
let (result, ran) = marker_round(Some(NestedCallGate::admitting_for_test())).await;
assert!(ran, "an admitted round runs: {:?}", result.error);
assert_eq!(result.termination, RlmTermination::Final);
let (result, ran) = marker_round(None).await;
assert!(!ran, "no gate, no code");
assert_eq!(result.termination, RlmTermination::Error);
let error = result.error.expect("refusal is reported");
assert!(error.contains("no permission gate"), "{error}");
let refusing = NestedCallGate::answering_for_test(|_, _| NestedCallVerdict::Refused {
error: crate::tools::spec::ToolError::permission_denied("not approved"),
decision: NestedDecision::Denied,
});
let (result, ran) = marker_round(Some(refusing)).await;
assert!(!ran, "a refused round does not run");
let error = result.error.expect("refusal is reported");
assert!(error.contains("not approved"), "{error}");
// A hook-rewritten or otherwise different admitted input is not
// permission for the code the model wrote.
let rewriting = NestedCallGate::answering_for_test(|name, _| NestedCallVerdict::Run {
name: name.to_string(),
input: serde_json::json!({ "code": "print('something else')" }),
supports_parallel: false,
decision: NestedDecision::Auto,
hook_context: None,
});
let (result, ran) = marker_round(Some(rewriting)).await;
assert!(!ran, "a different admitted input does not run this code");
assert!(
result
.error
.is_some_and(|error| error.contains("was not this code"))
);
}
#[tokio::test]
async fn max_tokens_complete_repl_is_not_executed_or_accepted() {
let workspace = tempfile::tempdir().expect("tempdir");
@@ -1295,6 +1440,7 @@ mod tests {
0,
usage.clone(),
tokio::time::Instant::now() + Duration::from_secs(1),
Some(crate::tools::codemode::NestedCallGate::admitting_for_test()),
),
)
.await
@@ -1352,6 +1498,7 @@ mod tests {
0,
RlmUsageAccumulator::new(),
tokio::time::Instant::now() + Duration::from_secs(1),
Some(crate::tools::codemode::NestedCallGate::admitting_for_test()),
),
)
.await
+24 -12
View File
@@ -1049,6 +1049,9 @@ fn open_runtime_threads_for_server(
Ok((manager, workshop_activation))
}
/// Prefix of the first line the Runtime prints once it holds its listener.
pub const RUNTIME_LISTENING_PREFIX: &str = "Runtime API listening on http://";
/// Start the runtime API server.
pub async fn run_http_server(
config: Config,
@@ -1058,6 +1061,17 @@ pub async fn run_http_server(
) -> Result<()> {
validate_runtime_listener_security(&options)?;
// Own the endpoint before building anything that names it: with an
// ephemeral port (`--port 0`) the kernel picks the port, and the address
// this process reports is the one it actually holds.
let addr = runtime_bind_address(&options.host, options.port)?;
let listener = TcpListener::bind(addr)
.await
.with_context(|| format!("Failed to bind {addr}"))?;
let bound_addr = listener
.local_addr()
.context("Failed to read Runtime API listener address")?;
// Keep the server usable before a local catalog arrives. Omitted API
// requests are checked at admission; background tasks keep the auto sentinel.
let task_default_model = runtime_request_model(&config, None).unwrap_or_else(|_| "auto".into());
@@ -1149,7 +1163,7 @@ pub async fn run_http_server(
skill_state: Arc::new(Mutex::new(skill_state)),
auth_required: auth_enabled,
bind_host: options.host.clone(),
bind_port: options.port,
bind_port: bound_addr.port(),
mobile_enabled: options.mobile,
mobile,
web,
@@ -1165,15 +1179,10 @@ pub async fn run_http_server(
};
let app = build_router(state);
let addr = runtime_bind_address(&options.host, options.port)?;
let listener = TcpListener::bind(addr)
.await
.with_context(|| format!("Failed to bind {addr}"))?;
let bound_addr = listener
.local_addr()
.context("Failed to read Runtime API listener address")?;
println!("Runtime API listening on http://{bound_addr}");
// First stdout line, flushed: a supervising parent reads the endpoint
// from here instead of guessing a port (stdout is block-buffered on a pipe).
println!("{RUNTIME_LISTENING_PREFIX}{bound_addr}");
let _ = std::io::Write::flush(&mut std::io::stdout());
for line in runtime_auth_status_lines(&resolved_auth) {
println!("{line}");
}
@@ -1216,7 +1225,7 @@ pub async fn run_http_server(
println!(
" /v1/runtime/info reports bind_host={host:?}, port={port}, auth_required={auth}.",
host = options.host,
port = options.port,
port = bound_addr.port(),
auth = auth_enabled,
);
}
@@ -1235,7 +1244,10 @@ pub async fn run_http_server(
/// overlay transport, so a non-loopback listener would expose the Runtime API
/// to peers that can observe or replay browser traffic.
fn validate_runtime_listener_security(options: &RuntimeApiOptions) -> Result<()> {
if options.port == 0 {
// Port 0 asks the kernel for an ephemeral port. Only a plain loopback
// Runtime may use it: web and mobile clients are given a fixed endpoint.
if options.port == 0 && (options.web || options.mobile || !is_loopback_bind_host(&options.host))
{
bail!("Port must be > 0");
}
if options.web && options.host != "127.0.0.1" {
+22
View File
@@ -782,6 +782,28 @@ fn runtime_token_scenario() {
}
}
/// Port 0 lets a supervised loopback Runtime own an ephemeral endpoint and
/// report it. Anything a client is handed a fixed address for keeps a real port.
#[test]
fn ephemeral_port_is_only_for_a_plain_loopback_runtime() {
let ephemeral = |host: &str, web: bool, mobile: bool| RuntimeApiOptions {
host: host.to_string(),
port: 0,
web,
mobile,
..RuntimeApiOptions::default()
};
assert!(validate_runtime_listener_security(&ephemeral("127.0.0.1", false, false)).is_ok());
for refused in [
ephemeral("0.0.0.0", false, false),
ephemeral("127.0.0.1", true, false),
ephemeral("127.0.0.1", false, true),
] {
let err = validate_runtime_listener_security(&refused).unwrap_err();
assert!(err.to_string().contains("Port must be > 0"), "{err}");
}
}
#[test]
fn mobile_listener_fails_closed_outside_loopback_without_verified_transport() {
let non_loopback = RuntimeApiOptions {
+100 -5
View File
@@ -409,13 +409,37 @@ fn is_env_file(path: &Path) -> bool {
/// - The user's source tree, `~/Documents`, `~/Downloads` — a coding agent must
/// still be able to read the user's code, which is the entire point.
fn default_denied_subtrees() -> Vec<(PathBuf, &'static str)> {
let mut out = machine_wide_denied_subtrees();
if let Ok(Some(active)) = codewhale_paths::codewhale_home() {
out.push((
active.join("secrets"),
"Codewhale secret store (active home)",
));
out.push((
active.join("credentials"),
"Codewhale OAuth credentials (active home)",
));
}
if let Some(legacy) = codewhale_paths::legacy_deepseek_home() {
out.push((
legacy.join("secrets"),
"Codewhale secret store (legacy home)",
));
out.push((
legacy.join("credentials"),
"Codewhale OAuth credentials (legacy home)",
));
}
out.push((
crate::oauth::auth_file_path(),
"Codex CLI login (resolved auth file)",
));
let Some(home) = dirs::home_dir() else {
// No home directory: only the machine-wide entries are meaningful.
return machine_wide_denied_subtrees();
return out;
};
let h = |rel: &str| home.join(rel);
let mut out = vec![
let mut ambient = vec![
// --- SSH / GPG ---
(h(".ssh"), "SSH keys and known-hosts (~/.ssh)"),
(h(".gnupg"), "GnuPG keyring (~/.gnupg)"),
@@ -489,6 +513,20 @@ fn default_denied_subtrees() -> Vec<(PathBuf, &'static str)> {
h(".deepseek/secrets"),
"Codewhale secret store (~/.deepseek/secrets)",
),
(
h(".codewhale/credentials"),
"Codewhale OAuth credentials (ambient home)",
),
(
h(".deepseek/credentials"),
"Codewhale OAuth credentials (ambient legacy home)",
),
(h(".kimi/credentials"), "Kimi CLI credentials"),
(h(".claude/.credentials.json"), "Claude Code credentials"),
(
h(".codewhale-cu/recordings"),
"Computer Use recordings and trajectories",
),
// --- Browser profiles (cookies, saved passwords, session tokens) ---
(h(".mozilla"), "Firefox profile (~/.mozilla)"),
(
@@ -506,7 +544,7 @@ fn default_denied_subtrees() -> Vec<(PathBuf, &'static str)> {
];
if cfg!(target_os = "macos") {
out.extend([
ambient.extend([
(
h("Library/Keychains"),
"macOS keychain (~/Library/Keychains)",
@@ -531,7 +569,7 @@ fn default_denied_subtrees() -> Vec<(PathBuf, &'static str)> {
]);
}
out.extend(machine_wide_denied_subtrees());
out.extend(ambient);
out
}
@@ -733,6 +771,63 @@ mod tests {
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[test]
fn sandbox_read_guard_denies_active_and_known_agent_credentials_in_real_file_tool() {
use crate::tools::spec::{ToolContext, ToolSpec};
let _env = crate::test_support::lock_test_env();
let temp = tempfile::tempdir().expect("private fixture");
let home = temp.path().join("home");
let active_home = temp.path().join("active");
let codex = temp.path().join("codex");
let _home = crate::test_support::EnvVarGuard::set("HOME", home.to_str().unwrap());
let _active =
crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", active_home.to_str().unwrap());
let _codex = crate::test_support::EnvVarGuard::set("CODEX_HOME", codex.to_str().unwrap());
let _auth = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_AUTH_FILE");
assert_eq!(dirs::home_dir().as_deref(), Some(home.as_path()));
let denied = [
active_home.join("credentials/fixture.json"),
codex.join("auth.json"),
home.join(".kimi/credentials/fixture.json"),
home.join(".claude/.credentials.json"),
home.join(".codewhale-cu/recordings/trajectories/fixture.jsonl"),
];
for path in &denied {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "read-s10-synthetic-value").unwrap();
}
struct RestoreActive(Option<Arc<ReadDenylist>>);
impl Drop for RestoreActive {
fn drop(&mut self) {
*ACTIVE.write().unwrap() = self.0.take();
}
}
let previous = ACTIVE.write().unwrap().take();
let _restore = RestoreActive(previous);
set_active(ReadDenylist::build(true, &[], &[]));
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let context = ToolContext::new(temp.path());
for path in denied {
let result = runtime.block_on(
crate::tools::file::ReadFileTool
.execute(serde_json::json!({"path": path}), &context),
);
assert!(result.is_err(), "file tool read a credential source");
}
let safe = home.join(".claude/settings.json");
std::fs::write(&safe, "ordinary-readable-setting").unwrap();
let allowed = runtime
.block_on(
crate::tools::file::ReadFileTool
.execute(serde_json::json!({"path": safe}), &context),
)
.expect("ordinary settings remain readable");
assert!(allowed.content.contains("ordinary-readable-setting"));
}
#[test]
fn empty_denylist_denies_nothing_and_reports_full_disk_read() {
let list = ReadDenylist::empty();
+107 -3
View File
@@ -49,9 +49,9 @@
//! Known limits of this stopgap: the calls are matched by MCP tool-name suffix
//! (`_consent`, `_consent_allow`, `_consent_revoke`, `_app_script`), so a
//! different MCP server exposing a tool with one of those names is gated the
//! same way (fail closed). The consent decision still travels through a model
//! tool call; MCP elicitation, where the plugin asks the host for the user's
//! answer directly, is the real fix and is not built.
//! same way (fail closed). Consent, script and computer registration/spawn
//! calls force an exact human card. Only its human decision can be attested
//! to the reviewed built-in plugin; grants and autonomous modes cannot mint it.
use std::fmt::Write as _;
use serde_json::Value;
@@ -252,6 +252,14 @@ pub(crate) enum ComputerUseUserGate {
/// not shown by `first_line`.
line_count: usize,
},
/// Registering or spawning a computer the plugin will then drive (and,
/// for ssh, push an agent to).
Computer {
action: &'static str,
transport: Option<String>,
/// `user@host:port` for ssh, the target or image otherwise.
destination: Option<String>,
},
}
/// Classify an MCP tool call as a Computer Use call that needs a human
@@ -272,6 +280,41 @@ pub(crate) fn computer_use_user_gate(
.filter(|value| !value.is_empty())
.map(str::to_string)
};
let computer_action = if tool_name.ends_with("_computer_register") {
Some("register")
} else if tool_name.ends_with("_computer_spawn") {
Some("spawn")
} else if tool_name.ends_with("_computer") {
match input.get("action").and_then(Value::as_str) {
Some("register") => Some("register"),
Some("spawn") => Some("spawn"),
_ => None,
}
} else {
None
};
if let Some(action) = computer_action {
let host = text("host");
let destination = match host {
Some(host) => {
let user = text("user")
.map(|user| format!("{user}@"))
.unwrap_or_default();
let port = input
.get("port")
.and_then(Value::as_u64)
.map(|port| format!(":{port}"))
.unwrap_or_default();
Some(format!("{user}{host}{port}"))
}
None => text("target").or_else(|| text("image")),
};
return Some(ComputerUseUserGate::Computer {
action,
transport: text("transport"),
destination,
});
}
let action = if tool_name.ends_with("_consent_allow") {
"allow"
} else if tool_name.ends_with("_consent_revoke") {
@@ -1306,4 +1349,65 @@ mod tests {
"denials stay exact-call scoped"
);
}
#[test]
fn computer_register_and_spawn_need_a_human_card() {
for (tool, input) in [
(
"mcp_codewhale-cu_computer_register",
json!({"computer": "box", "transport": "ssh", "host": "box.example", "user": "me", "port": 2222}),
),
(
"mcp_codewhale-cu_computer",
json!({"action": "register", "id": "box", "transport": "ssh", "host": "box.example"}),
),
(
"mcp_codewhale-cu_computer_spawn",
json!({"image": "desktop"}),
),
(
"mcp_codewhale-cu_computer",
json!({"action": "spawn", "id": "d"}),
),
] {
assert!(
matches!(
computer_use_user_gate(tool, &input),
Some(ComputerUseUserGate::Computer { .. })
),
"{tool} {input}"
);
}
assert_eq!(
computer_use_user_gate(
"mcp_codewhale-cu_computer_register",
&json!({"transport": "ssh", "host": "box.example", "user": "me", "port": 2222}),
),
Some(ComputerUseUserGate::Computer {
action: "register",
transport: Some("ssh".to_string()),
destination: Some("[email protected]:2222".to_string()),
})
);
for (tool, input) in [
("mcp_codewhale-cu_computer", json!({"action": "list"})),
(
"mcp_codewhale-cu_computer",
json!({"action": "switch", "id": "box"}),
),
(
"mcp_codewhale-cu_computer_switch",
json!({"computer": "box"}),
),
] {
assert_eq!(computer_use_user_gate(tool, &input), None, "{tool}");
}
assert_eq!(
computer_use_batch_hidden_gate(
"mcp_codewhale-cu_run_actions",
&json!({"steps": [{"tool": "codewhale-cu_computer_register", "arguments": {"host": "x"}}]}),
)
.as_deref(),
Some("codewhale-cu_computer_register")
);
}
}
+47 -3
View File
@@ -46,7 +46,7 @@
//! - Nested calls do not take per-tool locks against sibling top-level calls;
//! the program runs under its own exclusive lock instead. Inside the
//! program, calls the gate marks non-parallel run one at a time.
//! - No nested `agent`, `workflow`, `request_user_input`, interpreter,
//! - No nested `agent`, `workflow`, `rlm`, `request_user_input`, interpreter,
//! interactive shell, sandbox escalation, Computer Use consent/script, MCP
//! sign-in, or recursive `execute_tools`. Those stay direct calls.
//! - Rich content blocks (images) from nested results are dropped; text and
@@ -109,6 +109,9 @@ const PROHIBITED_NESTED: &[&str] = &[
"js_execution",
"agent",
"workflow",
// Recursive RLM rounds are admitted by the turn loop serving the direct
// `rlm` call; a program has no such server for a nested one.
"rlm",
crate::core::engine::tool_catalog::REQUEST_USER_INPUT_NAME,
crate::core::engine::tool_catalog::MULTI_TOOL_PARALLEL_NAME,
];
@@ -242,7 +245,9 @@ impl NestedCallGate {
)
}
async fn ask(&self, name: String, input: Value) -> NestedCallVerdict {
/// Ask the serving turn loop to decide one nested call. A gate nobody
/// serves any more refuses.
pub(crate) async fn ask(&self, name: String, input: Value) -> NestedCallVerdict {
let unavailable = || NestedCallVerdict::Refused {
error: ToolError::not_available(
"the turn that launched this program is no longer serving its permission gate",
@@ -262,6 +267,36 @@ impl NestedCallGate {
}
}
#[cfg(test)]
impl NestedCallGate {
/// A gate whose server admits every call exactly as asked, for tests of
/// consumers that are not about admission. Needs a Tokio runtime.
pub(crate) fn admitting_for_test() -> Self {
Self::answering_for_test(|name, input| NestedCallVerdict::Run {
name: name.to_string(),
input: input.clone(),
supports_parallel: false,
decision: NestedDecision::Auto,
hook_context: None,
})
}
/// A gate whose server answers every call with `answer`.
pub(crate) fn answering_for_test(
answer: impl Fn(&str, &Value) -> NestedCallVerdict + Send + 'static,
) -> Self {
let (tx_event, mut rx_event) = mpsc::channel(64);
tokio::spawn(async move { while rx_event.recv().await.is_some() {} });
let (gate, mut requests) = Self::new(None, tx_event, Duration::from_secs(60));
tokio::spawn(async move {
while let Some(request) = requests.recv().await {
let _ = request.reply.send(answer(&request.name, &request.input));
}
});
gate
}
}
/// Refusals decided from the request alone: calls that need the turn loop
/// itself or their own approval card stay direct. Checked on the name the
/// program sent, and again by the turn loop on the name planning resolved it
@@ -582,6 +617,8 @@ impl CodemodeInvoker {
&name,
input,
&disallowed,
// Calls a program makes never carry a person's decision.
None,
)
.await
})
@@ -1163,7 +1200,14 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let context = ToolContext::new(dir.path());
let invoker = CodemodeInvoker::new(vec![], context);
for name in ["agent", "workflow", "execute_tools", "tool_search", "nope"] {
for name in [
"agent",
"workflow",
"rlm",
"execute_tools",
"tool_search",
"nope",
] {
let err = invoker
.invoke(ToolCallRequest {
tool: name.to_string(),
+7 -2
View File
@@ -1166,7 +1166,7 @@ impl ToolRegistryBuilder {
pub fn with_rlm_tool(self, client: Option<CodewhaleClient>, root_model: String) -> Self {
use super::rlm::RlmTool;
self.with_tool(Arc::new(
RlmTool::new("rlm", client).with_root_model(root_model),
RlmTool::new(super::rlm::RLM_TOOL_NAME, client).with_root_model(root_model),
))
}
@@ -1648,7 +1648,12 @@ impl ToolSpec for McpToolAdapter {
) -> Result<RichToolResult, ToolError> {
let mut pool = self.pool.lock().await;
let result = pool
.call_tool_with_disallowed(&self.name, input, &context.disallowed_tools)
.call_tool_with_disallowed(
&self.name,
input,
&context.disallowed_tools,
context.human_decision.as_ref(),
)
.await
.map_err(|e| ToolError::execution_failed(format!("MCP tool failed: {e}")))?;
Ok(mcp_result_to_bounded_rich_tool_result(result))
+7 -2
View File
@@ -29,6 +29,8 @@ use crate::tools::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
};
/// Registered name of the persistent RLM session tool.
pub(crate) const RLM_TOOL_NAME: &str = "rlm";
const DEFAULT_CHILD_MODEL: &str = "deepseek-v4-flash";
const MAX_INLINE_CONTENT_CHARS: usize = 200_000;
const FULL_STDOUT_HEAD_CHARS: usize = 4_096;
@@ -506,7 +508,8 @@ impl RlmTool {
self.root_model.clone(),
config.sub_rlm_max_depth.min(HARD_SUB_RLM_DEPTH_CAP),
)
.with_deadline(Some(deadline));
.with_deadline(Some(deadline))
.with_gate(context.execution.nested_call_gate.clone());
let round_result =
tokio::time::timeout_at(bridge.deadline(), kernel.run(code, Some(&bridge)))
.await
@@ -1360,7 +1363,9 @@ mod tests {
let client = CodewhaleClient::new(&config).unwrap();
let tool = RlmTool::new("rlm", Some(client)).with_root_model("deepseek-v4-flash".into());
let temp = tempfile::tempdir().unwrap();
let context = ToolContext::new(temp.path());
let mut context = ToolContext::new(temp.path());
context.execution.nested_call_gate =
Some(crate::tools::codemode::NestedCallGate::admitting_for_test());
tool.execute(
json!({"action": "open", "name": "receipts", "content": "fixture context"}),
&context,
+70
View File
@@ -2086,6 +2086,23 @@ impl ShellManager {
self.sandbox_manager.configured_sandbox()
}
/// Prepare a program for a tool that runs workspace code outside
/// `exec_shell`, under the policy and sandbox configuration a shell
/// command would get in this session.
fn prepare_runner(
&self,
program: &str,
args: Vec<String>,
cwd: &Path,
timeout: Duration,
policy_override: Option<ExecutionSandboxPolicy>,
) -> ExecEnv {
let policy = policy_override.unwrap_or_else(|| self.sandbox_policy.clone());
let spec =
CommandSpec::program(program, args, cwd.to_path_buf(), timeout).with_policy(policy);
self.sandbox_manager.prepare(&spec)
}
/// Request that the active foreground shell wait detach and leave its
/// process running in the background job table.
pub fn request_foreground_background(&mut self) {
@@ -4176,6 +4193,59 @@ fn is_native_readonly_sandbox(sandbox_type: SandboxType) -> bool {
}
}
/// Build the child for a tool that runs workspace code outside `exec_shell`
/// (gate commands, the cargo test runner). It gets exactly the confinement a
/// shell command gets in this session: the same policy, the same OS sandbox
/// wrapper, and the sanitized child environment. Callers keep their own
/// process-tree containment, timeout and cancellation.
///
/// Known limitations: where the platform has no enforcing sandbox configured
/// (Linux without bubblewrap, Windows) a workspace-write policy runs
/// unsandboxed, as it does for `exec_shell`; a read-only policy refuses there.
/// A session whose commands run in an external sandbox backend cannot run
/// these local children at all.
pub(crate) fn sandboxed_runner_command(
context: &crate::tools::spec::ToolContext,
program: &str,
args: Vec<String>,
cwd: &Path,
timeout: Duration,
) -> std::result::Result<tokio::process::Command, crate::tools::spec::ToolError> {
use crate::tools::spec::ToolError;
if context.sandbox_backend.is_some() {
return Err(ToolError::not_available(
"this tool starts a local process, and this session runs commands in an external sandbox; run the command through the shell tool instead",
));
}
let exec_env = context
.shell_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.prepare_runner(
program,
args,
cwd,
timeout,
context.elevated_sandbox_policy.clone(),
);
if matches!(exec_env.policy, ExecutionSandboxPolicy::ReadOnly) {
require_native_readonly_execution(&exec_env)
.map_err(|error| ToolError::permission_denied(error.to_string()))?;
}
let mut cmd = tokio::process::Command::new(exec_env.program());
crate::utils::suppress_tokio_console_window(&mut cmd);
cmd.args(exec_env.args())
.current_dir(&exec_env.cwd)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
// Workspace code never inherits parent credentials.
crate::child_env::apply_to_tokio_command(
&mut cmd,
crate::child_env::string_map_env(&exec_env.env),
);
Ok(cmd)
}
fn require_native_readonly_execution(exec_env: &ExecEnv) -> Result<()> {
if matches!(exec_env.policy, ExecutionSandboxPolicy::ReadOnly)
&& is_native_readonly_sandbox(exec_env.sandbox_type)
+13
View File
@@ -586,6 +586,8 @@ pub struct ToolContext {
pub struct ToolExecutionState {
/// Effective session/ancestor tool ceiling, carried to MCP dispatch and runtime registration.
pub(crate) disallowed_tools: Vec<String>,
/// Set only on the context of one call a person approved on a card.
pub(crate) human_decision: Option<crate::core::engine::HumanDecision>,
/// Shared shell manager for background tasks and streaming IO.
pub shell_manager: SharedShellManager,
/// Per-session snapshots for files successfully observed by `read_file`.
@@ -787,6 +789,7 @@ impl ToolContext {
workspace,
execution: Box::new(ToolExecutionState {
disallowed_tools: Vec::new(),
human_decision: None,
shell_manager,
file_read_tracker: new_shared_file_read_tracker(),
owner_agent_id: None,
@@ -1327,6 +1330,16 @@ impl ToolContext {
self
}
/// Carry a person's card decision to the one call it approved.
#[must_use]
pub(crate) fn with_human_decision(
mut self,
decision: crate::core::engine::HumanDecision,
) -> Self {
self.human_decision = Some(decision);
self
}
/// Set the shell network-denial hint used by network-restricted modes.
pub fn with_shell_network_denied_hint(mut self, hint: impl Into<String>) -> Self {
self.shell_network_denied_hint = Some(hint.into());
+8
View File
@@ -18977,6 +18977,14 @@ impl SubAgentToolRegistry {
use crate::core::events::{ToolGate, ToolGateVerdict};
use crate::tui::auto_review::AutoReviewContext;
if crate::tools::approval_cache::computer_use_user_gate(name, input).is_some()
|| crate::tools::approval_cache::computer_use_batch_hidden_gate(name, input).is_some()
{
return ChildGateVerdict::Deny(format!(
"Computer Use call {name} needs the user's own approval and cannot run in a sub-agent. Ask the parent to run it."
));
}
// Approval posture is separate from authority: it can remove a
// prompt, never restore a tool removed by role, scope, or envelope.
let approval_mode = crate::core::authority::agent_approval_mode_for_turn(
+57
View File
@@ -26288,3 +26288,60 @@ async fn late_launch_permit_still_gets_the_full_work_budget() {
"saved deadline {saved_deadline_ms} must start from launch, not spawn ({spawned_at_ms})"
);
}
#[tokio::test]
async fn computer_use_consent_is_denied_in_child_gate_every_mode() {
let tmp = tempdir().expect("tempdir");
for (auto_approve, approval_mode) in [
(true, codewhale_execpolicy::ApprovalMode::Bypass),
(false, codewhale_execpolicy::ApprovalMode::Auto),
(false, codewhale_execpolicy::ApprovalMode::Suggest),
(false, codewhale_execpolicy::ApprovalMode::Never),
] {
let mut runtime = stub_runtime();
runtime.context = ToolContext::new(tmp.path());
runtime.context.auto_approve = auto_approve;
runtime.context.execution.approval_mode = approval_mode;
runtime.worker_profile = WorkerRuntimeProfile::for_role(FleetRole::Worker);
let registry = SubAgentToolRegistry::new(
runtime,
FleetRole::Worker,
None,
crate::tools::todo::new_shared_todo_list(),
crate::tools::plan::new_shared_plan_state(),
);
for (name, input) in [
(
"mcp_codewhale-cu_consent",
json!({"action": "allow", "app": "Safari", "remember": true}),
),
("mcp_codewhale-cu_consent_allow", json!({"app": "Terminal"})),
(
"mcp_codewhale-cu_app_script",
json!({"script": "tell application \"Finder\" to activate"}),
),
(
"mcp_codewhale-cu_computer_register",
json!({"computer": "box", "transport": "ssh", "host": "box.example"}),
),
(
"mcp_codewhale-cu_run_actions",
json!({"steps": [{"tool": "codewhale-cu_consent_allow", "arguments": {"app": "Safari"}}]}),
),
] {
let verdict = registry
.gate_held_call(
"agent_child",
"call_1",
name,
&input,
registry.registry.context(),
)
.await;
assert!(
matches!(verdict, ChildGateVerdict::Deny(ref reason) if reason.contains("own approval")),
"{approval_mode:?} {name}: {verdict:?}"
);
}
}
}
+50 -12
View File
@@ -1,7 +1,6 @@
//! Durable task, gate, and PR-attempt tools.
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Instant;
use async_trait::async_trait;
@@ -37,17 +36,16 @@ fn build_gate_command_parts(command: &str) -> (String, Vec<String>) {
)
}
fn build_gate_command(command: &str, cwd: &Path) -> Command {
/// Gate commands run workspace code, so they start like an `exec_shell`
/// command: inside this session's sandbox, from the sanitized environment.
fn build_gate_command(
command: &str,
cwd: &Path,
context: &ToolContext,
timeout: std::time::Duration,
) -> Result<Command, ToolError> {
let (program, args) = build_gate_command_parts(command);
let mut cmd = Command::new(program);
cmd.args(args)
.current_dir(cwd)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// Gate commands run workspace code; start them from the sanitized child
// environment, like `exec_shell`, so parent credentials are not inherited.
crate::child_env::apply_to_tokio_command(&mut cmd, std::iter::empty::<(&str, &str)>());
cmd
crate::tools::shell::sandboxed_runner_command(context, &program, args, cwd, timeout)
}
fn task_shell_wait_input(mut input: Value) -> Value {
@@ -673,7 +671,12 @@ impl TasksTool {
}
let started = Instant::now();
let mut cmd = build_gate_command(&command, &cwd);
let mut cmd = build_gate_command(
&command,
&cwd,
context,
std::time::Duration::from_millis(timeout_ms),
)?;
// Contained: when the timeout elapses the gate's whole process tree
// is killed, instead of leaving it running behind a "timeout" result,
// and what it wrote until then still reaches the log.
@@ -1843,6 +1846,38 @@ mod tests {
assert_eq!(args, vec!["-c".to_string(), "echo hello".to_string()]);
}
/// A gate command is workspace code: under a read-only posture it either
/// runs inside the enforcing sandbox, where its write fails, or is refused
/// outright where no enforcing sandbox exists. It never runs raw.
#[cfg(unix)]
#[tokio::test]
async fn gate_command_is_confined_by_the_session_sandbox() {
let tmp = crate::test_support::sandbox_visible_tempdir();
let written = tmp.path().join("written-by-gate.txt");
let mut context = ToolContext::new(tmp.path());
context.elevated_sandbox_policy = Some(crate::sandbox::SandboxPolicy::ReadOnly);
let command = format!(
"printf ran; printf x > '{}' 2>/dev/null; true",
written.display()
);
match build_gate_command(
&command,
tmp.path(),
&context,
std::time::Duration::from_secs(30),
) {
Ok(mut cmd) => {
let output = cmd.output().await.expect("sandboxed gate command runs");
assert_eq!(String::from_utf8_lossy(&output.stdout), "ran");
}
Err(error) => assert!(
error.to_string().contains("nothing was run"),
"only a missing enforcing sandbox may refuse: {error}"
),
}
assert!(!written.exists(), "a read-only posture must stop the write");
}
#[cfg(unix)]
#[tokio::test]
async fn gate_command_does_not_inherit_parent_secret_env() {
@@ -1853,7 +1888,10 @@ mod tests {
let output = build_gate_command(
"printf 'secret=%s\\n' \"${CODEWHALE_TEST_GATE_SECRET-unset}\"; printf 'path-ok\\n'",
tmp.path(),
&ToolContext::new(tmp.path()),
std::time::Duration::from_secs(30),
)
.expect("gate command builds")
.output()
.await
.expect("gate command runs");
+10 -4
View File
@@ -162,15 +162,21 @@ async fn run_cargo(
context: &ToolContext,
timeout: Duration,
) -> Result<crate::process_tree::ContainedOutput, ToolError> {
let Some(mut cmd) = crate::dependencies::Cargo::tokio_command() else {
let Some(cargo) = crate::dependencies::Cargo::resolve() else {
return Err(ToolError::not_available(
"cargo is not installed or not in PATH",
));
};
cmd.args(args).current_dir(workspace);
// `cargo test` builds and runs workspace code; do not hand it parent
// `cargo test` builds and runs workspace code: it starts like an
// `exec_shell` command, inside this session's sandbox and without parent
// credentials.
crate::child_env::apply_to_tokio_command(&mut cmd, std::iter::empty::<(&str, &str)>());
let mut cmd = crate::tools::shell::sandboxed_runner_command(
context,
&cargo,
args.to_vec(),
workspace,
timeout,
)?;
let run = crate::process_tree::contained_output_until(&mut cmd, tokio::time::sleep(timeout));
let cancelled = async {
match context.cancel_token.as_ref() {
+165 -12
View File
@@ -27,8 +27,8 @@
#![allow(dead_code)] // Public API; worktree manager wiring continues post-render polish.
use crate::dependencies::{ExternalTool, Git};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
@@ -286,6 +286,7 @@ pub fn probe_workspace_status(workspace: &Path) -> Result<PorcelainStatus, Strin
"--branch",
"-z",
"--untracked-files=normal",
"--ignore-submodules=dirty",
],
)
.ok();
@@ -299,7 +300,12 @@ pub fn probe_workspace_status(workspace: &Path) -> Result<PorcelainStatus, Strin
fn legacy_workspace_status(workspace: &Path) -> Result<PorcelainStatus, String> {
let raw = git_output(
workspace,
&["status", "--porcelain", "--untracked-files=normal"],
&[
"status",
"--porcelain",
"--untracked-files=normal",
"--ignore-submodules=dirty",
],
)?;
let (changes, changed_paths, changed_path_count) = parse_porcelain_v1(&raw);
let head = git_output(workspace, &["symbolic-ref", "--short", "HEAD"])
@@ -540,17 +546,24 @@ fn parse_worktree_list(porcelain: &str) -> Vec<WorktreeEntry> {
}
fn git_output(cwd: &Path, args: &[&str]) -> Result<String, String> {
let output = Command::new("git")
// Shared read policy disables repository-selected helpers and lazy
// fetch while keeping the sanitized environment and optional locks off.
let output = Git::review_command(cwd)
.map_err(|e| format!("{e:#}"))?
.args(["-c", "log.showSignature=false"])
.args(args)
// This probe runs against the user's own repository every two
// seconds. `git status` opportunistically refreshes the index, and
// that refresh takes `.git/index.lock` — colliding with a `git
// commit` the user runs in their own shell (#5617). Optional locks
// are exactly what we do not want here: we only ever read.
.env("GIT_OPTIONAL_LOCKS", "0")
.current_dir(cwd)
.output()
.map_err(|e| e.to_string())?;
finish_git_output(output)
}
// Explicit writes keep the user's own configured behavior. The shared Git
// command still scrubs parent credentials and never opens a hidden prompt.
fn git_write(cwd: &Path, args: &[&str]) -> Result<String, String> {
finish_git_output(Git::output(args, cwd).map_err(|e| e.to_string())?)
}
fn finish_git_output(output: std::process::Output) -> Result<String, String> {
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).into_owned());
}
@@ -656,7 +669,7 @@ pub fn create_worktree(
args.push(path.to_str().ok_or("invalid path")?);
args.push(branch);
}
git_output(repo, &args).map(|_| ())?;
git_write(repo, &args).map(|_| ())?;
force_refresh(repo);
Ok(())
}
@@ -966,7 +979,147 @@ locked
"commit.gpgsign=false",
];
all.extend_from_slice(args);
git_output(dir, &all).expect("git");
git_write(dir, &all).expect("git");
}
/// Even status may run repository-selected fsmonitor or clean-filter
/// code. A background read must neither run it nor expose parent env.
#[cfg(unix)]
#[test]
fn automatic_git_status_does_not_execute_repository_helpers() {
use std::os::unix::fs::PermissionsExt;
let _lock = crate::test_support::lock_test_env();
let _sentinel = crate::test_support::EnvVarGuard::set(
"CODEWHALE_TEST_GIT_POLL_SECRET",
"git-poll-sentinel",
);
let dir = tempfile::tempdir().expect("repo");
let hooks = tempfile::tempdir().expect("private hooks");
let repo = dir.path();
git(repo, &["init", "--initial-branch=main"]);
std::fs::write(repo.join("tracked.txt"), "one\n").unwrap();
std::fs::write(repo.join(".gitattributes"), "*.txt filter=fixture\n").unwrap();
git(repo, &["add", "."]);
git(repo, &["commit", "-m", "first commit"]);
let fsmonitor_seen = hooks.path().join("fsmonitor-seen");
let filter_seen = hooks.path().join("filter-seen");
for (name, marker, ending) in [
("fsmonitor.sh", &fsmonitor_seen, "exit 1"),
("clean.sh", &filter_seen, "cat"),
] {
let script = hooks.path().join(name);
std::fs::write(
&script,
format!(
"#!/bin/sh\nprintf 'leak=%s\\n' \"${{CODEWHALE_TEST_GIT_POLL_SECRET-unset}}\" >> '{}'\n{ending}\n",
marker.display(),
),
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
}
git(
repo,
&[
"config",
"core.fsmonitor",
&hooks.path().join("fsmonitor.sh").to_string_lossy(),
],
);
git(
repo,
&[
"config",
"filter.fixture.clean",
&hooks.path().join("clean.sh").to_string_lossy(),
],
);
git(repo, &["config", "filter.fixture.required", "true"]);
// Same length forces content comparison instead of the cheap size check.
std::fs::write(repo.join("tracked.txt"), "two\n").unwrap();
let snapshot = probe_status(repo);
assert_eq!(snapshot.error, None);
assert_eq!(snapshot.branch.as_deref(), Some("main"));
assert!(
snapshot.dirty && snapshot.changes.modified >= 1,
"{snapshot:?}"
);
for marker in [&fsmonitor_seen, &filter_seen] {
assert!(
!marker.exists(),
"automatic read ran a repository helper: {}",
std::fs::read_to_string(marker).unwrap_or_default(),
);
}
}
/// log.showSignature can run the repository's gpg.program even with
/// captured stdout. Recent-commit polling never requests verification.
#[cfg(unix)]
#[test]
fn automatic_git_log_does_not_execute_a_signature_helper() {
use std::io::Write as _;
use std::os::unix::fs::PermissionsExt;
use std::process::Stdio;
let _lock = crate::test_support::lock_test_env();
let _sentinel = crate::test_support::EnvVarGuard::set(
"CODEWHALE_TEST_GIT_POLL_SECRET",
"git-poll-sentinel",
);
let dir = tempfile::tempdir().expect("repo");
let hooks = tempfile::tempdir().expect("private hooks");
let repo = dir.path();
git(repo, &["init", "--initial-branch=main"]);
std::fs::write(repo.join("tracked.txt"), "one\n").unwrap();
git(repo, &["add", "."]);
git(repo, &["commit", "-m", "first commit"]);
let unsigned = git_write(repo, &["cat-file", "commit", "HEAD"]).unwrap();
let (headers, message) = unsigned.split_once("\n\n").unwrap();
let signed = format!(
"{headers}\ngpgsig -----BEGIN PGP SIGNATURE-----\n fixture\n -----END PGP SIGNATURE-----\n\n{message}"
);
let mut child = Git::command()
.expect("git available")
.args(["hash-object", "-t", "commit", "-w", "--stdin"])
.current_dir(repo)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(signed.as_bytes())
.unwrap();
let object = child.wait_with_output().unwrap();
assert!(object.status.success());
let oid = String::from_utf8(object.stdout).unwrap();
git(repo, &["update-ref", "HEAD", oid.trim()]);
let marker = hooks.path().join("signature-seen");
let script = hooks.path().join("signature.sh");
std::fs::write(
&script,
format!(
"#!/bin/sh\nprintf 'leak=%s\\n' \"${{CODEWHALE_TEST_GIT_POLL_SECRET-unset}}\" >> '{}'\nexit 1\n",
marker.display(),
),
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
git(repo, &["config", "gpg.program", &script.to_string_lossy()]);
git(
repo,
&["config", "gpg.openpgp.program", &script.to_string_lossy()],
);
git(repo, &["config", "log.showSignature", "true"]);
let log = git_output(repo, &["log", "-1", "--format=%s"]).unwrap();
assert!(log.contains("first commit"), "{log}");
assert!(
!marker.exists(),
"automatic log ran a signature helper: {}",
std::fs::read_to_string(marker).unwrap_or_default(),
);
}
/// One probe of a real repository: branch, changes, commits, and the
+131 -9
View File
@@ -325,20 +325,35 @@ pub(super) fn spillover_pager_section(app: &App, cell_index: usize) -> Option<St
Some(format!("── Full output ──\n\n{body}"))
}
// Pager reads are bounded; larger artifacts remain on disk and the existing
// unavailable-output state is shown. This is not a persistence size limit.
const MAX_SESSION_ARTIFACT_DISPLAY_BYTES: u64 = 64 * 1024 * 1024;
fn read_owned_session_artifact(artifact: &crate::artifacts::ArtifactRecord) -> Option<String> {
if artifact.storage_path.is_absolute() {
use std::io::Read;
if !artifact
.storage_path
.starts_with(crate::artifacts::ARTIFACTS_DIR_NAME)
{
return None;
}
let root = crate::artifacts::session_artifact_absolute_path(
&artifact.session_id,
std::path::Path::new(crate::artifacts::ARTIFACTS_DIR_NAME),
)?;
let candidate = crate::artifacts::session_artifact_absolute_path(
let file = crate::artifacts::open_session_relative(
&artifact.session_id,
&artifact.storage_path,
)?;
let path = canonical_owned_file(&candidate, &root)?;
std::fs::read_to_string(path).ok()
false,
)
.ok()?
.open_file()
.ok()?;
let mut bytes = Vec::new();
file.take(MAX_SESSION_ARTIFACT_DISPLAY_BYTES + 1)
.read_to_end(&mut bytes)
.ok()?;
if bytes.len() as u64 > MAX_SESSION_ARTIFACT_DISPLAY_BYTES {
return None;
}
String::from_utf8(bytes).ok()
}
fn read_owned_legacy_spillover(path: &std::path::Path, session_id: &str) -> Option<String> {
@@ -2146,4 +2161,111 @@ mod tests {
);
}
}
struct PrivateArtifactRoot(Option<PathBuf>);
impl PrivateArtifactRoot {
fn set(path: PathBuf) -> Self {
Self(crate::artifacts::set_test_artifact_sessions_root(Some(
path,
)))
}
}
impl Drop for PrivateArtifactRoot {
fn drop(&mut self) {
crate::artifacts::set_test_artifact_sessions_root(self.0.take());
}
}
fn owned_artifact_fixture() -> crate::artifacts::ArtifactRecord {
crate::artifacts::record_tool_output_artifact(
"session-a",
"call-fixture",
"bash",
PathBuf::from("artifacts/mutable.txt"),
"fixture",
)
}
#[test]
fn owned_artifact_pager_reads_regular_exact_session_output() {
let _guard = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD
.lock()
.unwrap_or_else(|err| err.into_inner());
let temp = tempfile::tempdir().unwrap();
let _root = PrivateArtifactRoot::set(temp.path().join("sessions"));
crate::artifacts::write_session_artifact("session-a", "mutable", "exact pager output")
.unwrap();
assert_eq!(
read_owned_session_artifact(&owned_artifact_fixture()).as_deref(),
Some("exact pager output")
);
let mut malformed = owned_artifact_fixture();
malformed.storage_path = PathBuf::from("other/mutable.txt");
assert!(read_owned_session_artifact(&malformed).is_none());
}
#[cfg(unix)]
#[test]
fn owned_artifact_pager_refuses_linked_session_parent() {
let _guard = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD
.lock()
.unwrap_or_else(|err| err.into_inner());
let temp = tempfile::tempdir().unwrap();
let sessions = temp.path().join("sessions");
let _root = PrivateArtifactRoot::set(sessions.clone());
std::fs::create_dir(&sessions).unwrap();
let outside = temp.path().join("outside");
std::fs::create_dir_all(outside.join("artifacts")).unwrap();
std::fs::write(
outside.join("artifacts/mutable.txt"),
b"outside-pager-synthetic",
)
.unwrap();
std::os::unix::fs::symlink(&outside, sessions.join("session-a")).unwrap();
assert!(
read_owned_session_artifact(&owned_artifact_fixture()).is_none(),
"pager accepted output from a linked outside session"
);
}
#[cfg(unix)]
#[test]
fn owned_artifact_pager_refuses_hardlinked_outside_bytes() {
let _guard = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD
.lock()
.unwrap_or_else(|err| err.into_inner());
let temp = tempfile::tempdir().unwrap();
let sessions = temp.path().join("sessions");
let _root = PrivateArtifactRoot::set(sessions.clone());
std::fs::create_dir_all(sessions.join("session-a/artifacts")).unwrap();
let outside = temp.path().join("outside.txt");
std::fs::write(&outside, b"outside-hardlink-synthetic").unwrap();
std::fs::hard_link(&outside, sessions.join("session-a/artifacts/mutable.txt")).unwrap();
assert!(
read_owned_session_artifact(&owned_artifact_fixture()).is_none(),
"pager exposed outside hardlinked bytes"
);
}
#[test]
fn owned_artifact_pager_refuses_oversized_output_without_deleting_it() {
let _guard = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD
.lock()
.unwrap_or_else(|err| err.into_inner());
let temp = tempfile::tempdir().unwrap();
let sessions = temp.path().join("sessions");
let _root = PrivateArtifactRoot::set(sessions.clone());
let directory = sessions.join("session-a/artifacts");
std::fs::create_dir_all(&directory).unwrap();
let path = directory.join("mutable.txt");
let file = std::fs::File::create(&path).unwrap();
file.set_len(MAX_SESSION_ARTIFACT_DISPLAY_BYTES + 1)
.unwrap();
assert!(
read_owned_session_artifact(&owned_artifact_fixture()).is_none(),
"pager allocated oversized session output"
);
assert_eq!(
std::fs::metadata(path).unwrap().len(),
MAX_SESSION_ARTIFACT_DISPLAY_BYTES + 1
);
}
}
+13
View File
@@ -30,3 +30,16 @@ advisories requires a separate assessment.
The Apps desktop lockfile and its platform dependencies are a separate graph;
this Core cleanup does not resolve their maintenance or GLib qualification work.
Automatic Git review and workspace polling require the runtime configuration
override interface introduced in [Git 2.31](https://github.com/git/git/blob/master/Documentation/RelNotes/2.31.0.adoc). The shared read authority probes the
resolved absolute executable once per process and pins subsequent commands to
that executable even if PATH changes. It refuses automatic reads if the executable
cannot honor those overrides; explicit user Git writes retain their existing behavior.
The porcelain-v1 display fallback does not waive this safety requirement.
The absolute path fixes executable search selection; it does not authenticate an
executable replaced at that path by the same local user.
Local fixtures cover native modern Git and an emulated executable that ignores
runtime overrides. They do not qualify a real older-Git installation, partial
clones, or all platform-specific Git behavior.
+117 -5
View File
@@ -185,12 +185,52 @@ export class ThreadStore {
return this.data.chats[chatId];
}
async putAction(action) {
/**
* Record only a turn accepted by Runtime, before its events are delivered.
* A later sender cannot overwrite that turn's origin. Records use the chat's
* existing lifecycle and action retention bound; retired records fail closed.
* This does not authenticate the platform SDK or pin replacement credentials.
*/
async recordTurnOrigin(chatId, threadId, turnId, actorId) {
const current = this.data.chats[chatId];
if (!current || current.threadId !== threadId || !turnId || !actorId) {
throw new ApprovalOwnershipError("accepted turn needs its current chat, thread and initiating human");
}
const origins = Array.isArray(current.turnOrigins) ? current.turnOrigins : [];
const existing = origins.find((origin) => origin.threadId === threadId && origin.turnId === turnId);
if (existing) {
if (existing.actorId !== actorId) throw new ApprovalOwnershipError("turn origin cannot change");
return existing;
}
const origin = { threadId, turnId, actorId };
// patchChat mutates synchronously before saving, preserving concurrent turns.
await this.patchChat(chatId, {
turnOrigins: [...origins.filter((entry) => entry.threadId === threadId), origin].slice(-this.options.actionLimit)
});
return origin;
}
turnOrigin(chatId, threadId, turnId) {
const state = this.data.chats[chatId];
if (!state || state.threadId !== threadId || !turnId) return null;
return (Array.isArray(state.turnOrigins) ? state.turnOrigins : [])
.find((origin) => origin.threadId === threadId && origin.turnId === turnId && origin.actorId) || null;
}
async putAction(action, owner) {
if (!this.options.actions) return "";
this.ensureShape();
const binding = actionOwner(owner);
if (!binding.chatId) throw new ApprovalOwnershipError("button action needs its receiving chat");
if (action.kind === "approval" &&
(!binding.threadId || !binding.turnId || !binding.actorId ||
this.turnOrigin(binding.chatId, binding.threadId, binding.turnId)?.actorId !== binding.actorId)) {
throw new ApprovalOwnershipError("approval button needs the accepted turn and initiating human");
}
const token = randomBytes(16).toString("hex");
this.data.actions[token] = {
...action,
owner: binding,
createdAt: new Date().toISOString()
};
this.pruneActions();
@@ -198,14 +238,22 @@ export class ThreadStore {
return token;
}
async getAction(token) {
async getAction(token, owner = null) {
if (!token || !this.options.actions) return null;
this.ensureShape();
return this.data.actions[token] || null;
this.pruneActions();
const action = this.data.actions[token] || null;
if (!action) return null;
// Legacy unbound buttons cannot prove which chat received authority.
if (!action.owner || !sameOwner(action.owner, owner)) return null;
if (action.kind === "approval" &&
(!action.owner.threadId || !action.owner.turnId || !action.owner.actorId ||
this.turnOrigin(action.owner.chatId, action.owner.threadId, action.owner.turnId)?.actorId !== action.owner.actorId)) return null;
return action;
}
async takeAction(token) {
const action = await this.getAction(token);
async takeAction(token, owner = null) {
const action = await this.getAction(token, owner);
if (action) {
delete this.data.actions[token];
await this.save();
@@ -254,6 +302,70 @@ export class ThreadStore {
}
}
function actionOwner(owner) {
return {
chatId: String(owner?.chatId ?? ""),
...(owner?.threadId ? { threadId: String(owner.threadId) } : {}),
...(owner?.turnId ? { turnId: String(owner.turnId) } : {}),
...(owner?.actorId ? { actorId: String(owner.actorId) } : {})
};
}
function sameOwner(stored, owner) {
if (!owner) return false;
const wanted = actionOwner(owner);
if (!stored.chatId || stored.chatId !== wanted.chatId) return false;
return (!stored.threadId || stored.threadId === wanted.threadId) &&
(!stored.actorId || stored.actorId === wanted.actorId) &&
(!stored.turnId || !wanted.turnId || stored.turnId === wanted.turnId);
}
export class ApprovalOwnershipError extends Error {
constructor(message) {
super(message);
this.name = "ApprovalOwnershipError";
}
}
/**
* Runtime owns pending approvals and their turn ids. The existing chat store
* owns the immutable human who initiated that accepted turn. Admission to a
* group, a newer sender or a legacy unbound button does not grant this authority.
*/
export async function decideApproval(runtimeJson, { store, chatId, actorId, approvalId, decision, remember = false, turnId = null }) {
if (decision !== "allow" && decision !== "deny") throw new ApprovalOwnershipError("decision must be allow or deny");
const state = await store.getChat(chatId);
const threadId = state?.threadId;
if (!threadId) throw new ApprovalOwnershipError("this chat has no Runtime thread yet");
if (!actorId) throw new ApprovalOwnershipError("missing initiating human");
if (!approvalId) throw new ApprovalOwnershipError("missing approval id");
const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(threadId)}`);
const pending = detail?.pending_approvals ?? detail?.thread?.pending_approvals ?? [];
const approval = pending.find((entry) => (entry?.id ?? entry?.approval_id) === approvalId);
const pendingTurnId = approval?.turn_id;
if (!pendingTurnId || (turnId && pendingTurnId !== turnId) ||
store.turnOrigin(chatId, threadId, pendingTurnId)?.actorId !== actorId) {
throw new ApprovalOwnershipError("approval needs its accepted turn's initiating human in this chat");
}
await runtimeJson(`/v1/approvals/${encodeURIComponent(approvalId)}`, {
method: "POST",
body: { decision, remember: remember === true }
});
}
/**
* Settle a stored approval button: look it up for this owner, deliver the
* decision, and consume the token only once the Runtime accepted it. A failed
* POST leaves the button usable.
*/
export async function settleStoredApproval(store, token, owner, deliver) {
const stored = await store.getAction(token, owner);
if (!stored || stored.kind !== "approval") return null;
await deliver(stored);
await store.takeAction(token, owner);
return stored;
}
export function envFirst(env, ...names) {
for (const name of names) {
const value = env?.[name];
@@ -0,0 +1,399 @@
import test from "node:test";
import { createServer } from "node:http";
import { once } from "node:events";
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import url from "node:url";
import {
ApprovalOwnershipError, ThreadStore, decideApproval, settleStoredApproval,
createRuntimeClient, activeTurnBlock, readSse, readJsonSafe, compactRuntimeError
} from "../src/lib.mjs";
import { incomingIdentity as wecomIdentity } from "../../wecom-bridge/src/lib.mjs";
const root = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..", "..");
const chatId = "chat-a", threadId = "thread-a", turnId = "turn-a";
const owner = { chatId, threadId, turnId, actorId: "telegram-user:alice" };
const identities = {
"telegram-bridge": (userId) => ({ chatId, chatType: "group", userId, isBot: false }),
"feishu-bridge": (openId) => ({ chatId, chatType: "group", openId }),
"wecom-bridge": (userId) => ({ body: { chatid: chatId, chattype: "group", from: { userid: userId } } }),
"weixin-bridge": () => undefined
};
const actors = {
"telegram-bridge": "telegram-user:alice", "feishu-bridge": "feishu-openId:alice",
"wecom-bridge": "wecom-user:alice", "weixin-bridge": "weixin-user:chat-a"
};
async function withStore(fn, options = {}) {
const dir = await mkdtemp(path.join(tmpdir(), "bridge-approvals-"));
try {
const store = await ThreadStore.open(path.join(dir, "state.json"), { actions: true, ...options });
await store.setChat(chatId, { threadId });
await fn(store);
} finally { await rm(dir, { recursive: true, force: true }); }
}
function extract(source, name) {
const marker = new RegExp(`^(?:async )?function ${name}\\(`, "m");
const start = source.search(marker);
assert.notEqual(start, -1, name);
const rest = source.slice(start);
const next = rest.search(/\n(?:async )?function \w+\(/);
return next === -1 ? rest : rest.slice(0, next);
}
async function shipping(bridge, names, values) {
const source = await readFile(path.join(root, bridge, "src/index.mjs"), "utf8");
return new Function(...Object.keys(values), names.map((name) => extract(source, name)).join("\n") +
`\nreturn { ${names.join(", ")} };`)(...Object.values(values));
}
async function withRuntime(fn) {
const state = { pending: [{ id: "approval-a", turn_id: turnId }], turns: [], posts: [],
startAttempts: 0, accepted: [], failStart: false, acceptDecision: true, events: [] };
const server = createServer(async (request, response) => {
assert.equal(request.headers.authorization, "Bearer bridge-runtime-fixture-token");
let text = "";
for await (const chunk of request) text += chunk;
response.setHeader("connection", "close");
if (request.url.includes("/events?")) {
response.setHeader("content-type", "text/event-stream");
response.end(state.events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""));
return;
}
response.setHeader("content-type", "application/json");
if (request.method === "POST" && request.url.endsWith("/turns")) {
state.startAttempts++;
if (state.failStart) {
response.statusCode = 503;
response.end(JSON.stringify({ error: { message: "start rejected" } }));
} else {
const id = `accepted-${state.startAttempts}`;
state.accepted.push(id);
response.end(JSON.stringify({ turn: { id } }));
}
} else if (request.method === "POST") {
state.posts.push({ route: request.url, body: JSON.parse(text) });
response.statusCode = state.acceptDecision ? 200 : 503;
response.end(JSON.stringify(state.acceptDecision ? { ok: true } : { error: { message: "fixture unavailable" } }));
} else {
response.end(JSON.stringify({ id: threadId, pending_approvals: state.pending, turns: state.turns, latest_seq: 0 }));
}
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const runtimeUrl = `http://127.0.0.1:${server.address().port}`;
const runtime = createRuntimeClient({ runtimeUrl, runtimeToken: "bridge-runtime-fixture-token" });
try { await fn({ ...runtime, runtimeUrl, state }); }
finally { await new Promise((resolve) => server.close(resolve)); }
}
function environment(store, runtime, extra = {}) {
return {
threadStore: store, runtimeJson: runtime.runtimeJson, decideRuntimeApproval: decideApproval,
settleStoredApproval, ApprovalOwnershipError, incomingIdentity: wecomIdentity,
sendText: async () => {}, replyText: async () => {}, sendTurnText: async () => {},
parseApprovalDecisionArgs: () => { throw new Error("fixture uses actual parsed approval id"); },
ensureThread: async () => store.getChat(chatId), activeTurnBlock,
config: { runtimeUrl: runtime.runtimeUrl, turnTimeoutMs: 1000, maxReplyChars: 10000 },
controlKeyboard: () => ({}), activeTurnKeyboard: () => ({}), helpText: () => "",
clearActiveTurn: () => store.patchChat(chatId, { activeTurnId: null }), stopping: false,
streamTurnEvents: async (chat, thread, turn) => {
const reopened = await ThreadStore.open(store.filePath, { actions: true });
assert.ok(reopened.turnOrigin(chat, thread, turn), "origin must be durable before delivery");
}, ...extra
};
}
async function invokeStart(bridge, fn, user = "alice") {
if (bridge === "telegram-bridge") return fn(chatId, "prompt", { actorId: `telegram-user:${user}` });
return fn(chatId, "prompt", identities[bridge](user));
}
test("approval tokens retain 128-bit entropy and require their accepted human and turn", async () => {
await withStore(async (store) => {
await store.recordTurnOrigin(chatId, threadId, turnId, owner.actorId);
const tokens = new Set(await Promise.all(Array.from({ length: 20 }, () => store.putAction({ kind: "approval", approvalId: "approval-a" }, owner))));
assert.equal(tokens.size, 20);
for (const token of tokens) assert.match(token, /^[a-f0-9]{32}$/);
const token = [...tokens][0];
for (const wrong of [null, { chatId }, { ...owner, chatId: "other" }, { ...owner, threadId: "other" },
{ ...owner, actorId: "" }, { ...owner, actorId: "telegram-user:bob" }, { ...owner, turnId: "other" }]) {
assert.equal(await store.takeAction(token, wrong), null);
}
assert.equal((await store.takeAction(token, owner)).approvalId, "approval-a");
assert.equal(await store.getAction(token, owner), null);
await assert.rejects(store.putAction({ kind: "approval" }, { chatId, threadId }), ApprovalOwnershipError);
});
});
test("legacy privileged tokens missing thread, turn or human refuse after reload", async () => {
await withStore(async (store) => {
await store.recordTurnOrigin(chatId, threadId, turnId, owner.actorId);
for (const binding of [undefined, { chatId }, { chatId, threadId }, { chatId, threadId, turnId }]) {
const token = String(Object.keys(store.data.actions).length);
store.data.actions[token] = { kind: "approval", owner: binding, approvalId: "approval-a", createdAt: new Date().toISOString() };
}
await store.save();
const reopened = await ThreadStore.open(store.filePath, { actions: true });
for (const token of Object.keys(reopened.data.actions)) assert.equal(await reopened.getAction(token, owner), null);
});
});
test("origins are immutable, concurrent, bounded and retired tokens never acquire newer humans", async () => {
await withStore(async (store) => {
await Promise.all([
store.recordTurnOrigin(chatId, threadId, turnId, owner.actorId),
store.recordTurnOrigin(chatId, threadId, "turn-b", "telegram-user:bob")
]);
assert.equal(store.turnOrigin(chatId, threadId, turnId).actorId, owner.actorId);
assert.equal(store.turnOrigin(chatId, threadId, "turn-b").actorId, "telegram-user:bob");
const token = await store.putAction({ kind: "approval", approvalId: "approval-a" }, owner);
await assert.rejects(store.recordTurnOrigin(chatId, threadId, turnId, "telegram-user:bob"), ApprovalOwnershipError);
await store.recordTurnOrigin(chatId, threadId, "turn-c", "telegram-user:bob");
const reopened = await ThreadStore.open(store.filePath, { actions: true, actionLimit: 2 });
assert.equal((await reopened.getChat(chatId)).turnOrigins.length, 2);
assert.equal(reopened.turnOrigin(chatId, threadId, turnId), null);
assert.equal(await reopened.getAction(token, owner), null);
await reopened.setChat(chatId, { threadId: "other" });
assert.equal(reopened.turnOrigin(chatId, threadId, "turn-c"), null);
await reopened.setChat(chatId, { threadId });
assert.equal(reopened.turnOrigin(chatId, threadId, "turn-c"), null);
}, { actionLimit: 2 });
});
test("shared decisions require Runtime pending turn and originating human, not current sender", async () => {
await withRuntime(async (runtime) => withStore(async (store) => {
await store.recordTurnOrigin(chatId, threadId, turnId, owner.actorId);
const args = { store, chatId, actorId: owner.actorId, approvalId: "approval-a", decision: "allow" };
for (const override of [{ actorId: "" }, { actorId: "telegram-user:bob" }, { approvalId: "approval-b" },
{ chatId: "other" }, { turnId: "turn-b" }]) {
await assert.rejects(decideApproval(runtime.runtimeJson, { ...args, ...override }), ApprovalOwnershipError);
}
runtime.state.pending = [{ id: "approval-a" }];
await assert.rejects(decideApproval(runtime.runtimeJson, args), ApprovalOwnershipError);
runtime.state.pending = [{ id: "approval-a", turn_id: "turn-b" }];
await assert.rejects(decideApproval(runtime.runtimeJson, args), ApprovalOwnershipError);
assert.equal(runtime.state.posts.length, 0);
runtime.state.pending = [{ id: "approval-a", turn_id: turnId }];
await decideApproval(runtime.runtimeJson, { ...args, decision: "deny", remember: "yes" });
assert.deepEqual(runtime.state.posts, [{ route: "/v1/approvals/approval-a", body: { decision: "deny", remember: false } }]);
}));
});
test("thread switch during Runtime read refuses before the approval POST", async () => {
await withStore(async (store) => {
await store.recordTurnOrigin(chatId, threadId, turnId, owner.actorId);
const runtimeJson = async (route, options) => {
assert.equal(options, undefined, "must not post after switch");
await store.setChat(chatId, { threadId: "other" });
return { pending_approvals: [{ id: "approval-a", turn_id: turnId }] };
};
await assert.rejects(decideApproval(runtimeJson, { store, chatId, actorId: owner.actorId, approvalId: "approval-a", decision: "allow" }), ApprovalOwnershipError);
});
});
for (const bridge of Object.keys(identities)) {
test(`${bridge} shipping decision refuses unrelated, missing and other originating human over Runtime HTTP`, async () => {
await withRuntime(async (runtime) => withStore(async (store) => {
await store.recordTurnOrigin(chatId, threadId, turnId, actors[bridge]);
await store.patchChat(chatId, { authorizedIdentity: identities[bridge]("bob"), activeTurnId: "later-turn" });
const reopened = await ThreadStore.open(store.filePath, { actions: true });
const { decideApproval: consumer } = await shipping(bridge, ["turnActor", "decideApproval"], environment(reopened, runtime));
const action = { decision: "allow", approvalId: "approval-a", remember: true };
await consumer(chatId, { ...action, approvalId: "approval-b" }, identities[bridge]("alice"));
if (bridge !== "weixin-bridge") {
await consumer(chatId, action, identities[bridge]("bob"));
await consumer(chatId, action, identities[bridge](""));
}
assert.equal(runtime.state.posts.length, 0);
await consumer(chatId, action, identities[bridge]("alice"));
assert.deepEqual(runtime.state.posts, [{ route: "/v1/approvals/approval-a", body: { decision: "allow", remember: bridge !== "wecom-bridge" } }]);
}));
});
}
for (const bridge of ["telegram-bridge", "feishu-bridge", "wecom-bridge"]) {
test(`${bridge} accepted start binds before delivery; failed, queued and later starts cannot transfer an old approval`, async () => {
await withRuntime(async (runtime) => withStore(async (store) => {
const functions = await shipping(bridge, ["turnActor", "runPrompt", "decideApproval"], environment(store, runtime, bridge === "wecom-bridge" ? {
streamTurnEvents: async (chat, frame, thread, turn) => {
const reopened = await ThreadStore.open(store.filePath, { actions: true });
assert.ok(reopened.turnOrigin(chat, thread, turn), "origin must be durable before delivery");
}
} : {}));
await invokeStart(bridge, functions.runPrompt, "alice");
const first = runtime.state.accepted[0];
assert.equal(store.turnOrigin(chatId, threadId, first).actorId, actors[bridge]);
runtime.state.pending = [{ id: "approval-a", turn_id: first }];
await store.patchChat(chatId, { authorizedIdentity: identities[bridge]("bob") });
runtime.state.failStart = true;
await assert.rejects(invokeStart(bridge, functions.runPrompt, "bob"), /start rejected/);
assert.equal(store.turnOrigin(chatId, threadId, first).actorId, actors[bridge]);
runtime.state.failStart = false;
runtime.state.turns = [{ id: first, status: "queued" }];
const before = runtime.state.startAttempts;
await invokeStart(bridge, functions.runPrompt, "bob");
assert.equal(runtime.state.startAttempts, before);
runtime.state.turns = [];
await invokeStart(bridge, functions.runPrompt, "bob");
assert.equal(store.turnOrigin(chatId, threadId, first).actorId, actors[bridge]);
const reopened = await ThreadStore.open(store.filePath, { actions: true });
const { decideApproval: consumer } = await shipping(bridge, ["turnActor", "decideApproval"], environment(reopened, runtime));
await consumer(chatId, { decision: "allow", approvalId: "approval-a" }, identities[bridge]("bob"));
assert.equal(runtime.state.posts.length, 0);
await consumer(chatId, { decision: "allow", approvalId: "approval-a" }, identities[bridge]("alice"));
assert.equal(runtime.state.posts.length, 1);
}));
});
}
test("Telegram shipping SSE mints a bound button and actual callback human controls retry, restart and replay", async () => {
await withRuntime(async (runtime) => withStore(async (store) => {
await store.recordTurnOrigin(chatId, threadId, turnId, owner.actorId);
runtime.state.events = [{ seq: 1, turn_id: turnId, event: "approval.required", payload: { approval_id: "approval-a" } }];
const telegram = await import("../../telegram-bridge/src/lib.mjs");
const env = environment(store, runtime, {
authHeaders: runtime.authHeaders, readSse, readJsonSafe, compactRuntimeError,
sendTypingAction: async () => {}, TYPING_INTERVAL_MS: 2000, LAST_SEQ_FLUSH_INTERVAL_MS: 2000,
approvalKeyboard: telegram.approvalKeyboard
});
const { streamTurnEvents } = await shipping("telegram-bridge", ["streamTurnEvents"], env);
await streamTurnEvents(chatId, threadId, turnId, 0);
const [token] = Object.keys(store.data.actions);
assert.ok(token, "actual SSE must mint a token");
assert.deepEqual(store.data.actions[token].owner, owner);
const invoke = async (current, user) => {
const values = environment(current, runtime, {
...telegram, config: { ...env.config, allowGroups: true, allowUnlisted: true, allowlist: [] },
answerCallback: async () => {}, editMessageReplyMarkup: async () => {},
rememberAuthorizedIdentity: async (identity) => current.patchChat(chatId, { authorizedIdentity: identity })
});
const { handleCallbackQuery } = await shipping("telegram-bridge",
["turnActor", "decideApproval", "handleStoredAction", "handleModalAction", "handleCallbackQuery"], values);
return handleCallbackQuery({ id: "callback-fixture", data: telegram.approvalKeyboard(token).inline_keyboard[0][0].callback_data,
from: { id: user }, message: { message_id: 1, chat: { id: chatId, type: "group" } } });
};
await invoke(store, "bob");
assert.equal(runtime.state.posts.length, 0);
// The same approval id cannot authorize a different accepted turn, even
// when the same human happens to have initiated both turns.
await store.recordTurnOrigin(chatId, threadId, "other-turn", owner.actorId);
runtime.state.pending = [{ id: "approval-a", turn_id: "other-turn" }];
await invoke(store, "alice");
assert.equal(runtime.state.posts.length, 0);
runtime.state.pending = [{ id: "approval-a", turn_id: turnId }];
runtime.state.acceptDecision = false;
await assert.rejects(invoke(store, "alice"), /fixture unavailable/);
const reopened = await ThreadStore.open(store.filePath, { actions: true });
assert.ok(await reopened.getAction(token, owner));
runtime.state.acceptDecision = true;
await invoke(reopened, "alice");
const accepted = await ThreadStore.open(store.filePath, { actions: true });
assert.equal(await accepted.getAction(token, owner), null);
await invoke(accepted, "alice");
assert.equal(runtime.state.posts.length, 2, "failed then accepted; accepted replay never posts");
}));
});
test("Telegram and Feishu actual dispatch passes the admitted human to prompt and text approval", async () => {
for (const bridge of ["telegram-bridge", "feishu-bridge"]) {
const lib = await import(`../../${bridge}/src/lib.mjs`);
const calls = [];
const identity = identities[bridge]("alice");
const values = { ...lib, sendText: async () => {},
decideApproval: async (...args) => calls.push(args),
startPromptTurn: (...args) => calls.push(args),
runPrompt: async (...args) => calls.push(args) };
const { handleCommand } = await shipping(bridge, ["handleCommand"], values);
await handleCommand(chatId, lib.parseCommand("/allow approval-a"), identity);
await handleCommand(chatId, lib.parseCommand("hello"), identity);
assert.equal(calls.length, 2, bridge);
assert.equal(calls[0][2], identity, bridge);
assert.equal(calls[1][2], identity, bridge);
}
});
test("Telegram and Feishu shipping inbound events retain the actual admitted human through accepted start", async () => {
for (const bridge of ["telegram-bridge", "feishu-bridge"]) {
await withRuntime(async (runtime) => withStore(async (store) => {
const lib = await import(`../../${bridge}/src/lib.mjs`);
let delivered;
const delivery = new Promise((resolve) => { delivered = resolve; });
const values = environment(store, runtime, {
...lib, activeTurnTasks: new Map(),
config: { allowGroups: true, allowUnlisted: false, allowlist: [chatId], requirePrefixInGroup: false },
streamTurnEvents: async (chat, thread, turn) => {
const reopened = await ThreadStore.open(store.filePath, { actions: true });
assert.equal(reopened.turnOrigin(chat, thread, turn).actorId, actors[bridge]);
delivered();
}
});
const names = bridge === "telegram-bridge" ?
["turnActor", "runPrompt", "startPromptTurn", "handleCommand", "rememberAuthorizedIdentity", "handleIncomingUpdate"] :
["turnActor", "runPrompt", "handleCommand", "handleIncomingMessage"];
const handlers = await shipping(bridge, names, values);
if (bridge === "telegram-bridge") {
await handlers.handleIncomingUpdate({ update_id: 1, message: { message_id: 1, text: "hello",
chat: { id: chatId, type: "group" }, from: { id: "alice" } } });
await delivery;
} else {
await handlers.handleIncomingMessage({ sender: { sender_id: { open_id: "alice" } },
message: { chat_id: chatId, chat_type: "group", message_id: "message-a", message_type: "text",
content: JSON.stringify({ text: "hello" }) } });
}
assert.equal(runtime.state.accepted.length, 1);
assert.equal(store.turnOrigin(chatId, threadId, runtime.state.accepted[0]).actorId, actors[bridge]);
}));
}
});
test("WeCom shipping SSE and natural approval callback use durable Runtime-turn origin, not the hint's latest sender", async () => {
await withRuntime(async (runtime) => withStore(async (store) => {
const lib = await import("../../wecom-bridge/src/lib.mjs");
await store.recordTurnOrigin(chatId, threadId, turnId, actors["wecom-bridge"]);
runtime.state.events = [{ seq: 1, turn_id: turnId, event: "approval.required", payload: { approval_id: "approval-a" } }];
const pendingApprovals = new Map();
const values = environment(store, runtime, {
...lib, authHeaders: runtime.authHeaders, readSse, readJsonSafe, compactRuntimeError,
config: { runtimeUrl: runtime.runtimeUrl, turnTimeoutMs: 1000, approvalTimeoutMs: 10000 },
pendingApprovals, generateReqId: () => "stream-fixture", client: { replyStream: async () => {} }
});
const { streamTurnEvents } = await shipping("wecom-bridge", ["streamTurnEvents"], values);
await streamTurnEvents(chatId, identities["wecom-bridge"]("bob"), threadId, turnId, 0);
assert.equal(pendingApprovals.get(chatId).approvalId, "approval-a");
const reopened = await ThreadStore.open(store.filePath, { actions: true });
const { handleCommand } = await shipping("wecom-bridge", ["turnActor", "decideApproval", "handleCommand"],
{ ...values, threadStore: reopened });
await handleCommand(chatId, lib.parseCommand("允许"), identities["wecom-bridge"]("bob"));
assert.equal(runtime.state.posts.length, 0);
assert.ok(pendingApprovals.has(chatId), "refused member must not retire the legitimate pending hint");
await handleCommand(chatId, lib.parseCommand("允许"), identities["wecom-bridge"]("alice"));
assert.equal(runtime.state.posts.length, 1);
assert.deepEqual(runtime.state.posts[0].body, { decision: "allow", remember: false });
assert.equal(pendingApprovals.has(chatId), false);
}));
});
test("retired origin refuses stale pending approval even when newer turns have the same human", async () => {
await withRuntime(async (runtime) => withStore(async (store) => {
await store.recordTurnOrigin(chatId, threadId, turnId, owner.actorId);
const token = await store.putAction({ kind: "approval", approvalId: "approval-a" }, owner);
await store.recordTurnOrigin(chatId, threadId, "newer-1", owner.actorId);
await store.recordTurnOrigin(chatId, threadId, "newer-2", owner.actorId);
const reopened = await ThreadStore.open(store.filePath, { actions: true, actionLimit: 2 });
assert.equal(await reopened.getAction(token, owner), null);
await assert.rejects(decideApproval(runtime.runtimeJson, { store: reopened, chatId, actorId: owner.actorId,
approvalId: "approval-a", decision: "allow" }), ApprovalOwnershipError);
assert.equal(runtime.state.posts.length, 0);
}, { actionLimit: 2 }));
});
test("existing action TTL is enforced on lookup after restart", async () => {
await withStore(async (store) => {
await store.recordTurnOrigin(chatId, threadId, turnId, owner.actorId);
const token = await store.putAction({ kind: "approval", approvalId: "approval-a" }, owner);
store.data.actions[token].createdAt = new Date(0).toISOString();
await store.save();
const reopened = await ThreadStore.open(store.filePath, { actions: true });
assert.equal(await reopened.getAction(token, owner), null);
});
});
+8 -8
View File
@@ -122,10 +122,10 @@ test("ThreadStore supports chat state, message dedupe, and action tokens", async
assert.equal(await store.recordMessage("m3"), false);
assert.deepEqual(store.data.messages, ["m2", "m3"]);
const token = await store.putAction({ kind: "resume", threadId: "thread-a" });
assert.equal((await store.getAction(token)).kind, "resume");
assert.equal((await store.takeAction(token)).threadId, "thread-a");
assert.equal(await store.getAction(token), null);
const token = await store.putAction({ kind: "resume", threadId: "thread-a" }, { chatId: "chat-a" });
assert.equal((await store.getAction(token, { chatId: "chat-a" })).kind, "resume");
assert.equal((await store.takeAction(token, { chatId: "chat-a" })).threadId, "thread-a");
assert.equal(await store.getAction(token, { chatId: "chat-a" }), null);
const saved = await ThreadStore.open(statePath, { messageLimit: 2, actions: true });
assert.equal((await saved.getChat("chat-a")).threadId, "thread-a");
@@ -141,12 +141,12 @@ test("action tokens remain distinct when clock and legacy randomness repeat", as
const store = await ThreadStore.open(path.join(dir, "state.json"), { actions: true });
t.mock.method(Date, "now", () => 1);
t.mock.method(Math, "random", () => 0.5);
const first = await store.putAction({ threadId: "thread-a" });
const second = await store.putAction({ threadId: "thread-b" });
const first = await store.putAction({ threadId: "thread-a" }, { chatId: "chat-a" });
const second = await store.putAction({ threadId: "thread-b" }, { chatId: "chat-a" });
assert.notEqual(first, second);
assert.match(first, /^[a-f0-9]{32}$/);
assert.equal((await store.getAction(first)).threadId, "thread-a");
assert.equal((await store.getAction(second)).threadId, "thread-b");
assert.equal((await store.getAction(first, { chatId: "chat-a" })).threadId, "thread-a");
assert.equal((await store.getAction(second, { chatId: "chat-a" })).threadId, "thread-b");
} finally {
await rm(dir, { recursive: true, force: true });
}
+29 -10
View File
@@ -19,7 +19,9 @@ import {
stripGroupPrefix
} from "./lib.mjs";
import {
ApprovalOwnershipError,
createRuntimeClient,
decideApproval as decideRuntimeApproval,
readJsonSafe,
readSse,
ThreadStore as CoreThreadStore
@@ -134,10 +136,10 @@ async function handleIncomingMessage(event) {
});
const command = parseCommand(scoped.text);
await handleCommand(identity.chatId, command);
await handleCommand(identity.chatId, command, identity);
}
async function handleCommand(chatId, command) {
async function handleCommand(chatId, command, identity) {
const action = commandAction(command);
switch (action.kind) {
case "help":
@@ -164,13 +166,13 @@ async function handleCommand(chatId, command) {
await compactThread(chatId);
return;
case "approval":
await decideApproval(chatId, action);
await decideApproval(chatId, action, identity);
return;
case "set_model":
await setChatModel(chatId, action.modelName);
return;
case "prompt":
await runPrompt(chatId, action.prompt);
await runPrompt(chatId, action.prompt, identity);
return;
default:
await sendText(chatId, helpText());
@@ -211,11 +213,20 @@ async function ensureThread(chatId, { forceNew = false } = {}) {
return state;
}
async function runPrompt(chatId, prompt) {
function turnActor(identity) {
for (const kind of ["openId", "userId", "unionId"]) {
if (identity?.[kind]) return `feishu-${kind}:${identity[kind]}`;
}
return "";
}
async function runPrompt(chatId, prompt, identity) {
if (!prompt.trim()) {
await sendText(chatId, helpText());
return;
}
const actorId = turnActor(identity);
if (!actorId) throw new ApprovalOwnershipError("turn needs its initiating human");
const state = await ensureThread(chatId);
// Use per-chat model for this turn (may differ from the thread's
// creation model if the user ran /model after the thread was created).
@@ -253,6 +264,11 @@ async function runPrompt(chatId, prompt) {
);
const turnId = turnResponse.turn?.id;
// A turn the Runtime accepted keeps streaming even when its origin cannot be
// recorded; its approvals then fail closed and are decided from the TUI.
if (turnId && actorId) {
await threadStore.recordTurnOrigin(chatId, state.threadId, turnId, actorId);
}
await threadStore.patchChat(chatId, {
activeTurnId: turnId || null,
lastSeq: sinceSeq,
@@ -493,7 +509,7 @@ async function compactThread(chatId) {
await sendText(chatId, `Compaction started: ${result.turn?.id || "unknown turn"}`);
}
async function decideApproval(chatId, action) {
async function decideApproval(chatId, action, identity) {
const decision = action.decision;
const { approvalId, remember } =
action.approvalId != null ? action : parseApprovalDecisionArgs(action.args);
@@ -501,10 +517,13 @@ async function decideApproval(chatId, action) {
await sendText(chatId, `Usage: /${decision} <approval_id>${decision === "allow" ? " [remember]" : ""}`);
return;
}
await runtimeJson(`/v1/approvals/${encodeURIComponent(approvalId)}`, {
method: "POST",
body: { decision, remember }
});
try {
await decideRuntimeApproval(runtimeJson, { store: threadStore, chatId, actorId: turnActor(identity), approvalId, decision, remember });
} catch (error) {
if (!(error instanceof ApprovalOwnershipError)) throw error;
await sendText(chatId, `Approval ${approvalId} is not waiting in this chat.`);
return;
}
await sendText(chatId, `Approval ${approvalId}: ${decision}${remember ? " and remember" : ""}`);
}
+64 -33
View File
@@ -31,7 +31,10 @@ import {
createRuntimeClient,
readJsonSafe,
readSse,
ThreadStore as CoreThreadStore
ThreadStore as CoreThreadStore,
ApprovalOwnershipError,
decideApproval as decideRuntimeApproval,
settleStoredApproval
} from "../../bridge-core/src/lib.mjs";
const TYPING_INTERVAL_MS = 2000;
@@ -212,7 +215,7 @@ async function handleIncomingUpdate(update) {
const command = parseCommand(scoped.text);
await rememberAuthorizedIdentity(identity);
await handleCommand(identity.chatId, command);
await handleCommand(identity.chatId, command, identity);
}
async function rememberAuthorizedIdentity({ chatId, chatType, userId, username, isBot }) {
@@ -226,7 +229,7 @@ async function isReplayCallbackUpdate(update) {
return threadStore.recordMessage(`callback:${update.update_id}`);
}
async function handleCommand(chatId, command) {
async function handleCommand(chatId, command, identity) {
const action = commandAction(command);
switch (action.kind) {
case "help":
@@ -256,13 +259,13 @@ async function handleCommand(chatId, command) {
await compactThread(chatId);
return;
case "approval":
await decideApproval(chatId, action);
await decideApproval(chatId, action, identity);
return;
case "set_model":
await setChatModel(chatId, action.modelName);
return;
case "prompt":
startPromptTurn(chatId, action.prompt);
startPromptTurn(chatId, action.prompt, identity);
return;
default:
await sendText(chatId, helpText(), { replyMarkup: controlKeyboard() });
@@ -304,10 +307,10 @@ async function handleCallbackQuery(query) {
answerCallback(query.id, "Working...").catch((error) => {
console.warn("failed to acknowledge Telegram callback", error);
});
await handleModalAction(identity.chatId, action, query);
await handleModalAction(identity.chatId, action, query, identity);
}
async function handleModalAction(chatId, action, query = null) {
async function handleModalAction(chatId, action, query = null, identity = null) {
switch (action.kind) {
case "help":
await sendText(chatId, helpText(), { replyMarkup: controlKeyboard() });
@@ -333,22 +336,25 @@ async function handleModalAction(chatId, action, query = null) {
await setChatModel(chatId, action.modelName);
return;
case "stored_action":
await handleStoredAction(chatId, action, query);
await handleStoredAction(chatId, action, query, identity);
return;
default:
await sendText(chatId, helpText(), { replyMarkup: controlKeyboard() });
}
}
async function handleStoredAction(chatId, action, query = null) {
const stored = await threadStore.getAction(action.token);
async function handleStoredAction(chatId, action, query = null, identity = null) {
// Approval buttons also belong to the thread still bound to this chat.
const state = await threadStore.getChat(chatId);
const owner = { chatId, threadId: state?.threadId, actorId: turnActor(identity) };
const stored = await threadStore.getAction(action.token, owner);
if (!stored) {
await sendText(chatId, "That action expired. Open /menu and try again.");
return;
}
if (stored.kind === "resume") {
await threadStore.takeAction(action.token);
await threadStore.takeAction(action.token, owner);
await resumeThread(chatId, stored.threadId);
return;
}
@@ -357,12 +363,17 @@ async function handleStoredAction(chatId, action, query = null) {
const suffix = action.suffix || "";
const decision = suffix === "deny" ? "deny" : "allow";
const remember = suffix === "remember";
await threadStore.takeAction(action.token);
await decideApproval(chatId, {
decision,
approvalId: stored.approvalId,
remember
});
// The token is consumed only after the Runtime accepted the decision.
try {
await settleStoredApproval(threadStore, action.token, owner, async () => {
if (!(await decideApproval(chatId, { decision, approvalId: stored.approvalId, remember, turnId: stored.owner.turnId }, identity))) {
throw new ApprovalOwnershipError("not delivered");
}
});
} catch (error) {
if (error instanceof ApprovalOwnershipError) return;
throw error;
}
if (query?.message?.message_id) {
await editMessageReplyMarkup(chatId, query.message.message_id, null).catch(() => {});
}
@@ -416,7 +427,7 @@ async function ensureThread(chatId, { forceNew = false } = {}) {
return state;
}
function startPromptTurn(chatId, prompt) {
function startPromptTurn(chatId, prompt, identity) {
if (activeTurnTasks.has(chatId)) {
void sendText(chatId, "Thread already has an active turn. Wait for it to finish or send /interrupt.", {
replyMarkup: activeTurnKeyboard()
@@ -429,7 +440,7 @@ function startPromptTurn(chatId, prompt) {
const controller = new AbortController();
const task = { controller };
activeTurnTasks.set(chatId, task);
void runPrompt(chatId, prompt, { signal: controller.signal })
void runPrompt(chatId, prompt, { signal: controller.signal, actorId: turnActor(identity) })
.catch((error) => {
console.error("failed to run Telegram bridge prompt", error);
})
@@ -476,11 +487,16 @@ function startTrackedTurnStream(chatId, threadId, turnId, sinceSeq) {
return true;
}
function turnActor(identity) {
return identity?.userId && !identity.isBot ? `telegram-user:${identity.userId}` : "";
}
async function runPrompt(chatId, prompt, options = {}) {
if (!prompt.trim()) {
await sendText(chatId, helpText(), { replyMarkup: controlKeyboard() });
return;
}
if (!options.actorId) throw new ApprovalOwnershipError("turn needs its initiating human");
const state = await ensureThread(chatId);
const effectiveModel = state?.model || config.model;
const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`);
@@ -515,6 +531,11 @@ async function runPrompt(chatId, prompt, options = {}) {
);
const turnId = turnResponse.turn?.id;
// A turn the Runtime accepted keeps streaming even when its origin cannot be
// recorded; its approvals then fail closed and are decided from the TUI.
if (turnId && options.actorId) {
await threadStore.recordTurnOrigin(chatId, state.threadId, turnId, options.actorId);
}
await threadStore.patchChat(chatId, {
activeTurnId: turnId || null,
lastSeq: sinceSeq,
@@ -678,10 +699,15 @@ async function streamTurnEvents(chatId, threadId, turnId, sinceSeq, options = {}
);
continue;
}
const actionToken = await threadStore.putAction({
kind: "approval",
approvalId
});
const origin = threadStore.turnOrigin(chatId, threadId, turnId);
if (!origin) {
await sendTurnText(chatId, "Approval has no recorded initiating human; decide from the TUI.");
continue;
}
const actionToken = await threadStore.putAction(
{ kind: "approval", approvalId },
{ chatId, threadId, turnId, actorId: origin.actorId }
);
await sendTurnText(
chatId,
[
@@ -774,10 +800,10 @@ async function sendThreads(chatId) {
}
const actions = [];
for (const [index, thread] of threads.slice(0, 8).entries()) {
const token = await threadStore.putAction({
kind: "resume",
threadId: thread.id
});
const token = await threadStore.putAction(
{ kind: "resume", threadId: thread.id },
{ chatId }
);
actions.push({ token, label: `Resume ${index + 1}` });
}
await sendText(
@@ -847,7 +873,8 @@ async function compactThread(chatId) {
});
}
async function decideApproval(chatId, action) {
/** Deliver a decision for an approval pending on this chat's thread. */
async function decideApproval(chatId, action, identity) {
const decision = action.decision;
const { approvalId, remember } = action;
if (!approvalId) {
@@ -855,13 +882,17 @@ async function decideApproval(chatId, action) {
chatId,
`Usage: /${decision} <approval_id>${decision === "allow" ? " [remember]" : ""}`
);
return;
return false;
}
try {
await decideRuntimeApproval(runtimeJson, { store: threadStore, chatId, actorId: turnActor(identity), approvalId, decision, remember, turnId: action.turnId });
} catch (error) {
if (!(error instanceof ApprovalOwnershipError)) throw error;
await sendText(chatId, `Approval ${approvalId} is not waiting in this chat.`);
return false;
}
await runtimeJson(`/v1/approvals/${encodeURIComponent(approvalId)}`, {
method: "POST",
body: { decision, remember }
});
await sendText(chatId, `Approval ${approvalId}: ${decision}${remember ? " and remember" : ""}`);
return true;
}
async function setChatModel(chatId, modelName) {
@@ -40,7 +40,7 @@ test("prompt command starts a tracked background turn instead of blocking update
const promptCase = handleCommand.slice(handleCommand.indexOf('case "prompt":'));
assert.match(source, /const activeTurnTasks = new Map\(\);/);
assert.match(promptCase, /startPromptTurn\(chatId, action\.prompt\);/);
assert.match(promptCase, /startPromptTurn\(chatId, action\.prompt, identity\);/);
assert.doesNotMatch(promptCase, /await\s+runPrompt\(/);
const starter = extractFunction(source, "startPromptTurn");
@@ -56,7 +56,7 @@ test("stale callback acknowledgements cannot skip modal actions", async () => {
assert.doesNotMatch(callbackHandler, /await\s+answerCallback\(query\.id,\s*"Working\.\.\."\)/);
assert.match(callbackHandler, /answerCallback\(query\.id,\s*"Working\.\.\."\)\.catch/);
assert.match(callbackHandler, /await handleModalAction\(identity\.chatId, action, query\);/);
assert.match(callbackHandler, /await handleModalAction\(identity\.chatId, action, query, identity\);/);
});
test("polling persists offsets only after successful update handling", async () => {
@@ -106,7 +106,7 @@ test("callback replay is ignored before modal dispatch", async () => {
assert.match(replayHelper, /if \(update\.update_id == null\) return false;/);
assert.match(replayHelper, /return threadStore\.recordMessage\(`callback:\$\{update\.update_id\}`\);/);
assert.ok(
resumeCase.indexOf("await threadStore.takeAction(action.token);") <
resumeCase.indexOf("await threadStore.takeAction(action.token, owner);") <
resumeCase.indexOf("await resumeThread(chatId, stored.threadId);"),
"resume callback actions should be consumed before dispatch"
);
+1 -1
View File
@@ -48,7 +48,7 @@ node src/index.mjs
| `/model <name\|default>` | 设置或重置聊天模型 |
| `/interrupt` | 中断活动 turn |
| `/compact` | 压缩当前线程 |
| `/allow <approval_id> [remember]` | 批准待处理的工具调用 |
| `/allow <approval_id>` | 批准待处理的工具调用 |
| `/deny <approval_id>` | 拒绝待处理的工具调用 |
其他所有内容均作为 Codewhale 提示发送。群聊中需要在消息前加 `/cw` 前缀。
+36 -12
View File
@@ -21,7 +21,13 @@ import {
stripGroupPrefix,
ThreadStore
} from "./lib.mjs";
import { createRuntimeClient, readJsonSafe, readSse } from "../../bridge-core/src/lib.mjs";
import {
ApprovalOwnershipError,
createRuntimeClient,
decideApproval as decideRuntimeApproval,
readJsonSafe,
readSse
} from "../../bridge-core/src/lib.mjs";
/** Map of chatId -> latest pending approval info for natural-language approval. */
const pendingApprovals = new Map();
@@ -185,19 +191,19 @@ async function handleCommand(chatId, command, frame) {
return;
case "prompt":
// Check if this is a natural-language approval/deny response
// This map supplies an approval id for convenience only. Runtime's
// pending turn and its durable initiating human still decide authority.
if (pendingApprovals.has(chatId)) {
const pending = pendingApprovals.get(chatId);
if (Date.now() - pending.timestamp < config.approvalTimeoutMs) {
if (isApprovalResponse(action.prompt)) {
const action2 = { kind: "approval", decision: "allow", approvalId: pending.approvalId };
await decideApproval(chatId, action2, frame);
pendingApprovals.delete(chatId);
if (await decideApproval(chatId, action2, frame)) pendingApprovals.delete(chatId);
return;
}
if (isDenyResponse(action.prompt)) {
const action2 = { kind: "approval", decision: "deny", approvalId: pending.approvalId };
await decideApproval(chatId, action2, frame);
pendingApprovals.delete(chatId);
if (await decideApproval(chatId, action2, frame)) pendingApprovals.delete(chatId);
return;
}
}
@@ -241,11 +247,17 @@ async function ensureThread(chatId, { forceNew = false } = {}) {
return state;
}
function turnActor(identity) {
return identity?.userId ? `wecom-user:${identity.userId}` : "";
}
async function runPrompt(chatId, prompt, frame) {
if (!prompt.trim()) {
await replyText(frame, helpText());
return;
}
const actorId = turnActor(incomingIdentity(frame.body));
if (!actorId) throw new ApprovalOwnershipError("turn needs its initiating human");
const state = await ensureThread(chatId);
const effectiveModel = state?.model || config.model;
const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`);
@@ -280,6 +292,11 @@ async function runPrompt(chatId, prompt, frame) {
);
const turnId = turnResponse.turn?.id;
// A turn the Runtime accepted keeps streaming even when its origin cannot be
// recorded; its approvals then fail closed and are decided from the TUI.
if (turnId && actorId) {
await threadStore.recordTurnOrigin(chatId, state.threadId, turnId, actorId);
}
await threadStore.patchChat(chatId, {
activeTurnId: turnId || null,
lastSeq: sinceSeq,
@@ -488,16 +505,22 @@ async function compactThread(chatId, frame) {
async function decideApproval(chatId, action, frame) {
const decision = action.decision;
const { approvalId, remember } =
// WeCom approvals are text: a session-wide "remember" is never taken from
// a chat message.
const { approvalId } =
action.approvalId != null ? action : parseApprovalDecisionArgs(action.args);
const remember = false;
if (!approvalId) {
await replyText(frame, `Usage: /${decision} <approval_id>${decision === "allow" ? " [remember]" : ""}`);
return;
await replyText(frame, `Usage: /${decision} <approval_id>`);
return false;
}
try {
await decideRuntimeApproval(runtimeJson, { store: threadStore, chatId, actorId: turnActor(incomingIdentity(frame.body)), approvalId, decision, remember });
} catch (error) {
if (!(error instanceof ApprovalOwnershipError)) throw error;
await replyText(frame, `Approval ${approvalId} is not waiting in this chat.`);
return false;
}
await runtimeJson(`/v1/approvals/${encodeURIComponent(approvalId)}`, {
method: "POST",
body: { decision, remember }
});
// Clear activeTurnId so the user can send follow-up messages
// immediately instead of being blocked by activeTurnBlock
@@ -508,6 +531,7 @@ async function decideApproval(chatId, action, frame) {
});
await replyText(frame, `Approval ${approvalId}: ${decision}${remember ? " and remember" : ""}`);
return true;
}
async function setChatModel(chatId, modelName, frame) {
+1 -1
View File
@@ -112,7 +112,7 @@ export function helpText() {
"/model <name|default> - 设置或重置聊天模型",
"/interrupt - 中断活动 turn",
"/compact - 压缩当前线程",
"/allow <approval_id> [remember] - 批准待处理的工具调用",
"/allow <approval_id> - 批准待处理的工具调用",
"/deny <approval_id> - 拒绝待处理的工具调用",
"",
"其他所有内容均作为 CodeWhale 提示发送。"
@@ -147,3 +147,20 @@ test("no keyword is both approval and denial", () => {
);
}
});
test("WeCom accepted turn origin survives reload and cannot transfer to a later member", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "codewhale-wecom-origin-"));
try {
const statePath = path.join(dir, "thread-map.json");
const store = await ThreadStore.open(statePath);
await store.setChat("group-a", { threadId: "thread-a" });
await store.recordTurnOrigin("group-a", "thread-a", "turn-a", "wecom-user:alice");
await store.patchChat("group-a", { authorizedIdentity: { userId: "bob" }, activeTurnId: "turn-b" });
const reopened = await ThreadStore.open(statePath);
assert.equal(reopened.turnOrigin("group-a", "thread-a", "turn-a").actorId, "wecom-user:alice");
await assert.rejects(reopened.recordTurnOrigin("group-a", "thread-a", "turn-a", "wecom-user:bob"));
assert.equal(reopened.turnOrigin("group-a", "thread-a", "turn-a").actorId, "wecom-user:alice");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
+15 -8
View File
@@ -26,7 +26,7 @@ import {
processUpdateBatch,
} from "./lib.mjs";
import { renderQrToText } from "./qr.mjs";
import { ThreadStore as CoreThreadStore, writeFileDurable } from "../../bridge-core/src/lib.mjs";
import { ThreadStore as CoreThreadStore, writeFileDurable, ApprovalOwnershipError, decideApproval as decideRuntimeApproval } from "../../bridge-core/src/lib.mjs";
// ============================================================================
// ThreadStore — JSON 文件持久化(与 feishu/telegram/wechat bridge 一致)
@@ -343,6 +343,10 @@ async function ensureThread(chatId, { forceNew = false } = {}) {
return state;
}
function turnActor(chatId) {
return chatId ? `weixin-user:${chatId}` : "";
}
async function runPrompt(chatId, prompt) {
if (!prompt.trim()) {
await sendText(chatId, helpText());
@@ -384,6 +388,11 @@ async function runPrompt(chatId, prompt) {
);
const turnId = turnResponse.turn?.id;
// A turn the Runtime accepted keeps streaming even when its origin cannot be
// recorded; its approvals then fail closed and are decided from the TUI.
if (turnId && turnActor(chatId)) {
await threadStore.recordTurnOrigin(chatId, state.threadId, turnId, turnActor(chatId));
}
await threadStore.patchChat(chatId, {
activeTurnId: turnId || null,
lastSeq: sinceSeq,
@@ -653,18 +662,16 @@ async function decideApproval(chatId, action) {
return;
}
try {
await runtimeJson(
`/v1/approvals/${encodeURIComponent(approvalId)}`,
{
method: "POST",
body: { decision, remember },
}
);
await decideRuntimeApproval(runtimeJson, { store: threadStore, chatId, actorId: turnActor(chatId), approvalId, decision, remember });
await sendText(
chatId,
`Approval ${approvalId}: ${decision}${remember ? " and remember" : ""}`
);
} catch (error) {
if (error instanceof ApprovalOwnershipError) {
await sendText(chatId, `Approval ${approvalId} is not waiting in this chat.`);
return;
}
await sendText(chatId, `Approval failed: ${error.message}`);
}
}