polyc-turn-runner 2026.9.0

polychrome turn-runner: run one agent turn from a wire request against an injected provider + tool executor.
#![allow(clippy::unwrap_used)] // test/example/bench: panics are acceptable
//! `#2508`: the Execution grant refuses `shell_exec` when it lacks the egress
//! authority, and it does not refuse a process-local read.
//!
//! `shell_exec` reaches every destination the harness `NetworkPolicy` permits.
//! `polyc_tools::capability::required_for_spec` therefore requires
//! `arbitrary-egress` for it, on top of local read and write. That
//! classification is only worth anything if the runtime ceiling enforces it.
//! This test proves the two halves meet.
//!
//! It lives here, not beside either half. The two halves sit in crates that
//! cannot link to each other. The real classification is in `polyc-tools`. The
//! real ceiling is in `polyc-agent`. `polyc-tools` depends on `polyc-agent`, so
//! the dependency cannot point back. `polyc-turn-runner` depends on both. It is
//! therefore the first place the real registry reaches the real gate. A test
//! that stubbed either half would assert its own fixture, not the shipped
//! behavior.
//!
//! The turn drives two tools under one grant. That separates process-local file
//! access from network-capable execution. A grant that refused both would pass a
//! weaker test and prove nothing about egress.
//!
//! The turn runs ATTENDED (`unattended` stays false). The ceiling check runs
//! before the taint and approval branches. So this denial is not the unattended
//! fail-closed path. It is the grant refusing an authority that no human
//! approval can widen.

use std::sync::Arc;

use polyc_agent::{RunTurnOptions, TurnResult};
use polyc_capability::{Capability, CapabilitySet};
use polyc_llm::Message;
use polyc_llm::turn::{STUB_TOOL_ARGS_ENV, STUB_TOOL_CALL_ENV, StubProvider};
use polyc_proto::proto::polychrome::agent::v1::content;
use polyc_tools::{CompositeRegistry, ToolRegistry};

/// The workspace file the local read returns. The assertion uses it to tell a
/// real read from a refusal.
const PROBE_CONTENTS: &str = "polychrome-2508-probe";

/// The text `shell_exec` would echo if it ever reached process creation.
const SHELL_MARKER: &str = "polychrome-2508-ran";

/// Every tool result of the turn, in order, rendered for inspection.
///
/// The wire `ToolResultContent` is matched through its debug rendering, the
/// same way this repository's other gate tests read a result payload.
fn tool_results(turn: &TurnResult) -> Vec<String> {
    turn.messages
        .iter()
        .filter_map(
            |message| match message.content.as_option().and_then(|c| c.r#type.as_ref()) {
                Some(content::Type::ToolResult(result)) => Some(format!("{result:?}")),
                _ => None,
            },
        )
        .collect()
}

#[tokio::test]
async fn an_execution_grant_without_egress_refuses_shell_exec_but_not_a_local_read() {
    let workspace = tempfile::tempdir().unwrap();
    std::fs::write(workspace.path().join("probe.txt"), PROBE_CONTENTS).unwrap();
    let root = workspace.path().to_path_buf();

    // The stub emits the Nth tool once N results have landed. This drives one
    // attended turn through the local read first and the shell second.
    let turn = temp_env::async_with_vars(
        [
            (STUB_TOOL_CALL_ENV, Some("file_read,shell_exec")),
            (
                STUB_TOOL_ARGS_ENV,
                Some(
                    r#"{"file_read":{"path":"probe.txt"},"shell_exec":{"command":"echo polychrome-2508-ran"}}"#,
                ),
            ),
        ],
        async move {
            let tools = CompositeRegistry::new().with(Arc::new(ToolRegistry::rooted_at(root, None)));
            // Local read and local write, deliberately WITHOUT `ArbitraryEgress`.
            let granted = CapabilitySet::of(Capability::LocalRead).with(Capability::LocalWrite);
            polyc_turn_runner::run_turn_captured_under_grant(
                &StubProvider,
                &tools,
                "stub",
                vec![Message::user("read the probe, then run the shell")],
                RunTurnOptions::default(),
                granted,
            )
            .await
            .expect("the stub provider never fails mid-stream")
        },
    )
    .await;

    let results = tool_results(&turn);
    assert_eq!(
        results.len(),
        2,
        "the turn puts both tools through the gate: {results:?}"
    );

    // The process-local read is inside the grant, so it really ran.
    assert!(
        results[0].contains(PROBE_CONTENTS),
        "a LocalRead grant must still permit the workspace read: {}",
        results[0]
    );

    // The network-capable call is outside the grant. The gate refuses it before
    // the process is created.
    assert!(
        results[1].contains("Execution grant does not authorize shell_exec"),
        "the grant must refuse shell_exec by name: {}",
        results[1]
    );
    assert!(
        results[1].contains("arbitrary-egress"),
        "the refusal must name the missing egress authority: {}",
        results[1]
    );
    assert!(
        !results[1].contains(SHELL_MARKER),
        "shell_exec must never reach process creation: {}",
        results[1]
    );

    // The refusal belongs to the grant, not to the unattended fail-closed path.
    assert!(
        turn.unattended_denials.is_empty(),
        "an attended turn records no unattended denial: {:?}",
        turn.unattended_denials
    );
    assert!(
        turn.pending_approvals.is_empty(),
        "no human approval can widen the grant, so nothing pauses: {:?}",
        turn.pending_approvals
    );
}