From 0671114989e3a77ca95382d4b7c5cd20d51e2bea Mon Sep 17 00:00:00 2001 From: OnlineChef <280567955+OnlineChef@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:59:37 +0200 Subject: [PATCH 01/12] feat(devin): establish session policy foundation Pin the live Devin CLI contract and add session-scoped modes, fail-closed policy decisions, persistence, and secret-minimizing audit records before expanding the tool runtime. Co-authored-by: Cursor --- docs/devin-rust-progress.md | 64 ++ src/agent.rs | 47 +- src/devin/audit.rs | 188 ++++++ src/devin/mod.rs | 21 + src/devin/policy.rs | 571 ++++++++++++++++++ src/devin/state.rs | 150 +++++ src/lib.rs | 2 + src/session.rs | 33 + tests/devin_contract.rs | 68 +++ tests/devin_session_state.rs | 58 ++ .../devin_cli/tool_schema_manifest.json | 36 ++ 11 files changed, 1234 insertions(+), 4 deletions(-) create mode 100644 docs/devin-rust-progress.md create mode 100644 src/devin/audit.rs create mode 100644 src/devin/mod.rs create mode 100644 src/devin/policy.rs create mode 100644 src/devin/state.rs create mode 100644 tests/devin_contract.rs create mode 100644 tests/devin_session_state.rs create mode 100644 tests/fixtures/devin_cli/tool_schema_manifest.json diff --git a/docs/devin-rust-progress.md b/docs/devin-rust-progress.md new file mode 100644 index 000000000..b27243c49 --- /dev/null +++ b/docs/devin-rust-progress.md @@ -0,0 +1,64 @@ +# Pi-Devin Rust parity progress + +## Baseline + +- Upstream: `Dicklesworthstone/pi_agent_rust` +- Pinned commit: `590d61899ae64e172f15d919632a9134ddec6fb6` +- Upstream version: `0.1.23` +- Implementation branch: `devin-rust-core` +- Writable fork: `OnlineChefGroep/pi_agent_rust` + +The pristine `--all-features` check exposed an upstream `wasm-host` failure: +21 `Future + Send` errors converge on an `asupersync::sync::MutexGuard` held +across `await` in `src/extensions.rs`. This predates Pi-Devin changes. The +default-feature gate was stopped before completion when the project policy +changed to CI-only Rust validation. + +No further Cargo, rustc, clippy, rustfmt, or release builds run on the local +workstation. Rust verification is performed by GitHub Actions. + +## Proven parity + +- The live local Devin CLI exposes 28 function-calling tools in four available + transcript fixtures. +- All four transcripts have identical JSON-schema hashes for every tool. +- `AgentMode` and `PermissionMode` are independent, session-scoped values. +- Devin mode, sandbox, workspace, and scope state round-trips through versioned + custom session entries. +- Plan and Ask mode restrictions are policy decisions, not prompt decoration. +- Autonomous mode rejects activation without an active OS sandbox. +- Tool policy validates object arguments, classifies effects and risk, checks + workspace/scoped paths, rejects traversal and symlink escapes, and returns + allow, ask, deny, or sandbox. +- Native agent tool execution can use the same central policy gate before + approvals, extension hooks, and tool execution. +- Audit records retain argument hashes instead of raw arguments. + +## Remaining gaps + +1. Expose persisted Devin state through TUI, ACP, and RPC. +2. Register full transcript-derived schemas and migrate the existing eight + tools behind the same canonical registry. +3. Implement process supervision and persistent plan/todo tools. +4. Implement managed subagents, MCP, skills, hooks, and web/browser adapters. +5. Add disabled-by-default cloud XML parsing with no direct execution path. +6. Complete file mutation hashes/diffs and persistent audit/recovery sinks. +7. Run the end-to-end repository, plan, edit, background process, subagent, + and MCP smoke test in CI. + +## CI reproduction + +```bash +cargo fmt --all -- --check +cargo clippy --all-targets -- -D warnings +cargo test --test devin_contract +cargo test devin:: +cargo test --all-targets +cargo build --release +``` + +The optional full-feature upstream defect remains separately reproducible with: + +```bash +cargo clippy --all-targets --all-features -- -D warnings +``` diff --git a/src/agent.rs b/src/agent.rs index 445f2652b..6b618cc75 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -17,6 +17,7 @@ use crate::compaction::{self, ResolvedCompactionSettings}; use crate::compaction_worker::{ CompactionAdmissionSignals, CompactionQuota, CompactionWorkerState, }; +use crate::devin::{PolicyAction, ToolPolicyEngine, ToolRequest, ToolRequestOrigin}; use crate::error::{Error, Result}; use crate::extension_events::{ BeforeAgentStartOutcome, InputEventOutcome, SessionBeforeCompactOutcome, @@ -1120,6 +1121,9 @@ pub struct Agent { /// Agent configuration. config: AgentConfig, + /// Session-scoped Devin policy gate, shared across frontend surfaces. + tool_policy: Option>, + /// Optional extension manager for tool/event hooks. extensions: Option, @@ -1146,6 +1150,7 @@ impl Agent { provider, tools, config, + tool_policy: None, extensions: None, messages: Vec::new(), steering_fetchers: Vec::new(), @@ -1155,6 +1160,16 @@ impl Agent { } } + /// Install the central session policy gate used before any tool executes. + pub fn set_tool_policy(&mut self, policy: Arc) { + self.tool_policy = Some(policy); + } + + /// Remove the session policy gate and restore legacy approval behavior. + pub fn clear_tool_policy(&mut self) { + self.tool_policy = None; + } + /// Get the current message history. #[must_use] pub fn messages(&self) -> &[Message] { @@ -2959,9 +2974,28 @@ impl Agent { ) -> (ToolOutput, bool) { let extensions = self.extensions.clone(); - let approval_denied_output = self - .request_tool_approval(&tool_call, Arc::clone(&on_event)) - .await; + let approval_denied_output = if let Some(policy) = &self.tool_policy { + let decision = policy.evaluate(&ToolRequest { + call_id: tool_call.id.clone(), + tool_name: tool_call.name.clone(), + arguments: tool_call.arguments.clone(), + origin: ToolRequestOrigin::Native, + }); + match decision.action { + PolicyAction::Allow => None, + PolicyAction::Ask => { + self.request_tool_approval(&tool_call, Arc::clone(&on_event), true) + .await + } + PolicyAction::Deny => Some(Self::tool_approval_denied_output(&decision.reason)), + PolicyAction::Sandbox => Some(Self::tool_approval_denied_output( + "sandbox execution adapter is not configured; refusing unsandboxed execution", + )), + } + } else { + self.request_tool_approval(&tool_call, Arc::clone(&on_event), false) + .await + }; let (mut output, is_error) = if let Some(output) = approval_denied_output { (output, true) @@ -3007,9 +3041,14 @@ impl Agent { &self, tool_call: &ToolCall, on_event: AgentEventHandler, + required: bool, ) -> Option { let Some(approval) = &self.config.tool_approval else { - return None; + return required.then(|| { + Self::tool_approval_denied_output( + "policy requires approval but no approval handler is available", + ) + }); }; let request = ToolApprovalRequest { diff --git a/src/devin/audit.rs b/src/devin/audit.rs new file mode 100644 index 000000000..53c73654c --- /dev/null +++ b/src/devin/audit.rs @@ -0,0 +1,188 @@ +//! Bounded, secret-minimizing audit records for Devin tool calls. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::VecDeque; +use std::sync::Mutex; + +use super::policy::{PolicyAction, RiskClass}; + +/// Coarse effect recorded without retaining sensitive arguments. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolEffect { + Read, + Write, + Process, + Network, + SessionState, + External, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuditStatus { + Pending, + Allowed, + Denied, + Succeeded, + Failed, + Cancelled, +} + +/// Stable audit shape. Raw tool arguments and credentials are deliberately +/// excluded; callers store a canonical hash and redacted error text instead. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuditRecord { + pub call_id: String, + pub session_id: String, + pub parent_agent: Option, + pub tool_name: String, + pub argument_hash: String, + pub effects: Vec, + pub risk: RiskClass, + pub policy_action: PolicyAction, + pub approval_source: Option, + pub started_at: DateTime, + pub ended_at: Option>, + pub status: AuditStatus, + pub artifact_refs: Vec, + pub redacted_error: Option, +} + +/// In-memory bounded audit buffer. A persistent sink can consume these records +/// without changing policy or frontend code. +#[derive(Debug)] +pub struct AuditLog { + capacity: usize, + records: Mutex>, +} + +impl AuditLog { + #[must_use] + pub fn new(capacity: usize) -> Self { + Self { + capacity: capacity.max(1), + records: Mutex::new(VecDeque::with_capacity(capacity.max(1))), + } + } + + pub fn push(&self, record: AuditRecord) { + let mut records = self.records.lock().unwrap_or_else(|err| err.into_inner()); + if records.len() == self.capacity { + records.pop_front(); + } + records.push_back(record); + } + + #[must_use] + pub fn snapshot(&self) -> Vec { + self.records + .lock() + .unwrap_or_else(|err| err.into_inner()) + .iter() + .cloned() + .collect() + } +} + +/// Hash canonical JSON so equivalent object key order produces the same audit +/// identity while secret-bearing values never enter the record. +#[must_use] +pub fn argument_hash(arguments: &Value) -> String { + let canonical = canonical_json(arguments); + let mut hasher = Sha256::new(); + hasher.update(canonical.as_bytes()); + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn canonical_json(value: &Value) -> String { + match value { + Value::Object(map) => { + let mut entries = map.iter().collect::>(); + entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + let body = entries + .into_iter() + .map(|(key, value)| { + format!( + "{}:{}", + serde_json::to_string(key).unwrap_or_else(|_| "\"\"".to_string()), + canonical_json(value) + ) + }) + .collect::>() + .join(","); + format!("{{{body}}}") + } + Value::Array(values) => { + let body = values + .iter() + .map(canonical_json) + .collect::>() + .join(","); + format!("[{body}]") + } + _ => serde_json::to_string(value).unwrap_or_else(|_| "null".to_string()), + } +} + +/// Redact common credential shapes before an error reaches an audit sink. +#[must_use] +pub fn redact_error(message: &str) -> String { + let mut redact_next = false; + message + .split_whitespace() + .map(|token| { + if redact_next { + redact_next = false; + return "[REDACTED]"; + } + let lower = token.to_ascii_lowercase(); + if lower.contains("token=") + || lower.contains("password=") + || lower.contains("secret=") + || lower.contains("api_key=") + || lower.contains("apikey=") + || lower.starts_with("authorization:") + || lower.starts_with("sk-") + { + "[REDACTED]" + } else if lower == "bearer" { + redact_next = true; + "[REDACTED]" + } else { + token + } + }) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn argument_hash_is_independent_of_object_key_order() { + assert_eq!( + argument_hash(&json!({"a": 1, "b": {"x": true, "y": false}})), + argument_hash(&json!({"b": {"y": false, "x": true}, "a": 1})) + ); + } + + #[test] + fn redacts_common_secret_tokens() { + let redacted = redact_error("failed token=abc password=hunter2 Bearer abc sk-example"); + assert_eq!( + redacted, + "failed [REDACTED] [REDACTED] [REDACTED] [REDACTED] [REDACTED]" + ); + } +} diff --git a/src/devin/mod.rs b/src/devin/mod.rs new file mode 100644 index 000000000..3761cb0dc --- /dev/null +++ b/src/devin/mod.rs @@ -0,0 +1,21 @@ +//! Devin-compatible session state, policy, and audit primitives. +//! +//! This module is intentionally independent from the TUI, ACP, and RPC +//! frontends. Those surfaces must share these core decisions instead of +//! implementing their own permission logic. + +pub mod audit; +pub mod policy; +pub mod state; + +pub use audit::{ + AuditLog, AuditRecord, AuditStatus, ToolEffect, argument_hash, redact_error, +}; +pub use policy::{ + PolicyAction, PolicyDecision, RiskClass, ToolCategory, ToolPolicyEngine, ToolRequest, + ToolRequestOrigin, +}; +pub use state::{ + AgentMode, DEVIN_SESSION_STATE_CUSTOM_TYPE, DevinSessionState, PermissionMode, SandboxStatus, + ScopeAccess, ScopeGrant, SharedDevinSessionState, +}; diff --git a/src/devin/policy.rs b/src/devin/policy.rs new file mode 100644 index 000000000..5402e4477 --- /dev/null +++ b/src/devin/policy.rs @@ -0,0 +1,571 @@ +//! Central policy evaluation for every Devin-compatible tool call. + +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; + +use super::audit::{AuditLog, AuditRecord, AuditStatus, ToolEffect, argument_hash}; +use super::state::{ + AgentMode, PermissionMode, SandboxStatus, ScopeAccess, SharedDevinSessionState, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolRequestOrigin { + Native, + Acp, + Rpc, + CloudXml, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ToolRequest { + pub call_id: String, + pub tool_name: String, + pub arguments: Value, + pub origin: ToolRequestOrigin, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolCategory { + Read, + FileMutation, + Process, + Network, + Planning, + SessionState, + External, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RiskClass { + Low, + Medium, + High, + Critical, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PolicyAction { + Allow, + Ask, + Deny, + Sandbox, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PolicyDecision { + pub action: PolicyAction, + pub category: ToolCategory, + pub risk: RiskClass, + pub reason: String, + pub scoped_paths: Vec, +} + +impl PolicyDecision { + fn deny(category: ToolCategory, risk: RiskClass, reason: impl Into) -> Self { + Self { + action: PolicyAction::Deny, + category, + risk, + reason: reason.into(), + scoped_paths: Vec::new(), + } + } +} + +/// Session-bound policy engine shared by TUI, ACP, RPC, and native tool calls. +#[derive(Debug, Clone)] +pub struct ToolPolicyEngine { + state: SharedDevinSessionState, + audit: Option>, +} + +impl ToolPolicyEngine { + #[must_use] + pub fn new(state: SharedDevinSessionState) -> Self { + Self { state, audit: None } + } + + #[must_use] + pub fn with_audit(mut self, audit: Arc) -> Self { + self.audit = Some(audit); + self + } + + #[must_use] + pub fn state(&self) -> &SharedDevinSessionState { + &self.state + } + + #[must_use] + pub fn evaluate(&self, request: &ToolRequest) -> PolicyDecision { + let state = self.state.read().unwrap_or_else(|err| err.into_inner()); + let category = classify_tool(&request.tool_name); + let risk = classify_risk(category, &request.tool_name); + + let mut decision = if !request.arguments.is_object() { + PolicyDecision::deny(category, risk, "tool arguments must be a JSON object") + } else if request.tool_name.trim().is_empty() { + PolicyDecision::deny(category, risk, "tool name must not be empty") + } else if !agent_mode_allows(state.agent_mode, &request.tool_name, category) { + PolicyDecision::deny( + category, + risk, + format!( + "{} mode does not permit `{}`", + agent_mode_name(state.agent_mode), + request.tool_name + ), + ) + } else { + match validate_paths(&state, request, category) { + Ok(scoped_paths) => { + let (action, reason) = permission_decision( + state.permission_mode, + state.sandbox_status, + category, + &request.tool_name, + ); + PolicyDecision { + action, + category, + risk, + reason, + scoped_paths, + } + } + Err(reason) => PolicyDecision::deny(category, risk, reason), + } + }; + + if request.origin == ToolRequestOrigin::CloudXml { + decision.reason = format!("cloud XML compatibility: {}", decision.reason); + } + self.audit_decision(&state, request, &decision); + decision + } + + fn audit_decision( + &self, + state: &super::state::DevinSessionState, + request: &ToolRequest, + decision: &PolicyDecision, + ) { + let Some(audit) = &self.audit else { + return; + }; + let now = Utc::now(); + audit.push(AuditRecord { + call_id: request.call_id.clone(), + session_id: state.session_id.clone(), + parent_agent: state.parent_agent.clone(), + tool_name: request.tool_name.clone(), + argument_hash: argument_hash(&request.arguments), + effects: effects_for(decision.category), + risk: decision.risk, + policy_action: decision.action, + approval_source: None, + started_at: now, + ended_at: Some(now), + status: match decision.action { + PolicyAction::Allow => AuditStatus::Allowed, + PolicyAction::Deny => AuditStatus::Denied, + PolicyAction::Ask | PolicyAction::Sandbox => AuditStatus::Pending, + }, + artifact_refs: Vec::new(), + redacted_error: None, + }); + } +} + +#[must_use] +pub fn classify_tool(name: &str) -> ToolCategory { + match name { + "read" | "grep" | "find" | "find_file_by_name" | "ls" | "notebook_read" + | "get_output" | "read_subagent" | "mcp_list_servers" | "mcp_list_tools" => { + ToolCategory::Read + } + "write" | "edit" | "apply_patch" | "hashline_edit" | "notebook_edit" => { + ToolCategory::FileMutation + } + "bash" | "exec" | "shell_command" | "kill_shell" | "write_to_process" => { + ToolCategory::Process + } + "web_search" | "webfetch" | "mcp_call_tool" | "mcp_read_resource" => { + ToolCategory::Network + } + "update_plan" | "todo_write" | "exit_plan_mode" => ToolCategory::Planning, + "ask_user_question" | "request_scope" => ToolCategory::SessionState, + "run_subagent" | "skill" | "cloud_handoff" => ToolCategory::External, + _ => ToolCategory::Unknown, + } +} + +fn classify_risk(category: ToolCategory, name: &str) -> RiskClass { + match category { + ToolCategory::Read | ToolCategory::Planning => RiskClass::Low, + ToolCategory::SessionState => RiskClass::Medium, + ToolCategory::FileMutation | ToolCategory::Network => RiskClass::High, + ToolCategory::Process | ToolCategory::External | ToolCategory::Unknown => { + if matches!(name, "cloud_handoff" | "run_subagent") { + RiskClass::High + } else { + RiskClass::Critical + } + } + } +} + +fn agent_mode_allows(mode: AgentMode, name: &str, category: ToolCategory) -> bool { + match mode { + AgentMode::Normal => true, + AgentMode::Plan => matches!( + name, + "read" + | "grep" + | "find" + | "find_file_by_name" + | "ls" + | "notebook_read" + | "update_plan" + | "todo_write" + | "ask_user_question" + | "exit_plan_mode" + ), + AgentMode::Ask => { + category == ToolCategory::Read + || matches!(name, "todo_write" | "ask_user_question") + } + } +} + +const fn agent_mode_name(mode: AgentMode) -> &'static str { + match mode { + AgentMode::Normal => "normal", + AgentMode::Plan => "plan", + AgentMode::Ask => "ask", + } +} + +fn permission_decision( + mode: PermissionMode, + sandbox: SandboxStatus, + category: ToolCategory, + name: &str, +) -> (PolicyAction, String) { + if category == ToolCategory::Unknown { + return ( + PolicyAction::Ask, + "unknown tools require explicit approval".to_string(), + ); + } + if name == "request_scope" { + return ( + PolicyAction::Ask, + "expanding session scope requires explicit approval".to_string(), + ); + } + if matches!( + category, + ToolCategory::Read | ToolCategory::Planning | ToolCategory::SessionState + ) { + return ( + PolicyAction::Allow, + "read-only or session-local operation".to_string(), + ); + } + + match mode { + PermissionMode::Normal => ( + PolicyAction::Ask, + "normal mode requires approval for side effects".to_string(), + ), + PermissionMode::AcceptEdits if category == ToolCategory::FileMutation => ( + PolicyAction::Allow, + "workspace edit allowed by accept-edits mode".to_string(), + ), + PermissionMode::AcceptEdits => ( + PolicyAction::Ask, + "accept-edits mode still requires approval for non-file effects".to_string(), + ), + PermissionMode::Smart => ( + PolicyAction::Ask, + "smart mode requires a risk-aware approval".to_string(), + ), + PermissionMode::Bypass => ( + PolicyAction::Allow, + "bypass mode allows calls inside enforced scopes".to_string(), + ), + PermissionMode::Autonomous if sandbox != SandboxStatus::Active => ( + PolicyAction::Deny, + "autonomous mode requires an active OS sandbox".to_string(), + ), + PermissionMode::Autonomous if category == ToolCategory::FileMutation => ( + PolicyAction::Ask, + "direct file tools execute outside the sandbox and require approval".to_string(), + ), + PermissionMode::Autonomous + if matches!(category, ToolCategory::Process | ToolCategory::Network) => + { + ( + PolicyAction::Sandbox, + "operation must execute through the active OS sandbox".to_string(), + ) + } + PermissionMode::Autonomous => ( + PolicyAction::Ask, + format!("autonomous mode requires approval for `{name}`"), + ), + } +} + +fn validate_paths( + state: &super::state::DevinSessionState, + request: &ToolRequest, + category: ToolCategory, +) -> Result, String> { + if request.tool_name == "request_scope" { + return Ok(Vec::new()); + } + let access = match category { + ToolCategory::FileMutation => ScopeAccess::Write, + ToolCategory::Read => ScopeAccess::Read, + _ => return Ok(Vec::new()), + }; + let Some(arguments) = request.arguments.as_object() else { + return Err("tool arguments must be a JSON object".to_string()); + }; + + let mut scoped = Vec::new(); + for key in ["file_path", "path", "notebook_path"] { + let Some(raw_path) = arguments.get(key).and_then(Value::as_str) else { + continue; + }; + let path = resolve_scoped_path(state, raw_path, access)?; + scoped.push(path); + } + Ok(scoped) +} + +fn resolve_scoped_path( + state: &super::state::DevinSessionState, + raw_path: &str, + access: ScopeAccess, +) -> Result { + let supplied = Path::new(raw_path); + if supplied + .components() + .any(|component| component == Component::ParentDir) + { + return Err(format!("path traversal is not allowed: `{raw_path}`")); + } + + let candidate = if supplied.is_absolute() { + supplied.to_path_buf() + } else { + state.workspace.join(supplied) + }; + let resolved = canonicalize_allow_missing(&candidate)?; + let workspace = canonicalize_allow_missing(&state.workspace)?; + if resolved.starts_with(&workspace) { + return Ok(resolved); + } + + for scope in &state.scopes { + let scope_root = canonicalize_allow_missing(&scope.root)?; + if resolved.starts_with(scope_root) && scope.access.permits(access) { + return Ok(resolved); + } + } + Err(format!( + "path `{}` is outside the allowed workspace and scopes", + candidate.display() + )) +} + +fn canonicalize_allow_missing(path: &Path) -> Result { + let mut existing = path.to_path_buf(); + let mut suffix = Vec::new(); + while !existing.exists() { + let Some(name) = existing.file_name() else { + break; + }; + suffix.push(name.to_os_string()); + if !existing.pop() { + break; + } + } + + let mut resolved = existing + .canonicalize() + .map_err(|err| format!("cannot resolve scoped path `{}`: {err}", path.display()))?; + for component in suffix.iter().rev() { + resolved.push(component); + } + Ok(resolved) +} + +fn effects_for(category: ToolCategory) -> Vec { + match category { + ToolCategory::Read => vec![ToolEffect::Read], + ToolCategory::FileMutation => vec![ToolEffect::Write], + ToolCategory::Process => vec![ToolEffect::Process], + ToolCategory::Network => vec![ToolEffect::Network], + ToolCategory::Planning | ToolCategory::SessionState => vec![ToolEffect::SessionState], + ToolCategory::External | ToolCategory::Unknown => vec![ToolEffect::External], + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::devin::state::DevinSessionState; + use serde_json::json; + use std::fs; + use std::sync::RwLock; + + fn engine( + workspace: &Path, + agent_mode: AgentMode, + permission_mode: PermissionMode, + ) -> ToolPolicyEngine { + let mut state = DevinSessionState::new("session", workspace); + state.agent_mode = agent_mode; + state.permission_mode = permission_mode; + ToolPolicyEngine::new(Arc::new(RwLock::new(state))) + } + + fn request(name: &str, arguments: Value) -> ToolRequest { + ToolRequest { + call_id: "call-1".to_string(), + tool_name: name.to_string(), + arguments, + origin: ToolRequestOrigin::Native, + } + } + + #[test] + fn plan_mode_blocks_writes_and_processes() { + let workspace = tempfile::tempdir().unwrap(); + let policy = engine( + workspace.path(), + AgentMode::Plan, + PermissionMode::Bypass, + ); + assert_eq!( + policy + .evaluate(&request( + "write", + json!({"file_path": workspace.path().join("x").display().to_string()}) + )) + .action, + PolicyAction::Deny + ); + assert_eq!( + policy + .evaluate(&request("exec", json!({"command": "true"}))) + .action, + PolicyAction::Deny + ); + assert_eq!( + policy + .evaluate(&request("exit_plan_mode", json!({}))) + .action, + PolicyAction::Allow + ); + } + + #[test] + fn traversal_and_symlink_escape_are_denied() { + let workspace = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let policy = engine( + workspace.path(), + AgentMode::Normal, + PermissionMode::AcceptEdits, + ); + + assert_eq!( + policy + .evaluate(&request("write", json!({"file_path": "../escape"}))) + .action, + PolicyAction::Deny + ); + + #[cfg(unix)] + { + std::os::unix::fs::symlink(outside.path(), workspace.path().join("link")).unwrap(); + assert_eq!( + policy + .evaluate(&request( + "write", + json!({"file_path": workspace.path().join("link/file").display().to_string()}) + )) + .action, + PolicyAction::Deny + ); + } + } + + #[test] + fn explicit_write_scope_allows_external_path() { + let workspace = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let mut state = DevinSessionState::new("session", workspace.path()); + state.permission_mode = PermissionMode::AcceptEdits; + state.grant_scope(outside.path(), ScopeAccess::Write); + let policy = ToolPolicyEngine::new(Arc::new(RwLock::new(state))); + let target = outside.path().join("new.txt"); + assert_eq!( + policy + .evaluate(&request( + "write", + json!({"file_path": target.display().to_string()}) + )) + .action, + PolicyAction::Allow + ); + } + + #[test] + fn autonomous_processes_fail_closed_without_sandbox() { + let workspace = tempfile::tempdir().unwrap(); + let mut state = DevinSessionState::new("session", workspace.path()); + state.permission_mode = PermissionMode::Autonomous; + let policy = ToolPolicyEngine::new(Arc::new(RwLock::new(state))); + assert_eq!( + policy + .evaluate(&request("exec", json!({"command": "true"}))) + .action, + PolicyAction::Deny + ); + } + + #[test] + fn audit_records_hash_but_not_arguments() { + let workspace = tempfile::tempdir().unwrap(); + fs::write(workspace.path().join("file"), "ok").unwrap(); + let state = Arc::new(RwLock::new(DevinSessionState::new( + "session", + workspace.path(), + ))); + let audit = Arc::new(AuditLog::new(8)); + let policy = ToolPolicyEngine::new(state).with_audit(Arc::clone(&audit)); + policy.evaluate(&request( + "read", + json!({"file_path": workspace.path().join("file").display().to_string()}), + )); + + let records = audit.snapshot(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].argument_hash.len(), 64); + assert!(!serde_json::to_string(&records[0]).unwrap().contains("file_path")); + } +} diff --git a/src/devin/state.rs b/src/devin/state.rs new file mode 100644 index 000000000..42fbfa57d --- /dev/null +++ b/src/devin/state.rs @@ -0,0 +1,150 @@ +//! Session-scoped Devin modes and access grants. + +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; + +pub const DEVIN_SESSION_STATE_CUSTOM_TYPE: &str = "devin_session_state_v1"; + +/// Agent behavior profile for the current session. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentMode { + /// Full coding agent. + #[default] + Normal, + /// Read-only planning agent until `exit_plan_mode` succeeds. + Plan, + /// Read-only question-answering agent. + Ask, +} + +/// Permission policy selected for the current session. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PermissionMode { + /// Prompt for writes, processes, and network access. + #[default] + Normal, + /// Auto-approve workspace edits while still prompting for processes. + AcceptEdits, + /// Risk-sensitive approval mode. + Smart, + /// Auto-approve calls that remain inside enforced scopes. + Bypass, + /// Execute process and network calls only through an active OS sandbox. + Autonomous, +} + +/// Availability of the OS-level sandbox required by autonomous mode. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SandboxStatus { + #[default] + Unavailable, + Available, + Active, +} + +/// Access level granted for a path outside the primary workspace. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ScopeAccess { + Read, + Write, +} + +impl ScopeAccess { + #[must_use] + pub const fn permits(self, requested: Self) -> bool { + matches!( + (self, requested), + (Self::Write, _) | (Self::Read, Self::Read) + ) + } +} + +/// A session-local filesystem scope. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScopeGrant { + pub root: PathBuf, + pub access: ScopeAccess, +} + +/// State shared by every frontend and tool call for one agent session. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DevinSessionState { + pub session_id: String, + pub parent_agent: Option, + pub workspace: PathBuf, + pub agent_mode: AgentMode, + pub permission_mode: PermissionMode, + pub sandbox_status: SandboxStatus, + pub scopes: Vec, +} + +impl DevinSessionState { + #[must_use] + pub fn new(session_id: impl Into, workspace: impl Into) -> Self { + Self { + session_id: session_id.into(), + parent_agent: None, + workspace: workspace.into(), + agent_mode: AgentMode::Normal, + permission_mode: PermissionMode::Normal, + sandbox_status: SandboxStatus::Unavailable, + scopes: Vec::new(), + } + } + + /// Select a permission mode, rejecting autonomous mode unless the sandbox + /// is already active. This keeps the transition fail-closed. + pub fn set_permission_mode(&mut self, mode: PermissionMode) -> Result<(), String> { + if mode == PermissionMode::Autonomous && self.sandbox_status != SandboxStatus::Active { + return Err("autonomous mode requires an active OS sandbox".to_string()); + } + self.permission_mode = mode; + Ok(()) + } + + pub fn grant_scope(&mut self, root: impl Into, access: ScopeAccess) { + self.scopes.push(ScopeGrant { + root: root.into(), + access, + }); + } + + #[must_use] + pub fn scope_permits(&self, path: &Path, access: ScopeAccess) -> bool { + self.scopes + .iter() + .any(|scope| path.starts_with(&scope.root) && scope.access.permits(access)) + } +} + +pub type SharedDevinSessionState = Arc>; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn autonomous_mode_requires_active_sandbox() { + let mut state = DevinSessionState::new("session", "/workspace"); + assert!(state + .set_permission_mode(PermissionMode::Autonomous) + .is_err()); + + state.sandbox_status = SandboxStatus::Active; + assert!(state + .set_permission_mode(PermissionMode::Autonomous) + .is_ok()); + } + + #[test] + fn write_scope_includes_read_access() { + assert!(ScopeAccess::Write.permits(ScopeAccess::Read)); + assert!(ScopeAccess::Write.permits(ScopeAccess::Write)); + assert!(!ScopeAccess::Read.permits(ScopeAccess::Write)); + } +} diff --git a/src/lib.rs b/src/lib.rs index 98fd93b72..b94e0b44b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -91,6 +91,8 @@ pub mod connectors; #[doc(hidden)] pub mod crypto_shim; #[doc(hidden)] +pub mod devin; +#[doc(hidden)] pub mod doctor; #[doc(hidden)] pub mod error; diff --git a/src/session.rs b/src/session.rs index afa318229..958d12490 100644 --- a/src/session.rs +++ b/src/session.rs @@ -2851,6 +2851,39 @@ impl Session { id } + /// Persist the current Devin modes, sandbox state, and scopes on this + /// session branch. + pub fn append_devin_state( + &mut self, + state: &crate::devin::DevinSessionState, + ) -> Result { + let data = serde_json::to_value(state) + .map_err(|err| Error::session(format!("Failed to serialize Devin state: {err}")))?; + Ok(self.append_custom_entry( + crate::devin::DEVIN_SESSION_STATE_CUSTOM_TYPE.to_string(), + Some(data), + )) + } + + /// Load the latest Devin state from the current session branch. + pub fn latest_devin_state(&self) -> Result> { + for entry in self.entries_for_current_path().into_iter().rev() { + let SessionEntry::Custom(custom) = entry else { + continue; + }; + if custom.custom_type != crate::devin::DEVIN_SESSION_STATE_CUSTOM_TYPE { + continue; + } + let Some(data) = custom.data.clone() else { + return Ok(None); + }; + let state = serde_json::from_value(data) + .map_err(|err| Error::session(format!("Failed to parse Devin state: {err}")))?; + return Ok(Some(state)); + } + Ok(None) + } + pub fn append_bash_execution( &mut self, command: String, diff --git a/tests/devin_contract.rs b/tests/devin_contract.rs new file mode 100644 index 000000000..4bc71ebfe --- /dev/null +++ b/tests/devin_contract.rs @@ -0,0 +1,68 @@ +//! Regression contract extracted from the installed Devin CLI transcripts. + +use serde::Deserialize; +use std::collections::BTreeMap; + +#[derive(Debug, Deserialize)] +struct ToolSchemaManifest { + schema_version: u32, + source: String, + transcripts_compared: usize, + canonicalization: String, + tools: BTreeMap, +} + +#[test] +fn local_devin_tool_surface_is_pinned() { + let manifest: ToolSchemaManifest = serde_json::from_str(include_str!( + "fixtures/devin_cli/tool_schema_manifest.json" + )) + .expect("valid Devin tool schema manifest"); + + let expected = [ + "apply_patch", + "ask_user_question", + "cloud_handoff", + "edit", + "exec", + "exit_plan_mode", + "find_file_by_name", + "get_output", + "grep", + "kill_shell", + "mcp_call_tool", + "mcp_list_servers", + "mcp_list_tools", + "mcp_read_resource", + "notebook_edit", + "notebook_read", + "read", + "read_subagent", + "request_scope", + "run_subagent", + "shell_command", + "skill", + "todo_write", + "update_plan", + "web_search", + "webfetch", + "write", + "write_to_process", + ]; + let actual = manifest.tools.keys().map(String::as_str).collect::>(); + + assert_eq!(manifest.schema_version, 1); + assert_eq!( + manifest.source, + "local_devin_cli_transcript_tool_definitions" + ); + assert_eq!(manifest.transcripts_compared, 4); + assert_eq!( + manifest.canonicalization, + "sorted_compact_json_sha256_prefix_12" + ); + assert_eq!(actual, expected); + assert!(manifest.tools.values().all(|digest| { + digest.len() == 12 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) + })); +} diff --git a/tests/devin_session_state.rs b/tests/devin_session_state.rs new file mode 100644 index 000000000..395152a50 --- /dev/null +++ b/tests/devin_session_state.rs @@ -0,0 +1,58 @@ +//! Session persistence coverage for Devin modes and scopes. + +use pi::devin::{ + AgentMode, DevinSessionState, PermissionMode, SandboxStatus, ScopeAccess, +}; +use pi::session::Session; + +#[test] +fn devin_state_round_trips_through_custom_session_entries() { + let workspace = tempfile::tempdir().expect("workspace"); + let external = tempfile::tempdir().expect("external scope"); + let mut state = DevinSessionState::new("session-1", workspace.path()); + state.agent_mode = AgentMode::Plan; + state.permission_mode = PermissionMode::Smart; + state.sandbox_status = SandboxStatus::Available; + state.grant_scope(external.path(), ScopeAccess::Read); + + let mut session = Session::in_memory(); + session + .append_devin_state(&state) + .expect("append Devin session state"); + let restored = session + .latest_devin_state() + .expect("parse Devin session state") + .expect("Devin session state exists"); + + assert_eq!(restored.session_id, "session-1"); + assert_eq!(restored.workspace, workspace.path()); + assert_eq!(restored.agent_mode, AgentMode::Plan); + assert_eq!(restored.permission_mode, PermissionMode::Smart); + assert_eq!(restored.sandbox_status, SandboxStatus::Available); + assert_eq!(restored.scopes.len(), 1); + assert_eq!(restored.scopes[0].access, ScopeAccess::Read); +} + +#[test] +fn latest_devin_state_wins_on_current_branch() { + let workspace = tempfile::tempdir().expect("workspace"); + let mut session = Session::in_memory(); + let mut state = DevinSessionState::new("session-1", workspace.path()); + session + .append_devin_state(&state) + .expect("append initial Devin state"); + + state.agent_mode = AgentMode::Ask; + session + .append_devin_state(&state) + .expect("append updated Devin state"); + + assert_eq!( + session + .latest_devin_state() + .expect("parse Devin session state") + .expect("Devin session state exists") + .agent_mode, + AgentMode::Ask + ); +} diff --git a/tests/fixtures/devin_cli/tool_schema_manifest.json b/tests/fixtures/devin_cli/tool_schema_manifest.json new file mode 100644 index 000000000..27c882562 --- /dev/null +++ b/tests/fixtures/devin_cli/tool_schema_manifest.json @@ -0,0 +1,36 @@ +{ + "schema_version": 1, + "source": "local_devin_cli_transcript_tool_definitions", + "transcripts_compared": 4, + "canonicalization": "sorted_compact_json_sha256_prefix_12", + "tools": { + "apply_patch": "44136fa355b3", + "ask_user_question": "543035bcee45", + "cloud_handoff": "4d42c47084fe", + "edit": "70aa9c762f6e", + "exec": "75d325f0ee6e", + "exit_plan_mode": "565d6980e2e2", + "find_file_by_name": "f1ece32204c1", + "get_output": "ba9743b81207", + "grep": "f566c924a162", + "kill_shell": "badcd1021e24", + "mcp_call_tool": "ed2861c67f38", + "mcp_list_servers": "99334726611c", + "mcp_list_tools": "c23907be861b", + "mcp_read_resource": "062b2771400a", + "notebook_edit": "4e14c9c22aa1", + "notebook_read": "62d1d592440c", + "read": "86b738b12cbd", + "read_subagent": "5988d9e4a2af", + "request_scope": "ca6b3e746d7c", + "run_subagent": "7a1917d4c752", + "shell_command": "bbe0da554d85", + "skill": "bc95e1244aa0", + "todo_write": "4af7b79177ba", + "update_plan": "83a12786224d", + "web_search": "c42356a674c9", + "webfetch": "d0ffe6943a2d", + "write": "4a7f885005eb", + "write_to_process": "10c1bd8e64f0" + } +} From 35f5fb3eb3a5acfd339be2a9a9eef095b2f3a7d4 Mon Sep 17 00:00:00 2001 From: OnlineChef <280567955+OnlineChef@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:01:27 +0200 Subject: [PATCH 02/12] ci(devin): validate Rust changes on GitHub Add a branch-scoped gate so the fork runs formatting, lint, focused policy tests, and release builds without using the local workstation. Co-authored-by: Cursor --- .github/workflows/devin-ci.yml | 66 ++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/workflows/devin-ci.yml diff --git a/.github/workflows/devin-ci.yml b/.github/workflows/devin-ci.yml new file mode 100644 index 000000000..56b62703b --- /dev/null +++ b/.github/workflows/devin-ci.yml @@ -0,0 +1,66 @@ +name: devin-ci + +on: + push: + branches: + - devin-rust-core + - "devin-*" + pull_request: + paths: + - ".github/workflows/devin-ci.yml" + - "src/agent.rs" + - "src/devin/**" + - "src/lib.rs" + - "src/session.rs" + - "tests/devin_*.rs" + - "tests/fixtures/devin_cli/**" + - "Cargo.toml" + - "Cargo.lock" + +permissions: + contents: read + +jobs: + rust: + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + CI: "true" + VCR_MODE: "playback" + RUST_BACKTRACE: "1" + CARGO_INCREMENTAL: "0" + CARGO_PROFILE_DEV_DEBUG: "line-tables-only" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + fd-find \ + libxcb1-dev \ + libxcb-render0-dev \ + libxcb-shape0-dev \ + libxcb-xfixes0-dev \ + ripgrep + sudo ln -sf "$(command -v fdfind)" /usr/local/bin/fd + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@nightly + with: + components: clippy, rustfmt + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Lint default features + run: cargo clippy --all-targets -- -D warnings + + - name: Test Devin contract and session policy + run: | + cargo test --test devin_contract --test devin_session_state + cargo test devin:: + + - name: Build release binary + run: cargo build --release From d05642271119efa1ff5fdf411cb2b97eac3ea8ee Mon Sep 17 00:00:00 2001 From: OnlineChef <280567955+OnlineChef@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:02:34 +0200 Subject: [PATCH 03/12] ci(devin): allow manual remote validation Expose workflow dispatch so CI-only Rust gates can be retriggered without local compilation. Co-authored-by: Cursor --- .github/workflows/devin-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/devin-ci.yml b/.github/workflows/devin-ci.yml index 56b62703b..ff1636bca 100644 --- a/.github/workflows/devin-ci.yml +++ b/.github/workflows/devin-ci.yml @@ -1,6 +1,7 @@ name: devin-ci on: + workflow_dispatch: push: branches: - devin-rust-core From d61be3cfebc6cd29ef696867d94c14918f77734a Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Fri, 31 Jul 2026 20:02:37 +0000 Subject: [PATCH 04/12] fix(ci): run conformance skip step from workspace root The 'Skip unmatched profile' step inherited the pi_agent_rust working-directory default, which does not exist when the checkout steps are skipped, so unmatched profiles failed instead of skipping. Co-authored-by: Codesmith --- .github/workflows/conformance.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 4550a4e6e..0f19d3131 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -107,6 +107,9 @@ jobs: steps: - name: Skip unmatched profile if: env.SHOULD_RUN != 'true' + # The default working-directory is the not-yet-checked-out repo path, so + # this step must run from the workspace root to avoid a shell failure. + working-directory: ${{ github.workspace }} run: echo "Skipping ${{ matrix.name }} for event/profile selection" - name: Free disk space From 896c48029cacd1679804e5eab5cb1ecd34fd425a Mon Sep 17 00:00:00 2001 From: OnlineChef <280567955+OnlineChef@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:03:21 +0200 Subject: [PATCH 05/12] style(devin): apply remote rustfmt findings Mirror GitHub's formatting output so the CI-only validation lane can advance to compilation and tests. Co-authored-by: Cursor --- src/devin/mod.rs | 4 +--- src/devin/policy.rs | 25 ++++++++++--------------- src/devin/state.rs | 16 ++++++++++------ tests/devin_contract.rs | 13 ++++++++----- tests/devin_session_state.rs | 4 +--- 5 files changed, 30 insertions(+), 32 deletions(-) diff --git a/src/devin/mod.rs b/src/devin/mod.rs index 3761cb0dc..02ee8bedd 100644 --- a/src/devin/mod.rs +++ b/src/devin/mod.rs @@ -8,9 +8,7 @@ pub mod audit; pub mod policy; pub mod state; -pub use audit::{ - AuditLog, AuditRecord, AuditStatus, ToolEffect, argument_hash, redact_error, -}; +pub use audit::{AuditLog, AuditRecord, AuditStatus, ToolEffect, argument_hash, redact_error}; pub use policy::{ PolicyAction, PolicyDecision, RiskClass, ToolCategory, ToolPolicyEngine, ToolRequest, ToolRequestOrigin, diff --git a/src/devin/policy.rs b/src/devin/policy.rs index 5402e4477..71d6af370 100644 --- a/src/devin/policy.rs +++ b/src/devin/policy.rs @@ -188,19 +188,15 @@ impl ToolPolicyEngine { #[must_use] pub fn classify_tool(name: &str) -> ToolCategory { match name { - "read" | "grep" | "find" | "find_file_by_name" | "ls" | "notebook_read" - | "get_output" | "read_subagent" | "mcp_list_servers" | "mcp_list_tools" => { - ToolCategory::Read - } + "read" | "grep" | "find" | "find_file_by_name" | "ls" | "notebook_read" | "get_output" + | "read_subagent" | "mcp_list_servers" | "mcp_list_tools" => ToolCategory::Read, "write" | "edit" | "apply_patch" | "hashline_edit" | "notebook_edit" => { ToolCategory::FileMutation } "bash" | "exec" | "shell_command" | "kill_shell" | "write_to_process" => { ToolCategory::Process } - "web_search" | "webfetch" | "mcp_call_tool" | "mcp_read_resource" => { - ToolCategory::Network - } + "web_search" | "webfetch" | "mcp_call_tool" | "mcp_read_resource" => ToolCategory::Network, "update_plan" | "todo_write" | "exit_plan_mode" => ToolCategory::Planning, "ask_user_question" | "request_scope" => ToolCategory::SessionState, "run_subagent" | "skill" | "cloud_handoff" => ToolCategory::External, @@ -240,8 +236,7 @@ fn agent_mode_allows(mode: AgentMode, name: &str, category: ToolCategory) -> boo | "exit_plan_mode" ), AgentMode::Ask => { - category == ToolCategory::Read - || matches!(name, "todo_write" | "ask_user_question") + category == ToolCategory::Read || matches!(name, "todo_write" | "ask_user_question") } } } @@ -454,11 +449,7 @@ mod tests { #[test] fn plan_mode_blocks_writes_and_processes() { let workspace = tempfile::tempdir().unwrap(); - let policy = engine( - workspace.path(), - AgentMode::Plan, - PermissionMode::Bypass, - ); + let policy = engine(workspace.path(), AgentMode::Plan, PermissionMode::Bypass); assert_eq!( policy .evaluate(&request( @@ -566,6 +557,10 @@ mod tests { let records = audit.snapshot(); assert_eq!(records.len(), 1); assert_eq!(records[0].argument_hash.len(), 64); - assert!(!serde_json::to_string(&records[0]).unwrap().contains("file_path")); + assert!( + !serde_json::to_string(&records[0]) + .unwrap() + .contains("file_path") + ); } } diff --git a/src/devin/state.rs b/src/devin/state.rs index 42fbfa57d..6c382889a 100644 --- a/src/devin/state.rs +++ b/src/devin/state.rs @@ -131,14 +131,18 @@ mod tests { #[test] fn autonomous_mode_requires_active_sandbox() { let mut state = DevinSessionState::new("session", "/workspace"); - assert!(state - .set_permission_mode(PermissionMode::Autonomous) - .is_err()); + assert!( + state + .set_permission_mode(PermissionMode::Autonomous) + .is_err() + ); state.sandbox_status = SandboxStatus::Active; - assert!(state - .set_permission_mode(PermissionMode::Autonomous) - .is_ok()); + assert!( + state + .set_permission_mode(PermissionMode::Autonomous) + .is_ok() + ); } #[test] diff --git a/tests/devin_contract.rs b/tests/devin_contract.rs index 4bc71ebfe..fc74fb958 100644 --- a/tests/devin_contract.rs +++ b/tests/devin_contract.rs @@ -14,10 +14,9 @@ struct ToolSchemaManifest { #[test] fn local_devin_tool_surface_is_pinned() { - let manifest: ToolSchemaManifest = serde_json::from_str(include_str!( - "fixtures/devin_cli/tool_schema_manifest.json" - )) - .expect("valid Devin tool schema manifest"); + let manifest: ToolSchemaManifest = + serde_json::from_str(include_str!("fixtures/devin_cli/tool_schema_manifest.json")) + .expect("valid Devin tool schema manifest"); let expected = [ "apply_patch", @@ -49,7 +48,11 @@ fn local_devin_tool_surface_is_pinned() { "write", "write_to_process", ]; - let actual = manifest.tools.keys().map(String::as_str).collect::>(); + let actual = manifest + .tools + .keys() + .map(String::as_str) + .collect::>(); assert_eq!(manifest.schema_version, 1); assert_eq!( diff --git a/tests/devin_session_state.rs b/tests/devin_session_state.rs index 395152a50..e5006ed09 100644 --- a/tests/devin_session_state.rs +++ b/tests/devin_session_state.rs @@ -1,8 +1,6 @@ //! Session persistence coverage for Devin modes and scopes. -use pi::devin::{ - AgentMode, DevinSessionState, PermissionMode, SandboxStatus, ScopeAccess, -}; +use pi::devin::{AgentMode, DevinSessionState, PermissionMode, SandboxStatus, ScopeAccess}; use pi::session::Session; #[test] From c02c1a4f8934d2bbc70c6ee9d85fe4ed9fb3c586 Mon Sep 17 00:00:00 2001 From: OnlineChef <280567955+OnlineChef@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:05:50 +0200 Subject: [PATCH 06/12] fix(devin): tighten policy audit and contract evidence Apply review findings by pinning exact transcript digests and provenance, salting audit hashes, preserving pending execution state, and matching Bypass and CLI mode aliases. Co-authored-by: Cursor --- docs/devin-rust-progress.md | 9 +- src/devin/audit.rs | 30 ++++- src/devin/mod.rs | 2 +- src/devin/policy.rs | 33 +++-- src/devin/state.rs | 18 +++ tests/devin_contract.rs | 114 ++++++++++++------ .../devin_cli/tool_schema_manifest.json | 25 +++- 7 files changed, 174 insertions(+), 57 deletions(-) diff --git a/docs/devin-rust-progress.md b/docs/devin-rust-progress.md index b27243c49..aba81d4e2 100644 --- a/docs/devin-rust-progress.md +++ b/docs/devin-rust-progress.md @@ -17,10 +17,11 @@ changed to CI-only Rust validation. No further Cargo, rustc, clippy, rustfmt, or release builds run on the local workstation. Rust verification is performed by GitHub Actions. -## Proven parity +## Implemented evidence -- The live local Devin CLI exposes 28 function-calling tools in four available - transcript fixtures. +- Four ATIF-v1.7 transcripts exported by Devin `3000.2.17` expose the same 28 + function-calling tools. The installed binary at extraction time was + `3000.3.22`; no current-version transcript was available. - All four transcripts have identical JSON-schema hashes for every tool. - `AgentMode` and `PermissionMode` are independent, session-scoped values. - Devin mode, sandbox, workspace, and scope state round-trips through versioned @@ -32,7 +33,7 @@ workstation. Rust verification is performed by GitHub Actions. allow, ask, deny, or sandbox. - Native agent tool execution can use the same central policy gate before approvals, extension hooks, and tool execution. -- Audit records retain argument hashes instead of raw arguments. +- Audit records retain per-log salted argument hashes instead of raw arguments. ## Remaining gaps diff --git a/src/devin/audit.rs b/src/devin/audit.rs index 53c73654c..05a686484 100644 --- a/src/devin/audit.rs +++ b/src/devin/audit.rs @@ -57,14 +57,21 @@ pub struct AuditRecord { #[derive(Debug)] pub struct AuditLog { capacity: usize, + salt: [u8; 32], records: Mutex>, } impl AuditLog { #[must_use] pub fn new(capacity: usize) -> Self { + let first = uuid::Uuid::new_v4(); + let second = uuid::Uuid::new_v4(); + let mut salt = [0_u8; 32]; + salt[..16].copy_from_slice(first.as_bytes()); + salt[16..].copy_from_slice(second.as_bytes()); Self { capacity: capacity.max(1), + salt, records: Mutex::new(VecDeque::with_capacity(capacity.max(1))), } } @@ -86,14 +93,19 @@ impl AuditLog { .cloned() .collect() } + + #[must_use] + pub(crate) fn hash_arguments(&self, arguments: &Value) -> String { + argument_hash(arguments, &self.salt) + } } /// Hash canonical JSON so equivalent object key order produces the same audit /// identity while secret-bearing values never enter the record. -#[must_use] -pub fn argument_hash(arguments: &Value) -> String { +fn argument_hash(arguments: &Value, salt: &[u8; 32]) -> String { let canonical = canonical_json(arguments); let mut hasher = Sha256::new(); + hasher.update(salt); hasher.update(canonical.as_bytes()); hasher .finalize() @@ -171,9 +183,19 @@ mod tests { #[test] fn argument_hash_is_independent_of_object_key_order() { + let audit = AuditLog::new(8); assert_eq!( - argument_hash(&json!({"a": 1, "b": {"x": true, "y": false}})), - argument_hash(&json!({"b": {"y": false, "x": true}, "a": 1})) + audit.hash_arguments(&json!({"a": 1, "b": {"x": true, "y": false}})), + audit.hash_arguments(&json!({"b": {"y": false, "x": true}, "a": 1})) + ); + } + + #[test] + fn separate_audit_logs_use_distinct_hash_salts() { + let arguments = json!({"token": "low-entropy"}); + assert_ne!( + AuditLog::new(8).hash_arguments(&arguments), + AuditLog::new(8).hash_arguments(&arguments) ); } diff --git a/src/devin/mod.rs b/src/devin/mod.rs index 02ee8bedd..1e582b90c 100644 --- a/src/devin/mod.rs +++ b/src/devin/mod.rs @@ -8,7 +8,7 @@ pub mod audit; pub mod policy; pub mod state; -pub use audit::{AuditLog, AuditRecord, AuditStatus, ToolEffect, argument_hash, redact_error}; +pub use audit::{AuditLog, AuditRecord, AuditStatus, ToolEffect, redact_error}; pub use policy::{ PolicyAction, PolicyDecision, RiskClass, ToolCategory, ToolPolicyEngine, ToolRequest, ToolRequestOrigin, diff --git a/src/devin/policy.rs b/src/devin/policy.rs index 71d6af370..f56e0c694 100644 --- a/src/devin/policy.rs +++ b/src/devin/policy.rs @@ -6,7 +6,7 @@ use serde_json::Value; use std::path::{Component, Path, PathBuf}; use std::sync::Arc; -use super::audit::{AuditLog, AuditRecord, AuditStatus, ToolEffect, argument_hash}; +use super::audit::{AuditLog, AuditRecord, AuditStatus, ToolEffect}; use super::state::{ AgentMode, PermissionMode, SandboxStatus, ScopeAccess, SharedDevinSessionState, }; @@ -162,22 +162,23 @@ impl ToolPolicyEngine { return; }; let now = Utc::now(); + let denied = decision.action == PolicyAction::Deny; audit.push(AuditRecord { call_id: request.call_id.clone(), session_id: state.session_id.clone(), parent_agent: state.parent_agent.clone(), tool_name: request.tool_name.clone(), - argument_hash: argument_hash(&request.arguments), + argument_hash: audit.hash_arguments(&request.arguments), effects: effects_for(decision.category), risk: decision.risk, policy_action: decision.action, approval_source: None, started_at: now, - ended_at: Some(now), - status: match decision.action { - PolicyAction::Allow => AuditStatus::Allowed, - PolicyAction::Deny => AuditStatus::Denied, - PolicyAction::Ask | PolicyAction::Sandbox => AuditStatus::Pending, + ended_at: denied.then_some(now), + status: if denied { + AuditStatus::Denied + } else { + AuditStatus::Pending }, artifact_refs: Vec::new(), redacted_error: None, @@ -255,6 +256,12 @@ fn permission_decision( category: ToolCategory, name: &str, ) -> (PolicyAction, String) { + if mode == PermissionMode::Bypass { + return ( + PolicyAction::Allow, + "bypass mode allows calls inside enforced scopes".to_string(), + ); + } if category == ToolCategory::Unknown { return ( PolicyAction::Ask, @@ -539,6 +546,18 @@ mod tests { ); } + #[test] + fn bypass_allows_unknown_extension_tools() { + let workspace = tempfile::tempdir().unwrap(); + let policy = engine(workspace.path(), AgentMode::Normal, PermissionMode::Bypass); + assert_eq!( + policy + .evaluate(&request("extension_custom_tool", json!({}))) + .action, + PolicyAction::Allow + ); + } + #[test] fn audit_records_hash_but_not_arguments() { let workspace = tempfile::tempdir().unwrap(); diff --git a/src/devin/state.rs b/src/devin/state.rs index 6c382889a..fe6098006 100644 --- a/src/devin/state.rs +++ b/src/devin/state.rs @@ -27,10 +27,12 @@ pub enum PermissionMode { #[default] Normal, /// Auto-approve workspace edits while still prompting for processes. + #[serde(rename = "accept-edits", alias = "accept_edits")] AcceptEdits, /// Risk-sensitive approval mode. Smart, /// Auto-approve calls that remain inside enforced scopes. + #[serde(alias = "dangerous", alias = "yolo")] Bypass, /// Execute process and network calls only through an active OS sandbox. Autonomous, @@ -151,4 +153,20 @@ mod tests { assert!(ScopeAccess::Write.permits(ScopeAccess::Write)); assert!(!ScopeAccess::Read.permits(ScopeAccess::Write)); } + + #[test] + fn permission_modes_use_devin_cli_names_and_aliases() { + assert_eq!( + serde_json::to_string(&PermissionMode::AcceptEdits).unwrap(), + "\"accept-edits\"" + ); + assert_eq!( + serde_json::from_str::("\"dangerous\"").unwrap(), + PermissionMode::Bypass + ); + assert_eq!( + serde_json::from_str::("\"yolo\"").unwrap(), + PermissionMode::Bypass + ); + } } diff --git a/tests/devin_contract.rs b/tests/devin_contract.rs index fc74fb958..f9688f0bc 100644 --- a/tests/devin_contract.rs +++ b/tests/devin_contract.rs @@ -8,51 +8,58 @@ struct ToolSchemaManifest { schema_version: u32, source: String, transcripts_compared: usize, + transcript_format: String, + devin_version: String, + installed_devin_version_at_extraction: String, + hash_scope: String, canonicalization: String, + toolset_fingerprint: String, + sources: Vec, tools: BTreeMap, } +#[derive(Debug, Deserialize, PartialEq, Eq)] +struct TranscriptSource { + name: String, + sha256: String, +} + #[test] fn local_devin_tool_surface_is_pinned() { let manifest: ToolSchemaManifest = serde_json::from_str(include_str!("fixtures/devin_cli/tool_schema_manifest.json")) .expect("valid Devin tool schema manifest"); - let expected = [ - "apply_patch", - "ask_user_question", - "cloud_handoff", - "edit", - "exec", - "exit_plan_mode", - "find_file_by_name", - "get_output", - "grep", - "kill_shell", - "mcp_call_tool", - "mcp_list_servers", - "mcp_list_tools", - "mcp_read_resource", - "notebook_edit", - "notebook_read", - "read", - "read_subagent", - "request_scope", - "run_subagent", - "shell_command", - "skill", - "todo_write", - "update_plan", - "web_search", - "webfetch", - "write", - "write_to_process", - ]; - let actual = manifest - .tools - .keys() - .map(String::as_str) - .collect::>(); + let expected = BTreeMap::from([ + ("apply_patch".to_string(), "44136fa355b3".to_string()), + ("ask_user_question".to_string(), "543035bcee45".to_string()), + ("cloud_handoff".to_string(), "4d42c47084fe".to_string()), + ("edit".to_string(), "70aa9c762f6e".to_string()), + ("exec".to_string(), "75d325f0ee6e".to_string()), + ("exit_plan_mode".to_string(), "565d6980e2e2".to_string()), + ("find_file_by_name".to_string(), "f1ece32204c1".to_string()), + ("get_output".to_string(), "ba9743b81207".to_string()), + ("grep".to_string(), "f566c924a162".to_string()), + ("kill_shell".to_string(), "badcd1021e24".to_string()), + ("mcp_call_tool".to_string(), "ed2861c67f38".to_string()), + ("mcp_list_servers".to_string(), "99334726611c".to_string()), + ("mcp_list_tools".to_string(), "c23907be861b".to_string()), + ("mcp_read_resource".to_string(), "062b2771400a".to_string()), + ("notebook_edit".to_string(), "4e14c9c22aa1".to_string()), + ("notebook_read".to_string(), "62d1d592440c".to_string()), + ("read".to_string(), "86b738b12cbd".to_string()), + ("read_subagent".to_string(), "5988d9e4a2af".to_string()), + ("request_scope".to_string(), "ca6b3e746d7c".to_string()), + ("run_subagent".to_string(), "7a1917d4c752".to_string()), + ("shell_command".to_string(), "bbe0da554d85".to_string()), + ("skill".to_string(), "bc95e1244aa0".to_string()), + ("todo_write".to_string(), "4af7b79177ba".to_string()), + ("update_plan".to_string(), "83a12786224d".to_string()), + ("web_search".to_string(), "c42356a674c9".to_string()), + ("webfetch".to_string(), "d0ffe6943a2d".to_string()), + ("write".to_string(), "4a7f885005eb".to_string()), + ("write_to_process".to_string(), "10c1bd8e64f0".to_string()), + ]); assert_eq!(manifest.schema_version, 1); assert_eq!( @@ -60,12 +67,39 @@ fn local_devin_tool_surface_is_pinned() { "local_devin_cli_transcript_tool_definitions" ); assert_eq!(manifest.transcripts_compared, 4); + assert_eq!(manifest.transcript_format, "ATIF-v1.7"); + assert_eq!(manifest.devin_version, "3000.2.17"); + assert_eq!(manifest.installed_devin_version_at_extraction, "3000.3.22"); + assert_eq!(manifest.hash_scope, "function.parameters"); assert_eq!( manifest.canonicalization, - "sorted_compact_json_sha256_prefix_12" + "python_json_dumps_sort_keys_compact_ascii_sha256_prefix_12" + ); + assert_eq!(manifest.toolset_fingerprint, "444e21eed402"); + assert_eq!( + manifest.sources, + vec![ + TranscriptSource { + name: "wooded-guest.json".to_string(), + sha256: "42cef0e7da607eb19af1578cada3cae6aed8e83972b6ddeb0f12e45343649748" + .to_string(), + }, + TranscriptSource { + name: "unmarred-barbecue.json".to_string(), + sha256: "5919e087067ce352cfa8791c2dd081efb63b55c4eb6f3461c814290c0c8212da" + .to_string(), + }, + TranscriptSource { + name: "fork-class.json".to_string(), + sha256: "7903fecfa02b4a376c1474eaf011dbdcb3254d401253f1caac7fc1a81f79ad91" + .to_string(), + }, + TranscriptSource { + name: "steep-sidecar.json".to_string(), + sha256: "97e7b8cca32ac5fd711d4f58ec2da96ff190e9e75d5ebeca0b25cdb630223e7e" + .to_string(), + }, + ] ); - assert_eq!(actual, expected); - assert!(manifest.tools.values().all(|digest| { - digest.len() == 12 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) - })); + assert_eq!(manifest.tools, expected); } diff --git a/tests/fixtures/devin_cli/tool_schema_manifest.json b/tests/fixtures/devin_cli/tool_schema_manifest.json index 27c882562..158a25e50 100644 --- a/tests/fixtures/devin_cli/tool_schema_manifest.json +++ b/tests/fixtures/devin_cli/tool_schema_manifest.json @@ -2,7 +2,30 @@ "schema_version": 1, "source": "local_devin_cli_transcript_tool_definitions", "transcripts_compared": 4, - "canonicalization": "sorted_compact_json_sha256_prefix_12", + "transcript_format": "ATIF-v1.7", + "devin_version": "3000.2.17", + "installed_devin_version_at_extraction": "3000.3.22", + "hash_scope": "function.parameters", + "canonicalization": "python_json_dumps_sort_keys_compact_ascii_sha256_prefix_12", + "toolset_fingerprint": "444e21eed402", + "sources": [ + { + "name": "wooded-guest.json", + "sha256": "42cef0e7da607eb19af1578cada3cae6aed8e83972b6ddeb0f12e45343649748" + }, + { + "name": "unmarred-barbecue.json", + "sha256": "5919e087067ce352cfa8791c2dd081efb63b55c4eb6f3461c814290c0c8212da" + }, + { + "name": "fork-class.json", + "sha256": "7903fecfa02b4a376c1474eaf011dbdcb3254d401253f1caac7fc1a81f79ad91" + }, + { + "name": "steep-sidecar.json", + "sha256": "97e7b8cca32ac5fd711d4f58ec2da96ff190e9e75d5ebeca0b25cdb630223e7e" + } + ], "tools": { "apply_patch": "44136fa355b3", "ask_user_question": "543035bcee45", From 63be2dd5e27198a332a9b7d4e7010d55fcd6133f Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Fri, 31 Jul 2026 20:09:32 +0000 Subject: [PATCH 07/12] test(security): raise memory budget above bridge init baseline The 1MB cap in memory_limit_prevents_large_allocation OOMed while installing PI_BRIDGE_JS, so runtime construction failed before the large-allocation assertion ran. 4MB clears the init baseline and stays far below the ~1GB the test allocates. Co-authored-by: Codesmith --- tests/security_budgets.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/security_budgets.rs b/tests/security_budgets.rs index 0beed1482..67fad8005 100644 --- a/tests/security_budgets.rs +++ b/tests/security_budgets.rs @@ -181,8 +181,11 @@ fn interrupt_budget_preserves_state_after_trip() { fn memory_limit_prevents_large_allocation() { futures::executor::block_on(async { let config = config_with_limits(PiJsRuntimeLimits { - // 1MB memory limit - memory_limit_bytes: Some(1024 * 1024), + // 4MB memory limit — bridge JS init alone now needs >1MB, so a + // tighter cap OOMs during runtime construction instead of during + // the allocation under test. This is still ~250x below the ~1GB + // the eval below attempts. + memory_limit_bytes: Some(4 * 1024 * 1024), ..Default::default() }); From 749064598e1206b711d729317491c0484e9cb3fe Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Fri, 31 Jul 2026 20:13:24 +0000 Subject: [PATCH 08/12] fix(gitignore): anchor core-dump ignore rules to repo root An unanchored `core` pattern matched every nested directory named core, which excluded the vendored pi-mono packages/coding-agent/src/core tree that the TS conformance oracle imports. Co-authored-by: Codesmith --- .gitignore | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 81e44046d..0e4fb3bf0 100644 --- a/.gitignore +++ b/.gitignore @@ -218,8 +218,11 @@ src/bin/test_time.rs /fix_tools_write.rs # ACFS ephemeral patterns (auto-generated agent artifacts) -core -core.* +# Anchored: an unanchored `core` also excluded every nested directory named +# `core`, which silently dropped the vendored pi-mono +# packages/coding-agent/src/core/** tree the TS conformance oracle imports. +/core +/core.* cline.mcp.json codex.mcp.json cursor.mcp.json From 05a1772d48c61fbc879877b5d7abb80e8818188e Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Fri, 31 Jul 2026 20:16:55 +0000 Subject: [PATCH 09/12] fix(ci): skip feature-gated cargo test targets in shard selection cargo test --test hard-errors when the target declares required-features that are not enabled, so the classified but opt-in hostcall_queue_loom target failed the unit shard build. Selection now drops targets with required-features, mirroring --all-targets behavior. Co-authored-by: Codesmith --- scripts/e2e/run_all.sh | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/scripts/e2e/run_all.sh b/scripts/e2e/run_all.sh index 49dad697e..db37495f1 100755 --- a/scripts/e2e/run_all.sh +++ b/scripts/e2e/run_all.sh @@ -478,9 +478,49 @@ if [[ -z "$CORRELATION_ID" ]]; then fi export CI_CORRELATION_ID="$CORRELATION_ID" +# Cargo test targets declared with `required-features` cannot be built by name +# unless those features are enabled: `cargo test --test ` hard-errors +# instead of skipping the way `--all-targets` does. Drop them from the selection +# so a feature-gated target (e.g. the loom model checker) does not fail the +# shard it is classified into. +mapfile -t FEATURE_GATED_TARGETS < <(python3 - "$PROJECT_ROOT/Cargo.toml" <<'PY' 2>/dev/null || true +import sys, tomllib + +with open(sys.argv[1], "rb") as handle: + manifest = tomllib.load(handle) +for entry in manifest.get("test", []): + if entry.get("required-features") and entry.get("name"): + print(entry["name"]) +PY +) + +is_feature_gated_target() { + local candidate="$1" + local gated + for gated in "${FEATURE_GATED_TARGETS[@]:-}"; do + [[ "$candidate" == "$gated" ]] && return 0 + done + return 1 +} + if (( ${#SELECTED_UNIT_TARGETS[@]} > 0 )); then mapfile -t SELECTED_UNIT_TARGETS < <(printf '%s\n' "${SELECTED_UNIT_TARGETS[@]}" | awk 'NF' | LC_ALL=C sort -u) fi +if (( ${#SELECTED_UNIT_TARGETS[@]} > 0 && ${#FEATURE_GATED_TARGETS[@]} > 0 )); then + RETAINED_UNIT_TARGETS=() + for target in "${SELECTED_UNIT_TARGETS[@]}"; do + if is_feature_gated_target "$target"; then + echo "[select] unit:$target requires opt-in cargo features, skipping" + continue + fi + RETAINED_UNIT_TARGETS+=("$target") + done + if (( ${#RETAINED_UNIT_TARGETS[@]} > 0 )); then + SELECTED_UNIT_TARGETS=("${RETAINED_UNIT_TARGETS[@]}") + else + SELECTED_UNIT_TARGETS=() + fi +fi if (( ${#SELECTED_SUITES[@]} > 0 )); then mapfile -t SELECTED_SUITES < <(printf '%s\n' "${SELECTED_SUITES[@]}" | awk 'NF' | LC_ALL=C sort -u) fi From 9e530763f54fbfe5aac7c0c755a87a1879ff2368 Mon Sep 17 00:00:00 2001 From: OnlineChef <280567955+OnlineChef@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:19:44 +0200 Subject: [PATCH 10/12] fix(devin): satisfy strict clippy policy Resolve every reported default-feature Clippy error while tightening lock scope and retaining explicit audit assertions. Co-authored-by: Cursor --- src/devin/audit.rs | 21 +++++++++++++-------- src/devin/policy.rs | 16 +++++++++++----- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/devin/audit.rs b/src/devin/audit.rs index 05a686484..6b140c1f4 100644 --- a/src/devin/audit.rs +++ b/src/devin/audit.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; use std::collections::VecDeque; +use std::fmt::Write as _; use std::sync::Mutex; use super::policy::{PolicyAction, RiskClass}; @@ -77,7 +78,10 @@ impl AuditLog { } pub fn push(&self, record: AuditRecord) { - let mut records = self.records.lock().unwrap_or_else(|err| err.into_inner()); + let mut records = self + .records + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); if records.len() == self.capacity { records.pop_front(); } @@ -88,7 +92,7 @@ impl AuditLog { pub fn snapshot(&self) -> Vec { self.records .lock() - .unwrap_or_else(|err| err.into_inner()) + .unwrap_or_else(std::sync::PoisonError::into_inner) .iter() .cloned() .collect() @@ -107,18 +111,19 @@ fn argument_hash(arguments: &Value, salt: &[u8; 32]) -> String { let mut hasher = Sha256::new(); hasher.update(salt); hasher.update(canonical.as_bytes()); - hasher - .finalize() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() + let digest = hasher.finalize(); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); + } + encoded } fn canonical_json(value: &Value) -> String { match value { Value::Object(map) => { let mut entries = map.iter().collect::>(); - entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + entries.sort_by_key(|(left, _)| *left); let body = entries .into_iter() .map(|(key, value)| { diff --git a/src/devin/policy.rs b/src/devin/policy.rs index f56e0c694..d2d6ed726 100644 --- a/src/devin/policy.rs +++ b/src/devin/policy.rs @@ -20,7 +20,7 @@ pub enum ToolRequestOrigin { CloudXml, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ToolRequest { pub call_id: String, pub tool_name: String, @@ -89,7 +89,7 @@ pub struct ToolPolicyEngine { impl ToolPolicyEngine { #[must_use] - pub fn new(state: SharedDevinSessionState) -> Self { + pub const fn new(state: SharedDevinSessionState) -> Self { Self { state, audit: None } } @@ -100,13 +100,18 @@ impl ToolPolicyEngine { } #[must_use] - pub fn state(&self) -> &SharedDevinSessionState { + pub const fn state(&self) -> &SharedDevinSessionState { &self.state } #[must_use] pub fn evaluate(&self, request: &ToolRequest) -> PolicyDecision { - let state = self.state.read().unwrap_or_else(|err| err.into_inner()); + let state_guard = self + .state + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let state = state_guard.clone(); + drop(state_guard); let category = classify_tool(&request.tool_name); let risk = classify_risk(category, &request.tool_name); @@ -568,10 +573,11 @@ mod tests { ))); let audit = Arc::new(AuditLog::new(8)); let policy = ToolPolicyEngine::new(state).with_audit(Arc::clone(&audit)); - policy.evaluate(&request( + let decision = policy.evaluate(&request( "read", json!({"file_path": workspace.path().join("file").display().to_string()}), )); + assert_eq!(decision.action, PolicyAction::Allow); let records = audit.snapshot(); assert_eq!(records.len(), 1); From 9c41faa7735873afae504ee045e8ded4b802416d Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Fri, 31 Jul 2026 22:47:26 +0200 Subject: [PATCH 11/12] chore(ci): export source for agent worktree --- .github/workflows/agent-source-export.yml | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/agent-source-export.yml diff --git a/.github/workflows/agent-source-export.yml b/.github/workflows/agent-source-export.yml new file mode 100644 index 000000000..8a2a80b11 --- /dev/null +++ b/.github/workflows/agent-source-export.yml @@ -0,0 +1,25 @@ +name: agent-source-export + +on: + push: + branches: + - agent-source-export + +permissions: + contents: read + +jobs: + export: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Package source + run: tar --exclude=.git --exclude=target -czf "$RUNNER_TEMP/pi_agent_rust-source.tar.gz" . + - name: Upload source + uses: actions/upload-artifact@v4 + with: + name: pi-agent-rust-source + path: ${{ runner.temp }}/pi_agent_rust-source.tar.gz + if-no-files-found: error + retention-days: 1 From 86833587392cbb72baf4c013beebb58696b971f8 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Fri, 31 Jul 2026 22:48:59 +0200 Subject: [PATCH 12/12] chore(ci): expose agent source export on pull request --- .github/workflows/agent-source-export.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/agent-source-export.yml b/.github/workflows/agent-source-export.yml index 8a2a80b11..8ba26f059 100644 --- a/.github/workflows/agent-source-export.yml +++ b/.github/workflows/agent-source-export.yml @@ -4,6 +4,9 @@ on: push: branches: - agent-source-export + pull_request: + branches: + - devin-rust-core permissions: contents: read