diff --git a/.github/workflows/agent-source-export.yml b/.github/workflows/agent-source-export.yml new file mode 100644 index 000000000..8ba26f059 --- /dev/null +++ b/.github/workflows/agent-source-export.yml @@ -0,0 +1,28 @@ +name: agent-source-export + +on: + push: + branches: + - agent-source-export + pull_request: + branches: + - devin-rust-core + +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 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 diff --git a/.github/workflows/devin-ci.yml b/.github/workflows/devin-ci.yml new file mode 100644 index 000000000..ff1636bca --- /dev/null +++ b/.github/workflows/devin-ci.yml @@ -0,0 +1,67 @@ +name: devin-ci + +on: + workflow_dispatch: + 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 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 diff --git a/docs/devin-rust-progress.md b/docs/devin-rust-progress.md new file mode 100644 index 000000000..aba81d4e2 --- /dev/null +++ b/docs/devin-rust-progress.md @@ -0,0 +1,65 @@ +# 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. + +## Implemented evidence + +- 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 + 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 per-log salted 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/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 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..6b140c1f4 --- /dev/null +++ b/src/devin/audit.rs @@ -0,0 +1,215 @@ +//! 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::fmt::Write as _; +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, + 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))), + } + } + + pub fn push(&self, record: AuditRecord) { + let mut records = self + .records + .lock() + .unwrap_or_else(std::sync::PoisonError::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(std::sync::PoisonError::into_inner) + .iter() + .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. +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()); + 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_key(|(left, _)| *left); + 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() { + let audit = AuditLog::new(8); + assert_eq!( + 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) + ); + } + + #[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..1e582b90c --- /dev/null +++ b/src/devin/mod.rs @@ -0,0 +1,19 @@ +//! 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, 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..d2d6ed726 --- /dev/null +++ b/src/devin/policy.rs @@ -0,0 +1,591 @@ +//! 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}; +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, Eq, 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 const 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 const fn state(&self) -> &SharedDevinSessionState { + &self.state + } + + #[must_use] + pub fn evaluate(&self, request: &ToolRequest) -> PolicyDecision { + 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); + + 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(); + 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: 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: denied.then_some(now), + status: if denied { + AuditStatus::Denied + } else { + 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 mode == PermissionMode::Bypass { + return ( + PolicyAction::Allow, + "bypass mode allows calls inside enforced scopes".to_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 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(); + 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)); + 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); + 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..fe6098006 --- /dev/null +++ b/src/devin/state.rs @@ -0,0 +1,172 @@ +//! 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. + #[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, +} + +/// 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)); + } + + #[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/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..f9688f0bc --- /dev/null +++ b/tests/devin_contract.rs @@ -0,0 +1,105 @@ +//! 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, + 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 = 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!( + manifest.source, + "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, + "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!(manifest.tools, expected); +} diff --git a/tests/devin_session_state.rs b/tests/devin_session_state.rs new file mode 100644 index 000000000..e5006ed09 --- /dev/null +++ b/tests/devin_session_state.rs @@ -0,0 +1,56 @@ +//! 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..158a25e50 --- /dev/null +++ b/tests/fixtures/devin_cli/tool_schema_manifest.json @@ -0,0 +1,59 @@ +{ + "schema_version": 1, + "source": "local_devin_cli_transcript_tool_definitions", + "transcripts_compared": 4, + "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", + "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" + } +} 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() });