polyc-tools 2026.9.0

The in-process tool core for polychrome agents: local executors (coding, web fetch, wallet, ...), the tool registry, and MCP composition. The networked connectors live in polyc-connectors.
//! `#598` — immutable derivation inputs: in-sandbox attempts to modify or
//! CREATE anything shaped like a capability-derivation input are refused by
//! the workspace confinement, lexically, before any filesystem touch. The
//! structural fact these tests pin is that the file tools can never reach a
//! path outside the workspace root — present or absent — so no in-pod write
//! can become a classification input.

#![allow(clippy::pedantic, clippy::nursery, missing_docs)]

use polyc_tools::coding::{self, workspace};

/// Paths shaped like the places security-defining configuration could live.
/// None of them exists in the harness image (there is no file-sourced
/// derivation input at all) — which is exactly the creatable-because-absent
/// variant: creating them must fail the same way editing them would.
const DERIVATION_INPUT_SHAPED_PATHS: &[&str] = &[
    "/etc/polychrome/agent.yaml",
    "/etc/polychrome/policy.json",
    "/var/run/secrets/kubernetes.io/serviceaccount/token",
    "/workspace/../harness-config.toml",
    "../agent-policy.yaml",
    "../../etc/polychrome/connectors.yaml",
    "..",
    "/",
];

#[test]
fn workspace_confinement_refuses_derivation_input_paths_lexically() {
    let root = std::path::Path::new("/workspace");
    for path in DERIVATION_INPUT_SHAPED_PATHS {
        assert!(
            workspace::resolve(root, path).is_err(),
            "{path}: must be refused before any filesystem touch"
        );
    }
    // The refusal is lexical (no filesystem involved), so it covers targets
    // that do not exist yet exactly like existing ones — the
    // creatable-because-absent escape variant has no opening.
    let absent = "/definitely/not/created/by/this/test.yaml";
    assert!(workspace::resolve(root, absent).is_err());
    assert!(
        !std::path::Path::new(absent).exists(),
        "the refused path must not have been created"
    );
}

#[tokio::test]
async fn file_tools_refuse_to_write_or_create_outside_the_workspace() {
    for path in DERIVATION_INPUT_SHAPED_PATHS {
        let args = serde_json::json!({ "path": path, "content": "attacker config" }).to_string();
        let out = coding::execute("file_write", &args)
            .await
            .expect("file_write is a coding tool");
        let v: serde_json::Value = serde_json::from_str(&out).expect("JSON result");
        assert!(
            v.get("error").is_some(),
            "file_write {path}: must fail, got {out}"
        );

        let args = serde_json::json!({
            "path": path,
            "old_text": "a",
            "new_text": "b",
        })
        .to_string();
        let out = coding::execute("file_edit", &args)
            .await
            .expect("file_edit is a coding tool");
        let v: serde_json::Value = serde_json::from_str(&out).expect("JSON result");
        assert!(
            v.get("error").is_some(),
            "file_edit {path}: must fail, got {out}"
        );
    }
    // Nothing was created along the way.
    assert!(!std::path::Path::new("/etc/polychrome").exists());
}

#[test]
fn escaping_writes_are_predicted_as_sandbox_denials() {
    // The same lexical check drives the #301 escalation predicate, so a
    // derivation-input-shaped write is *predicted* as a denial before any
    // side effect — the gate can escalate it to a human instead of running
    // it into the refusal.
    for path in DERIVATION_INPUT_SHAPED_PATHS {
        let args = serde_json::json!({ "path": path, "content": "x" }).to_string();
        assert!(
            coding::sandbox_would_deny("file_write", &args),
            "{path}: an escaping write must be a predicted denial"
        );
    }
    // A workspace-confined write is not.
    assert!(!coding::sandbox_would_deny(
        "file_write",
        &serde_json::json!({ "path": "notes.txt", "content": "x" }).to_string(),
    ));
}