mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
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:
1 parent
eb162047e5
commit
e86d67711f
82 files changed
+4402
-386
No files matched your search
Generated
+1
@@ -1251,6 +1251,7 @@ dependencies = [
|
||||
"ratatui",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"ring",
|
||||
"rio-vt",
|
||||
"rmcp",
|
||||
"rquickjs",
|
||||
|
||||
+116
-13
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,8 @@ impl McpDispatchUnderTest for McpPoolDispatch {
|
||||
model_name,
|
||||
input,
|
||||
&[],
|
||||
// Conformance probes cannot supply a person's card decision.
|
||||
None,
|
||||
) => result,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(¤t_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!(
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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, ¶ms["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
@@ -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;
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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" : ""}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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` 前缀。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user