Compare commits

...

10 Commits

Author SHA1 Message Date
Mufeed VH
f1377833b3 feat(ui): enhance timeline sidebar with slide-in animation and responsive layout
- Add slide-in animation to timeline sidebar using framer-motion
- Implement responsive layout adjustments for main content area
- Add proper timeline header with close button
- Fix floating prompt input positioning to accommodate timeline
- Reorder CSS imports for proper style precedence
- Fix animation scaling issue in rotating symbol keyframes

The timeline now slides in from the right with a smooth spring animation
and properly adjusts the main content area width on larger screens.
2025-07-02 20:49:56 +05:30
Mufeed VH
2d73a38222 refactor: remove screenshot functionality and headless_chrome dependency
Remove the screenshot capture system and associated UI components to simplify the codebase.

- Remove screenshot.rs command module and related functionality
- Remove headless_chrome dependency from Cargo.toml
- Update Cargo.lock to reflect dependency changes
- Remove screenshot handlers and UI from WebviewPreview component
- Clean up screenshot-related API calls and imports
- Remove camera icon and capture controls from preview interface

This reduces bundle size and eliminates an underutilized feature.
2025-07-02 20:29:38 +05:30
Mufeed VH
c3f39b966f feat(agents): enhance git commit bot with remote sync and conflict resolution
Add comprehensive remote repository synchronization workflow to git commit bot:
- Implement git fetch and status checking before committing
- Add automatic pull and merge conflict resolution procedures
- Include detailed merge conflict handling instructions
- Ensure local repository is up-to-date before proceeding with commits

This enhancement improves the reliability of the git commit bot by preventing
conflicts and ensuring proper synchronization with remote repositories.
2025-07-02 20:15:09 +05:30
Mufeed VH
444b2758b2 Merge remote-tracking branch 'origin/main'
Resolves merge conflicts after sandbox removal and prompt queuing implementation.
This merge combines:
- feat(ui): add prompt queuing system and improve session management (local)
- refactor: remove sandbox system and simplify agent architecture (remote)
- feat: enhance timeline UI with slide-in animation and improved layout (remote)
2025-07-02 20:06:20 +05:30
Mufeed VH
b126288797 feat(ui): add prompt queuing system and improve session management
- Add prompt queue to handle multiple prompts when Claude is processing
- Improve session reconnection with better event listener management
- Fix race conditions in session initialization and cleanup
- Replace Loader2 with rotating symbol for consistent loading states
- Remove TokenCounter integration and loading-disabled input restrictions
- Enhance cancellation logic with proper state cleanup
- Update thinking mode phrase formatting in FloatingPromptInput
- Improve UI layout with better spacing and error positioning

This enables users to queue multiple prompts without waiting for the current
one to complete, providing a smoother interaction experience.
2025-07-02 19:49:00 +05:30
Vivek R
443e8e157a feat: enhance timeline UI with slide-in animation and improved layout
- Add slide-in animation for timeline panel using framer-motion
- Implement responsive layout adjustments when timeline is open
- Add close button (X icon) to timeline header for better UX
- Wrap timeline in AnimatePresence for smooth enter/exit transitions
- Adjust main content and floating prompt input positioning when timeline is visible
- Improve timeline visual hierarchy with proper header and content sections
- Add spring animation with damping for smooth timeline panel transitions
2025-07-02 19:35:38 +05:30
Vivek R
2dfdf31b83 refactor: remove sandbox system and simplify agent architecture
Remove the entire sandbox security system including:
- All sandbox-related Rust code and dependencies (gaol crate)
- Sandbox command handlers and platform-specific implementations
- Comprehensive test suite for sandbox functionality
- Agent sandbox settings UI components

Simplify agent configuration by removing sandbox and permission fields:
- Remove sandbox_enabled, enable_file_read, enable_file_write, enable_network from agent configs
- Update all CC agents to use simplified configuration format
- Remove sandbox references from documentation and UI
2025-07-02 19:19:20 +05:30
Mufeed VH
124fe1544f fix(agents): improve session tracking accuracy with process registry cross-check
- Cross-check database running sessions with actual process registry
- Filter out stale database entries for crashed processes without cleanup
- Add comprehensive comments explaining the reliability improvements
- Fix sticky header z-index layering issue in AgentExecution component

This addresses cases where agent processes crash without properly updating
the database status, ensuring the UI shows only truly active sessions.
2025-07-02 18:34:13 +05:30
Mufeed VH
d431f3286d feat(ui): add WebFetch tool widget with comprehensive content display
- Add WebFetchWidget component with URL display, prompt handling, and content preview
- Integrate WebFetch widget into StreamMessage tool rendering pipeline
- Include loading states, error handling, and expandable content functionality
- Support both basic URL fetching and prompted analysis workflows
2025-07-02 18:26:57 +05:30
Mufeed VH
a7e17f16ec fix(agents): improve process termination with multi-layer kill strategy
Resolves #87 and #9 by implementing a robust three-tier process
termination approach:

1. ProcessRegistry kill - primary method using run_id tracking
2. ClaudeProcessState kill - fallback via stored process handle
3. System kill command - last resort using PID and OS commands

Key improvements:
- Enhanced logging throughout termination flow for better debugging
- Graceful fallback between termination methods
- Proper UI state management even when backend termination fails
- Track run_id in AgentExecution component for targeted process killing
- Comprehensive error handling with user-friendly feedback
- Consistent event emission for UI synchronization

This ensures agents can be properly stopped without requiring
application restart, addressing the core issue where STOP
requests were ignored and processes continued running.
2025-07-02 18:17:05 +05:30
57 changed files with 1027 additions and 8823 deletions

View File

@@ -37,7 +37,7 @@ Think of Claudia as your command center for Claude Code - bridging the gap betwe
- [✨ Features](#-features)
- [🗂️ Project & Session Management](#-project--session-management)
- [🤖 CC Agents](#-cc-agents)
- [🛡️ Advanced Sandboxing](#-advanced-sandboxing)
- [📊 Usage Analytics Dashboard](#-usage-analytics-dashboard)
- [🔌 MCP Server Management](#-mcp-server-management)
- [⏰ Timeline & Checkpoints](#-timeline--checkpoints)
@@ -67,14 +67,10 @@ Think of Claudia as your command center for Claude Code - bridging the gap betwe
### 🤖 **CC Agents**
- **Custom AI Agents**: Create specialized agents with custom system prompts and behaviors
- **Agent Library**: Build a collection of purpose-built agents for different tasks
- **Secure Execution**: Run agents in sandboxed environments with fine-grained permissions
- **Background Execution**: Run agents in separate processes for non-blocking operations
- **Execution History**: Track all agent runs with detailed logs and performance metrics
### 🛡️ **Advanced Sandboxing**
- **OS-Level Security**: Platform-specific sandboxing (seccomp on Linux, Seatbelt on macOS)
- **Permission Profiles**: Create reusable security profiles with granular access controls
- **Violation Tracking**: Monitor and log all security violations in real-time
- **Import/Export**: Share sandbox profiles across teams and systems
### 📊 **Usage Analytics Dashboard**
- **Cost Tracking**: Monitor your Claude API usage and costs in real-time
@@ -127,7 +123,7 @@ CC Agents → Create Agent → Configure → Execute
1. **Design Your Agent**: Set name, icon, and system prompt
2. **Configure Model**: Choose between available Claude models
3. **Set Sandbox Profile**: Apply security restrictions
3. **Set Permissions**: Configure file read/write and network access
4. **Execute Tasks**: Run your agent on any project
### Tracking Usage
@@ -351,8 +347,8 @@ claudia/
├── src-tauri/ # Rust backend
│ ├── src/
│ │ ├── commands/ # Tauri command handlers
│ │ ├── sandbox/ # Security sandboxing
│ │ └── checkpoint/ # Timeline management
│ │ ├── checkpoint/ # Timeline management
│ │ └── process/ # Process management
│ └── tests/ # Rust test suite
└── public/ # Public assets
```
@@ -378,13 +374,13 @@ cd src-tauri && cargo fmt
## 🔒 Security
Claudia implements multiple layers of security:
Claudia prioritizes your privacy and security:
1. **Process Isolation**: Agents run in separate sandboxed processes
2. **Filesystem Access Control**: Whitelist-based file access
3. **Network Restrictions**: Control external connections
4. **Audit Logging**: All security violations are logged
5. **No Data Collection**: Everything stays local on your machine
1. **Process Isolation**: Agents run in separate processes
2. **Permission Control**: Configure file and network access per agent
3. **Local Storage**: All data stays on your machine
4. **No Telemetry**: No data collection or tracking
5. **Open Source**: Full transparency through open source code
## 🤝 Contributing

View File

@@ -16,11 +16,11 @@
## 📦 Available Agents
| Agent | Model | Permissions | Description | Default Task |
|-------|-------|-------------|-------------|--------------|
| **🎯 Git Commit Bot**<br/>🤖 `bot` | <img src="https://img.shields.io/badge/Sonnet-blue?style=flat-square" alt="Sonnet"> | ✅ File Read<br/>✅ File Write<br/>✅ Network<br/>❌ Sandbox | **Automate your Git workflow with intelligent commit messages**<br/><br/>Analyzes Git repository changes, generates detailed commit messages following Conventional Commits specification, and pushes changes to remote repository. | "Push all changes." |
| **🛡️ Security Scanner**<br/>🛡️ `shield` | <img src="https://img.shields.io/badge/Opus-purple?style=flat-square" alt="Opus"> | ✅ File Read<br/>✅ File Write<br/>❌ Network<br/>❌ Sandbox | **Advanced AI-powered Static Application Security Testing (SAST)**<br/><br/>Performs comprehensive security audits by spawning specialized sub-agents for: codebase intelligence gathering, threat modeling (STRIDE), vulnerability scanning (OWASP Top 10, CWE), exploit validation, remediation design, and professional report generation. | "Review the codebase for security issues." |
| **🧪 Unit Tests Bot**<br/>💻 `code` | <img src="https://img.shields.io/badge/Opus-purple?style=flat-square" alt="Opus"> | ✅ File Read<br/>✅ File Write<br/>❌ Network<br/>❌ Sandbox | **Automated comprehensive unit test generation for any codebase**<br/><br/>Analyzes codebase and generates comprehensive unit tests by: analyzing code structure, creating test plans, writing tests matching your style, verifying execution, optimizing coverage (>80% overall, 100% critical paths), and generating documentation. | "Generate unit tests for this codebase." |
| Agent | Model | Description | Default Task |
|-------|-------|-------------|--------------|
| **🎯 Git Commit Bot**<br/>🤖 `bot` | <img src="https://img.shields.io/badge/Sonnet-blue?style=flat-square" alt="Sonnet"> | **Automate your Git workflow with intelligent commit messages**<br/><br/>Analyzes Git repository changes, generates detailed commit messages following Conventional Commits specification, and pushes changes to remote repository. | "Push all changes." |
| **🛡️ Security Scanner**<br/>🛡️ `shield` | <img src="https://img.shields.io/badge/Opus-purple?style=flat-square" alt="Opus"> | **Advanced AI-powered Static Application Security Testing (SAST)**<br/><br/>Performs comprehensive security audits by spawning specialized sub-agents for: codebase intelligence gathering, threat modeling (STRIDE), vulnerability scanning (OWASP Top 10, CWE), exploit validation, remediation design, and professional report generation. | "Review the codebase for security issues." |
| **🧪 Unit Tests Bot**<br/>💻 `code` | <img src="https://img.shields.io/badge/Opus-purple?style=flat-square" alt="Opus"> | **Automated comprehensive unit test generation for any codebase**<br/><br/>Analyzes codebase and generates comprehensive unit tests by: analyzing code structure, creating test plans, writing tests matching your style, verifying execution, optimizing coverage (>80% overall, 100% critical paths), and generating documentation. | "Generate unit tests for this codebase." |
### Available Icons
@@ -76,11 +76,7 @@ All agents are stored in `.claudia.json` format with the following structure:
"icon": "bot",
"model": "opus|sonnet|haiku",
"system_prompt": "Your agent's instructions...",
"default_task": "Default task description",
"sandbox_enabled": false,
"enable_file_read": true,
"enable_file_write": true,
"enable_network": false
"default_task": "Default task description"
}
}
```
@@ -109,9 +105,8 @@ The agent import/export system is built on a robust architecture:
1. **Version Control**: Each agent export includes version metadata
2. **Duplicate Prevention**: Automatic naming conflict resolution
3. **Permission System**: Granular control over file, network, and sandbox access
4. **Model Selection**: Choose between Opus, Sonnet, and Haiku models
5. **GitHub Integration**: Direct import from the official repository
3. **Model Selection**: Choose between Opus, Sonnet, and Haiku models
4. **GitHub Integration**: Direct import from the official repository
## 🤝 Contributing
@@ -133,7 +128,6 @@ Export your agent to a `.claudia.json` file with a descriptive name.
- **Single Purpose**: Each agent should excel at one specific task
- **Clear Documentation**: Write comprehensive system prompts
- **Safe Defaults**: Be conservative with permissions
- **Model Choice**: Use Haiku for simple tasks, Sonnet for general purpose, Opus for complex reasoning
- **Naming**: Use descriptive names that clearly indicate the agent's function
@@ -145,4 +139,4 @@ These agents are provided under the same license as the Claudia project. See the
<div align="center">
<strong>Built with ❤️ by the Claudia community</strong>
</div>
</div>

View File

@@ -1,15 +1,11 @@
{
"agent": {
"default_task": "Push all changes.",
"enable_file_read": true,
"enable_file_write": true,
"enable_network": true,
"icon": "bot",
"model": "sonnet",
"name": "Git Commit Bot",
"sandbox_enabled": false,
"system_prompt": "<task>\nYou are a Git Commit Push bot. Your task is to analyze changes in a git repository, write a detailed commit message following the Conventional Commits specification, and push the changes to git.\n</task>\n\n# Instructions\n\n<instructions>\nAnalyze the changes shown in the git diff and status outputs. Pay attention to:\n1. Which files were modified, added, or deleted\n2. The nature of the changes (e.g., bug fixes, new features, refactoring)\n3. The scope of the changes (which part of the project was affected)\n\nBased on your analysis, write a commit message following the Conventional Commits specification:\n1. Use one of the following types: feat, fix, docs, style, refactor, perf, test, or chore\n2. Include a scope in parentheses if applicable\n3. Write a concise description in the present tense\n4. If necessary, add a longer description after a blank line\n5. Include any breaking changes or issues closed\n\nThen finally push the changes to git.\n</instructions>\n\n# Notes\n\n<notes>\n- Replace [branch_name] with the appropriate branch name based on the information in the git log. If you cannot determine the branch name, use \"main\" as the default.\n- Remember to think carefully about the changes and their impact on the project when crafting your commit message. Your goal is to provide a clear and informative record of the changes made to the repository.\n</notes>"
"system_prompt": "<task>\nYou are a Git Commit Push bot. Your task is to analyze changes in a git repository, write a detailed commit message following the Conventional Commits specification, and push the changes to git.\n</task>\n\n# Instructions\n\n<instructions>\nFirst, check if there are commits in the remote repository that have not been synced locally:\n1. Run `git fetch` to update remote tracking branches\n2. Check if the local branch is behind the remote using `git status` or `git log`\n3. If there are unsynced commits from the remote:\n - Perform a `git pull` to merge remote changes\n - If merge conflicts occur:\n a. Carefully analyze the conflicting changes\n b. Resolve conflicts by keeping the appropriate changes from both versions\n c. Mark conflicts as resolved using `git add`\n d. Complete the merge\n4. Only proceed with the following steps after ensuring the local repository is up-to-date\n\nAnalyze the changes shown in the git diff and status outputs. Pay attention to:\n1. Which files were modified, added, or deleted\n2. The nature of the changes (e.g., bug fixes, new features, refactoring)\n3. The scope of the changes (which part of the project was affected)\n\nBased on your analysis, write a commit message following the Conventional Commits specification:\n1. Use one of the following types: feat, fix, docs, style, refactor, perf, test, or chore\n2. Include a scope in parentheses if applicable\n3. Write a concise description in the present tense\n4. If necessary, add a longer description after a blank line\n5. Include any breaking changes or issues closed\n\nThen finally push the changes to git.\n</instructions>\n\n# Notes\n\n<notes>\n- Replace [branch_name] with the appropriate branch name based on the information in the git log. If you cannot determine the branch name, use \"main\" as the default.\n- Remember to think carefully about the changes and their impact on the project when crafting your commit message. Your goal is to provide a clear and informative record of the changes made to the repository.\n- When resolving merge conflicts, prioritize maintaining functionality and avoiding breaking changes. If unsure about a conflict resolution, prefer a conservative approach that preserves existing behavior.\n</notes>"
},
"exported_at": "2025-06-23T14:29:58.156063+00:00",
"version": 1
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

392
src-tauri/Cargo.lock generated
View File

@@ -17,17 +17,6 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -345,20 +334,6 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auto_generate_cdp"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6e1961a0d5d77969057eba90d448e610d3c439024d135d9dbd98e33ec973520"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"serde",
"serde_json",
"ureq 2.12.1",
]
[[package]]
name = "autocfg"
version = "1.4.0"
@@ -507,25 +482,6 @@ dependencies = [
"serde",
]
[[package]]
name = "bzip2"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
dependencies = [
"bzip2-sys",
]
[[package]]
name = "bzip2-sys"
version = "0.1.13+1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "cairo-rs"
version = "0.18.5"
@@ -658,16 +614,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "claudia"
version = "0.1.0"
@@ -680,9 +626,7 @@ dependencies = [
"dirs 5.0.1",
"env_logger",
"futures",
"gaol",
"glob",
"headless_chrome",
"libc",
"log",
"objc",
@@ -774,12 +718,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "constant_time_eq"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -874,21 +812,6 @@ dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.4.2"
@@ -995,24 +918,12 @@ dependencies = [
"syn 2.0.101",
]
[[package]]
name = "data-encoding"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]]
name = "data-url"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a"
[[package]]
name = "deflate64"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b"
[[package]]
name = "deranged"
version = "0.4.0"
@@ -1034,37 +945,6 @@ dependencies = [
"syn 2.0.101",
]
[[package]]
name = "derive_builder"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
dependencies = [
"derive_builder_macro",
]
[[package]]
name = "derive_builder_core"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "derive_builder_macro"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
"syn 2.0.101",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -1086,16 +966,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
name = "directories"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
dependencies = [
"dirs-sys 0.5.0",
]
[[package]]
@@ -1640,16 +1510,6 @@ dependencies = [
"byteorder",
]
[[package]]
name = "gaol"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "061957ca7a966a39a79ebca393a9a6c7babda10bf9dd6f11d00041558d929c22"
dependencies = [
"libc",
"log",
]
[[package]]
name = "gdk"
version = "0.18.2"
@@ -2028,33 +1888,6 @@ dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "headless_chrome"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c268ea01c2902b2acb382c1fae26818113dd661e0dba036a893f0ba40f00cdd8"
dependencies = [
"anyhow",
"auto_generate_cdp",
"base64 0.22.1",
"derive_builder",
"directories",
"log",
"rand 0.9.1",
"regex",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.12",
"tungstenite",
"ureq 3.0.12",
"url",
"walkdir",
"which",
"winreg",
"zip 2.4.2",
]
[[package]]
name = "heck"
version = "0.4.1"
@@ -2079,15 +1912,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "html5ever"
version = "0.26.0"
@@ -2182,7 +2006,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots 1.0.1",
"webpki-roots",
]
[[package]]
@@ -2418,15 +2242,6 @@ dependencies = [
"cfb",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -2735,27 +2550,6 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-rs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e"
dependencies = [
"byteorder",
"crc",
]
[[package]]
name = "lzma-sys"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "mac"
version = "0.1.1"
@@ -3422,16 +3216,6 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]]
name = "percent-encoding"
version = "2.3.1"
@@ -4090,7 +3874,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots 1.0.1",
"webpki-roots",
]
[[package]]
@@ -4199,7 +3983,6 @@ version = "0.23.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -4208,15 +3991,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.12.0"
@@ -4501,17 +4275,6 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -4591,17 +4354,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "socks"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b"
dependencies = [
"byteorder",
"libc",
"winapi",
]
[[package]]
name = "softbuffer"
version = "0.4.6"
@@ -5159,7 +4911,7 @@ dependencies = [
"tokio",
"url",
"windows-sys 0.60.2",
"zip 4.2.0",
"zip",
]
[[package]]
@@ -5652,23 +5404,6 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.1",
"sha1",
"thiserror 2.0.12",
"utf-8",
]
[[package]]
name = "typeid"
version = "1.0.3"
@@ -5751,53 +5486,6 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64 0.22.1",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"socks",
"url",
"webpki-roots 0.26.11",
]
[[package]]
name = "ureq"
version = "3.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f0fde9bc91026e381155f8c67cb354bcd35260b2f4a29bcc84639f762760c39"
dependencies = [
"base64 0.22.1",
"flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pemfile",
"rustls-pki-types",
"ureq-proto",
"utf-8",
"webpki-roots 0.26.11",
]
[[package]]
name = "ureq-proto"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59db78ad1923f2b1be62b6da81fe80b173605ca0d57f85da2e005382adf693f7"
dependencies = [
"base64 0.22.1",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.4"
@@ -6148,15 +5836,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.1",
]
[[package]]
name = "webpki-roots"
version = "1.0.1"
@@ -6850,15 +6529,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xz2"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
dependencies = [
"lzma-sys",
]
[[package]]
name = "yoke"
version = "0.8.0"
@@ -6990,20 +6660,6 @@ name = "zeroize"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "zerotrie"
@@ -7038,36 +6694,6 @@ dependencies = [
"syn 2.0.101",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"aes",
"arbitrary",
"bzip2",
"constant_time_eq",
"crc32fast",
"crossbeam-utils",
"deflate64",
"displaydoc",
"flate2",
"getrandom 0.3.3",
"hmac",
"indexmap 2.9.0",
"lzma-rs",
"memchr",
"pbkdf2",
"sha1",
"thiserror 2.0.12",
"time",
"xz2",
"zeroize",
"zopfli",
"zstd",
]
[[package]]
name = "zip"
version = "4.2.0"
@@ -7080,18 +6706,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "zopfli"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"

View File

@@ -43,14 +43,11 @@ futures = "0.3"
async-trait = "0.1"
tempfile = "3"
which = "7"
headless_chrome = { version = "1.0", features = ["fetch"] }
sha2 = "0.10"
zstd = "0.13"
uuid = { version = "1.6", features = ["v4", "serde"] }
walkdir = "2"
[target.'cfg(unix)'.dependencies]
gaol = "0.2"
[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.26"

View File

@@ -353,7 +353,7 @@ fn select_best_installation(installations: Vec<ClaudeInstallation>) -> Option<Cl
(None, Some(_)) => Ordering::Less,
// Neither have version info: prefer the one that is not just
// the bare "claude" lookup from PATH, because that may fail
// at runtime if PATH is sandbox-stripped.
// at runtime if PATH is modified.
(None, None) => {
if a.path == "claude" && b.path != "claude" {
Ordering::Less

View File

@@ -1,4 +1,3 @@
use crate::sandbox::profile::ProfileBuilder;
use anyhow::Result;
use chrono;
use log::{debug, error, info, warn};
@@ -6,7 +5,6 @@ use reqwest;
use rusqlite::{params, Connection, Result as SqliteResult};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Mutex;
use tauri::{AppHandle, Emitter, Manager, State};
@@ -28,7 +26,6 @@ pub struct Agent {
pub system_prompt: String,
pub default_task: Option<String>,
pub model: String,
pub sandbox_enabled: bool,
pub enable_file_read: bool,
pub enable_file_write: bool,
pub enable_network: bool,
@@ -88,10 +85,6 @@ pub struct AgentData {
pub system_prompt: String,
pub default_task: Option<String>,
pub model: String,
pub sandbox_enabled: bool,
pub enable_file_read: bool,
pub enable_file_write: bool,
pub enable_network: bool,
}
/// Database connection state
@@ -235,7 +228,6 @@ pub fn init_database(app: &AppHandle) -> SqliteResult<Connection> {
system_prompt TEXT NOT NULL,
default_task TEXT,
model TEXT NOT NULL DEFAULT 'sonnet',
sandbox_enabled BOOLEAN NOT NULL DEFAULT 1,
enable_file_read BOOLEAN NOT NULL DEFAULT 1,
enable_file_write BOOLEAN NOT NULL DEFAULT 1,
enable_network BOOLEAN NOT NULL DEFAULT 0,
@@ -251,14 +243,6 @@ pub fn init_database(app: &AppHandle) -> SqliteResult<Connection> {
"ALTER TABLE agents ADD COLUMN model TEXT DEFAULT 'sonnet'",
[],
);
let _ = conn.execute(
"ALTER TABLE agents ADD COLUMN sandbox_profile_id INTEGER REFERENCES sandbox_profiles(id)",
[],
);
let _ = conn.execute(
"ALTER TABLE agents ADD COLUMN sandbox_enabled BOOLEAN DEFAULT 1",
[],
);
let _ = conn.execute(
"ALTER TABLE agents ADD COLUMN enable_file_read BOOLEAN DEFAULT 1",
[],
@@ -329,75 +313,6 @@ pub fn init_database(app: &AppHandle) -> SqliteResult<Connection> {
[],
)?;
// Create sandbox profiles table
conn.execute(
"CREATE TABLE IF NOT EXISTS sandbox_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT 0,
is_default BOOLEAN NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)",
[],
)?;
// Create sandbox rules table
conn.execute(
"CREATE TABLE IF NOT EXISTS sandbox_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER NOT NULL,
operation_type TEXT NOT NULL,
pattern_type TEXT NOT NULL,
pattern_value TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT 1,
platform_support TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (profile_id) REFERENCES sandbox_profiles(id) ON DELETE CASCADE
)",
[],
)?;
// Create trigger to update sandbox profile timestamp
conn.execute(
"CREATE TRIGGER IF NOT EXISTS update_sandbox_profile_timestamp
AFTER UPDATE ON sandbox_profiles
FOR EACH ROW
BEGIN
UPDATE sandbox_profiles SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END",
[],
)?;
// Create sandbox violations table
conn.execute(
"CREATE TABLE IF NOT EXISTS sandbox_violations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER,
agent_id INTEGER,
agent_run_id INTEGER,
operation_type TEXT NOT NULL,
pattern_value TEXT,
process_name TEXT,
pid INTEGER,
denied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (profile_id) REFERENCES sandbox_profiles(id) ON DELETE CASCADE,
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE,
FOREIGN KEY (agent_run_id) REFERENCES agent_runs(id) ON DELETE CASCADE
)",
[],
)?;
// Create index for efficient querying
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_sandbox_violations_denied_at
ON sandbox_violations(denied_at DESC)",
[],
)?;
// Create default sandbox profiles if they don't exist
crate::sandbox::defaults::create_default_profiles(&conn)?;
// Create settings table for app-wide settings
conn.execute(
@@ -430,7 +345,7 @@ pub async fn list_agents(db: State<'_, AgentDb>) -> Result<Vec<Agent>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare("SELECT id, name, icon, system_prompt, default_task, model, sandbox_enabled, enable_file_read, enable_file_write, enable_network, created_at, updated_at FROM agents ORDER BY created_at DESC")
.prepare("SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, created_at, updated_at FROM agents ORDER BY created_at DESC")
.map_err(|e| e.to_string())?;
let agents = stmt
@@ -444,12 +359,11 @@ pub async fn list_agents(db: State<'_, AgentDb>) -> Result<Vec<Agent>, String> {
model: row
.get::<_, String>(5)
.unwrap_or_else(|_| "sonnet".to_string()),
sandbox_enabled: row.get::<_, bool>(6).unwrap_or(true),
enable_file_read: row.get::<_, bool>(7).unwrap_or(true),
enable_file_write: row.get::<_, bool>(8).unwrap_or(true),
enable_network: row.get::<_, bool>(9).unwrap_or(false),
created_at: row.get(10)?,
updated_at: row.get(11)?,
enable_file_read: row.get::<_, bool>(6).unwrap_or(true),
enable_file_write: row.get::<_, bool>(7).unwrap_or(true),
enable_network: row.get::<_, bool>(8).unwrap_or(false),
created_at: row.get(9)?,
updated_at: row.get(10)?,
})
})
.map_err(|e| e.to_string())?
@@ -468,21 +382,19 @@ pub async fn create_agent(
system_prompt: String,
default_task: Option<String>,
model: Option<String>,
sandbox_enabled: Option<bool>,
enable_file_read: Option<bool>,
enable_file_write: Option<bool>,
enable_network: Option<bool>,
) -> Result<Agent, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
let model = model.unwrap_or_else(|| "sonnet".to_string());
let sandbox_enabled = sandbox_enabled.unwrap_or(true);
let enable_file_read = enable_file_read.unwrap_or(true);
let enable_file_write = enable_file_write.unwrap_or(true);
let enable_network = enable_network.unwrap_or(false);
conn.execute(
"INSERT INTO agents (name, icon, system_prompt, default_task, model, sandbox_enabled, enable_file_read, enable_file_write, enable_network) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![name, icon, system_prompt, default_task, model, sandbox_enabled, enable_file_read, enable_file_write, enable_network],
"INSERT INTO agents (name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network],
)
.map_err(|e| e.to_string())?;
@@ -491,7 +403,7 @@ pub async fn create_agent(
// Fetch the created agent
let agent = conn
.query_row(
"SELECT id, name, icon, system_prompt, default_task, model, sandbox_enabled, enable_file_read, enable_file_write, enable_network, created_at, updated_at FROM agents WHERE id = ?1",
"SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, created_at, updated_at FROM agents WHERE id = ?1",
params![id],
|row| {
Ok(Agent {
@@ -501,12 +413,11 @@ pub async fn create_agent(
system_prompt: row.get(3)?,
default_task: row.get(4)?,
model: row.get(5)?,
sandbox_enabled: row.get(6)?,
enable_file_read: row.get(7)?,
enable_file_write: row.get(8)?,
enable_network: row.get(9)?,
created_at: row.get(10)?,
updated_at: row.get(11)?,
enable_file_read: row.get(6)?,
enable_file_write: row.get(7)?,
enable_network: row.get(8)?,
created_at: row.get(9)?,
updated_at: row.get(10)?,
})
},
)
@@ -525,7 +436,6 @@ pub async fn update_agent(
system_prompt: String,
default_task: Option<String>,
model: Option<String>,
sandbox_enabled: Option<bool>,
enable_file_read: Option<bool>,
enable_file_write: Option<bool>,
enable_network: Option<bool>,
@@ -546,11 +456,6 @@ pub async fn update_agent(
];
let mut param_count = 5;
if let Some(se) = sandbox_enabled {
param_count += 1;
query.push_str(&format!(", sandbox_enabled = ?{}", param_count));
params_vec.push(Box::new(se));
}
if let Some(efr) = enable_file_read {
param_count += 1;
query.push_str(&format!(", enable_file_read = ?{}", param_count));
@@ -580,7 +485,7 @@ pub async fn update_agent(
// Fetch the updated agent
let agent = conn
.query_row(
"SELECT id, name, icon, system_prompt, default_task, model, sandbox_enabled, enable_file_read, enable_file_write, enable_network, created_at, updated_at FROM agents WHERE id = ?1",
"SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, created_at, updated_at FROM agents WHERE id = ?1",
params![id],
|row| {
Ok(Agent {
@@ -590,12 +495,11 @@ pub async fn update_agent(
system_prompt: row.get(3)?,
default_task: row.get(4)?,
model: row.get(5)?,
sandbox_enabled: row.get(6)?,
enable_file_read: row.get(7)?,
enable_file_write: row.get(8)?,
enable_network: row.get(9)?,
created_at: row.get(10)?,
updated_at: row.get(11)?,
enable_file_read: row.get(6)?,
enable_file_write: row.get(7)?,
enable_network: row.get(8)?,
created_at: row.get(9)?,
updated_at: row.get(10)?,
})
},
)
@@ -622,7 +526,7 @@ pub async fn get_agent(db: State<'_, AgentDb>, id: i64) -> Result<Agent, String>
let agent = conn
.query_row(
"SELECT id, name, icon, system_prompt, default_task, model, sandbox_enabled, enable_file_read, enable_file_write, enable_network, created_at, updated_at FROM agents WHERE id = ?1",
"SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, created_at, updated_at FROM agents WHERE id = ?1",
params![id],
|row| {
Ok(Agent {
@@ -632,12 +536,11 @@ pub async fn get_agent(db: State<'_, AgentDb>, id: i64) -> Result<Agent, String>
system_prompt: row.get(3)?,
default_task: row.get(4)?,
model: row.get::<_, String>(5).unwrap_or_else(|_| "sonnet".to_string()),
sandbox_enabled: row.get::<_, bool>(6).unwrap_or(true),
enable_file_read: row.get::<_, bool>(7).unwrap_or(true),
enable_file_write: row.get::<_, bool>(8).unwrap_or(true),
enable_network: row.get::<_, bool>(9).unwrap_or(false),
created_at: row.get(10)?,
updated_at: row.get(11)?,
enable_file_read: row.get::<_, bool>(6).unwrap_or(true),
enable_file_write: row.get::<_, bool>(7).unwrap_or(true),
enable_network: row.get::<_, bool>(8).unwrap_or(false),
created_at: row.get(9)?,
updated_at: row.get(10)?,
})
},
)
@@ -788,411 +691,30 @@ pub async fn execute_agent(
conn.last_insert_rowid()
};
// Create sandbox rules based on agent-specific permissions (no database dependency)
let sandbox_profile = if !agent.sandbox_enabled {
info!("🔓 Agent '{}': Sandbox DISABLED", agent.name);
None
} else {
info!(
"🔒 Agent '{}': Sandbox enabled | File Read: {} | File Write: {} | Network: {}",
agent.name, agent.enable_file_read, agent.enable_file_write, agent.enable_network
);
// Create rules dynamically based on agent permissions
let mut rules = Vec::new();
// Add file read rules if enabled
if agent.enable_file_read {
// Project directory access
rules.push(crate::sandbox::profile::SandboxRule {
id: Some(1),
profile_id: 0,
operation_type: "file_read_all".to_string(),
pattern_type: "subpath".to_string(),
pattern_value: "{{PROJECT_PATH}}".to_string(),
enabled: true,
platform_support: Some(r#"["linux", "macos", "windows"]"#.to_string()),
created_at: String::new(),
});
// System libraries (for language runtimes, etc.)
rules.push(crate::sandbox::profile::SandboxRule {
id: Some(2),
profile_id: 0,
operation_type: "file_read_all".to_string(),
pattern_type: "subpath".to_string(),
pattern_value: "/usr/lib".to_string(),
enabled: true,
platform_support: Some(r#"["linux", "macos"]"#.to_string()),
created_at: String::new(),
});
rules.push(crate::sandbox::profile::SandboxRule {
id: Some(3),
profile_id: 0,
operation_type: "file_read_all".to_string(),
pattern_type: "subpath".to_string(),
pattern_value: "/usr/local/lib".to_string(),
enabled: true,
platform_support: Some(r#"["linux", "macos"]"#.to_string()),
created_at: String::new(),
});
rules.push(crate::sandbox::profile::SandboxRule {
id: Some(4),
profile_id: 0,
operation_type: "file_read_all".to_string(),
pattern_type: "subpath".to_string(),
pattern_value: "/System/Library".to_string(),
enabled: true,
platform_support: Some(r#"["macos"]"#.to_string()),
created_at: String::new(),
});
rules.push(crate::sandbox::profile::SandboxRule {
id: Some(5),
profile_id: 0,
operation_type: "file_read_metadata".to_string(),
pattern_type: "subpath".to_string(),
pattern_value: "/".to_string(),
enabled: true,
platform_support: Some(r#"["macos"]"#.to_string()),
created_at: String::new(),
});
}
// Add network rules if enabled
if agent.enable_network {
rules.push(crate::sandbox::profile::SandboxRule {
id: Some(6),
profile_id: 0,
operation_type: "network_outbound".to_string(),
pattern_type: "all".to_string(),
pattern_value: "".to_string(),
enabled: true,
platform_support: Some(r#"["linux", "macos"]"#.to_string()),
created_at: String::new(),
});
}
// Always add essential system paths (needed for executables to run)
rules.push(crate::sandbox::profile::SandboxRule {
id: Some(7),
profile_id: 0,
operation_type: "file_read_all".to_string(),
pattern_type: "subpath".to_string(),
pattern_value: "/usr/bin".to_string(),
enabled: true,
platform_support: Some(r#"["linux", "macos"]"#.to_string()),
created_at: String::new(),
});
rules.push(crate::sandbox::profile::SandboxRule {
id: Some(8),
profile_id: 0,
operation_type: "file_read_all".to_string(),
pattern_type: "subpath".to_string(),
pattern_value: "/opt/homebrew/bin".to_string(),
enabled: true,
platform_support: Some(r#"["macos"]"#.to_string()),
created_at: String::new(),
});
rules.push(crate::sandbox::profile::SandboxRule {
id: Some(9),
profile_id: 0,
operation_type: "file_read_all".to_string(),
pattern_type: "subpath".to_string(),
pattern_value: "/usr/local/bin".to_string(),
enabled: true,
platform_support: Some(r#"["linux", "macos"]"#.to_string()),
created_at: String::new(),
});
rules.push(crate::sandbox::profile::SandboxRule {
id: Some(10),
profile_id: 0,
operation_type: "file_read_all".to_string(),
pattern_type: "subpath".to_string(),
pattern_value: "/bin".to_string(),
enabled: true,
platform_support: Some(r#"["linux", "macos"]"#.to_string()),
created_at: String::new(),
});
// System libraries (needed for executables to link)
rules.push(crate::sandbox::profile::SandboxRule {
id: Some(11),
profile_id: 0,
operation_type: "file_read_all".to_string(),
pattern_type: "subpath".to_string(),
pattern_value: "/usr/lib".to_string(),
enabled: true,
platform_support: Some(r#"["linux", "macos"]"#.to_string()),
created_at: String::new(),
});
rules.push(crate::sandbox::profile::SandboxRule {
id: Some(12),
profile_id: 0,
operation_type: "file_read_all".to_string(),
pattern_type: "subpath".to_string(),
pattern_value: "/System/Library".to_string(),
enabled: true,
platform_support: Some(r#"["macos"]"#.to_string()),
created_at: String::new(),
});
// Always add system info reading (minimal requirement)
rules.push(crate::sandbox::profile::SandboxRule {
id: Some(13),
profile_id: 0,
operation_type: "system_info_read".to_string(),
pattern_type: "all".to_string(),
pattern_value: "".to_string(),
enabled: true,
platform_support: Some(r#"["linux", "macos"]"#.to_string()),
created_at: String::new(),
});
Some(("Agent-specific".to_string(), rules))
};
// Build the command
let mut cmd = if let Some((_profile_name, rules)) = sandbox_profile {
info!("🧪 DEBUG: Testing Claude command first without sandbox...");
// Quick test to see if Claude is accessible at all
let claude_path = match find_claude_binary(&app) {
Ok(path) => path,
Err(e) => {
error!("❌ Claude binary not found: {}", e);
return Err(e);
}
};
match std::process::Command::new(&claude_path)
.arg("--version")
.output()
{
Ok(output) => {
if output.status.success() {
info!(
"✅ Claude command works: {}",
String::from_utf8_lossy(&output.stdout).trim()
);
} else {
warn!("⚠️ Claude command failed with status: {}", output.status);
warn!(" stdout: {}", String::from_utf8_lossy(&output.stdout));
warn!(" stderr: {}", String::from_utf8_lossy(&output.stderr));
}
}
Err(e) => {
error!("❌ Claude command not found or not executable: {}", e);
error!(" This could be why the agent is failing to start");
}
info!("Running agent '{}'", agent.name);
let claude_path = match find_claude_binary(&app) {
Ok(path) => path,
Err(e) => {
error!("Failed to find claude binary: {}", e);
return Err(e);
}
// Test if Claude can actually start a session (this might reveal auth issues)
info!("🧪 Testing Claude with exact same arguments as agent (without sandbox env vars)...");
let mut test_cmd = std::process::Command::new(&claude_path);
test_cmd
.arg("-p")
.arg(&task)
.arg("--system-prompt")
.arg(&agent.system_prompt)
.arg("--model")
.arg(&execution_model)
.arg("--output-format")
.arg("stream-json")
.arg("--verbose")
.arg("--dangerously-skip-permissions")
.current_dir(&project_path);
info!("🧪 Testing command: claude -p \"{}\" --system-prompt \"{}\" --model {} --output-format stream-json --verbose --dangerously-skip-permissions",
task, agent.system_prompt, execution_model);
// Start the test process and give it 5 seconds to produce output
match test_cmd.spawn() {
Ok(mut child) => {
// Wait for 5 seconds to see if it produces output
let start = std::time::Instant::now();
let mut output_received = false;
while start.elapsed() < std::time::Duration::from_secs(5) {
match child.try_wait() {
Ok(Some(status)) => {
info!("🧪 Test process exited with status: {}", status);
output_received = true;
break;
}
Ok(None) => {
// Still running
std::thread::sleep(std::time::Duration::from_millis(100));
}
Err(e) => {
warn!("🧪 Error checking test process: {}", e);
break;
}
}
}
if !output_received {
warn!("🧪 Test process is still running after 5 seconds - this suggests Claude might be waiting for input");
// Kill the test process
let _ = child.kill();
let _ = child.wait();
} else {
info!("🧪 Test process completed quickly - command seems to work");
}
}
Err(e) => {
error!("❌ Failed to spawn test Claude process: {}", e);
}
}
info!("🧪 End of Claude test, proceeding with sandbox...");
// Build the gaol profile using agent-specific permissions
let project_path_buf = PathBuf::from(&project_path);
match ProfileBuilder::new(project_path_buf.clone()) {
Ok(builder) => {
// Build agent-specific profile with permission filtering
match builder.build_agent_profile(
rules,
agent.sandbox_enabled,
agent.enable_file_read,
agent.enable_file_write,
agent.enable_network,
) {
Ok(build_result) => {
// Create the enhanced sandbox executor
#[cfg(unix)]
let executor =
crate::sandbox::executor::SandboxExecutor::new_with_serialization(
build_result.profile,
project_path_buf.clone(),
build_result.serialized,
);
#[cfg(not(unix))]
let executor =
crate::sandbox::executor::SandboxExecutor::new_with_serialization(
(),
project_path_buf.clone(),
build_result.serialized,
);
// Prepare the sandboxed command
let args = vec![
"-p",
&task,
"--system-prompt",
&agent.system_prompt,
"--model",
&execution_model,
"--output-format",
"stream-json",
"--verbose",
"--dangerously-skip-permissions",
];
let claude_path = match find_claude_binary(&app) {
Ok(path) => path,
Err(e) => {
error!("Failed to find claude binary: {}", e);
return Err(e);
}
};
executor.prepare_sandboxed_command(&claude_path, &args, &project_path_buf)
}
Err(e) => {
error!("Failed to build agent-specific sandbox profile: {}, falling back to non-sandboxed", e);
let claude_path = match find_claude_binary(&app) {
Ok(path) => path,
Err(e) => {
error!("Failed to find claude binary: {}", e);
return Err(e);
}
};
let mut cmd = create_command_with_env(&claude_path);
cmd.arg("-p")
.arg(&task)
.arg("--system-prompt")
.arg(&agent.system_prompt)
.arg("--model")
.arg(&execution_model)
.arg("--output-format")
.arg("stream-json")
.arg("--verbose")
.arg("--dangerously-skip-permissions")
.current_dir(&project_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
cmd
}
}
}
Err(e) => {
error!(
"Failed to create ProfileBuilder: {}, falling back to non-sandboxed",
e
);
// Fall back to non-sandboxed command
let claude_path = match find_claude_binary(&app) {
Ok(path) => path,
Err(e) => {
error!("Failed to find claude binary: {}", e);
return Err(e);
}
};
let mut cmd = create_command_with_env(&claude_path);
cmd.arg("-p")
.arg(&task)
.arg("--system-prompt")
.arg(&agent.system_prompt)
.arg("--model")
.arg(&execution_model)
.arg("--output-format")
.arg("stream-json")
.arg("--verbose")
.arg("--dangerously-skip-permissions")
.current_dir(&project_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
cmd
}
}
} else {
// No sandbox or sandbox disabled, use regular command
warn!(
"🚨 Running agent '{}' WITHOUT SANDBOX - full system access!",
agent.name
);
let claude_path = match find_claude_binary(&app) {
Ok(path) => path,
Err(e) => {
error!("Failed to find claude binary: {}", e);
return Err(e);
}
};
let mut cmd = create_command_with_env(&claude_path);
cmd.arg("-p")
.arg(&task)
.arg("--system-prompt")
.arg(&agent.system_prompt)
.arg("--model")
.arg(&execution_model)
.arg("--output-format")
.arg("stream-json")
.arg("--verbose")
.arg("--dangerously-skip-permissions")
.current_dir(&project_path)
.stdin(Stdio::null()) // Don't pipe stdin - we have no input to send
.stdout(Stdio::piped())
.stderr(Stdio::piped());
cmd
};
let mut cmd = create_command_with_env(&claude_path);
cmd.arg("-p")
.arg(&task)
.arg("--system-prompt")
.arg(&agent.system_prompt)
.arg("--model")
.arg(&execution_model)
.arg("--output-format")
.arg("stream-json")
.arg("--verbose")
.arg("--dangerously-skip-permissions")
.current_dir(&project_path)
.stdin(Stdio::null()) // Don't pipe stdin - we have no input to send
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// Spawn the process
info!("🚀 Spawning Claude process...");
@@ -1385,7 +907,7 @@ pub async fn execute_agent(
warn!("⏰ TIMEOUT: No output from Claude process after 30 seconds");
warn!("💡 This usually means:");
warn!(" 1. Claude process is waiting for user input");
warn!(" 2. Sandbox permissions are too restrictive");
warn!(" 3. Claude failed to initialize but didn't report an error");
warn!(" 4. Network connectivity issues");
warn!(" 5. Authentication issues (API key not found/invalid)");
@@ -1466,15 +988,19 @@ pub async fn execute_agent(
/// List all currently running agent sessions
#[tauri::command]
pub async fn list_running_sessions(db: State<'_, AgentDb>) -> Result<Vec<AgentRun>, String> {
pub async fn list_running_sessions(
db: State<'_, AgentDb>,
registry: State<'_, crate::process::ProcessRegistryState>,
) -> Result<Vec<AgentRun>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// First get all running sessions from the database
let mut stmt = conn.prepare(
"SELECT id, agent_id, agent_name, agent_icon, task, model, project_path, session_id, status, pid, process_started_at, created_at, completed_at
FROM agent_runs WHERE status = 'running' ORDER BY process_started_at DESC"
).map_err(|e| e.to_string())?;
let runs = stmt
let mut runs = stmt
.query_map([], |row| {
Ok(AgentRun {
id: Some(row.get(0)?),
@@ -1502,6 +1028,27 @@ pub async fn list_running_sessions(db: State<'_, AgentDb>) -> Result<Vec<AgentRu
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
drop(stmt);
drop(conn);
// Cross-check with the process registry to ensure accuracy
// Get actually running processes from the registry
let registry_processes = registry.0.get_running_agent_processes()?;
let registry_run_ids: std::collections::HashSet<i64> = registry_processes
.iter()
.map(|p| p.run_id)
.collect();
// Filter out any database entries that aren't actually running in the registry
// This handles cases where processes crashed without updating the database
runs.retain(|run| {
if let Some(run_id) = run.id {
registry_run_ids.contains(&run_id)
} else {
false
}
});
Ok(runs)
}
@@ -1782,7 +1329,7 @@ pub async fn export_agent(db: State<'_, AgentDb>, id: i64) -> Result<String, Str
// Fetch the agent
let agent = conn
.query_row(
"SELECT name, icon, system_prompt, default_task, model, sandbox_enabled, enable_file_read, enable_file_write, enable_network FROM agents WHERE id = ?1",
"SELECT name, icon, system_prompt, default_task, model FROM agents WHERE id = ?1",
params![id],
|row| {
Ok(serde_json::json!({
@@ -1790,11 +1337,7 @@ pub async fn export_agent(db: State<'_, AgentDb>, id: i64) -> Result<String, Str
"icon": row.get::<_, String>(1)?,
"system_prompt": row.get::<_, String>(2)?,
"default_task": row.get::<_, Option<String>>(3)?,
"model": row.get::<_, String>(4)?,
"sandbox_enabled": row.get::<_, bool>(5)?,
"enable_file_read": row.get::<_, bool>(6)?,
"enable_file_write": row.get::<_, bool>(7)?,
"enable_network": row.get::<_, bool>(8)?
"model": row.get::<_, String>(4)?
}))
},
)
@@ -1985,17 +1528,13 @@ pub async fn import_agent(db: State<'_, AgentDb>, json_data: String) -> Result<A
// Create the agent
conn.execute(
"INSERT INTO agents (name, icon, system_prompt, default_task, model, sandbox_enabled, enable_file_read, enable_file_write, enable_network) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
"INSERT INTO agents (name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network) VALUES (?1, ?2, ?3, ?4, ?5, 1, 1, 0)",
params![
final_name,
agent_data.icon,
agent_data.system_prompt,
agent_data.default_task,
agent_data.model,
agent_data.sandbox_enabled,
agent_data.enable_file_read,
agent_data.enable_file_write,
agent_data.enable_network
agent_data.model
],
)
.map_err(|e| format!("Failed to create agent: {}", e))?;
@@ -2005,7 +1544,7 @@ pub async fn import_agent(db: State<'_, AgentDb>, json_data: String) -> Result<A
// Fetch the created agent
let agent = conn
.query_row(
"SELECT id, name, icon, system_prompt, default_task, model, sandbox_enabled, enable_file_read, enable_file_write, enable_network, created_at, updated_at FROM agents WHERE id = ?1",
"SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, created_at, updated_at FROM agents WHERE id = ?1",
params![id],
|row| {
Ok(Agent {
@@ -2015,12 +1554,11 @@ pub async fn import_agent(db: State<'_, AgentDb>, json_data: String) -> Result<A
system_prompt: row.get(3)?,
default_task: row.get(4)?,
model: row.get(5)?,
sandbox_enabled: row.get(6)?,
enable_file_read: row.get(7)?,
enable_file_write: row.get(8)?,
enable_network: row.get(9)?,
created_at: row.get(10)?,
updated_at: row.get(11)?,
enable_file_read: row.get(6)?,
enable_file_write: row.get(7)?,
enable_network: row.get(8)?,
created_at: row.get(9)?,
updated_at: row.get(10)?,
})
},
)

View File

@@ -798,15 +798,8 @@ pub async fn execute_claude_code(
model
);
// Check if sandboxing should be used
let use_sandbox = should_use_sandbox(&app)?;
let mut cmd = if use_sandbox {
create_sandboxed_claude_command(&app, &project_path)?
} else {
let claude_path = find_claude_binary(&app)?;
create_command_with_env(&claude_path)
};
let claude_path = find_claude_binary(&app)?;
let mut cmd = create_command_with_env(&claude_path);
cmd.arg("-p")
.arg(&prompt)
@@ -837,15 +830,8 @@ pub async fn continue_claude_code(
model
);
// Check if sandboxing should be used
let use_sandbox = should_use_sandbox(&app)?;
let mut cmd = if use_sandbox {
create_sandboxed_claude_command(&app, &project_path)?
} else {
let claude_path = find_claude_binary(&app)?;
create_command_with_env(&claude_path)
};
let claude_path = find_claude_binary(&app)?;
let mut cmd = create_command_with_env(&claude_path);
cmd.arg("-c") // Continue flag
.arg("-p")
@@ -879,15 +865,8 @@ pub async fn resume_claude_code(
model
);
// Check if sandboxing should be used
let use_sandbox = should_use_sandbox(&app)?;
let mut cmd = if use_sandbox {
create_sandboxed_claude_command(&app, &project_path)?
} else {
let claude_path = find_claude_binary(&app)?;
create_command_with_env(&claude_path)
};
let claude_path = find_claude_binary(&app)?;
let mut cmd = create_command_with_env(&claude_path);
cmd.arg("--resume")
.arg(&session_id)
@@ -917,25 +896,41 @@ pub async fn cancel_claude_execution(
session_id
);
let killed = if let Some(sid) = &session_id {
// Try to find and kill via ProcessRegistry first
let registry = app.state::<crate::process::ProcessRegistryState>();
if let Ok(Some(process_info)) = registry.0.get_claude_session_by_id(sid) {
match registry.0.kill_process(process_info.run_id).await {
Ok(success) => success,
Err(e) => {
log::warn!("Failed to kill via registry: {}", e);
false
}
}
} else {
false
}
} else {
false
};
let mut killed = false;
let mut attempted_methods = Vec::new();
// If registry kill didn't work, try the legacy approach
// Method 1: Try to find and kill via ProcessRegistry using session ID
if let Some(sid) = &session_id {
let registry = app.state::<crate::process::ProcessRegistryState>();
match registry.0.get_claude_session_by_id(sid) {
Ok(Some(process_info)) => {
log::info!("Found process in registry for session {}: run_id={}, PID={}",
sid, process_info.run_id, process_info.pid);
match registry.0.kill_process(process_info.run_id).await {
Ok(success) => {
if success {
log::info!("Successfully killed process via registry");
killed = true;
} else {
log::warn!("Registry kill returned false");
}
}
Err(e) => {
log::warn!("Failed to kill via registry: {}", e);
}
}
attempted_methods.push("registry");
}
Ok(None) => {
log::warn!("Session {} not found in ProcessRegistry", sid);
}
Err(e) => {
log::error!("Error querying ProcessRegistry: {}", e);
}
}
}
// Method 2: Try the legacy approach via ClaudeProcessState
if !killed {
let claude_state = app.state::<ClaudeProcessState>();
let mut current_process = claude_state.current_process.lock().await;
@@ -943,24 +938,57 @@ pub async fn cancel_claude_execution(
if let Some(mut child) = current_process.take() {
// Try to get the PID before killing
let pid = child.id();
log::info!("Attempting to kill Claude process with PID: {:?}", pid);
log::info!("Attempting to kill Claude process via ClaudeProcessState with PID: {:?}", pid);
// Kill the process
match child.kill().await {
Ok(_) => {
log::info!("Successfully killed Claude process");
log::info!("Successfully killed Claude process via ClaudeProcessState");
killed = true;
}
Err(e) => {
log::error!("Failed to kill Claude process: {}", e);
return Err(format!("Failed to kill Claude process: {}", e));
log::error!("Failed to kill Claude process via ClaudeProcessState: {}", e);
// Method 3: If we have a PID, try system kill as last resort
if let Some(pid) = pid {
log::info!("Attempting system kill as last resort for PID: {}", pid);
let kill_result = if cfg!(target_os = "windows") {
std::process::Command::new("taskkill")
.args(["/F", "/PID", &pid.to_string()])
.output()
} else {
std::process::Command::new("kill")
.args(["-KILL", &pid.to_string()])
.output()
};
match kill_result {
Ok(output) if output.status.success() => {
log::info!("Successfully killed process via system command");
killed = true;
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
log::error!("System kill failed: {}", stderr);
}
Err(e) => {
log::error!("Failed to execute system kill command: {}", e);
}
}
}
}
}
attempted_methods.push("claude_state");
} else {
log::warn!("No active Claude process to cancel");
log::warn!("No active Claude process in ClaudeProcessState");
}
}
// Emit cancellation events
if !killed && attempted_methods.is_empty() {
log::warn!("No active Claude process found to cancel");
}
// Always emit cancellation events for UI consistency
if let Some(sid) = session_id {
let _ = app.emit(&format!("claude-cancelled:{}", sid), true);
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
@@ -972,6 +1000,12 @@ pub async fn cancel_claude_execution(
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let _ = app.emit("claude-complete", false);
if killed {
log::info!("Claude process cancellation completed successfully");
} else if !attempted_methods.is_empty() {
log::warn!("Claude process cancellation attempted but process may have already exited. Attempted methods: {:?}", attempted_methods);
}
Ok(())
}
@@ -997,200 +1031,8 @@ pub async fn get_claude_session_output(
}
}
/// Helper function to check if sandboxing should be used based on settings
fn should_use_sandbox(app: &AppHandle) -> Result<bool, String> {
// First check if sandboxing is even available on this platform
if !crate::sandbox::platform::is_sandboxing_available() {
log::info!("Sandboxing not available on this platform");
return Ok(false);
}
// Check if a setting exists to enable/disable sandboxing
let settings = get_claude_settings_sync(app)?;
// Check for a sandboxing setting in the settings
if let Some(sandbox_enabled) = settings
.data
.get("sandboxEnabled")
.and_then(|v| v.as_bool())
{
return Ok(sandbox_enabled);
}
// Default to true (sandboxing enabled) on supported platforms
Ok(true)
}
/// Helper function to create a sandboxed Claude command
fn create_sandboxed_claude_command(app: &AppHandle, project_path: &str) -> Result<Command, String> {
use crate::sandbox::{executor::create_sandboxed_command, profile::ProfileBuilder};
use std::path::PathBuf;
// Get the database connection
let conn = {
let app_data_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
let db_path = app_data_dir.join("agents.db");
rusqlite::Connection::open(&db_path)
.map_err(|e| format!("Failed to open database: {}", e))?
};
// Query for the default active sandbox profile
let profile_id: Option<i64> = conn
.query_row(
"SELECT id FROM sandbox_profiles WHERE is_default = 1 AND is_active = 1",
[],
|row| row.get(0),
)
.ok();
match profile_id {
Some(profile_id) => {
log::info!(
"Using default sandbox profile: {} (id: {})",
profile_id,
profile_id
);
// Get all rules for this profile
let mut stmt = conn
.prepare(
"SELECT operation_type, pattern_type, pattern_value, enabled, platform_support
FROM sandbox_rules WHERE profile_id = ?1 AND enabled = 1",
)
.map_err(|e| e.to_string())?;
let rules = stmt
.query_map(rusqlite::params![profile_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, bool>(3)?,
row.get::<_, Option<String>>(4)?,
))
})
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
log::info!("Building sandbox profile with {} rules", rules.len());
// Build the gaol profile
let project_path_buf = PathBuf::from(project_path);
match ProfileBuilder::new(project_path_buf.clone()) {
Ok(builder) => {
// Convert database rules to SandboxRule structs
let mut sandbox_rules = Vec::new();
for (idx, (op_type, pattern_type, pattern_value, enabled, platform_support)) in
rules.into_iter().enumerate()
{
// Check if this rule applies to the current platform
if let Some(platforms_json) = &platform_support {
if let Ok(platforms) =
serde_json::from_str::<Vec<String>>(platforms_json)
{
let current_platform = if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "freebsd") {
"freebsd"
} else {
"unsupported"
};
if !platforms.contains(&current_platform.to_string()) {
continue;
}
}
}
// Create SandboxRule struct
let rule = crate::sandbox::profile::SandboxRule {
id: Some(idx as i64),
profile_id: 0,
operation_type: op_type,
pattern_type,
pattern_value,
enabled,
platform_support,
created_at: String::new(),
};
sandbox_rules.push(rule);
}
// Try to build the profile
match builder.build_profile(sandbox_rules) {
Ok(profile) => {
log::info!("Successfully built sandbox profile '{}'", profile_id);
// Use the helper function to create sandboxed command
let claude_path = find_claude_binary(app)?;
#[cfg(unix)]
return Ok(create_sandboxed_command(
&claude_path,
&[],
&project_path_buf,
profile,
project_path_buf.clone(),
));
#[cfg(not(unix))]
{
log::warn!(
"Sandboxing not supported on Windows, using regular command"
);
Ok(create_command_with_env(&claude_path))
}
}
Err(e) => {
log::error!("Failed to build sandbox profile: {}, falling back to non-sandboxed", e);
let claude_path = find_claude_binary(app)?;
Ok(create_command_with_env(&claude_path))
}
}
}
Err(e) => {
log::error!(
"Failed to create ProfileBuilder: {}, falling back to non-sandboxed",
e
);
let claude_path = find_claude_binary(app)?;
Ok(create_command_with_env(&claude_path))
}
}
}
None => {
log::info!("No default active sandbox profile found: proceeding without sandbox");
let claude_path = find_claude_binary(app)?;
Ok(create_command_with_env(&claude_path))
}
}
}
/// Synchronous version of get_claude_settings for use in non-async contexts
fn get_claude_settings_sync(_app: &AppHandle) -> Result<ClaudeSettings, String> {
let claude_dir = get_claude_dir().map_err(|e| e.to_string())?;
let settings_path = claude_dir.join("settings.json");
if !settings_path.exists() {
return Ok(ClaudeSettings::default());
}
let content = std::fs::read_to_string(&settings_path)
.map_err(|e| format!("Failed to read settings file: {}", e))?;
let data: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse settings JSON: {}", e))?;
Ok(ClaudeSettings { data })
}
/// Helper function to spawn Claude process and handle streaming
async fn spawn_claude_process(app: AppHandle, mut cmd: Command, prompt: String, model: String, project_path: String) -> Result<(), String> {
@@ -2063,3 +1905,4 @@ pub async fn track_session_messages(
}
Ok(())
}

View File

@@ -1,6 +1,4 @@
pub mod agents;
pub mod claude;
pub mod mcp;
pub mod sandbox;
pub mod screenshot;
pub mod usage;

View File

@@ -1,947 +0,0 @@
use crate::{
commands::agents::AgentDb,
sandbox::{
platform::PlatformCapabilities,
profile::{SandboxProfile, SandboxRule},
},
};
use rusqlite::params;
use serde::{Deserialize, Serialize};
use tauri::State;
/// Represents a sandbox violation event
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxViolation {
pub id: Option<i64>,
pub profile_id: Option<i64>,
pub agent_id: Option<i64>,
pub agent_run_id: Option<i64>,
pub operation_type: String,
pub pattern_value: Option<String>,
pub process_name: Option<String>,
pub pid: Option<i32>,
pub denied_at: String,
}
/// Represents sandbox profile export data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxProfileExport {
pub version: u32,
pub exported_at: String,
pub platform: String,
pub profiles: Vec<SandboxProfileWithRules>,
}
/// Represents a profile with its rules for export
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxProfileWithRules {
pub profile: SandboxProfile,
pub rules: Vec<SandboxRule>,
}
/// Import result for a profile
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportResult {
pub profile_name: String,
pub imported: bool,
pub reason: Option<String>,
pub new_name: Option<String>,
}
/// List all sandbox profiles
#[tauri::command]
pub async fn list_sandbox_profiles(db: State<'_, AgentDb>) -> Result<Vec<SandboxProfile>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare("SELECT id, name, description, is_active, is_default, created_at, updated_at FROM sandbox_profiles ORDER BY name")
.map_err(|e| e.to_string())?;
let profiles = stmt
.query_map([], |row| {
Ok(SandboxProfile {
id: Some(row.get(0)?),
name: row.get(1)?,
description: row.get(2)?,
is_active: row.get(3)?,
is_default: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
})
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
Ok(profiles)
}
/// Create a new sandbox profile
#[tauri::command]
pub async fn create_sandbox_profile(
db: State<'_, AgentDb>,
name: String,
description: Option<String>,
) -> Result<SandboxProfile, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
conn.execute(
"INSERT INTO sandbox_profiles (name, description) VALUES (?1, ?2)",
params![name, description],
)
.map_err(|e| e.to_string())?;
let id = conn.last_insert_rowid();
// Fetch the created profile
let profile = conn
.query_row(
"SELECT id, name, description, is_active, is_default, created_at, updated_at FROM sandbox_profiles WHERE id = ?1",
params![id],
|row| {
Ok(SandboxProfile {
id: Some(row.get(0)?),
name: row.get(1)?,
description: row.get(2)?,
is_active: row.get(3)?,
is_default: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
},
)
.map_err(|e| e.to_string())?;
Ok(profile)
}
/// Update a sandbox profile
#[tauri::command]
pub async fn update_sandbox_profile(
db: State<'_, AgentDb>,
id: i64,
name: String,
description: Option<String>,
is_active: bool,
is_default: bool,
) -> Result<SandboxProfile, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// If setting as default, unset other defaults
if is_default {
conn.execute(
"UPDATE sandbox_profiles SET is_default = 0 WHERE id != ?1",
params![id],
)
.map_err(|e| e.to_string())?;
}
conn.execute(
"UPDATE sandbox_profiles SET name = ?1, description = ?2, is_active = ?3, is_default = ?4 WHERE id = ?5",
params![name, description, is_active, is_default, id],
)
.map_err(|e| e.to_string())?;
// Fetch the updated profile
let profile = conn
.query_row(
"SELECT id, name, description, is_active, is_default, created_at, updated_at FROM sandbox_profiles WHERE id = ?1",
params![id],
|row| {
Ok(SandboxProfile {
id: Some(row.get(0)?),
name: row.get(1)?,
description: row.get(2)?,
is_active: row.get(3)?,
is_default: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
},
)
.map_err(|e| e.to_string())?;
Ok(profile)
}
/// Delete a sandbox profile
#[tauri::command]
pub async fn delete_sandbox_profile(db: State<'_, AgentDb>, id: i64) -> Result<(), String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// Check if it's the default profile
let is_default: bool = conn
.query_row(
"SELECT is_default FROM sandbox_profiles WHERE id = ?1",
params![id],
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
if is_default {
return Err("Cannot delete the default profile".to_string());
}
conn.execute("DELETE FROM sandbox_profiles WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
/// Get a single sandbox profile by ID
#[tauri::command]
pub async fn get_sandbox_profile(
db: State<'_, AgentDb>,
id: i64,
) -> Result<SandboxProfile, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
let profile = conn
.query_row(
"SELECT id, name, description, is_active, is_default, created_at, updated_at FROM sandbox_profiles WHERE id = ?1",
params![id],
|row| {
Ok(SandboxProfile {
id: Some(row.get(0)?),
name: row.get(1)?,
description: row.get(2)?,
is_active: row.get(3)?,
is_default: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
},
)
.map_err(|e| e.to_string())?;
Ok(profile)
}
/// List rules for a sandbox profile
#[tauri::command]
pub async fn list_sandbox_rules(
db: State<'_, AgentDb>,
profile_id: i64,
) -> Result<Vec<SandboxRule>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare("SELECT id, profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support, created_at FROM sandbox_rules WHERE profile_id = ?1 ORDER BY operation_type, pattern_value")
.map_err(|e| e.to_string())?;
let rules = stmt
.query_map(params![profile_id], |row| {
Ok(SandboxRule {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
operation_type: row.get(2)?,
pattern_type: row.get(3)?,
pattern_value: row.get(4)?,
enabled: row.get(5)?,
platform_support: row.get(6)?,
created_at: row.get(7)?,
})
})
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
Ok(rules)
}
/// Create a new sandbox rule
#[tauri::command]
pub async fn create_sandbox_rule(
db: State<'_, AgentDb>,
profile_id: i64,
operation_type: String,
pattern_type: String,
pattern_value: String,
enabled: bool,
platform_support: Option<String>,
) -> Result<SandboxRule, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// Validate rule doesn't conflict
// TODO: Add more validation logic here
conn.execute(
"INSERT INTO sandbox_rules (profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support],
)
.map_err(|e| e.to_string())?;
let id = conn.last_insert_rowid();
// Fetch the created rule
let rule = conn
.query_row(
"SELECT id, profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support, created_at FROM sandbox_rules WHERE id = ?1",
params![id],
|row| {
Ok(SandboxRule {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
operation_type: row.get(2)?,
pattern_type: row.get(3)?,
pattern_value: row.get(4)?,
enabled: row.get(5)?,
platform_support: row.get(6)?,
created_at: row.get(7)?,
})
},
)
.map_err(|e| e.to_string())?;
Ok(rule)
}
/// Update a sandbox rule
#[tauri::command]
pub async fn update_sandbox_rule(
db: State<'_, AgentDb>,
id: i64,
operation_type: String,
pattern_type: String,
pattern_value: String,
enabled: bool,
platform_support: Option<String>,
) -> Result<SandboxRule, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
conn.execute(
"UPDATE sandbox_rules SET operation_type = ?1, pattern_type = ?2, pattern_value = ?3, enabled = ?4, platform_support = ?5 WHERE id = ?6",
params![operation_type, pattern_type, pattern_value, enabled, platform_support, id],
)
.map_err(|e| e.to_string())?;
// Fetch the updated rule
let rule = conn
.query_row(
"SELECT id, profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support, created_at FROM sandbox_rules WHERE id = ?1",
params![id],
|row| {
Ok(SandboxRule {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
operation_type: row.get(2)?,
pattern_type: row.get(3)?,
pattern_value: row.get(4)?,
enabled: row.get(5)?,
platform_support: row.get(6)?,
created_at: row.get(7)?,
})
},
)
.map_err(|e| e.to_string())?;
Ok(rule)
}
/// Delete a sandbox rule
#[tauri::command]
pub async fn delete_sandbox_rule(db: State<'_, AgentDb>, id: i64) -> Result<(), String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM sandbox_rules WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
/// Get platform capabilities for sandbox configuration
#[tauri::command]
pub async fn get_platform_capabilities() -> Result<PlatformCapabilities, String> {
Ok(crate::sandbox::platform::get_platform_capabilities())
}
/// Test a sandbox profile by creating a simple test process
#[tauri::command]
pub async fn test_sandbox_profile(
db: State<'_, AgentDb>,
profile_id: i64,
) -> Result<String, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// Load the profile and rules
let profile = crate::sandbox::profile::load_profile(&conn, profile_id)
.map_err(|e| format!("Failed to load profile: {}", e))?;
if !profile.is_active {
return Ok(format!(
"Profile '{}' is currently inactive. Activate it to use with agents.",
profile.name
));
}
let rules = crate::sandbox::profile::load_profile_rules(&conn, profile_id)
.map_err(|e| format!("Failed to load profile rules: {}", e))?;
if rules.is_empty() {
return Ok(format!(
"Profile '{}' has no rules configured. Add rules to define sandbox permissions.",
profile.name
));
}
// Try to build the gaol profile
let test_path = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("/tmp"));
let builder = crate::sandbox::profile::ProfileBuilder::new(test_path.clone())
.map_err(|e| format!("Failed to create profile builder: {}", e))?;
let build_result = builder
.build_profile_with_serialization(rules.clone())
.map_err(|e| format!("Failed to build sandbox profile: {}", e))?;
// Check platform support
let platform_caps = crate::sandbox::platform::get_platform_capabilities();
if !platform_caps.sandboxing_supported {
return Ok(format!(
"Profile '{}' validated successfully. {} rules loaded.\n\nNote: Sandboxing is not supported on {} platform. The profile configuration is valid but sandbox enforcement will not be active.",
profile.name,
rules.len(),
platform_caps.os
));
}
// Try to execute a simple command in the sandbox
let executor = crate::sandbox::executor::SandboxExecutor::new_with_serialization(
build_result.profile,
test_path.clone(),
build_result.serialized,
);
// Use a simple echo command for testing
let test_command = if cfg!(windows) { "cmd" } else { "echo" };
let test_args = if cfg!(windows) {
vec!["/C", "echo", "sandbox test successful"]
} else {
vec!["sandbox test successful"]
};
match executor.execute_sandboxed_spawn(test_command, &test_args, &test_path) {
Ok(mut child) => {
// Wait for the process to complete with a timeout
match child.wait() {
Ok(status) => {
if status.success() {
Ok(format!(
"✅ Profile '{}' tested successfully!\n\n\
{} rules loaded and validated\n\
• Sandbox activation: Success\n\
• Test process execution: Success\n\
• Platform: {} (fully supported)",
profile.name,
rules.len(),
platform_caps.os
))
} else {
Ok(format!(
"⚠️ Profile '{}' validated with warnings.\n\n\
{} rules loaded and validated\n\
• Sandbox activation: Success\n\
• Test process exit code: {}\n\
• Platform: {}",
profile.name,
rules.len(),
status.code().unwrap_or(-1),
platform_caps.os
))
}
}
Err(e) => Ok(format!(
"⚠️ Profile '{}' validated with warnings.\n\n\
{} rules loaded and validated\n\
• Sandbox activation: Partial\n\
• Test process: Could not get exit status ({})\n\
• Platform: {}",
profile.name,
rules.len(),
e,
platform_caps.os
)),
}
}
Err(e) => {
// Check if it's a permission error or platform limitation
let error_str = e.to_string();
if error_str.contains("permission") || error_str.contains("denied") {
Ok(format!(
"⚠️ Profile '{}' validated with limitations.\n\n\
{} rules loaded and validated\n\
• Sandbox configuration: Valid\n\
• Sandbox enforcement: Limited by system permissions\n\
• Platform: {}\n\n\
Note: The sandbox profile is correctly configured but may require elevated privileges or system configuration to fully enforce on this platform.",
profile.name,
rules.len(),
platform_caps.os
))
} else {
Ok(format!(
"⚠️ Profile '{}' validated with limitations.\n\n\
{} rules loaded and validated\n\
• Sandbox configuration: Valid\n\
• Test execution: Failed ({})\n\
• Platform: {}\n\n\
The sandbox profile is correctly configured. The test execution failed due to platform-specific limitations, but the profile can still be used.",
profile.name,
rules.len(),
e,
platform_caps.os
))
}
}
}
}
/// List sandbox violations with optional filtering
#[tauri::command]
pub async fn list_sandbox_violations(
db: State<'_, AgentDb>,
profile_id: Option<i64>,
agent_id: Option<i64>,
limit: Option<i64>,
) -> Result<Vec<SandboxViolation>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// Build dynamic query
let mut query = String::from(
"SELECT id, profile_id, agent_id, agent_run_id, operation_type, pattern_value, process_name, pid, denied_at
FROM sandbox_violations WHERE 1=1"
);
let mut param_idx = 1;
if profile_id.is_some() {
query.push_str(&format!(" AND profile_id = ?{}", param_idx));
param_idx += 1;
}
if agent_id.is_some() {
query.push_str(&format!(" AND agent_id = ?{}", param_idx));
param_idx += 1;
}
query.push_str(" ORDER BY denied_at DESC");
if limit.is_some() {
query.push_str(&format!(" LIMIT ?{}", param_idx));
}
// Execute query based on parameters
let violations: Vec<SandboxViolation> = if let Some(pid) = profile_id {
if let Some(aid) = agent_id {
if let Some(lim) = limit {
// All three parameters
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![pid, aid, lim], |row| {
Ok(SandboxViolation {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
agent_id: row.get(2)?,
agent_run_id: row.get(3)?,
operation_type: row.get(4)?,
pattern_value: row.get(5)?,
process_name: row.get(6)?,
pid: row.get(7)?,
denied_at: row.get(8)?,
})
})
.map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?
} else {
// profile_id and agent_id only
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![pid, aid], |row| {
Ok(SandboxViolation {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
agent_id: row.get(2)?,
agent_run_id: row.get(3)?,
operation_type: row.get(4)?,
pattern_value: row.get(5)?,
process_name: row.get(6)?,
pid: row.get(7)?,
denied_at: row.get(8)?,
})
})
.map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?
}
} else if let Some(lim) = limit {
// profile_id and limit only
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![pid, lim], |row| {
Ok(SandboxViolation {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
agent_id: row.get(2)?,
agent_run_id: row.get(3)?,
operation_type: row.get(4)?,
pattern_value: row.get(5)?,
process_name: row.get(6)?,
pid: row.get(7)?,
denied_at: row.get(8)?,
})
})
.map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?
} else {
// profile_id only
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![pid], |row| {
Ok(SandboxViolation {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
agent_id: row.get(2)?,
agent_run_id: row.get(3)?,
operation_type: row.get(4)?,
pattern_value: row.get(5)?,
process_name: row.get(6)?,
pid: row.get(7)?,
denied_at: row.get(8)?,
})
})
.map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?
}
} else if let Some(aid) = agent_id {
if let Some(lim) = limit {
// agent_id and limit only
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![aid, lim], |row| {
Ok(SandboxViolation {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
agent_id: row.get(2)?,
agent_run_id: row.get(3)?,
operation_type: row.get(4)?,
pattern_value: row.get(5)?,
process_name: row.get(6)?,
pid: row.get(7)?,
denied_at: row.get(8)?,
})
})
.map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?
} else {
// agent_id only
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![aid], |row| {
Ok(SandboxViolation {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
agent_id: row.get(2)?,
agent_run_id: row.get(3)?,
operation_type: row.get(4)?,
pattern_value: row.get(5)?,
process_name: row.get(6)?,
pid: row.get(7)?,
denied_at: row.get(8)?,
})
})
.map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?
}
} else if let Some(lim) = limit {
// limit only
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![lim], |row| {
Ok(SandboxViolation {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
agent_id: row.get(2)?,
agent_run_id: row.get(3)?,
operation_type: row.get(4)?,
pattern_value: row.get(5)?,
process_name: row.get(6)?,
pid: row.get(7)?,
denied_at: row.get(8)?,
})
})
.map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?
} else {
// No parameters
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| {
Ok(SandboxViolation {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
agent_id: row.get(2)?,
agent_run_id: row.get(3)?,
operation_type: row.get(4)?,
pattern_value: row.get(5)?,
process_name: row.get(6)?,
pid: row.get(7)?,
denied_at: row.get(8)?,
})
})
.map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?
};
Ok(violations)
}
/// Log a sandbox violation
#[tauri::command]
pub async fn log_sandbox_violation(
db: State<'_, AgentDb>,
profile_id: Option<i64>,
agent_id: Option<i64>,
agent_run_id: Option<i64>,
operation_type: String,
pattern_value: Option<String>,
process_name: Option<String>,
pid: Option<i32>,
) -> Result<(), String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
conn.execute(
"INSERT INTO sandbox_violations (profile_id, agent_id, agent_run_id, operation_type, pattern_value, process_name, pid)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![profile_id, agent_id, agent_run_id, operation_type, pattern_value, process_name, pid],
)
.map_err(|e| e.to_string())?;
Ok(())
}
/// Clear old sandbox violations
#[tauri::command]
pub async fn clear_sandbox_violations(
db: State<'_, AgentDb>,
older_than_days: Option<i64>,
) -> Result<i64, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
let query = if let Some(days) = older_than_days {
format!(
"DELETE FROM sandbox_violations WHERE denied_at < datetime('now', '-{} days')",
days
)
} else {
"DELETE FROM sandbox_violations".to_string()
};
let deleted = conn.execute(&query, []).map_err(|e| e.to_string())?;
Ok(deleted as i64)
}
/// Get sandbox violation statistics
#[tauri::command]
pub async fn get_sandbox_violation_stats(
db: State<'_, AgentDb>,
) -> Result<serde_json::Value, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// Get total violations
let total: i64 = conn
.query_row("SELECT COUNT(*) FROM sandbox_violations", [], |row| {
row.get(0)
})
.map_err(|e| e.to_string())?;
// Get violations by operation type
let mut stmt = conn
.prepare(
"SELECT operation_type, COUNT(*) as count
FROM sandbox_violations
GROUP BY operation_type
ORDER BY count DESC",
)
.map_err(|e| e.to_string())?;
let by_operation: Vec<(String, i64)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
// Get recent violations count (last 24 hours)
let recent: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sandbox_violations WHERE denied_at > datetime('now', '-1 day')",
[],
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"total": total,
"recent_24h": recent,
"by_operation": by_operation.into_iter().map(|(op, count)| {
serde_json::json!({
"operation": op,
"count": count
})
}).collect::<Vec<_>>()
}))
}
/// Export a single sandbox profile with its rules
#[tauri::command]
pub async fn export_sandbox_profile(
db: State<'_, AgentDb>,
profile_id: i64,
) -> Result<SandboxProfileExport, String> {
// Get the profile
let profile = {
let conn = db.0.lock().map_err(|e| e.to_string())?;
crate::sandbox::profile::load_profile(&conn, profile_id).map_err(|e| e.to_string())?
};
// Get the rules
let rules = list_sandbox_rules(db.clone(), profile_id).await?;
Ok(SandboxProfileExport {
version: 1,
exported_at: chrono::Utc::now().to_rfc3339(),
platform: std::env::consts::OS.to_string(),
profiles: vec![SandboxProfileWithRules { profile, rules }],
})
}
/// Export all sandbox profiles
#[tauri::command]
pub async fn export_all_sandbox_profiles(
db: State<'_, AgentDb>,
) -> Result<SandboxProfileExport, String> {
let profiles = list_sandbox_profiles(db.clone()).await?;
let mut profile_exports = Vec::new();
for profile in profiles {
if let Some(id) = profile.id {
let rules = list_sandbox_rules(db.clone(), id).await?;
profile_exports.push(SandboxProfileWithRules { profile, rules });
}
}
Ok(SandboxProfileExport {
version: 1,
exported_at: chrono::Utc::now().to_rfc3339(),
platform: std::env::consts::OS.to_string(),
profiles: profile_exports,
})
}
/// Import sandbox profiles from export data
#[tauri::command]
pub async fn import_sandbox_profiles(
db: State<'_, AgentDb>,
export_data: SandboxProfileExport,
) -> Result<Vec<ImportResult>, String> {
let mut results = Vec::new();
// Validate version
if export_data.version != 1 {
return Err(format!(
"Unsupported export version: {}",
export_data.version
));
}
for profile_export in export_data.profiles {
let mut profile = profile_export.profile;
let original_name = profile.name.clone();
// Check for name conflicts
let existing: Result<i64, _> = {
let conn = db.0.lock().map_err(|e| e.to_string())?;
conn.query_row(
"SELECT id FROM sandbox_profiles WHERE name = ?1",
params![&profile.name],
|row| row.get(0),
)
};
let (imported, new_name) = match existing {
Ok(_) => {
// Name conflict - append timestamp
let new_name = format!(
"{} (imported {})",
profile.name,
chrono::Utc::now().format("%Y-%m-%d %H:%M")
);
profile.name = new_name.clone();
(true, Some(new_name))
}
Err(_) => (true, None),
};
if imported {
// Reset profile fields for new insert
profile.id = None;
profile.is_default = false; // Never import as default
// Create the profile
let created_profile =
create_sandbox_profile(db.clone(), profile.name.clone(), profile.description)
.await?;
if let Some(new_id) = created_profile.id {
// Import rules
for rule in profile_export.rules {
if rule.enabled {
// Create the rule with the new profile ID
let _ = create_sandbox_rule(
db.clone(),
new_id,
rule.operation_type,
rule.pattern_type,
rule.pattern_value,
rule.enabled,
rule.platform_support,
)
.await;
}
}
// Update profile status if needed
if profile.is_active {
let _ = update_sandbox_profile(
db.clone(),
new_id,
created_profile.name,
created_profile.description,
profile.is_active,
false, // Never set as default on import
)
.await;
}
}
results.push(ImportResult {
profile_name: original_name,
imported: true,
reason: new_name
.as_ref()
.map(|_| "Name conflict resolved".to_string()),
new_name,
});
}
}
Ok(results)
}

View File

@@ -1,269 +0,0 @@
use headless_chrome::protocol::cdp::Page;
use headless_chrome::{Browser, LaunchOptions};
use std::fs;
use std::time::Duration;
use tauri::AppHandle;
/// Captures a screenshot of a URL using headless Chrome
///
/// This function launches a headless Chrome browser, navigates to the specified URL,
/// and captures a screenshot of either the entire page or a specific element.
///
/// # Arguments
/// * `app` - The Tauri application handle
/// * `url` - The URL to capture
/// * `selector` - Optional CSS selector for a specific element to capture
/// * `full_page` - Whether to capture the entire page or just the viewport
///
/// # Returns
/// * `Result<String, String>` - The path to the saved screenshot file, or an error message
#[tauri::command]
pub async fn capture_url_screenshot(
_app: AppHandle,
url: String,
selector: Option<String>,
full_page: bool,
) -> Result<String, String> {
log::info!(
"Capturing screenshot of URL: {}, selector: {:?}, full_page: {}",
url,
selector,
full_page
);
// Run the browser operations in a blocking task since headless_chrome is not async
let result =
tokio::task::spawn_blocking(move || capture_screenshot_sync(url, selector, full_page))
.await
.map_err(|e| format!("Failed to spawn blocking task: {}", e))?;
// Log the result of the headless Chrome capture before returning
match &result {
Ok(path) => log::info!("capture_url_screenshot returning path: {}", path),
Err(err) => log::error!("capture_url_screenshot encountered error: {}", err),
}
result
}
/// Synchronous helper function to capture screenshots using headless Chrome
fn capture_screenshot_sync(
url: String,
selector: Option<String>,
full_page: bool,
) -> Result<String, String> {
// Configure browser launch options
let launch_options = LaunchOptions {
headless: true,
window_size: Some((1920, 1080)),
..Default::default()
};
// Launch the browser
let browser =
Browser::new(launch_options).map_err(|e| format!("Failed to launch browser: {}", e))?;
// Create a new tab
let tab = browser
.new_tab()
.map_err(|e| format!("Failed to create new tab: {}", e))?;
// Set a reasonable timeout for page navigation
tab.set_default_timeout(Duration::from_secs(30));
// Navigate to the URL
tab.navigate_to(&url)
.map_err(|e| format!("Failed to navigate to URL: {}", e))?;
// Wait for the page to load
// Try to wait for network idle, but don't fail if it times out
let _ = tab.wait_until_navigated();
// Additional wait to ensure dynamic content loads
std::thread::sleep(Duration::from_millis(500));
// Wait explicitly for the <body> element to exist this often prevents
// "Unable to capture screenshot" CDP errors on some pages
if let Err(e) = tab.wait_for_element("body") {
log::warn!(
"Timed out waiting for <body> element: {} continuing anyway",
e
);
}
// Capture the screenshot
let screenshot_data = if let Some(selector) = selector {
// Wait for the element and capture it
log::info!("Waiting for element with selector: {}", selector);
let element = tab
.wait_for_element(&selector)
.map_err(|e| format!("Failed to find element '{}': {}", selector, e))?;
element
.capture_screenshot(Page::CaptureScreenshotFormatOption::Png)
.map_err(|e| format!("Failed to capture element screenshot: {}", e))?
} else {
// Capture the entire page or viewport
log::info!(
"Capturing {} screenshot",
if full_page { "full page" } else { "viewport" }
);
// Get the page dimensions for full page screenshot
let clip = if full_page {
// Execute JavaScript to get the full page dimensions
let dimensions = tab
.evaluate(
r#"
({
width: Math.max(
document.body.scrollWidth,
document.documentElement.scrollWidth,
document.body.offsetWidth,
document.documentElement.offsetWidth,
document.documentElement.clientWidth
),
height: Math.max(
document.body.scrollHeight,
document.documentElement.scrollHeight,
document.body.offsetHeight,
document.documentElement.offsetHeight,
document.documentElement.clientHeight
)
})
"#,
false,
)
.map_err(|e| format!("Failed to get page dimensions: {}", e))?;
// Extract dimensions from the result
let width = dimensions
.value
.as_ref()
.and_then(|v| v.as_object())
.and_then(|obj| obj.get("width"))
.and_then(|v| v.as_f64())
.unwrap_or(1920.0);
let height = dimensions
.value
.as_ref()
.and_then(|v| v.as_object())
.and_then(|obj| obj.get("height"))
.and_then(|v| v.as_f64())
.unwrap_or(1080.0);
Some(Page::Viewport {
x: 0.0,
y: 0.0,
width,
height,
scale: 1.0,
})
} else {
None
};
let capture_result = tab.capture_screenshot(
Page::CaptureScreenshotFormatOption::Png,
None,
clip.clone(),
full_page, // capture_beyond_viewport only makes sense for full page
);
match capture_result {
Ok(data) => data,
Err(err) => {
// Retry once with capture_beyond_viewport=true which works around some Chromium bugs
log::warn!(
"Initial screenshot attempt failed: {}. Retrying with capture_beyond_viewport=true",
err
);
tab.capture_screenshot(Page::CaptureScreenshotFormatOption::Png, None, clip, true)
.map_err(|e| format!("Failed to capture screenshot after retry: {}", e))?
}
}
};
// Save to temporary file
let temp_dir = std::env::temp_dir();
let timestamp = chrono::Utc::now().timestamp_millis();
let filename = format!("claudia_screenshot_{}.png", timestamp);
let file_path = temp_dir.join(filename);
fs::write(&file_path, screenshot_data)
.map_err(|e| format!("Failed to save screenshot: {}", e))?;
// Log the screenshot path prominently
println!("═══════════════════════════════════════════════════════════════");
println!("📸 SCREENSHOT SAVED SUCCESSFULLY!");
println!("📁 Location: {}", file_path.display());
println!("═══════════════════════════════════════════════════════════════");
log::info!("Screenshot saved to: {:?}", file_path);
Ok(file_path.to_string_lossy().to_string())
}
/// Cleans up old screenshot files from the temporary directory
///
/// This function removes screenshot files older than the specified number of minutes
/// to prevent accumulation of temporary files.
///
/// # Arguments
/// * `older_than_minutes` - Remove files older than this many minutes (default: 60)
///
/// # Returns
/// * `Result<usize, String>` - The number of files deleted, or an error message
#[tauri::command]
pub async fn cleanup_screenshot_temp_files(
older_than_minutes: Option<u64>,
) -> Result<usize, String> {
let minutes = older_than_minutes.unwrap_or(60);
log::info!(
"Cleaning up screenshot files older than {} minutes",
minutes
);
let temp_dir = std::env::temp_dir();
let cutoff_time = chrono::Utc::now() - chrono::Duration::minutes(minutes as i64);
let mut deleted_count = 0;
// Read directory entries
let entries =
fs::read_dir(&temp_dir).map_err(|e| format!("Failed to read temp directory: {}", e))?;
for entry in entries {
if let Ok(entry) = entry {
let path = entry.path();
// Check if it's a claudia screenshot file
if let Some(filename) = path.file_name() {
if let Some(filename_str) = filename.to_str() {
if filename_str.starts_with("claudia_screenshot_")
&& filename_str.ends_with(".png")
{
// Check file age
if let Ok(metadata) = fs::metadata(&path) {
if let Ok(modified) = metadata.modified() {
let modified_time = chrono::DateTime::<chrono::Utc>::from(modified);
if modified_time < cutoff_time {
// Delete the file
if fs::remove_file(&path).is_ok() {
deleted_count += 1;
log::debug!("Deleted old screenshot: {:?}", path);
}
}
}
}
}
}
}
}
}
log::info!("Cleaned up {} old screenshot files", deleted_count);
Ok(deleted_count)
}

View File

@@ -5,7 +5,6 @@ pub mod checkpoint;
pub mod claude_binary;
pub mod commands;
pub mod process;
pub mod sandbox;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {

View File

@@ -5,7 +5,6 @@ mod checkpoint;
mod claude_binary;
mod commands;
mod process;
mod sandbox;
use checkpoint::state::CheckpointState;
use commands::agents::{
@@ -34,14 +33,7 @@ use commands::mcp::{
mcp_read_project_config, mcp_remove, mcp_reset_project_choices, mcp_save_project_config,
mcp_serve, mcp_test_connection,
};
use commands::sandbox::{
clear_sandbox_violations, create_sandbox_profile, create_sandbox_rule, delete_sandbox_profile,
delete_sandbox_rule, export_all_sandbox_profiles, export_sandbox_profile,
get_platform_capabilities, get_sandbox_profile, get_sandbox_violation_stats,
import_sandbox_profiles, list_sandbox_profiles, list_sandbox_rules, list_sandbox_violations,
log_sandbox_violation, test_sandbox_profile, update_sandbox_profile, update_sandbox_rule,
};
use commands::screenshot::{capture_url_screenshot, cleanup_screenshot_temp_files};
use commands::usage::{
get_session_stats, get_usage_by_date_range, get_usage_details, get_usage_stats,
};
@@ -53,14 +45,6 @@ fn main() {
// Initialize logger
env_logger::init();
// Check if we need to activate sandbox in this process
if sandbox::executor::should_activate_sandbox() {
// This is a child process that needs sandbox activation
if let Err(e) = sandbox::executor::SandboxExecutor::activate_sandbox_in_child() {
log::error!("Failed to activate sandbox: {}", e);
// Continue without sandbox rather than crashing
}
}
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
@@ -161,24 +145,6 @@ fn main() {
fetch_github_agents,
fetch_github_agent_content,
import_agent_from_github,
list_sandbox_profiles,
get_sandbox_profile,
create_sandbox_profile,
update_sandbox_profile,
delete_sandbox_profile,
list_sandbox_rules,
create_sandbox_rule,
update_sandbox_rule,
delete_sandbox_rule,
test_sandbox_profile,
get_platform_capabilities,
list_sandbox_violations,
log_sandbox_violation,
clear_sandbox_violations,
get_sandbox_violation_stats,
export_sandbox_profile,
export_all_sandbox_profiles,
import_sandbox_profiles,
get_usage_stats,
get_usage_by_date_range,
get_usage_details,
@@ -194,9 +160,7 @@ fn main() {
mcp_reset_project_choices,
mcp_get_server_status,
mcp_read_project_config,
mcp_save_project_config,
capture_url_screenshot,
cleanup_screenshot_temp_files
mcp_save_project_config
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -213,6 +213,7 @@ impl ProcessRegistry {
if let Some(handle) = processes.get(&run_id) {
(handle.info.pid, handle.child.clone())
} else {
warn!("Process {} not found in registry", run_id);
return Ok(false); // Process not found
}
};
@@ -233,16 +234,25 @@ impl ProcessRegistry {
}
Err(e) => {
error!("Failed to send kill signal to process {}: {}", run_id, e);
return Err(format!("Failed to kill process: {}", e));
// Don't return error here, try fallback method
false
}
}
} else {
false // Process already killed
warn!("No child handle available for process {} (PID: {}), attempting system kill", run_id, pid);
false // Process handle not available, try fallback
}
};
// If direct kill didn't work, try system command as fallback
if !kill_sent {
return Ok(false);
info!("Attempting fallback kill for process {} (PID: {})", run_id, pid);
match self.kill_process_by_pid(run_id, pid) {
Ok(true) => return Ok(true),
Ok(false) => warn!("Fallback kill also failed for process {} (PID: {})", run_id, pid),
Err(e) => error!("Error during fallback kill: {}", e),
}
// Continue with the rest of the cleanup even if fallback failed
}
// Wait for the process to exit (with timeout)
@@ -297,6 +307,8 @@ impl ProcessRegistry {
if let Ok(mut child_guard) = child_arc.lock() {
*child_guard = None;
}
// One more attempt with system kill
let _ = self.kill_process_by_pid(run_id, pid);
}
}

View File

@@ -1,212 +0,0 @@
use crate::sandbox::profile::{SandboxProfile, SandboxRule};
use rusqlite::{params, Connection, Result};
/// Create default sandbox profiles for initial setup
pub fn create_default_profiles(conn: &Connection) -> Result<()> {
// Check if we already have profiles
let count: i64 = conn.query_row("SELECT COUNT(*) FROM sandbox_profiles", [], |row| {
row.get(0)
})?;
if count > 0 {
// Already have profiles, don't create defaults
return Ok(());
}
// Create Standard Profile
create_standard_profile(conn)?;
// Create Minimal Profile
create_minimal_profile(conn)?;
// Create Development Profile
create_development_profile(conn)?;
Ok(())
}
fn create_standard_profile(conn: &Connection) -> Result<()> {
// Insert profile
conn.execute(
"INSERT INTO sandbox_profiles (name, description, is_active, is_default) VALUES (?1, ?2, ?3, ?4)",
params![
"Standard",
"Standard sandbox profile with balanced permissions for most use cases",
true,
true // Set as default
],
)?;
let profile_id = conn.last_insert_rowid();
// Add rules
let rules = vec![
// File access
(
"file_read_all",
"subpath",
"{{PROJECT_PATH}}",
true,
Some(r#"["linux", "macos"]"#),
),
(
"file_read_all",
"subpath",
"/usr/lib",
true,
Some(r#"["linux", "macos"]"#),
),
(
"file_read_all",
"subpath",
"/usr/local/lib",
true,
Some(r#"["linux", "macos"]"#),
),
(
"file_read_all",
"subpath",
"/System/Library",
true,
Some(r#"["macos"]"#),
),
(
"file_read_metadata",
"subpath",
"/",
true,
Some(r#"["macos"]"#),
),
// Network access
(
"network_outbound",
"all",
"",
true,
Some(r#"["linux", "macos"]"#),
),
];
for (op_type, pattern_type, pattern_value, enabled, platforms) in rules {
conn.execute(
"INSERT INTO sandbox_rules (profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![profile_id, op_type, pattern_type, pattern_value, enabled, platforms],
)?;
}
Ok(())
}
fn create_minimal_profile(conn: &Connection) -> Result<()> {
// Insert profile
conn.execute(
"INSERT INTO sandbox_profiles (name, description, is_active, is_default) VALUES (?1, ?2, ?3, ?4)",
params![
"Minimal",
"Minimal sandbox profile with only project directory access",
true,
false
],
)?;
let profile_id = conn.last_insert_rowid();
// Add minimal rules - only project access
conn.execute(
"INSERT INTO sandbox_rules (profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
profile_id,
"file_read_all",
"subpath",
"{{PROJECT_PATH}}",
true,
Some(r#"["linux", "macos", "windows"]"#)
],
)?;
Ok(())
}
fn create_development_profile(conn: &Connection) -> Result<()> {
// Insert profile
conn.execute(
"INSERT INTO sandbox_profiles (name, description, is_active, is_default) VALUES (?1, ?2, ?3, ?4)",
params![
"Development",
"Development profile with broader permissions for development tasks",
true,
false
],
)?;
let profile_id = conn.last_insert_rowid();
// Add development rules
let rules = vec![
// Broad file access
(
"file_read_all",
"subpath",
"{{PROJECT_PATH}}",
true,
Some(r#"["linux", "macos"]"#),
),
(
"file_read_all",
"subpath",
"{{HOME}}",
true,
Some(r#"["linux", "macos"]"#),
),
(
"file_read_all",
"subpath",
"/usr",
true,
Some(r#"["linux", "macos"]"#),
),
(
"file_read_all",
"subpath",
"/opt",
true,
Some(r#"["linux", "macos"]"#),
),
(
"file_read_all",
"subpath",
"/Applications",
true,
Some(r#"["macos"]"#),
),
(
"file_read_metadata",
"subpath",
"/",
true,
Some(r#"["macos"]"#),
),
// Network access
(
"network_outbound",
"all",
"",
true,
Some(r#"["linux", "macos"]"#),
),
// System info (macOS only)
("system_info_read", "all", "", true, Some(r#"["macos"]"#)),
];
for (op_type, pattern_type, pattern_value, enabled, platforms) in rules {
conn.execute(
"INSERT INTO sandbox_rules (profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![profile_id, op_type, pattern_type, pattern_value, enabled, platforms],
)?;
}
Ok(())
}

View File

@@ -1,511 +0,0 @@
use anyhow::{Context, Result};
#[cfg(unix)]
use gaol::sandbox::{
ChildSandbox, ChildSandboxMethods, Command as GaolCommand, Sandbox, SandboxMethods,
};
use log::{debug, error, info, warn};
use std::env;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use tokio::process::Command;
/// Sandbox executor for running commands in a sandboxed environment
pub struct SandboxExecutor {
#[cfg(unix)]
profile: gaol::profile::Profile,
project_path: PathBuf,
serialized_profile: Option<SerializedProfile>,
}
#[cfg(unix)]
impl SandboxExecutor {
/// Create a new sandbox executor with the given profile
pub fn new(profile: gaol::profile::Profile, project_path: PathBuf) -> Self {
Self {
profile,
project_path,
serialized_profile: None,
}
}
/// Create a new sandbox executor with serialized profile for child process communication
pub fn new_with_serialization(
profile: gaol::profile::Profile,
project_path: PathBuf,
serialized_profile: SerializedProfile,
) -> Self {
Self {
profile,
project_path,
serialized_profile: Some(serialized_profile),
}
}
/// Execute a command in the sandbox (for the parent process)
/// This is used when we need to spawn a child process with sandbox
pub fn execute_sandboxed_spawn(
&self,
command: &str,
args: &[&str],
cwd: &Path,
) -> Result<std::process::Child> {
info!("Executing sandboxed command: {} {:?}", command, args);
// On macOS, we need to check if the command is allowed by the system
#[cfg(target_os = "macos")]
{
// For testing purposes, we'll skip actual sandboxing for simple commands like echo
if command == "echo" || command == "/bin/echo" {
debug!(
"Using direct execution for simple test command: {}",
command
);
return std::process::Command::new(command)
.args(args)
.current_dir(cwd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("Failed to spawn test command");
}
}
// Create the sandbox
let sandbox = Sandbox::new(self.profile.clone());
// Create the command
let mut gaol_command = GaolCommand::new(command);
for arg in args {
gaol_command.arg(arg);
}
// Set environment variables
gaol_command.env("GAOL_CHILD_PROCESS", "1");
gaol_command.env("GAOL_SANDBOX_ACTIVE", "1");
gaol_command.env(
"GAOL_PROJECT_PATH",
self.project_path.to_string_lossy().as_ref(),
);
// Inherit specific parent environment variables that are safe
for (key, value) in env::vars() {
// Only pass through safe environment variables
if key.starts_with("PATH")
|| key.starts_with("HOME")
|| key.starts_with("USER")
|| key == "SHELL"
|| key == "LANG"
|| key == "LC_ALL"
|| key.starts_with("LC_")
{
gaol_command.env(&key, &value);
}
}
// Try to start the sandboxed process using gaol
match sandbox.start(&mut gaol_command) {
Ok(process) => {
debug!("Successfully started sandboxed process using gaol");
// Unfortunately, gaol doesn't expose the underlying Child process
// So we need to use a different approach for now
// This is a limitation of the gaol library - we can't get the Child back
// For now, we'll have to use the fallback approach
warn!(
"Gaol started the process but we can't get the Child handle - using fallback"
);
// Drop the process to avoid zombie
drop(process);
// Fall through to fallback
}
Err(e) => {
warn!("Failed to start sandboxed process with gaol: {}", e);
debug!("Gaol error details: {:?}", e);
}
}
// Fallback: Use regular process spawn with sandbox activation in child
info!("Using child-side sandbox activation as fallback");
// Serialize the sandbox rules for the child process
let rules_json = if let Some(ref serialized) = self.serialized_profile {
serde_json::to_string(serialized)?
} else {
let serialized_rules = self.extract_sandbox_rules()?;
serde_json::to_string(&serialized_rules)?
};
let mut std_command = std::process::Command::new(command);
std_command
.args(args)
.current_dir(cwd)
.env("GAOL_SANDBOX_ACTIVE", "1")
.env(
"GAOL_PROJECT_PATH",
self.project_path.to_string_lossy().as_ref(),
)
.env("GAOL_SANDBOX_RULES", rules_json)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
std_command
.spawn()
.context("Failed to spawn process with sandbox environment")
}
/// Prepare a tokio Command for sandboxed execution
/// The sandbox will be activated in the child process
pub fn prepare_sandboxed_command(&self, command: &str, args: &[&str], cwd: &Path) -> Command {
info!("Preparing sandboxed command: {} {:?}", command, args);
let mut cmd = Command::new(command);
cmd.args(args).current_dir(cwd);
// Inherit essential environment variables from parent process
// This is crucial for commands like Claude that need to find Node.js
for (key, value) in env::vars() {
// Pass through PATH and other essential environment variables
if key == "PATH"
|| key == "HOME"
|| key == "USER"
|| key == "SHELL"
|| key == "LANG"
|| key == "LC_ALL"
|| key.starts_with("LC_")
|| key == "NODE_PATH"
|| key == "NVM_DIR"
|| key == "NVM_BIN"
{
debug!("Inheriting env var: {}={}", key, value);
cmd.env(&key, &value);
}
}
// Serialize the sandbox rules for the child process
let rules_json = if let Some(ref serialized) = self.serialized_profile {
let json = serde_json::to_string(serialized).ok();
info!(
"🔧 Using serialized sandbox profile with {} operations",
serialized.operations.len()
);
for (i, op) in serialized.operations.iter().enumerate() {
match op {
SerializedOperation::FileReadAll { path, is_subpath } => {
info!(
" Rule {}: FileReadAll {} (subpath: {})",
i,
path.display(),
is_subpath
);
}
SerializedOperation::NetworkOutbound { pattern } => {
info!(" Rule {}: NetworkOutbound {}", i, pattern);
}
SerializedOperation::SystemInfoRead => {
info!(" Rule {}: SystemInfoRead", i);
}
_ => {
info!(" Rule {}: {:?}", i, op);
}
}
}
json
} else {
info!("🔧 No serialized profile, extracting from gaol profile");
self.extract_sandbox_rules()
.ok()
.and_then(|r| serde_json::to_string(&r).ok())
};
if let Some(json) = rules_json {
// TEMPORARILY DISABLED: Claude Code might not understand these env vars and could hang
// cmd.env("GAOL_SANDBOX_ACTIVE", "1");
// cmd.env("GAOL_PROJECT_PATH", self.project_path.to_string_lossy().as_ref());
// cmd.env("GAOL_SANDBOX_RULES", &json);
warn!("🚨 TEMPORARILY DISABLED sandbox environment variables for debugging");
info!("🔧 Would have set sandbox environment variables for child process");
info!(" GAOL_SANDBOX_ACTIVE=1 (disabled)");
info!(
" GAOL_PROJECT_PATH={} (disabled)",
self.project_path.display()
);
info!(" GAOL_SANDBOX_RULES={} chars (disabled)", json.len());
} else {
warn!("🚨 Failed to serialize sandbox rules - running without sandbox!");
}
cmd.stdin(Stdio::null()) // Don't pipe stdin - we have no input to send
.stdout(Stdio::piped())
.stderr(Stdio::piped());
cmd
}
/// Extract sandbox rules from the profile
/// This is a workaround since gaol doesn't expose the operations
fn extract_sandbox_rules(&self) -> Result<SerializedProfile> {
// We need to track the rules when building the profile
// For now, return a default set based on what we know
// This should be improved by tracking rules during profile creation
let operations = vec![
SerializedOperation::FileReadAll {
path: self.project_path.clone(),
is_subpath: true,
},
SerializedOperation::NetworkOutbound {
pattern: "all".to_string(),
},
];
Ok(SerializedProfile { operations })
}
/// Activate sandbox in the current process (for child processes)
/// This should be called early in the child process
pub fn activate_sandbox_in_child() -> Result<()> {
// Check if sandbox should be activated
if !should_activate_sandbox() {
return Ok(());
}
info!("Activating sandbox in child process");
// Get project path
let project_path = env::var("GAOL_PROJECT_PATH").context("GAOL_PROJECT_PATH not set")?;
let project_path = PathBuf::from(project_path);
// Try to deserialize the sandbox rules from environment
let profile = if let Ok(rules_json) = env::var("GAOL_SANDBOX_RULES") {
match serde_json::from_str::<SerializedProfile>(&rules_json) {
Ok(serialized) => {
debug!(
"Deserializing {} sandbox rules",
serialized.operations.len()
);
deserialize_profile(serialized, &project_path)?
}
Err(e) => {
warn!("Failed to deserialize sandbox rules: {}", e);
// Fallback to minimal profile
create_minimal_profile(project_path)?
}
}
} else {
debug!("No sandbox rules found in environment, using minimal profile");
// Fallback to minimal profile
create_minimal_profile(project_path)?
};
// Create and activate the child sandbox
let sandbox = ChildSandbox::new(profile);
match sandbox.activate() {
Ok(_) => {
info!("Sandbox activated successfully");
Ok(())
}
Err(e) => {
error!("Failed to activate sandbox: {:?}", e);
Err(anyhow::anyhow!("Failed to activate sandbox: {:?}", e))
}
}
}
}
// Windows implementation - no sandboxing
#[cfg(not(unix))]
impl SandboxExecutor {
/// Create a new sandbox executor (no-op on Windows)
pub fn new(_profile: (), project_path: PathBuf) -> Self {
Self {
project_path,
serialized_profile: None,
}
}
/// Create a new sandbox executor with serialized profile (no-op on Windows)
pub fn new_with_serialization(
_profile: (),
project_path: PathBuf,
serialized_profile: SerializedProfile,
) -> Self {
Self {
project_path,
serialized_profile: Some(serialized_profile),
}
}
/// Execute a command in the sandbox (Windows - no sandboxing)
pub fn execute_sandboxed_spawn(
&self,
command: &str,
args: &[&str],
cwd: &Path,
) -> Result<std::process::Child> {
info!(
"Executing command without sandbox on Windows: {} {:?}",
command, args
);
std::process::Command::new(command)
.args(args)
.current_dir(cwd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("Failed to spawn process")
}
/// Prepare a sandboxed tokio Command (Windows - no sandboxing)
pub fn prepare_sandboxed_command(&self, command: &str, args: &[&str], cwd: &Path) -> Command {
info!(
"Preparing command without sandbox on Windows: {} {:?}",
command, args
);
let mut cmd = Command::new(command);
cmd.args(args)
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
cmd
}
/// Extract sandbox rules (no-op on Windows)
fn extract_sandbox_rules(&self) -> Result<SerializedProfile> {
Ok(SerializedProfile { operations: vec![] })
}
/// Activate sandbox in child process (no-op on Windows)
pub fn activate_sandbox_in_child() -> Result<()> {
debug!("Sandbox activation skipped on Windows");
Ok(())
}
}
/// Check if the current process should activate sandbox
pub fn should_activate_sandbox() -> bool {
env::var("GAOL_SANDBOX_ACTIVE").unwrap_or_default() == "1"
}
/// Helper to create a sandboxed tokio Command
#[cfg(unix)]
pub fn create_sandboxed_command(
command: &str,
args: &[&str],
cwd: &Path,
profile: gaol::profile::Profile,
project_path: PathBuf,
) -> Command {
let executor = SandboxExecutor::new(profile, project_path);
executor.prepare_sandboxed_command(command, args, cwd)
}
// Serialization helpers for passing profile between processes
#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct SerializedProfile {
pub operations: Vec<SerializedOperation>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub enum SerializedOperation {
FileReadAll { path: PathBuf, is_subpath: bool },
FileReadMetadata { path: PathBuf, is_subpath: bool },
NetworkOutbound { pattern: String },
NetworkTcp { port: u16 },
NetworkLocalSocket { path: PathBuf },
SystemInfoRead,
}
#[cfg(unix)]
fn deserialize_profile(
serialized: SerializedProfile,
project_path: &Path,
) -> Result<gaol::profile::Profile> {
let mut operations = Vec::new();
for op in serialized.operations {
match op {
SerializedOperation::FileReadAll { path, is_subpath } => {
let pattern = if is_subpath {
gaol::profile::PathPattern::Subpath(path)
} else {
gaol::profile::PathPattern::Literal(path)
};
operations.push(gaol::profile::Operation::FileReadAll(pattern));
}
SerializedOperation::FileReadMetadata { path, is_subpath } => {
let pattern = if is_subpath {
gaol::profile::PathPattern::Subpath(path)
} else {
gaol::profile::PathPattern::Literal(path)
};
operations.push(gaol::profile::Operation::FileReadMetadata(pattern));
}
SerializedOperation::NetworkOutbound { pattern } => {
let addr_pattern = match pattern.as_str() {
"all" => gaol::profile::AddressPattern::All,
_ => {
warn!("Unknown network pattern '{}', defaulting to All", pattern);
gaol::profile::AddressPattern::All
}
};
operations.push(gaol::profile::Operation::NetworkOutbound(addr_pattern));
}
SerializedOperation::NetworkTcp { port } => {
operations.push(gaol::profile::Operation::NetworkOutbound(
gaol::profile::AddressPattern::Tcp(port),
));
}
SerializedOperation::NetworkLocalSocket { path } => {
operations.push(gaol::profile::Operation::NetworkOutbound(
gaol::profile::AddressPattern::LocalSocket(path),
));
}
SerializedOperation::SystemInfoRead => {
operations.push(gaol::profile::Operation::SystemInfoRead);
}
}
}
// Always ensure project path access
let has_project_access = operations.iter().any(|op| {
matches!(op, gaol::profile::Operation::FileReadAll(gaol::profile::PathPattern::Subpath(p)) if p == project_path)
});
if !has_project_access {
operations.push(gaol::profile::Operation::FileReadAll(
gaol::profile::PathPattern::Subpath(project_path.to_path_buf()),
));
}
let op_count = operations.len();
gaol::profile::Profile::new(operations).map_err(|e| {
error!("Failed to create profile: {:?}", e);
anyhow::anyhow!(
"Failed to create profile from {} operations: {:?}",
op_count,
e
)
})
}
#[cfg(unix)]
fn create_minimal_profile(project_path: PathBuf) -> Result<gaol::profile::Profile> {
let operations = vec![
gaol::profile::Operation::FileReadAll(gaol::profile::PathPattern::Subpath(project_path)),
gaol::profile::Operation::NetworkOutbound(gaol::profile::AddressPattern::All),
];
gaol::profile::Profile::new(operations).map_err(|e| {
error!("Failed to create minimal profile: {:?}", e);
anyhow::anyhow!("Failed to create minimal sandbox profile: {:?}", e)
})
}

View File

@@ -1,21 +0,0 @@
#[allow(unused)]
pub mod defaults;
#[allow(unused)]
pub mod executor;
#[allow(unused)]
pub mod platform;
#[allow(unused)]
pub mod profile;
// These are used in agents.rs and claude.rs via direct module paths
#[allow(unused)]
pub use profile::{ProfileBuilder, SandboxProfile, SandboxRule};
// These are used in main.rs and sandbox.rs
#[allow(unused)]
pub use executor::{should_activate_sandbox, SandboxExecutor};
// These are used in sandbox.rs
#[allow(unused)]
pub use platform::{get_platform_capabilities, PlatformCapabilities};
// Used for initial setup
#[allow(unused)]
pub use defaults::create_default_profiles;

View File

@@ -1,179 +0,0 @@
use serde::{Deserialize, Serialize};
use std::env;
/// Represents the sandbox capabilities of the current platform
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlatformCapabilities {
/// The current operating system
pub os: String,
/// Whether sandboxing is supported on this platform
pub sandboxing_supported: bool,
/// Supported operations and their support levels
pub operations: Vec<OperationSupport>,
/// Platform-specific notes or warnings
pub notes: Vec<String>,
}
/// Represents support for a specific operation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OperationSupport {
/// The operation type
pub operation: String,
/// Support level: "never", "can_be_allowed", "cannot_be_precisely", "always"
pub support_level: String,
/// Human-readable description
pub description: String,
}
/// Get the platform capabilities for sandboxing
pub fn get_platform_capabilities() -> PlatformCapabilities {
let os = env::consts::OS;
match os {
"linux" => get_linux_capabilities(),
"macos" => get_macos_capabilities(),
"freebsd" => get_freebsd_capabilities(),
_ => get_unsupported_capabilities(os),
}
}
fn get_linux_capabilities() -> PlatformCapabilities {
PlatformCapabilities {
os: "linux".to_string(),
sandboxing_supported: true,
operations: vec![
OperationSupport {
operation: "file_read_all".to_string(),
support_level: "can_be_allowed".to_string(),
description: "Can allow file reading via bind mounts in chroot jail".to_string(),
},
OperationSupport {
operation: "file_read_metadata".to_string(),
support_level: "cannot_be_precisely".to_string(),
description: "Cannot be precisely controlled, allowed if file read is allowed".to_string(),
},
OperationSupport {
operation: "network_outbound_all".to_string(),
support_level: "can_be_allowed".to_string(),
description: "Can allow all network access by not creating network namespace".to_string(),
},
OperationSupport {
operation: "network_outbound_tcp".to_string(),
support_level: "cannot_be_precisely".to_string(),
description: "Cannot filter by specific ports with seccomp".to_string(),
},
OperationSupport {
operation: "network_outbound_local".to_string(),
support_level: "cannot_be_precisely".to_string(),
description: "Cannot filter by specific socket paths with seccomp".to_string(),
},
OperationSupport {
operation: "system_info_read".to_string(),
support_level: "never".to_string(),
description: "Not supported on Linux".to_string(),
},
],
notes: vec![
"Linux sandboxing uses namespaces (user, PID, IPC, mount, UTS, network) and seccomp-bpf".to_string(),
"File access is controlled via bind mounts in a chroot jail".to_string(),
"Network filtering is all-or-nothing (cannot filter by port/address)".to_string(),
"Process creation and privilege escalation are always blocked".to_string(),
],
}
}
fn get_macos_capabilities() -> PlatformCapabilities {
PlatformCapabilities {
os: "macos".to_string(),
sandboxing_supported: true,
operations: vec![
OperationSupport {
operation: "file_read_all".to_string(),
support_level: "can_be_allowed".to_string(),
description: "Can allow file reading with Seatbelt profiles".to_string(),
},
OperationSupport {
operation: "file_read_metadata".to_string(),
support_level: "can_be_allowed".to_string(),
description: "Can allow metadata reading with Seatbelt profiles".to_string(),
},
OperationSupport {
operation: "network_outbound_all".to_string(),
support_level: "can_be_allowed".to_string(),
description: "Can allow all network access".to_string(),
},
OperationSupport {
operation: "network_outbound_tcp".to_string(),
support_level: "can_be_allowed".to_string(),
description: "Can allow specific TCP ports".to_string(),
},
OperationSupport {
operation: "network_outbound_local".to_string(),
support_level: "can_be_allowed".to_string(),
description: "Can allow specific local socket paths".to_string(),
},
OperationSupport {
operation: "system_info_read".to_string(),
support_level: "can_be_allowed".to_string(),
description: "Can allow sysctl reads".to_string(),
},
],
notes: vec![
"macOS sandboxing uses Seatbelt (sandbox_init API)".to_string(),
"More fine-grained control compared to Linux".to_string(),
"Can filter network access by port and socket path".to_string(),
"Supports platform-specific operations like Mach port lookups".to_string(),
],
}
}
fn get_freebsd_capabilities() -> PlatformCapabilities {
PlatformCapabilities {
os: "freebsd".to_string(),
sandboxing_supported: true,
operations: vec![
OperationSupport {
operation: "system_info_read".to_string(),
support_level: "always".to_string(),
description: "Always allowed with Capsicum".to_string(),
},
OperationSupport {
operation: "file_read_all".to_string(),
support_level: "never".to_string(),
description: "Not supported with current Capsicum implementation".to_string(),
},
OperationSupport {
operation: "file_read_metadata".to_string(),
support_level: "never".to_string(),
description: "Not supported with current Capsicum implementation".to_string(),
},
OperationSupport {
operation: "network_outbound_all".to_string(),
support_level: "never".to_string(),
description: "Not supported with current Capsicum implementation".to_string(),
},
],
notes: vec![
"FreeBSD support is very limited in gaol".to_string(),
"Uses Capsicum for capability-based security".to_string(),
"Most operations are not supported".to_string(),
],
}
}
fn get_unsupported_capabilities(os: &str) -> PlatformCapabilities {
PlatformCapabilities {
os: os.to_string(),
sandboxing_supported: false,
operations: vec![],
notes: vec![
format!("Sandboxing is not supported on {} platform", os),
"Claude Code will run without sandbox restrictions".to_string(),
],
}
}
/// Check if sandboxing is available on the current platform
pub fn is_sandboxing_available() -> bool {
matches!(env::consts::OS, "linux" | "macos" | "freebsd")
}

View File

@@ -1,556 +0,0 @@
use crate::sandbox::executor::{SerializedOperation, SerializedProfile};
use anyhow::{Context, Result};
#[cfg(unix)]
use gaol::profile::{AddressPattern, Operation, OperationSupport, PathPattern, Profile};
use log::{debug, info, warn};
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Represents a sandbox profile from the database
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxProfile {
pub id: Option<i64>,
pub name: String,
pub description: Option<String>,
pub is_active: bool,
pub is_default: bool,
pub created_at: String,
pub updated_at: String,
}
/// Represents a sandbox rule from the database
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxRule {
pub id: Option<i64>,
pub profile_id: i64,
pub operation_type: String,
pub pattern_type: String,
pub pattern_value: String,
pub enabled: bool,
pub platform_support: Option<String>,
pub created_at: String,
}
/// Result of building a profile
pub struct ProfileBuildResult {
#[cfg(unix)]
pub profile: Profile,
#[cfg(not(unix))]
pub profile: (), // Placeholder for Windows
pub serialized: SerializedProfile,
}
/// Builder for creating gaol profiles from database configuration
pub struct ProfileBuilder {
project_path: PathBuf,
home_dir: PathBuf,
}
impl ProfileBuilder {
/// Create a new profile builder
pub fn new(project_path: PathBuf) -> Result<Self> {
let home_dir = dirs::home_dir().context("Could not determine home directory")?;
Ok(Self {
project_path,
home_dir,
})
}
/// Build a gaol Profile from database rules filtered by agent permissions
pub fn build_agent_profile(
&self,
rules: Vec<SandboxRule>,
sandbox_enabled: bool,
enable_file_read: bool,
enable_file_write: bool,
enable_network: bool,
) -> Result<ProfileBuildResult> {
// If sandbox is completely disabled, return an empty profile
if !sandbox_enabled {
return Ok(ProfileBuildResult {
#[cfg(unix)]
profile: Profile::new(vec![])
.map_err(|_| anyhow::anyhow!("Failed to create empty profile"))?,
#[cfg(not(unix))]
profile: (),
serialized: SerializedProfile { operations: vec![] },
});
}
let mut filtered_rules = Vec::new();
for rule in rules {
if !rule.enabled {
continue;
}
// Filter rules based on agent permissions
let include_rule = match rule.operation_type.as_str() {
"file_read_all" | "file_read_metadata" => enable_file_read,
"network_outbound" => enable_network,
"system_info_read" => true, // Always allow system info reading
_ => true, // Include unknown rule types by default
};
if include_rule {
filtered_rules.push(rule);
}
}
// Always ensure project path access if file reading is enabled
if enable_file_read {
let has_project_access = filtered_rules.iter().any(|rule| {
rule.operation_type == "file_read_all"
&& rule.pattern_type == "subpath"
&& rule.pattern_value.contains("{{PROJECT_PATH}}")
});
if !has_project_access {
// Add a default project access rule
filtered_rules.push(SandboxRule {
id: None,
profile_id: 0,
operation_type: "file_read_all".to_string(),
pattern_type: "subpath".to_string(),
pattern_value: "{{PROJECT_PATH}}".to_string(),
enabled: true,
platform_support: None,
created_at: String::new(),
});
}
}
self.build_profile_with_serialization(filtered_rules)
}
/// Build a gaol Profile from database rules
#[cfg(unix)]
pub fn build_profile(&self, rules: Vec<SandboxRule>) -> Result<Profile> {
let result = self.build_profile_with_serialization(rules)?;
Ok(result.profile)
}
/// Build a gaol Profile from database rules (Windows stub)
#[cfg(not(unix))]
pub fn build_profile(&self, _rules: Vec<SandboxRule>) -> Result<()> {
warn!("Sandbox profiles are not supported on Windows");
Ok(())
}
/// Build a gaol Profile from database rules and return serialized operations
pub fn build_profile_with_serialization(
&self,
rules: Vec<SandboxRule>,
) -> Result<ProfileBuildResult> {
#[cfg(unix)]
{
let mut operations = Vec::new();
let mut serialized_operations = Vec::new();
for rule in rules {
if !rule.enabled {
continue;
}
// Check platform support
if !self.is_rule_supported_on_platform(&rule) {
debug!(
"Skipping rule {} - not supported on current platform",
rule.operation_type
);
continue;
}
match self.build_operation_with_serialization(&rule) {
Ok(Some((op, serialized))) => {
// Check if operation is supported on current platform
if matches!(
op.support(),
gaol::profile::OperationSupportLevel::CanBeAllowed
) {
operations.push(op);
serialized_operations.push(serialized);
} else {
warn!(
"Operation {:?} not supported at desired level on current platform",
rule.operation_type
);
}
}
Ok(None) => {
debug!(
"Skipping unsupported operation type: {}",
rule.operation_type
);
}
Err(e) => {
warn!(
"Failed to build operation for rule {}: {}",
rule.id.unwrap_or(0),
e
);
}
}
}
// Ensure project path access is included
let has_project_access = serialized_operations.iter().any(|op| {
matches!(op, SerializedOperation::FileReadAll { path, is_subpath: true } if path == &self.project_path)
});
if !has_project_access {
operations.push(Operation::FileReadAll(PathPattern::Subpath(
self.project_path.clone(),
)));
serialized_operations.push(SerializedOperation::FileReadAll {
path: self.project_path.clone(),
is_subpath: true,
});
}
// Create the profile
let profile = Profile::new(operations)
.map_err(|_| anyhow::anyhow!("Failed to create sandbox profile - some operations may not be supported on this platform"))?;
Ok(ProfileBuildResult {
profile,
serialized: SerializedProfile {
operations: serialized_operations,
},
})
}
#[cfg(not(unix))]
{
// On Windows, we just create a serialized profile without actual sandboxing
let mut serialized_operations = Vec::new();
for rule in rules {
if !rule.enabled {
continue;
}
if let Ok(Some(serialized)) = self.build_serialized_operation(&rule) {
serialized_operations.push(serialized);
}
}
Ok(ProfileBuildResult {
profile: (),
serialized: SerializedProfile {
operations: serialized_operations,
},
})
}
}
/// Build a gaol Operation from a database rule
#[cfg(unix)]
fn build_operation(&self, rule: &SandboxRule) -> Result<Option<Operation>> {
match self.build_operation_with_serialization(rule) {
Ok(Some((op, _))) => Ok(Some(op)),
Ok(None) => Ok(None),
Err(e) => Err(e),
}
}
/// Build a gaol Operation and its serialized form from a database rule
#[cfg(unix)]
fn build_operation_with_serialization(
&self,
rule: &SandboxRule,
) -> Result<Option<(Operation, SerializedOperation)>> {
match rule.operation_type.as_str() {
"file_read_all" => {
let (pattern, path, is_subpath) =
self.build_path_pattern_with_info(&rule.pattern_type, &rule.pattern_value)?;
Ok(Some((
Operation::FileReadAll(pattern),
SerializedOperation::FileReadAll { path, is_subpath },
)))
}
"file_read_metadata" => {
let (pattern, path, is_subpath) =
self.build_path_pattern_with_info(&rule.pattern_type, &rule.pattern_value)?;
Ok(Some((
Operation::FileReadMetadata(pattern),
SerializedOperation::FileReadMetadata { path, is_subpath },
)))
}
"network_outbound" => {
let (pattern, serialized) = self.build_address_pattern_with_serialization(
&rule.pattern_type,
&rule.pattern_value,
)?;
Ok(Some((Operation::NetworkOutbound(pattern), serialized)))
}
"system_info_read" => Ok(Some((
Operation::SystemInfoRead,
SerializedOperation::SystemInfoRead,
))),
_ => Ok(None),
}
}
/// Build a PathPattern from pattern type and value
#[cfg(unix)]
fn build_path_pattern(&self, pattern_type: &str, pattern_value: &str) -> Result<PathPattern> {
let (pattern, _, _) = self.build_path_pattern_with_info(pattern_type, pattern_value)?;
Ok(pattern)
}
/// Build a PathPattern and return additional info for serialization
#[cfg(unix)]
fn build_path_pattern_with_info(
&self,
pattern_type: &str,
pattern_value: &str,
) -> Result<(PathPattern, PathBuf, bool)> {
// Replace template variables
let expanded_value = pattern_value
.replace("{{PROJECT_PATH}}", &self.project_path.to_string_lossy())
.replace("{{HOME}}", &self.home_dir.to_string_lossy());
let path = PathBuf::from(expanded_value);
match pattern_type {
"literal" => Ok((PathPattern::Literal(path.clone()), path, false)),
"subpath" => Ok((PathPattern::Subpath(path.clone()), path, true)),
_ => Err(anyhow::anyhow!(
"Unknown path pattern type: {}",
pattern_type
)),
}
}
/// Build an AddressPattern from pattern type and value
#[cfg(unix)]
fn build_address_pattern(
&self,
pattern_type: &str,
pattern_value: &str,
) -> Result<AddressPattern> {
let (pattern, _) =
self.build_address_pattern_with_serialization(pattern_type, pattern_value)?;
Ok(pattern)
}
/// Build an AddressPattern and its serialized form
#[cfg(unix)]
fn build_address_pattern_with_serialization(
&self,
pattern_type: &str,
pattern_value: &str,
) -> Result<(AddressPattern, SerializedOperation)> {
match pattern_type {
"all" => Ok((
AddressPattern::All,
SerializedOperation::NetworkOutbound {
pattern: "all".to_string(),
},
)),
"tcp" => {
let port = pattern_value
.parse::<u16>()
.context("Invalid TCP port number")?;
Ok((
AddressPattern::Tcp(port),
SerializedOperation::NetworkTcp { port },
))
}
"local_socket" => {
let path = PathBuf::from(pattern_value);
Ok((
AddressPattern::LocalSocket(path.clone()),
SerializedOperation::NetworkLocalSocket { path },
))
}
_ => Err(anyhow::anyhow!(
"Unknown address pattern type: {}",
pattern_type
)),
}
}
/// Check if a rule is supported on the current platform
fn is_rule_supported_on_platform(&self, rule: &SandboxRule) -> bool {
if let Some(platforms_json) = &rule.platform_support {
if let Ok(platforms) = serde_json::from_str::<Vec<String>>(platforms_json) {
let current_os = std::env::consts::OS;
return platforms.contains(&current_os.to_string());
}
}
// If no platform support specified, assume it's supported
true
}
/// Build only the serialized operation (for Windows)
#[cfg(not(unix))]
fn build_serialized_operation(
&self,
rule: &SandboxRule,
) -> Result<Option<SerializedOperation>> {
let pattern_value = self.expand_pattern_value(&rule.pattern_value);
match rule.operation_type.as_str() {
"file_read_all" => {
let (path, is_subpath) =
self.parse_path_pattern(&rule.pattern_type, &pattern_value)?;
Ok(Some(SerializedOperation::FileReadAll { path, is_subpath }))
}
"file_read_metadata" => {
let (path, is_subpath) =
self.parse_path_pattern(&rule.pattern_type, &pattern_value)?;
Ok(Some(SerializedOperation::FileReadMetadata {
path,
is_subpath,
}))
}
"network_outbound" => Ok(Some(SerializedOperation::NetworkOutbound {
pattern: pattern_value,
})),
"network_tcp" => {
let port = pattern_value.parse::<u16>().context("Invalid TCP port")?;
Ok(Some(SerializedOperation::NetworkTcp { port }))
}
"network_local_socket" => {
let path = PathBuf::from(pattern_value);
Ok(Some(SerializedOperation::NetworkLocalSocket { path }))
}
"system_info_read" => Ok(Some(SerializedOperation::SystemInfoRead)),
_ => Ok(None),
}
}
/// Helper method to expand pattern values (Windows version)
#[cfg(not(unix))]
fn expand_pattern_value(&self, pattern_value: &str) -> String {
pattern_value
.replace("{{PROJECT_PATH}}", &self.project_path.to_string_lossy())
.replace("{{HOME}}", &self.home_dir.to_string_lossy())
}
/// Helper method to parse path patterns (Windows version)
#[cfg(not(unix))]
fn parse_path_pattern(
&self,
pattern_type: &str,
pattern_value: &str,
) -> Result<(PathBuf, bool)> {
let path = PathBuf::from(pattern_value);
match pattern_type {
"literal" => Ok((path, false)),
"subpath" => Ok((path, true)),
_ => Err(anyhow::anyhow!(
"Unknown path pattern type: {}",
pattern_type
)),
}
}
}
/// Load a sandbox profile by ID
pub fn load_profile(conn: &Connection, profile_id: i64) -> Result<SandboxProfile> {
conn.query_row(
"SELECT id, name, description, is_active, is_default, created_at, updated_at
FROM sandbox_profiles WHERE id = ?1",
params![profile_id],
|row| {
Ok(SandboxProfile {
id: Some(row.get(0)?),
name: row.get(1)?,
description: row.get(2)?,
is_active: row.get(3)?,
is_default: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
},
)
.context("Failed to load sandbox profile")
}
/// Load the default sandbox profile
pub fn load_default_profile(conn: &Connection) -> Result<SandboxProfile> {
conn.query_row(
"SELECT id, name, description, is_active, is_default, created_at, updated_at
FROM sandbox_profiles WHERE is_default = 1",
[],
|row| {
Ok(SandboxProfile {
id: Some(row.get(0)?),
name: row.get(1)?,
description: row.get(2)?,
is_active: row.get(3)?,
is_default: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
},
)
.context("Failed to load default sandbox profile")
}
/// Load rules for a sandbox profile
pub fn load_profile_rules(conn: &Connection, profile_id: i64) -> Result<Vec<SandboxRule>> {
let mut stmt = conn.prepare(
"SELECT id, profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support, created_at
FROM sandbox_rules WHERE profile_id = ?1 AND enabled = 1"
)?;
let rules = stmt
.query_map(params![profile_id], |row| {
Ok(SandboxRule {
id: Some(row.get(0)?),
profile_id: row.get(1)?,
operation_type: row.get(2)?,
pattern_type: row.get(3)?,
pattern_value: row.get(4)?,
enabled: row.get(5)?,
platform_support: row.get(6)?,
created_at: row.get(7)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rules)
}
/// Get or create the gaol Profile for execution
#[cfg(unix)]
pub fn get_gaol_profile(
conn: &Connection,
profile_id: Option<i64>,
project_path: PathBuf,
) -> Result<Profile> {
// Load the profile
let profile = if let Some(id) = profile_id {
load_profile(conn, id)?
} else {
load_default_profile(conn)?
};
info!("Using sandbox profile: {}", profile.name);
// Load the rules
let rules = load_profile_rules(conn, profile.id.unwrap())?;
info!("Loaded {} sandbox rules", rules.len());
// Build the gaol profile
let builder = ProfileBuilder::new(project_path)?;
builder.build_profile(rules)
}
/// Get or create the gaol Profile for execution (Windows stub)
#[cfg(not(unix))]
pub fn get_gaol_profile(
_conn: &Connection,
_profile_id: Option<i64>,
_project_path: PathBuf,
) -> Result<()> {
warn!("Sandbox profiles are not supported on Windows");
Ok(())
}

View File

@@ -1,143 +0,0 @@
# Sandbox Test Suite Summary
## Overview
A comprehensive test suite has been created for the sandbox functionality in Claudia. The test suite validates that the sandboxing operations using the `gaol` crate work correctly across different platforms (Linux, macOS, FreeBSD).
## Test Structure Created
### 1. **Test Organization** (`tests/sandbox_tests.rs`)
- Main entry point for all sandbox tests
- Integrates all test modules
### 2. **Common Test Utilities** (`tests/sandbox/common/`)
- **fixtures.rs**: Test data, database setup, file system creation, and standard profiles
- **helpers.rs**: Helper functions, platform detection, test command execution, and code generation
### 3. **Unit Tests** (`tests/sandbox/unit/`)
- **profile_builder.rs**: Tests for ProfileBuilder including rule parsing, platform filtering, and template expansion
- **platform.rs**: Tests for platform capability detection and operation support levels
- **executor.rs**: Tests for SandboxExecutor creation and command preparation
### 4. **Integration Tests** (`tests/sandbox/integration/`)
- **file_operations.rs**: Tests file access control (allowed/forbidden reads, writes, metadata)
- **network_operations.rs**: Tests network access control (TCP, local sockets, port filtering)
- **system_info.rs**: Tests system information access (platform-specific)
- **process_isolation.rs**: Tests process spawning restrictions (fork, exec, threads)
- **violations.rs**: Tests violation detection and patterns
### 5. **End-to-End Tests** (`tests/sandbox/e2e/`)
- **agent_sandbox.rs**: Tests agent execution with sandbox profiles
- **claude_sandbox.rs**: Tests Claude command execution with sandboxing
## Key Features
### Platform Support
- **Cross-platform testing**: Tests adapt to platform capabilities
- **Skip unsupported**: Tests gracefully skip on unsupported platforms
- **Platform-specific tests**: Special tests for platform-specific features
### Test Helpers
- **Test binary creation**: Dynamically compiles test programs
- **Mock file systems**: Creates temporary test environments
- **Database fixtures**: Sets up test databases with profiles
- **Assertion helpers**: Specialized assertions for sandbox behavior
### Safety Features
- **Serial execution**: Tests run serially to avoid conflicts
- **Timeout handling**: Commands have timeout protection
- **Resource cleanup**: Temporary files and resources are cleaned up
## Running the Tests
```bash
# Run all sandbox tests
cargo test --test sandbox_tests
# Run specific categories
cargo test --test sandbox_tests unit::
cargo test --test sandbox_tests integration::
cargo test --test sandbox_tests e2e:: -- --ignored
# Run with output
cargo test --test sandbox_tests -- --nocapture
# Run serially (required for some tests)
cargo test --test sandbox_tests -- --test-threads=1
```
## Test Coverage
The test suite covers:
1. **Profile Management**
- Profile creation and validation
- Rule parsing and conflicts
- Template variable expansion
- Platform compatibility
2. **File Operations**
- Allowed file reads
- Forbidden file access
- File write prevention
- Metadata operations
3. **Network Operations**
- Network access control
- Port-specific rules (macOS)
- Local socket connections
4. **Process Isolation**
- Process spawn prevention
- Fork/exec blocking
- Thread creation (allowed)
5. **System Information**
- Platform-specific access control
- macOS sysctl operations
6. **Violation Tracking**
- Violation detection
- Pattern matching
- Multiple violations
## Platform-Specific Behavior
| Feature | Linux | macOS | FreeBSD |
|---------|-------|-------|---------|
| File Read Control | ✅ | ✅ | ❌ |
| Metadata Read | 🟡¹ | ✅ | ❌ |
| Network All | ✅ | ✅ | ❌ |
| Network TCP Port | ❌ | ✅ | ❌ |
| Network Local Socket | ❌ | ✅ | ❌ |
| System Info Read | ❌ | ✅ | ✅² |
¹ Cannot be precisely controlled on Linux
² Always allowed on FreeBSD
## Dependencies Added
```toml
[dev-dependencies]
tempfile = "3"
serial_test = "3"
test-case = "3"
once_cell = "1"
proptest = "1"
pretty_assertions = "1"
```
## Next Steps
1. **CI Integration**: Configure CI to run sandbox tests on multiple platforms
2. **Performance Tests**: Add benchmarks for sandbox overhead
3. **Stress Tests**: Test with many simultaneous sandboxed processes
4. **Mock Claude**: Create mock Claude command for E2E tests without dependencies
5. **Coverage Report**: Generate test coverage reports
## Notes
- Some E2E tests are marked `#[ignore]` as they require Claude to be installed
- Integration tests use `serial_test` to prevent conflicts
- Test binaries are compiled on-demand for realistic testing
- The test suite gracefully handles platform limitations

View File

@@ -21,7 +21,7 @@ test result: ok. 58 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
### Implementation Details:
#### Real Claude Execution (`tests/sandbox/common/claude_real.rs`):
#### Real Claude Execution:
- `execute_claude_task()` - Executes Claude with specified task and captures output
- Supports timeout handling (gtimeout on macOS, timeout on Linux)
- Returns structured output with stdout, stderr, exit code, and duration
@@ -33,18 +33,18 @@ test result: ok. 58 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
- 20-second timeout to allow Claude sufficient time to respond
#### Key Test Updates:
1. **Agent Tests** (`agent_sandbox.rs`):
- `test_agent_with_minimal_profile` - Tests with minimal sandbox permissions
- `test_agent_with_standard_profile` - Tests with standard permissions
- `test_agent_without_sandbox` - Control test without sandbox
1. **Agent Tests**:
- Test agent execution with various permission configurations
- Test agent execution in different project contexts
- Control tests for baseline behavior
2. **Claude Sandbox Tests** (`claude_sandbox.rs`):
- `test_claude_with_default_sandbox` - Tests default sandbox profile
- `test_claude_sandbox_disabled` - Tests with inactive sandbox
2. **Claude Tests**:
- Test Claude execution with default settings
- Test Claude execution with custom configurations
### Benefits of Real Claude Testing:
- **Authenticity**: Tests validate actual Claude behavior, not mocked responses
- **Integration**: Ensures the sandbox system works with real Claude execution
- **Integration**: Ensures the system works with real Claude execution
- **End-to-End**: Complete validation from command invocation to output parsing
- **No External Dependencies**: Uses `--dangerously-skip-permissions` flag
@@ -53,6 +53,6 @@ test result: ok. 58 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
- No ignored tests
- No TODOs in test code
- Clean compilation with no warnings
- Platform-aware sandbox expectations (Linux vs macOS)
- Platform-aware expectations for different operating systems
The test suite now provides comprehensive end-to-end validation with actual Claude execution.
The test suite now provides comprehensive end-to-end validation with actual Claude execution.

View File

@@ -13,7 +13,7 @@
- Created `create_test_binary_with_deps` function
3. **Fixed Database Schema Issue**
- Added missing tables (agents, agent_runs, sandbox_violations) to test database
- Added missing tables (agents, agent_runs) to test database
- Fixed foreign key constraint issues
4. **Fixed Mutex Poisoning**
@@ -35,8 +35,8 @@
7. **Removed All TODOs**
- No TODOs remain in test code
8. **Handled Platform-Specific Sandbox Limitations**
- Tests properly handle macOS sandbox limitations
8. **Handled Platform-Specific Limitations**
- Tests properly handle platform-specific differences
- Platform-aware assertions prevent false failures
## Test Results:
@@ -52,4 +52,4 @@ test result: ok. 61 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
- Comprehensive mock system for external dependencies
- Platform-aware testing for cross-platform compatibility
The test suite is now production-ready with full coverage and no issues.
The test suite is now production-ready with full coverage and no issues.

View File

@@ -1,155 +0,0 @@
# Sandbox Test Suite
This directory contains a comprehensive test suite for the sandbox functionality in Claudia. The tests are designed to verify that the sandboxing operations work correctly across different platforms (Linux, macOS, FreeBSD).
## Test Structure
```
sandbox/
├── common/ # Shared test utilities
│ ├── fixtures.rs # Test data and environment setup
│ └── helpers.rs # Helper functions and assertions
├── unit/ # Unit tests for individual components
│ ├── profile_builder.rs # ProfileBuilder tests
│ ├── platform.rs # Platform capability tests
│ └── executor.rs # SandboxExecutor tests
├── integration/ # Integration tests for sandbox operations
│ ├── file_operations.rs # File access control tests
│ ├── network_operations.rs # Network access control tests
│ ├── system_info.rs # System info access tests
│ ├── process_isolation.rs # Process spawning tests
│ └── violations.rs # Violation detection tests
└── e2e/ # End-to-end tests
├── agent_sandbox.rs # Agent execution with sandbox
└── claude_sandbox.rs # Claude command with sandbox
```
## Running Tests
### Run all sandbox tests:
```bash
cargo test --test sandbox_tests
```
### Run specific test categories:
```bash
# Unit tests only
cargo test --test sandbox_tests unit::
# Integration tests only
cargo test --test sandbox_tests integration::
# End-to-end tests only (requires Claude to be installed)
cargo test --test sandbox_tests e2e:: -- --ignored
```
### Run tests with output:
```bash
cargo test --test sandbox_tests -- --nocapture
```
### Run tests serially (required for some integration tests):
```bash
cargo test --test sandbox_tests -- --test-threads=1
```
## Test Coverage
### Unit Tests
1. **ProfileBuilder Tests** (`unit/profile_builder.rs`)
- Profile creation and validation
- Rule parsing and platform filtering
- Template variable expansion
- Invalid operation handling
2. **Platform Tests** (`unit/platform.rs`)
- Platform capability detection
- Operation support levels
- Cross-platform compatibility
3. **Executor Tests** (`unit/executor.rs`)
- Sandbox executor creation
- Command preparation
- Environment variable handling
### Integration Tests
1. **File Operations** (`integration/file_operations.rs`)
- ✅ Allowed file reads succeed
- ❌ Forbidden file reads fail
- ❌ File writes always fail
- 📊 Metadata operations respect permissions
- 🔄 Template variable expansion works
2. **Network Operations** (`integration/network_operations.rs`)
- ✅ Allowed network connections succeed
- ❌ Forbidden network connections fail
- 🎯 Port-specific rules (macOS only)
- 🔌 Local socket connections
3. **System Information** (`integration/system_info.rs`)
- 🍎 macOS: Can be allowed/forbidden
- 🐧 Linux: Never allowed
- 👹 FreeBSD: Always allowed
4. **Process Isolation** (`integration/process_isolation.rs`)
- ❌ Process spawning forbidden
- ❌ Fork/exec operations blocked
- ✅ Thread creation allowed
5. **Violations** (`integration/violations.rs`)
- 🚨 Violation detection
- 📝 Violation patterns
- 🔢 Multiple violations handling
### End-to-End Tests
1. **Agent Sandbox** (`e2e/agent_sandbox.rs`)
- Agent execution with profiles
- Profile switching
- Violation logging
2. **Claude Sandbox** (`e2e/claude_sandbox.rs`)
- Claude command sandboxing
- Settings integration
- Session management
## Platform Support
| Feature | Linux | macOS | FreeBSD |
|---------|-------|-------|---------|
| File Read Control | ✅ | ✅ | ❌ |
| Metadata Read | 🟡¹ | ✅ | ❌ |
| Network All | ✅ | ✅ | ❌ |
| Network TCP Port | ❌ | ✅ | ❌ |
| Network Local Socket | ❌ | ✅ | ❌ |
| System Info Read | ❌ | ✅ | ✅² |
¹ Cannot be precisely controlled on Linux (allowed if file read is allowed)
² Always allowed on FreeBSD (cannot be restricted)
## Important Notes
1. **Serial Execution**: Many integration tests are marked with `#[serial]` and must run one at a time to avoid conflicts.
2. **Platform Dependencies**: Some tests will be skipped on unsupported platforms. The test suite handles this gracefully.
3. **Privilege Requirements**: Sandbox tests generally don't require elevated privileges, but some operations may fail in restricted environments (e.g., CI).
4. **Claude Dependency**: E2E tests that actually execute Claude are marked with `#[ignore]` by default. Run with `--ignored` flag when Claude is installed.
## Debugging Failed Tests
1. **Enable Logging**: Set `RUST_LOG=debug` to see detailed sandbox operations
2. **Check Platform**: Verify the test is supported on your platform
3. **Check Permissions**: Ensure test binaries can be created and executed
4. **Inspect Output**: Use `--nocapture` to see all test output
## Adding New Tests
1. Choose the appropriate category (unit/integration/e2e)
2. Use the test helpers from `common/`
3. Mark with `#[serial]` if the test modifies global state
4. Use `skip_if_unsupported!()` macro for platform-specific tests
5. Document any special requirements or limitations

View File

@@ -1,187 +0,0 @@
//! Helper functions for executing real Claude commands in tests
use anyhow::{Context, Result};
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::Duration;
/// Execute Claude with a specific task and capture output
pub fn execute_claude_task(
project_path: &Path,
task: &str,
system_prompt: Option<&str>,
model: Option<&str>,
sandbox_profile_id: Option<i64>,
timeout_secs: u64,
) -> Result<ClaudeOutput> {
let mut cmd = Command::new("claude");
// Add task
cmd.arg("-p").arg(task);
// Add system prompt if provided
if let Some(prompt) = system_prompt {
cmd.arg("--system-prompt").arg(prompt);
}
// Add model if provided
if let Some(m) = model {
cmd.arg("--model").arg(m);
}
// Always add these flags for testing
cmd.arg("--output-format")
.arg("stream-json")
.arg("--verbose")
.arg("--dangerously-skip-permissions")
.current_dir(project_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// Add sandbox profile ID if provided
if let Some(profile_id) = sandbox_profile_id {
cmd.env("CLAUDIA_SANDBOX_PROFILE_ID", profile_id.to_string());
}
// Execute with timeout (use gtimeout on macOS, timeout on Linux)
let start = std::time::Instant::now();
let timeout_cmd = if cfg!(target_os = "macos") {
// On macOS, try gtimeout (from GNU coreutils) first, fallback to direct execution
if std::process::Command::new("which")
.arg("gtimeout")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
"gtimeout"
} else {
// If gtimeout not available, just run without timeout
""
}
} else {
"timeout"
};
let output = if timeout_cmd.is_empty() {
// Run without timeout wrapper
cmd.output().context("Failed to execute Claude command")?
} else {
// Run with timeout wrapper
let mut timeout_cmd = Command::new(timeout_cmd);
timeout_cmd
.arg(timeout_secs.to_string())
.arg("claude")
.args(cmd.get_args())
.current_dir(project_path)
.envs(cmd.get_envs().filter_map(|(k, v)| v.map(|v| (k, v))))
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.context("Failed to execute Claude command with timeout")?
};
let duration = start.elapsed();
Ok(ClaudeOutput {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
exit_code: output.status.code().unwrap_or(-1),
duration,
})
}
/// Result of Claude execution
#[derive(Debug)]
pub struct ClaudeOutput {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub duration: Duration,
}
impl ClaudeOutput {
/// Check if the output contains evidence of a specific operation
pub fn contains_operation(&self, operation: &str) -> bool {
self.stdout.contains(operation) || self.stderr.contains(operation)
}
/// Check if operation was blocked (look for permission denied, sandbox violation, etc)
pub fn operation_was_blocked(&self, operation: &str) -> bool {
let blocked_patterns = [
"permission denied",
"not permitted",
"blocked by sandbox",
"operation not allowed",
"access denied",
"sandbox violation",
];
let output = format!("{}\n{}", self.stdout, self.stderr).to_lowercase();
let op_lower = operation.to_lowercase();
// Check if operation was mentioned along with a block pattern
blocked_patterns
.iter()
.any(|pattern| output.contains(&op_lower) && output.contains(pattern))
}
/// Check if file read was successful
pub fn file_read_succeeded(&self, filename: &str) -> bool {
// Look for patterns indicating successful file read
let patterns = [
&format!("Read {}", filename),
&format!("Reading {}", filename),
&format!("Contents of {}", filename),
"test content", // Our test files contain this
];
patterns
.iter()
.any(|pattern| self.contains_operation(pattern))
}
/// Check if network connection was attempted
pub fn network_attempted(&self, host: &str) -> bool {
let patterns = [
&format!("Connecting to {}", host),
&format!("Connected to {}", host),
&format!("connect to {}", host),
host,
];
patterns
.iter()
.any(|pattern| self.contains_operation(pattern))
}
}
/// Common test tasks for Claude
pub mod tasks {
/// Task to read a file
pub fn read_file(filename: &str) -> String {
format!("Read the file {} and show me its contents", filename)
}
/// Task to attempt network connection
pub fn connect_network(host: &str) -> String {
format!("Try to connect to {} and tell me if it works", host)
}
/// Task to do multiple operations
pub fn multi_operation() -> String {
"Read the file ./test.txt in the current directory and show its contents".to_string()
}
/// Task to test file write
pub fn write_file(filename: &str, content: &str) -> String {
format!(
"Create a file called {} with the content '{}'",
filename, content
)
}
/// Task to test process spawning
pub fn spawn_process(command: &str) -> String {
format!("Run the command '{}' and show me the output", command)
}
}

View File

@@ -1,332 +0,0 @@
//! Test fixtures and data for sandbox testing
use anyhow::Result;
use once_cell::sync::Lazy;
use rusqlite::{params, Connection};
use std::path::PathBuf;
// Removed std::sync::Mutex - using parking_lot::Mutex instead
use tempfile::{tempdir, TempDir};
/// Global test database for sandbox testing
/// Using parking_lot::Mutex which doesn't poison on panic
use parking_lot::Mutex;
pub static TEST_DB: Lazy<Mutex<TestDatabase>> =
Lazy::new(|| Mutex::new(TestDatabase::new().expect("Failed to create test database")));
/// Test database manager
pub struct TestDatabase {
pub conn: Connection,
pub temp_dir: TempDir,
}
impl TestDatabase {
/// Create a new test database with schema
pub fn new() -> Result<Self> {
let temp_dir = tempdir()?;
let db_path = temp_dir.path().join("test_sandbox.db");
let conn = Connection::open(&db_path)?;
// Initialize schema
Self::init_schema(&conn)?;
Ok(Self { conn, temp_dir })
}
/// Initialize database schema
fn init_schema(conn: &Connection) -> Result<()> {
// Create sandbox profiles table
conn.execute(
"CREATE TABLE IF NOT EXISTS sandbox_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT 0,
is_default BOOLEAN NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)",
[],
)?;
// Create sandbox rules table
conn.execute(
"CREATE TABLE IF NOT EXISTS sandbox_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER NOT NULL,
operation_type TEXT NOT NULL,
pattern_type TEXT NOT NULL,
pattern_value TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT 1,
platform_support TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (profile_id) REFERENCES sandbox_profiles(id) ON DELETE CASCADE
)",
[],
)?;
// Create agents table
conn.execute(
"CREATE TABLE IF NOT EXISTS agents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
icon TEXT NOT NULL,
system_prompt TEXT NOT NULL,
default_task TEXT,
model TEXT NOT NULL DEFAULT 'sonnet',
sandbox_profile_id INTEGER REFERENCES sandbox_profiles(id),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)",
[],
)?;
// Create agent_runs table
conn.execute(
"CREATE TABLE IF NOT EXISTS agent_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL,
agent_name TEXT NOT NULL,
agent_icon TEXT NOT NULL,
task TEXT NOT NULL,
model TEXT NOT NULL,
project_path TEXT NOT NULL,
output TEXT NOT NULL DEFAULT '',
duration_ms INTEGER,
total_tokens INTEGER,
cost_usd REAL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TEXT,
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
)",
[],
)?;
// Create sandbox violations table
conn.execute(
"CREATE TABLE IF NOT EXISTS sandbox_violations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER,
agent_id INTEGER,
agent_run_id INTEGER,
operation_type TEXT NOT NULL,
pattern_value TEXT,
process_name TEXT,
pid INTEGER,
denied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (profile_id) REFERENCES sandbox_profiles(id) ON DELETE CASCADE,
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE,
FOREIGN KEY (agent_run_id) REFERENCES agent_runs(id) ON DELETE CASCADE
)",
[],
)?;
// Create trigger to update the updated_at timestamp for agents
conn.execute(
"CREATE TRIGGER IF NOT EXISTS update_agent_timestamp
AFTER UPDATE ON agents
FOR EACH ROW
BEGIN
UPDATE agents SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END",
[],
)?;
// Create trigger to update sandbox profile timestamp
conn.execute(
"CREATE TRIGGER IF NOT EXISTS update_sandbox_profile_timestamp
AFTER UPDATE ON sandbox_profiles
FOR EACH ROW
BEGIN
UPDATE sandbox_profiles SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END",
[],
)?;
Ok(())
}
/// Create a test profile with rules
pub fn create_test_profile(&self, name: &str, rules: Vec<TestRule>) -> Result<i64> {
// Insert profile
self.conn.execute(
"INSERT INTO sandbox_profiles (name, description, is_active, is_default) VALUES (?1, ?2, ?3, ?4)",
params![name, format!("Test profile: {name}"), true, false],
)?;
let profile_id = self.conn.last_insert_rowid();
// Insert rules
for rule in rules {
self.conn.execute(
"INSERT INTO sandbox_rules (profile_id, operation_type, pattern_type, pattern_value, enabled, platform_support)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
profile_id,
rule.operation_type,
rule.pattern_type,
rule.pattern_value,
rule.enabled,
rule.platform_support
],
)?;
}
Ok(profile_id)
}
/// Reset database to clean state
pub fn reset(&self) -> Result<()> {
// Delete in the correct order to respect foreign key constraints
self.conn.execute("DELETE FROM sandbox_violations", [])?;
self.conn.execute("DELETE FROM agent_runs", [])?;
self.conn.execute("DELETE FROM agents", [])?;
self.conn.execute("DELETE FROM sandbox_rules", [])?;
self.conn.execute("DELETE FROM sandbox_profiles", [])?;
Ok(())
}
}
/// Test rule structure
#[derive(Clone, Debug)]
pub struct TestRule {
pub operation_type: String,
pub pattern_type: String,
pub pattern_value: String,
pub enabled: bool,
pub platform_support: Option<String>,
}
impl TestRule {
/// Create a file read rule
pub fn file_read(path: &str, subpath: bool) -> Self {
Self {
operation_type: "file_read_all".to_string(),
pattern_type: if subpath { "subpath" } else { "literal" }.to_string(),
pattern_value: path.to_string(),
enabled: true,
platform_support: Some(r#"["linux", "macos"]"#.to_string()),
}
}
/// Create a network rule
pub fn network_all() -> Self {
Self {
operation_type: "network_outbound".to_string(),
pattern_type: "all".to_string(),
pattern_value: String::new(),
enabled: true,
platform_support: Some(r#"["linux", "macos"]"#.to_string()),
}
}
/// Create a network TCP rule
pub fn network_tcp(port: u16) -> Self {
Self {
operation_type: "network_outbound".to_string(),
pattern_type: "tcp".to_string(),
pattern_value: port.to_string(),
enabled: true,
platform_support: Some(r#"["macos"]"#.to_string()),
}
}
/// Create a system info read rule
pub fn system_info_read() -> Self {
Self {
operation_type: "system_info_read".to_string(),
pattern_type: "all".to_string(),
pattern_value: String::new(),
enabled: true,
platform_support: Some(r#"["macos"]"#.to_string()),
}
}
}
/// Test file system structure
pub struct TestFileSystem {
pub root: TempDir,
pub project_path: PathBuf,
pub allowed_path: PathBuf,
pub forbidden_path: PathBuf,
}
impl TestFileSystem {
/// Create a new test file system with predefined structure
pub fn new() -> Result<Self> {
let root = tempdir()?;
let root_path = root.path();
// Create project directory
let project_path = root_path.join("test_project");
std::fs::create_dir_all(&project_path)?;
// Create allowed directory
let allowed_path = root_path.join("allowed");
std::fs::create_dir_all(&allowed_path)?;
std::fs::write(allowed_path.join("test.txt"), "allowed content")?;
// Create forbidden directory
let forbidden_path = root_path.join("forbidden");
std::fs::create_dir_all(&forbidden_path)?;
std::fs::write(forbidden_path.join("secret.txt"), "forbidden content")?;
// Create project files
std::fs::write(project_path.join("main.rs"), "fn main() {}")?;
std::fs::write(
project_path.join("Cargo.toml"),
"[package]\nname = \"test\"",
)?;
Ok(Self {
root,
project_path,
allowed_path,
forbidden_path,
})
}
}
/// Standard test profiles
pub mod profiles {
use super::*;
/// Minimal profile - only project access
pub fn minimal(project_path: &str) -> Vec<TestRule> {
vec![TestRule::file_read(project_path, true)]
}
/// Standard profile - project + system libraries
pub fn standard(project_path: &str) -> Vec<TestRule> {
vec![
TestRule::file_read(project_path, true),
TestRule::file_read("/usr/lib", true),
TestRule::file_read("/usr/local/lib", true),
TestRule::network_all(),
]
}
/// Development profile - more permissive
pub fn development(project_path: &str, home_dir: &str) -> Vec<TestRule> {
vec![
TestRule::file_read(project_path, true),
TestRule::file_read("/usr", true),
TestRule::file_read("/opt", true),
TestRule::file_read(home_dir, true),
TestRule::network_all(),
TestRule::system_info_read(),
]
}
/// Network-only profile
pub fn network_only() -> Vec<TestRule> {
vec![TestRule::network_all()]
}
/// File-only profile
pub fn file_only(paths: Vec<&str>) -> Vec<TestRule> {
paths
.into_iter()
.map(|path| TestRule::file_read(path, true))
.collect()
}
}

View File

@@ -1,483 +0,0 @@
//! Helper functions for sandbox testing
use anyhow::{Context, Result};
use std::env;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::Duration;
/// Check if sandboxing is supported on the current platform
pub fn is_sandboxing_supported() -> bool {
matches!(env::consts::OS, "linux" | "macos" | "freebsd")
}
/// Skip test if sandboxing is not supported
#[macro_export]
macro_rules! skip_if_unsupported {
() => {
if !$crate::sandbox::common::is_sandboxing_supported() {
eprintln!(
"Skipping test: sandboxing not supported on {}",
std::env::consts::OS
);
return;
}
};
}
/// Platform-specific test configuration
pub struct PlatformConfig {
pub supports_file_read: bool,
pub supports_metadata_read: bool,
pub supports_network_all: bool,
pub supports_network_tcp: bool,
pub supports_network_local: bool,
pub supports_system_info: bool,
}
impl PlatformConfig {
/// Get configuration for current platform
pub fn current() -> Self {
match env::consts::OS {
"linux" => Self {
supports_file_read: true,
supports_metadata_read: false, // Cannot be precisely controlled
supports_network_all: true,
supports_network_tcp: false, // Cannot filter by port
supports_network_local: false, // Cannot filter by path
supports_system_info: false,
},
"macos" => Self {
supports_file_read: true,
supports_metadata_read: true,
supports_network_all: true,
supports_network_tcp: true,
supports_network_local: true,
supports_system_info: true,
},
"freebsd" => Self {
supports_file_read: false,
supports_metadata_read: false,
supports_network_all: false,
supports_network_tcp: false,
supports_network_local: false,
supports_system_info: true, // Always allowed
},
_ => Self {
supports_file_read: false,
supports_metadata_read: false,
supports_network_all: false,
supports_network_tcp: false,
supports_network_local: false,
supports_system_info: false,
},
}
}
}
/// Test command builder
pub struct TestCommand {
command: String,
args: Vec<String>,
env_vars: Vec<(String, String)>,
working_dir: Option<PathBuf>,
}
impl TestCommand {
/// Create a new test command
pub fn new(command: &str) -> Self {
Self {
command: command.to_string(),
args: Vec::new(),
env_vars: Vec::new(),
working_dir: None,
}
}
/// Add an argument
pub fn arg(mut self, arg: &str) -> Self {
self.args.push(arg.to_string());
self
}
/// Add multiple arguments
pub fn args(mut self, args: &[&str]) -> Self {
self.args.extend(args.iter().map(|s| s.to_string()));
self
}
/// Set an environment variable
pub fn env(mut self, key: &str, value: &str) -> Self {
self.env_vars.push((key.to_string(), value.to_string()));
self
}
/// Set working directory
pub fn current_dir(mut self, dir: &Path) -> Self {
self.working_dir = Some(dir.to_path_buf());
self
}
/// Execute the command with timeout
pub fn execute_with_timeout(&self, timeout: Duration) -> Result<Output> {
let mut cmd = Command::new(&self.command);
cmd.args(&self.args);
for (key, value) in &self.env_vars {
cmd.env(key, value);
}
if let Some(dir) = &self.working_dir {
cmd.current_dir(dir);
}
// On Unix, we can use a timeout mechanism
#[cfg(unix)]
{
use std::time::Instant;
let start = Instant::now();
let mut child = cmd.spawn().context("Failed to spawn command")?;
loop {
match child.try_wait() {
Ok(Some(status)) => {
let output = child.wait_with_output()?;
return Ok(Output {
status,
stdout: output.stdout,
stderr: output.stderr,
});
}
Ok(None) => {
if start.elapsed() > timeout {
child.kill()?;
return Err(anyhow::anyhow!("Command timed out"));
}
std::thread::sleep(Duration::from_millis(100));
}
Err(e) => return Err(e.into()),
}
}
}
#[cfg(not(unix))]
{
// Fallback for non-Unix platforms
cmd.output().context("Failed to execute command")
}
}
/// Execute and expect success
pub fn execute_expect_success(&self) -> Result<String> {
let output = self.execute_with_timeout(Duration::from_secs(10))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!(
"Command failed with status {:?}. Stderr: {stderr}",
output.status.code()
));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
/// Execute and expect failure
pub fn execute_expect_failure(&self) -> Result<String> {
let output = self.execute_with_timeout(Duration::from_secs(10))?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
return Err(anyhow::anyhow!(
"Command unexpectedly succeeded. Stdout: {stdout}"
));
}
Ok(String::from_utf8_lossy(&output.stderr).to_string())
}
}
/// Create a simple test binary that attempts an operation
pub fn create_test_binary(name: &str, code: &str, test_dir: &Path) -> Result<PathBuf> {
create_test_binary_with_deps(name, code, test_dir, &[])
}
/// Create a test binary with optional dependencies
pub fn create_test_binary_with_deps(
name: &str,
code: &str,
test_dir: &Path,
dependencies: &[(&str, &str)],
) -> Result<PathBuf> {
let src_dir = test_dir.join("src");
std::fs::create_dir_all(&src_dir)?;
// Build dependencies section
let deps_section = if dependencies.is_empty() {
String::new()
} else {
let mut deps = String::from("\n[dependencies]\n");
for (dep_name, dep_version) in dependencies {
deps.push_str(&format!("{dep_name} = \"{dep_version}\"\n"));
}
deps
};
// Create Cargo.toml
let cargo_toml = format!(
r#"[package]
name = "{name}"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "{name}"
path = "src/main.rs"
{deps_section}"#
);
std::fs::write(test_dir.join("Cargo.toml"), cargo_toml)?;
// Create main.rs
std::fs::write(src_dir.join("main.rs"), code)?;
// Build the binary
let output = Command::new("cargo")
.arg("build")
.arg("--release")
.current_dir(test_dir)
.output()
.context("Failed to build test binary")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to build test binary: {stderr}"));
}
let binary_path = test_dir.join("target/release").join(name);
Ok(binary_path)
}
/// Test code snippets for various operations
pub mod test_code {
/// Code that reads a file
pub fn file_read(path: &str) -> String {
format!(
r#"
fn main() {{
match std::fs::read_to_string("{path}") {{
Ok(content) => {{
println!("SUCCESS: Read {{}} bytes", content.len());
}}
Err(e) => {{
eprintln!("FAILURE: {{}}", e);
std::process::exit(1);
}}
}}
}}
"#
)
}
/// Code that reads file metadata
pub fn file_metadata(path: &str) -> String {
format!(
r#"
fn main() {{
match std::fs::metadata("{path}") {{
Ok(metadata) => {{
println!("SUCCESS: File size: {{}} bytes", metadata.len());
}}
Err(e) => {{
eprintln!("FAILURE: {{}}", e);
std::process::exit(1);
}}
}}
}}
"#
)
}
/// Code that makes a network connection
pub fn network_connect(addr: &str) -> String {
format!(
r#"
use std::net::TcpStream;
fn main() {{
match TcpStream::connect("{addr}") {{
Ok(_) => {{
println!("SUCCESS: Connected to {addr}");
}}
Err(e) => {{
eprintln!("FAILURE: {{}}", e);
std::process::exit(1);
}}
}}
}}
"#
)
}
/// Code that reads system information
pub fn system_info() -> &'static str {
r#"
#[cfg(target_os = "macos")]
fn main() {
use std::ffi::CString;
use std::os::raw::c_void;
extern "C" {
fn sysctlbyname(
name: *const std::os::raw::c_char,
oldp: *mut c_void,
oldlenp: *mut usize,
newp: *const c_void,
newlen: usize,
) -> std::os::raw::c_int;
}
let name = CString::new("hw.ncpu").unwrap();
let mut ncpu: i32 = 0;
let mut len = std::mem::size_of::<i32>();
unsafe {
let result = sysctlbyname(
name.as_ptr(),
&mut ncpu as *mut _ as *mut c_void,
&mut len,
std::ptr::null(),
0,
);
if result == 0 {
println!("SUCCESS: CPU count: {}", ncpu);
} else {
eprintln!("FAILURE: sysctlbyname failed");
std::process::exit(1);
}
}
}
#[cfg(not(target_os = "macos"))]
fn main() {
println!("SUCCESS: System info test not applicable on this platform");
}
"#
}
/// Code that tries to spawn a process
pub fn spawn_process() -> &'static str {
r#"
use std::process::Command;
fn main() {
match Command::new("echo").arg("test").output() {
Ok(_) => {
println!("SUCCESS: Spawned process");
}
Err(e) => {
eprintln!("FAILURE: {}", e);
std::process::exit(1);
}
}
}
"#
}
/// Code that uses fork (requires libc)
pub fn fork_process() -> &'static str {
r#"
#[cfg(unix)]
fn main() {
unsafe {
let pid = libc::fork();
if pid < 0 {
eprintln!("FAILURE: fork failed");
std::process::exit(1);
} else if pid == 0 {
// Child process
println!("SUCCESS: Child process created");
std::process::exit(0);
} else {
// Parent process
let mut status = 0;
libc::waitpid(pid, &mut status, 0);
println!("SUCCESS: Fork completed");
}
}
}
#[cfg(not(unix))]
fn main() {
eprintln!("FAILURE: fork not supported on this platform");
std::process::exit(1);
}
"#
}
/// Code that uses exec (requires libc)
pub fn exec_process() -> &'static str {
r#"
use std::ffi::CString;
#[cfg(unix)]
fn main() {
unsafe {
let program = CString::new("/bin/echo").unwrap();
let arg = CString::new("test").unwrap();
let args = vec![program.as_ptr(), arg.as_ptr(), std::ptr::null()];
let result = libc::execv(program.as_ptr(), args.as_ptr());
// If we reach here, exec failed
eprintln!("FAILURE: exec failed with result {}", result);
std::process::exit(1);
}
}
#[cfg(not(unix))]
fn main() {
eprintln!("FAILURE: exec not supported on this platform");
std::process::exit(1);
}
"#
}
/// Code that tries to write a file
pub fn file_write(path: &str) -> String {
format!(
r#"
fn main() {{
match std::fs::write("{path}", "test content") {{
Ok(_) => {{
println!("SUCCESS: Wrote file");
}}
Err(e) => {{
eprintln!("FAILURE: {{}}", e);
std::process::exit(1);
}}
}}
}}
"#
)
}
}
/// Assert that a command output contains expected text
pub fn assert_output_contains(output: &str, expected: &str) {
assert!(
output.contains(expected),
"Expected output to contain '{expected}', but got: {output}"
);
}
/// Assert that a command output indicates success
pub fn assert_sandbox_success(output: &str) {
assert_output_contains(output, "SUCCESS:");
}
/// Assert that a command output indicates failure
pub fn assert_sandbox_failure(output: &str) {
assert_output_contains(output, "FAILURE:");
}

View File

@@ -1,8 +0,0 @@
//! Common test utilities and helpers for sandbox testing
pub mod claude_real;
pub mod fixtures;
pub mod helpers;
pub use claude_real::*;
pub use fixtures::*;
pub use helpers::*;

View File

@@ -1,294 +0,0 @@
//! End-to-end tests for agent execution with sandbox profiles
use crate::sandbox::common::*;
use crate::skip_if_unsupported;
use serial_test::serial;
/// Test agent execution with minimal sandbox profile
#[test]
#[serial]
fn test_agent_with_minimal_profile() {
skip_if_unsupported!();
// Create test environment
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
let test_db = TEST_DB.lock();
test_db.reset().expect("Failed to reset database");
// Create minimal sandbox profile
let rules = profiles::minimal(&test_fs.project_path.to_string_lossy());
let profile_id = test_db
.create_test_profile("minimal_agent_test", rules)
.expect("Failed to create test profile");
// Create test agent
test_db.conn.execute(
"INSERT INTO agents (name, icon, system_prompt, model, sandbox_profile_id) VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![
"Test Agent",
"🤖",
"You are a test agent. Only perform the requested task.",
"sonnet",
profile_id
],
).expect("Failed to create agent");
let _agent_id = test_db.conn.last_insert_rowid();
// Execute real Claude command with minimal profile
let result = execute_claude_task(
&test_fs.project_path,
&tasks::multi_operation(),
Some("You are a test agent. Only perform the requested task."),
Some("sonnet"),
Some(profile_id),
20, // 20 second timeout
)
.expect("Failed to execute Claude command");
// Debug output
eprintln!("=== Claude Output ===");
eprintln!("Exit code: {}", result.exit_code);
eprintln!("STDOUT:\n{}", result.stdout);
eprintln!("STDERR:\n{}", result.stderr);
eprintln!("Duration: {:?}", result.duration);
eprintln!("===================");
// Basic verification - just check Claude ran
assert!(
result.exit_code == 0 || result.exit_code == 124, // 0 = success, 124 = timeout
"Claude should execute (exit code: {})",
result.exit_code
);
}
/// Test agent execution with standard sandbox profile
#[test]
#[serial]
fn test_agent_with_standard_profile() {
skip_if_unsupported!();
// Create test environment
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
let test_db = TEST_DB.lock();
test_db.reset().expect("Failed to reset database");
// Create standard sandbox profile
let rules = profiles::standard(&test_fs.project_path.to_string_lossy());
let profile_id = test_db
.create_test_profile("standard_agent_test", rules)
.expect("Failed to create test profile");
// Create test agent
test_db.conn.execute(
"INSERT INTO agents (name, icon, system_prompt, model, sandbox_profile_id) VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![
"Standard Agent",
"🔧",
"You are a test agent with standard permissions.",
"sonnet",
profile_id
],
).expect("Failed to create agent");
let _agent_id = test_db.conn.last_insert_rowid();
// Execute real Claude command with standard profile
let result = execute_claude_task(
&test_fs.project_path,
&tasks::multi_operation(),
Some("You are a test agent with standard permissions."),
Some("sonnet"),
Some(profile_id),
20, // 20 second timeout
)
.expect("Failed to execute Claude command");
// Debug output
eprintln!("=== Claude Output (Standard Profile) ===");
eprintln!("Exit code: {}", result.exit_code);
eprintln!("STDOUT:\n{}", result.stdout);
eprintln!("STDERR:\n{}", result.stderr);
eprintln!("===================");
// Basic verification
assert!(
result.exit_code == 0 || result.exit_code == 124,
"Claude should execute with standard profile (exit code: {})",
result.exit_code
);
}
/// Test agent execution without sandbox (control test)
#[test]
#[serial]
fn test_agent_without_sandbox() {
skip_if_unsupported!();
// Create test environment
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
let test_db = TEST_DB.lock();
test_db.reset().expect("Failed to reset database");
// Create agent without sandbox profile
test_db
.conn
.execute(
"INSERT INTO agents (name, icon, system_prompt, model) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![
"Unsandboxed Agent",
"⚠️",
"You are a test agent without sandbox restrictions.",
"sonnet"
],
)
.expect("Failed to create agent");
let _agent_id = test_db.conn.last_insert_rowid();
// Execute real Claude command without sandbox profile
let result = execute_claude_task(
&test_fs.project_path,
&tasks::multi_operation(),
Some("You are a test agent without sandbox restrictions."),
Some("sonnet"),
None, // No sandbox profile
20, // 20 second timeout
)
.expect("Failed to execute Claude command");
// Debug output
eprintln!("=== Claude Output (No Sandbox) ===");
eprintln!("Exit code: {}", result.exit_code);
eprintln!("STDOUT:\n{}", result.stdout);
eprintln!("STDERR:\n{}", result.stderr);
eprintln!("===================");
// Basic verification
assert!(
result.exit_code == 0 || result.exit_code == 124,
"Claude should execute without sandbox (exit code: {})",
result.exit_code
);
}
/// Test agent run violation logging
#[test]
#[serial]
fn test_agent_run_violation_logging() {
skip_if_unsupported!();
// Create test environment
let test_db = TEST_DB.lock();
test_db.reset().expect("Failed to reset database");
// Create a test profile first
let profile_id = test_db
.create_test_profile("violation_test", vec![])
.expect("Failed to create test profile");
// Create a test agent
test_db.conn.execute(
"INSERT INTO agents (name, icon, system_prompt, model, sandbox_profile_id) VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![
"Violation Test Agent",
"⚠️",
"Test agent for violation logging.",
"sonnet",
profile_id
],
).expect("Failed to create agent");
let agent_id = test_db.conn.last_insert_rowid();
// Create a test agent run
test_db.conn.execute(
"INSERT INTO agent_runs (agent_id, agent_name, agent_icon, task, model, project_path) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
agent_id,
"Violation Test Agent",
"⚠️",
"Test task",
"sonnet",
"/test/path"
],
).expect("Failed to create agent run");
let agent_run_id = test_db.conn.last_insert_rowid();
// Insert test violations
test_db.conn.execute(
"INSERT INTO sandbox_violations (profile_id, agent_id, agent_run_id, operation_type, pattern_value)
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![profile_id, agent_id, agent_run_id, "file_read_all", "/etc/passwd"],
).expect("Failed to insert violation");
// Query violations
let count: i64 = test_db
.conn
.query_row(
"SELECT COUNT(*) FROM sandbox_violations WHERE agent_id = ?1",
rusqlite::params![agent_id],
|row| row.get(0),
)
.expect("Failed to query violations");
assert_eq!(count, 1, "Should have recorded one violation");
}
/// Test profile switching between agent runs
#[test]
#[serial]
fn test_profile_switching() {
skip_if_unsupported!();
// Create test environment
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
let test_db = TEST_DB.lock();
test_db.reset().expect("Failed to reset database");
// Create two different profiles
let minimal_rules = profiles::minimal(&test_fs.project_path.to_string_lossy());
let minimal_id = test_db
.create_test_profile("minimal_switch", minimal_rules)
.expect("Failed to create minimal profile");
let standard_rules = profiles::standard(&test_fs.project_path.to_string_lossy());
let standard_id = test_db
.create_test_profile("standard_switch", standard_rules)
.expect("Failed to create standard profile");
// Create agent initially with minimal profile
test_db.conn.execute(
"INSERT INTO agents (name, icon, system_prompt, model, sandbox_profile_id) VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![
"Switchable Agent",
"🔄",
"Test agent for profile switching.",
"sonnet",
minimal_id
],
).expect("Failed to create agent");
let agent_id = test_db.conn.last_insert_rowid();
// Update agent to use standard profile
test_db
.conn
.execute(
"UPDATE agents SET sandbox_profile_id = ?1 WHERE id = ?2",
rusqlite::params![standard_id, agent_id],
)
.expect("Failed to update agent profile");
// Verify profile was updated
let current_profile: i64 = test_db
.conn
.query_row(
"SELECT sandbox_profile_id FROM agents WHERE id = ?1",
rusqlite::params![agent_id],
|row| row.get(0),
)
.expect("Failed to query agent profile");
assert_eq!(current_profile, standard_id, "Profile should be updated");
}

View File

@@ -1,220 +0,0 @@
//! End-to-end tests for Claude command execution with sandbox profiles
use crate::sandbox::common::*;
use crate::skip_if_unsupported;
use serial_test::serial;
/// Test Claude Code execution with default sandbox profile
#[test]
#[serial]
fn test_claude_with_default_sandbox() {
skip_if_unsupported!();
// Create test environment
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
let test_db = TEST_DB.lock();
test_db.reset().expect("Failed to reset database");
// Create default sandbox profile
let rules = profiles::standard(&test_fs.project_path.to_string_lossy());
let profile_id = test_db
.create_test_profile("claude_default", rules)
.expect("Failed to create test profile");
// Set as default and active
test_db
.conn
.execute(
"UPDATE sandbox_profiles SET is_default = 1, is_active = 1 WHERE id = ?1",
rusqlite::params![profile_id],
)
.expect("Failed to set default profile");
// Execute real Claude command with default sandbox profile
let result = execute_claude_task(
&test_fs.project_path,
&tasks::multi_operation(),
Some("You are Claude. Only perform the requested task."),
Some("sonnet"),
Some(profile_id),
20, // 20 second timeout
)
.expect("Failed to execute Claude command");
// Debug output
eprintln!("=== Claude Output (Default Sandbox) ===");
eprintln!("Exit code: {}", result.exit_code);
eprintln!("STDOUT:\n{}", result.stdout);
eprintln!("STDERR:\n{}", result.stderr);
eprintln!("===================");
// Basic verification
assert!(
result.exit_code == 0 || result.exit_code == 124,
"Claude should execute with default sandbox (exit code: {})",
result.exit_code
);
}
/// Test Claude Code with sandboxing disabled
#[test]
#[serial]
fn test_claude_sandbox_disabled() {
skip_if_unsupported!();
// Create test environment
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
let test_db = TEST_DB.lock();
test_db.reset().expect("Failed to reset database");
// Create profile but mark as inactive
let rules = profiles::standard(&test_fs.project_path.to_string_lossy());
let profile_id = test_db
.create_test_profile("claude_inactive", rules)
.expect("Failed to create test profile");
// Set as default but inactive
test_db
.conn
.execute(
"UPDATE sandbox_profiles SET is_default = 1, is_active = 0 WHERE id = ?1",
rusqlite::params![profile_id],
)
.expect("Failed to set inactive profile");
// Execute real Claude command without active sandbox
let result = execute_claude_task(
&test_fs.project_path,
&tasks::multi_operation(),
Some("You are Claude. Only perform the requested task."),
Some("sonnet"),
None, // No sandbox since profile is inactive
20, // 20 second timeout
)
.expect("Failed to execute Claude command");
// Debug output
eprintln!("=== Claude Output (Inactive Sandbox) ===");
eprintln!("Exit code: {}", result.exit_code);
eprintln!("STDOUT:\n{}", result.stdout);
eprintln!("STDERR:\n{}", result.stderr);
eprintln!("===================");
// Basic verification
assert!(
result.exit_code == 0 || result.exit_code == 124,
"Claude should execute without active sandbox (exit code: {})",
result.exit_code
);
}
/// Test Claude Code session operations
#[test]
#[serial]
fn test_claude_session_operations() {
// This test doesn't require actual Claude execution
// Create test environment
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
// Create mock session structure
let claude_dir = test_fs.root.path().join(".claude");
let projects_dir = claude_dir.join("projects");
let project_id = test_fs.project_path.to_string_lossy().replace('/', "-");
let session_dir = projects_dir.join(&project_id);
std::fs::create_dir_all(&session_dir).expect("Failed to create session dir");
// Create mock session file
let session_id = "test-session-123";
let session_file = session_dir.join(format!("{}.jsonl", session_id));
let session_data = serde_json::json!({
"type": "session_start",
"cwd": test_fs.project_path.to_string_lossy(),
"timestamp": "2024-01-01T00:00:00Z"
});
std::fs::write(&session_file, format!("{}\n", session_data))
.expect("Failed to write session file");
// Verify session file exists
assert!(session_file.exists(), "Session file should exist");
}
/// Test Claude settings with sandbox configuration
#[test]
#[serial]
fn test_claude_settings_sandbox_config() {
// Create test environment
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
// Create mock settings
let claude_dir = test_fs.root.path().join(".claude");
std::fs::create_dir_all(&claude_dir).expect("Failed to create claude dir");
let settings_file = claude_dir.join("settings.json");
let settings = serde_json::json!({
"sandboxEnabled": true,
"defaultSandboxProfile": "standard",
"theme": "dark",
"model": "sonnet"
});
std::fs::write(
&settings_file,
serde_json::to_string_pretty(&settings).unwrap(),
)
.expect("Failed to write settings");
// Read and verify settings
let content = std::fs::read_to_string(&settings_file).expect("Failed to read settings");
let parsed: serde_json::Value =
serde_json::from_str(&content).expect("Failed to parse settings");
assert_eq!(parsed["sandboxEnabled"], true, "Sandbox should be enabled");
assert_eq!(
parsed["defaultSandboxProfile"], "standard",
"Default profile should be standard"
);
}
/// Test profile-based file access restrictions
#[test]
#[serial]
fn test_profile_file_access_simulation() {
skip_if_unsupported!();
// Create test environment
let _test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
let test_db = TEST_DB.lock();
test_db.reset().expect("Failed to reset database");
// Create a custom profile with specific file access
let custom_rules = vec![
TestRule::file_read("{{PROJECT_PATH}}", true),
TestRule::file_read("/usr/local/bin", true),
TestRule::file_read("/etc/hosts", false), // Literal file
];
let profile_id = test_db
.create_test_profile("file_access_test", custom_rules)
.expect("Failed to create test profile");
// Load the profile rules
let loaded_rules: Vec<(String, String, String)> = test_db.conn
.prepare("SELECT operation_type, pattern_type, pattern_value FROM sandbox_rules WHERE profile_id = ?1")
.expect("Failed to prepare query")
.query_map(rusqlite::params![profile_id], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
})
.expect("Failed to query rules")
.collect::<Result<Vec<_>, _>>()
.expect("Failed to collect rules");
// Verify rules were created correctly
assert_eq!(loaded_rules.len(), 3, "Should have 3 rules");
assert!(
loaded_rules.iter().any(|(op, _, _)| op == "file_read_all"),
"Should have file_read_all operation"
);
}

View File

@@ -1,5 +0,0 @@
//! End-to-end tests for sandbox integration with agents and Claude
#[cfg(test)]
mod agent_sandbox;
#[cfg(test)]
mod claude_sandbox;

View File

@@ -1,301 +0,0 @@
//! Integration tests for file operations in sandbox
use crate::sandbox::common::*;
use crate::skip_if_unsupported;
use claudia_lib::sandbox::executor::SandboxExecutor;
use claudia_lib::sandbox::profile::ProfileBuilder;
use gaol::profile::{Operation, PathPattern, Profile};
use serial_test::serial;
use tempfile::TempDir;
/// Test allowed file read operations
#[test]
#[serial]
fn test_allowed_file_read() {
skip_if_unsupported!();
let platform = PlatformConfig::current();
if !platform.supports_file_read {
eprintln!("Skipping test: file read not supported on this platform");
return;
}
// Create test file system
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
// Create profile allowing project path access
let operations = vec![Operation::FileReadAll(PathPattern::Subpath(
test_fs.project_path.clone(),
))];
let profile = match Profile::new(operations) {
Ok(p) => p,
Err(_) => {
eprintln!("Failed to create profile - operation not supported");
return;
}
};
// Create test binary that reads from allowed path
let test_code = test_code::file_read(&test_fs.project_path.join("main.rs").to_string_lossy());
let binary_dir = TempDir::new().expect("Failed to create temp dir");
let binary_path = create_test_binary("test_file_read", &test_code, binary_dir.path())
.expect("Failed to create test binary");
// Execute in sandbox
let executor = SandboxExecutor::new(profile, test_fs.project_path.clone());
match executor.execute_sandboxed_spawn(
&binary_path.to_string_lossy(),
&[],
&test_fs.project_path,
) {
Ok(mut child) => {
let status = child.wait().expect("Failed to wait for child");
assert!(status.success(), "Allowed file read should succeed");
}
Err(e) => {
eprintln!("Sandbox execution failed: {} (may be expected in CI)", e);
}
}
}
/// Test forbidden file read operations
#[test]
#[serial]
fn test_forbidden_file_read() {
skip_if_unsupported!();
let platform = PlatformConfig::current();
if !platform.supports_file_read {
eprintln!("Skipping test: file read not supported on this platform");
return;
}
// Create test file system
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
// Create profile allowing only project path (not forbidden path)
let operations = vec![Operation::FileReadAll(PathPattern::Subpath(
test_fs.project_path.clone(),
))];
let profile = match Profile::new(operations) {
Ok(p) => p,
Err(_) => {
eprintln!("Failed to create profile - operation not supported");
return;
}
};
// Create test binary that reads from forbidden path
let forbidden_file = test_fs.forbidden_path.join("secret.txt");
let test_code = test_code::file_read(&forbidden_file.to_string_lossy());
let binary_dir = TempDir::new().expect("Failed to create temp dir");
let binary_path = create_test_binary("test_forbidden_read", &test_code, binary_dir.path())
.expect("Failed to create test binary");
// Execute in sandbox
let executor = SandboxExecutor::new(profile, test_fs.project_path.clone());
match executor.execute_sandboxed_spawn(
&binary_path.to_string_lossy(),
&[],
&test_fs.project_path,
) {
Ok(mut child) => {
let status = child.wait().expect("Failed to wait for child");
// On some platforms (like macOS), gaol might not block all file reads
// so we check if the operation failed OR if it's a platform limitation
if status.success() {
eprintln!(
"WARNING: File read was not blocked - this might be a platform limitation"
);
// Check if we're on a platform where this is expected
let platform_config = PlatformConfig::current();
if !platform_config.supports_file_read {
panic!("File read should have been blocked on this platform");
}
}
}
Err(e) => {
eprintln!("Sandbox execution failed: {} (may be expected in CI)", e);
}
}
}
/// Test file write operations (should always be forbidden)
#[test]
#[serial]
fn test_file_write_always_forbidden() {
skip_if_unsupported!();
// Create test file system
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
// Create profile with file read permissions (write should still be blocked)
let operations = vec![Operation::FileReadAll(PathPattern::Subpath(
test_fs.project_path.clone(),
))];
let profile = match Profile::new(operations) {
Ok(p) => p,
Err(_) => {
eprintln!("Failed to create profile - operation not supported");
return;
}
};
// Create test binary that tries to write a file
let write_path = test_fs.project_path.join("test_write.txt");
let test_code = test_code::file_write(&write_path.to_string_lossy());
let binary_dir = TempDir::new().expect("Failed to create temp dir");
let binary_path = create_test_binary("test_file_write", &test_code, binary_dir.path())
.expect("Failed to create test binary");
// Execute in sandbox
let executor = SandboxExecutor::new(profile, test_fs.project_path.clone());
match executor.execute_sandboxed_spawn(
&binary_path.to_string_lossy(),
&[],
&test_fs.project_path,
) {
Ok(mut child) => {
let status = child.wait().expect("Failed to wait for child");
// File writes might not be blocked on all platforms
if status.success() {
eprintln!("WARNING: File write was not blocked - checking platform capabilities");
// On macOS, file writes might not be fully blocked by gaol
if std::env::consts::OS != "macos" {
panic!("File write should have been blocked on this platform");
}
}
}
Err(e) => {
eprintln!("Sandbox execution failed: {} (may be expected in CI)", e);
}
}
}
/// Test file metadata operations
#[test]
#[serial]
fn test_file_metadata_operations() {
skip_if_unsupported!();
let platform = PlatformConfig::current();
if !platform.supports_metadata_read && !platform.supports_file_read {
eprintln!("Skipping test: metadata read not supported on this platform");
return;
}
// Create test file system
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
// Create profile with metadata read permission
let operations = if platform.supports_metadata_read {
vec![Operation::FileReadMetadata(PathPattern::Subpath(
test_fs.project_path.clone(),
))]
} else {
// On Linux, metadata is allowed if file read is allowed
vec![Operation::FileReadAll(PathPattern::Subpath(
test_fs.project_path.clone(),
))]
};
let profile = match Profile::new(operations) {
Ok(p) => p,
Err(_) => {
eprintln!("Failed to create profile - operation not supported");
return;
}
};
// Create test binary that reads file metadata
let test_file = test_fs.project_path.join("main.rs");
let test_code = test_code::file_metadata(&test_file.to_string_lossy());
let binary_dir = TempDir::new().expect("Failed to create temp dir");
let binary_path = create_test_binary("test_metadata", &test_code, binary_dir.path())
.expect("Failed to create test binary");
// Execute in sandbox
let executor = SandboxExecutor::new(profile, test_fs.project_path.clone());
match executor.execute_sandboxed_spawn(
&binary_path.to_string_lossy(),
&[],
&test_fs.project_path,
) {
Ok(mut child) => {
let status = child.wait().expect("Failed to wait for child");
if platform.supports_metadata_read || platform.supports_file_read {
assert!(
status.success(),
"Metadata read should succeed when allowed"
);
}
}
Err(e) => {
eprintln!("Sandbox execution failed: {} (may be expected in CI)", e);
}
}
}
/// Test template variable expansion in file paths
#[test]
#[serial]
fn test_template_variable_expansion() {
skip_if_unsupported!();
let platform = PlatformConfig::current();
if !platform.supports_file_read {
eprintln!("Skipping test: file read not supported on this platform");
return;
}
// Create test database and profile
let test_db = TEST_DB.lock();
test_db.reset().expect("Failed to reset database");
// Create a profile with template variables
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
let rules = vec![TestRule::file_read("{{PROJECT_PATH}}", true)];
let profile_id = test_db
.create_test_profile("template_test", rules)
.expect("Failed to create test profile");
// Load and build the profile
let db_rules = claudia_lib::sandbox::profile::load_profile_rules(&test_db.conn, profile_id)
.expect("Failed to load profile rules");
let builder = ProfileBuilder::new(test_fs.project_path.clone())
.expect("Failed to create profile builder");
let profile = match builder.build_profile(db_rules) {
Ok(p) => p,
Err(_) => {
eprintln!("Failed to build profile with templates");
return;
}
};
// Create test binary that reads from project path
let test_code = test_code::file_read(&test_fs.project_path.join("main.rs").to_string_lossy());
let binary_dir = TempDir::new().expect("Failed to create temp dir");
let binary_path = create_test_binary("test_template", &test_code, binary_dir.path())
.expect("Failed to create test binary");
// Execute in sandbox
let executor = SandboxExecutor::new(profile, test_fs.project_path.clone());
match executor.execute_sandboxed_spawn(
&binary_path.to_string_lossy(),
&[],
&test_fs.project_path,
) {
Ok(mut child) => {
let status = child.wait().expect("Failed to wait for child");
assert!(status.success(), "Template-based file access should work");
}
Err(e) => {
eprintln!("Sandbox execution failed: {} (may be expected in CI)", e);
}
}
}

View File

@@ -1,11 +0,0 @@
//! Integration tests for sandbox functionality
#[cfg(test)]
mod file_operations;
#[cfg(test)]
mod network_operations;
#[cfg(test)]
mod process_isolation;
#[cfg(test)]
mod system_info;
#[cfg(test)]
mod violations;

View File

@@ -1,312 +0,0 @@
//! Integration tests for network operations in sandbox
use crate::sandbox::common::*;
use crate::skip_if_unsupported;
use claudia_lib::sandbox::executor::SandboxExecutor;
use gaol::profile::{AddressPattern, Operation, Profile};
use serial_test::serial;
use std::net::TcpListener;
use tempfile::TempDir;
/// Get an available port for testing
fn get_available_port() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind to 0");
let port = listener
.local_addr()
.expect("Failed to get local addr")
.port();
drop(listener); // Release the port
port
}
/// Test allowed network operations
#[test]
#[serial]
fn test_allowed_network_all() {
skip_if_unsupported!();
let platform = PlatformConfig::current();
if !platform.supports_network_all {
eprintln!("Skipping test: network all not supported on this platform");
return;
}
// Create test project
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
// Create profile allowing all network access
let operations = vec![Operation::NetworkOutbound(AddressPattern::All)];
let profile = match Profile::new(operations) {
Ok(p) => p,
Err(_) => {
eprintln!("Failed to create profile - operation not supported");
return;
}
};
// Create test binary that connects to localhost
let port = get_available_port();
let test_code = test_code::network_connect(&format!("127.0.0.1:{}", port));
let binary_dir = TempDir::new().expect("Failed to create temp dir");
let binary_path = create_test_binary("test_network", &test_code, binary_dir.path())
.expect("Failed to create test binary");
// Start a listener on the port
let listener =
TcpListener::bind(format!("127.0.0.1:{}", port)).expect("Failed to bind listener");
// Execute in sandbox
let executor = SandboxExecutor::new(profile, test_fs.project_path.clone());
match executor.execute_sandboxed_spawn(
&binary_path.to_string_lossy(),
&[],
&test_fs.project_path,
) {
Ok(mut child) => {
// Accept connection in a thread
std::thread::spawn(move || {
let _ = listener.accept();
});
let status = child.wait().expect("Failed to wait for child");
assert!(
status.success(),
"Network connection should succeed when allowed"
);
}
Err(e) => {
eprintln!("Sandbox execution failed: {} (may be expected in CI)", e);
}
}
}
/// Test forbidden network operations
#[test]
#[serial]
fn test_forbidden_network() {
skip_if_unsupported!();
// Create test project
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
// Create profile without network permissions
let operations = vec![Operation::FileReadAll(gaol::profile::PathPattern::Subpath(
test_fs.project_path.clone(),
))];
let profile = match Profile::new(operations) {
Ok(p) => p,
Err(_) => {
eprintln!("Failed to create profile - operation not supported");
return;
}
};
// Create test binary that tries to connect
let test_code = test_code::network_connect("google.com:80");
let binary_dir = TempDir::new().expect("Failed to create temp dir");
let binary_path = create_test_binary("test_no_network", &test_code, binary_dir.path())
.expect("Failed to create test binary");
// Execute in sandbox
let executor = SandboxExecutor::new(profile, test_fs.project_path.clone());
match executor.execute_sandboxed_spawn(
&binary_path.to_string_lossy(),
&[],
&test_fs.project_path,
) {
Ok(mut child) => {
let status = child.wait().expect("Failed to wait for child");
// Network restrictions might not work on all platforms
if status.success() {
eprintln!("WARNING: Network connection was not blocked (platform limitation)");
if std::env::consts::OS == "linux" {
panic!("Network should be blocked on Linux when not allowed");
}
}
}
Err(e) => {
eprintln!("Sandbox execution failed: {} (may be expected in CI)", e);
}
}
}
/// Test TCP port-specific network rules (macOS only)
#[test]
#[serial]
#[cfg(target_os = "macos")]
fn test_network_tcp_port_specific() {
let platform = PlatformConfig::current();
if !platform.supports_network_tcp {
eprintln!("Skipping test: TCP port filtering not supported");
return;
}
// Create test project
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
// Get two ports - one allowed, one forbidden
let allowed_port = get_available_port();
let forbidden_port = get_available_port();
// Create profile allowing only specific port
let operations = vec![Operation::NetworkOutbound(AddressPattern::Tcp(
allowed_port,
))];
let profile = match Profile::new(operations) {
Ok(p) => p,
Err(_) => {
eprintln!("Failed to create profile - operation not supported");
return;
}
};
// Test 1: Allowed port
{
let test_code = test_code::network_connect(&format!("127.0.0.1:{}", allowed_port));
let binary_dir = TempDir::new().expect("Failed to create temp dir");
let binary_path = create_test_binary("test_allowed_port", &test_code, binary_dir.path())
.expect("Failed to create test binary");
let listener = TcpListener::bind(format!("127.0.0.1:{}", allowed_port))
.expect("Failed to bind listener");
let executor = SandboxExecutor::new(profile.clone(), test_fs.project_path.clone());
match executor.execute_sandboxed_spawn(
&binary_path.to_string_lossy(),
&[],
&test_fs.project_path,
) {
Ok(mut child) => {
std::thread::spawn(move || {
let _ = listener.accept();
});
let status = child.wait().expect("Failed to wait for child");
assert!(
status.success(),
"Connection to allowed port should succeed"
);
}
Err(e) => {
eprintln!("Sandbox execution failed: {} (may be expected in CI)", e);
}
}
}
// Test 2: Forbidden port
{
let test_code = test_code::network_connect(&format!("127.0.0.1:{}", forbidden_port));
let binary_dir = TempDir::new().expect("Failed to create temp dir");
let binary_path = create_test_binary("test_forbidden_port", &test_code, binary_dir.path())
.expect("Failed to create test binary");
let executor = SandboxExecutor::new(profile, test_fs.project_path.clone());
match executor.execute_sandboxed_spawn(
&binary_path.to_string_lossy(),
&[],
&test_fs.project_path,
) {
Ok(mut child) => {
let status = child.wait().expect("Failed to wait for child");
assert!(
!status.success(),
"Connection to forbidden port should fail"
);
}
Err(e) => {
eprintln!("Sandbox execution failed: {} (may be expected in CI)", e);
}
}
}
}
/// Test local socket connections (Unix domain sockets)
#[test]
#[serial]
#[cfg(unix)]
fn test_local_socket_connections() {
skip_if_unsupported!();
let platform = PlatformConfig::current();
// Create test project
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
let socket_path = test_fs.project_path.join("test.sock");
// Create appropriate profile based on platform
let operations = if platform.supports_network_local {
vec![Operation::NetworkOutbound(AddressPattern::LocalSocket(
socket_path.clone(),
))]
} else if platform.supports_network_all {
// Fallback to allowing all network
vec![Operation::NetworkOutbound(AddressPattern::All)]
} else {
eprintln!("Skipping test: no network support on this platform");
return;
};
let profile = match Profile::new(operations) {
Ok(p) => p,
Err(_) => {
eprintln!("Failed to create profile - operation not supported");
return;
}
};
// Create test binary that connects to local socket
let test_code = format!(
r#"
use std::os::unix::net::UnixStream;
fn main() {{
match UnixStream::connect("{}") {{
Ok(_) => {{
println!("SUCCESS: Connected to local socket");
}}
Err(e) => {{
eprintln!("FAILURE: {{}}", e);
std::process::exit(1);
}}
}}
}}
"#,
socket_path.to_string_lossy()
);
let binary_dir = TempDir::new().expect("Failed to create temp dir");
let binary_path = create_test_binary("test_local_socket", &test_code, binary_dir.path())
.expect("Failed to create test binary");
// Create Unix socket listener
use std::os::unix::net::UnixListener;
let listener = UnixListener::bind(&socket_path).expect("Failed to bind Unix socket");
// Execute in sandbox
let executor = SandboxExecutor::new(profile, test_fs.project_path.clone());
match executor.execute_sandboxed_spawn(
&binary_path.to_string_lossy(),
&[],
&test_fs.project_path,
) {
Ok(mut child) => {
std::thread::spawn(move || {
let _ = listener.accept();
});
let status = child.wait().expect("Failed to wait for child");
assert!(
status.success(),
"Local socket connection should succeed when allowed"
);
}
Err(e) => {
eprintln!("Sandbox execution failed: {} (may be expected in CI)", e);
}
}
// Clean up socket file
let _ = std::fs::remove_file(&socket_path);
}

View File

@@ -1,247 +0,0 @@
//! Integration tests for process isolation in sandbox
use crate::sandbox::common::*;
use crate::skip_if_unsupported;
use claudia_lib::sandbox::executor::SandboxExecutor;
use gaol::profile::{AddressPattern, Operation, PathPattern, Profile};
use serial_test::serial;
use tempfile::TempDir;
/// Test that process spawning is always forbidden
#[test]
#[serial]
fn test_process_spawn_forbidden() {
skip_if_unsupported!();
// Create test project
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
// Create profile with various permissions (process spawn should still be blocked)
let operations = vec![
Operation::FileReadAll(PathPattern::Subpath(test_fs.project_path.clone())),
Operation::NetworkOutbound(AddressPattern::All),
];
let profile = match Profile::new(operations) {
Ok(p) => p,
Err(_) => {
eprintln!("Failed to create profile - operation not supported");
return;
}
};
// Create test binary that tries to spawn a process
let test_code = test_code::spawn_process();
let binary_dir = TempDir::new().expect("Failed to create temp dir");
let binary_path = create_test_binary("test_spawn", test_code, binary_dir.path())
.expect("Failed to create test binary");
// Execute in sandbox
let executor = SandboxExecutor::new(profile, test_fs.project_path.clone());
match executor.execute_sandboxed_spawn(
&binary_path.to_string_lossy(),
&[],
&test_fs.project_path,
) {
Ok(mut child) => {
let status = child.wait().expect("Failed to wait for child");
// Process spawning might not be blocked on all platforms
if status.success() {
eprintln!("WARNING: Process spawning was not blocked");
// macOS sandbox might have limitations
if std::env::consts::OS != "linux" {
eprintln!(
"Process spawning might not be fully blocked on {}",
std::env::consts::OS
);
} else {
panic!("Process spawning should be blocked on Linux");
}
}
}
Err(e) => {
eprintln!("Sandbox execution failed: {} (may be expected in CI)", e);
}
}
}
/// Test that fork is blocked
#[test]
#[serial]
#[cfg(unix)]
fn test_fork_forbidden() {
skip_if_unsupported!();
// Create test project
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
// Create minimal profile
let operations = vec![Operation::FileReadAll(PathPattern::Subpath(
test_fs.project_path.clone(),
))];
let profile = match Profile::new(operations) {
Ok(p) => p,
Err(_) => {
eprintln!("Failed to create profile - operation not supported");
return;
}
};
// Create test binary that tries to fork
let test_code = test_code::fork_process();
let binary_dir = TempDir::new().expect("Failed to create temp dir");
let binary_path = create_test_binary_with_deps(
"test_fork",
test_code,
binary_dir.path(),
&[("libc", "0.2")],
)
.expect("Failed to create test binary");
// Execute in sandbox
let executor = SandboxExecutor::new(profile, test_fs.project_path.clone());
match executor.execute_sandboxed_spawn(
&binary_path.to_string_lossy(),
&[],
&test_fs.project_path,
) {
Ok(mut child) => {
let status = child.wait().expect("Failed to wait for child");
// Fork might not be blocked on all platforms
if status.success() {
eprintln!("WARNING: Fork was not blocked (platform limitation)");
if std::env::consts::OS == "linux" {
panic!("Fork should be blocked on Linux");
}
}
}
Err(e) => {
eprintln!("Sandbox execution failed: {} (may be expected in CI)", e);
}
}
}
/// Test that exec is blocked
#[test]
#[serial]
#[cfg(unix)]
fn test_exec_forbidden() {
skip_if_unsupported!();
// Create test project
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
// Create minimal profile
let operations = vec![Operation::FileReadAll(PathPattern::Subpath(
test_fs.project_path.clone(),
))];
let profile = match Profile::new(operations) {
Ok(p) => p,
Err(_) => {
eprintln!("Failed to create profile - operation not supported");
return;
}
};
// Create test binary that tries to exec
let test_code = test_code::exec_process();
let binary_dir = TempDir::new().expect("Failed to create temp dir");
let binary_path = create_test_binary_with_deps(
"test_exec",
test_code,
binary_dir.path(),
&[("libc", "0.2")],
)
.expect("Failed to create test binary");
// Execute in sandbox
let executor = SandboxExecutor::new(profile, test_fs.project_path.clone());
match executor.execute_sandboxed_spawn(
&binary_path.to_string_lossy(),
&[],
&test_fs.project_path,
) {
Ok(mut child) => {
let status = child.wait().expect("Failed to wait for child");
// Exec might not be blocked on all platforms
if status.success() {
eprintln!("WARNING: Exec was not blocked (platform limitation)");
if std::env::consts::OS == "linux" {
panic!("Exec should be blocked on Linux");
}
}
}
Err(e) => {
eprintln!("Sandbox execution failed: {} (may be expected in CI)", e);
}
}
}
/// Test thread creation is allowed
#[test]
#[serial]
fn test_thread_creation_allowed() {
skip_if_unsupported!();
// Create test project
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
// Create minimal profile
let operations = vec![Operation::FileReadAll(PathPattern::Subpath(
test_fs.project_path.clone(),
))];
let profile = match Profile::new(operations) {
Ok(p) => p,
Err(_) => {
eprintln!("Failed to create profile - operation not supported");
return;
}
};
// Create test binary that creates threads
let test_code = r#"
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
thread::sleep(Duration::from_millis(100));
42
});
match handle.join() {
Ok(value) => {
println!("SUCCESS: Thread returned {}", value);
}
Err(_) => {
eprintln!("FAILURE: Thread panicked");
std::process::exit(1);
}
}
}
"#;
let binary_dir = TempDir::new().expect("Failed to create temp dir");
let binary_path = create_test_binary("test_thread", test_code, binary_dir.path())
.expect("Failed to create test binary");
// Execute in sandbox
let executor = SandboxExecutor::new(profile, test_fs.project_path.clone());
match executor.execute_sandboxed_spawn(
&binary_path.to_string_lossy(),
&[],
&test_fs.project_path,
) {
Ok(mut child) => {
let status = child.wait().expect("Failed to wait for child");
assert!(status.success(), "Thread creation should be allowed");
}
Err(e) => {
eprintln!("Sandbox execution failed: {} (may be expected in CI)", e);
}
}
}

View File

@@ -1,151 +0,0 @@
//! Integration tests for system information operations in sandbox
use crate::sandbox::common::*;
use crate::skip_if_unsupported;
use claudia_lib::sandbox::executor::SandboxExecutor;
use gaol::profile::{Operation, Profile};
use serial_test::serial;
use tempfile::TempDir;
/// Test system info read operations
#[test]
#[serial]
fn test_system_info_read() {
skip_if_unsupported!();
let platform = PlatformConfig::current();
if !platform.supports_system_info {
eprintln!("Skipping test: system info read not supported on this platform");
return;
}
// Create test project
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
// Create profile allowing system info read
let operations = vec![Operation::SystemInfoRead];
let profile = match Profile::new(operations) {
Ok(p) => p,
Err(_) => {
eprintln!("Failed to create profile - operation not supported");
return;
}
};
// Create test binary that reads system info
let test_code = test_code::system_info();
let binary_dir = TempDir::new().expect("Failed to create temp dir");
let binary_path = create_test_binary("test_sysinfo", test_code, binary_dir.path())
.expect("Failed to create test binary");
// Execute in sandbox
let executor = SandboxExecutor::new(profile, test_fs.project_path.clone());
match executor.execute_sandboxed_spawn(
&binary_path.to_string_lossy(),
&[],
&test_fs.project_path,
) {
Ok(mut child) => {
let status = child.wait().expect("Failed to wait for child");
assert!(
status.success(),
"System info read should succeed when allowed"
);
}
Err(e) => {
eprintln!("Sandbox execution failed: {} (may be expected in CI)", e);
}
}
}
/// Test forbidden system info access
#[test]
#[serial]
#[cfg(target_os = "macos")]
fn test_forbidden_system_info() {
// Create test project
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
// Create profile without system info permission
let operations = vec![Operation::FileReadAll(gaol::profile::PathPattern::Subpath(
test_fs.project_path.clone(),
))];
let profile = match Profile::new(operations) {
Ok(p) => p,
Err(_) => {
eprintln!("Failed to create profile - operation not supported");
return;
}
};
// Create test binary that reads system info
let test_code = test_code::system_info();
let binary_dir = TempDir::new().expect("Failed to create temp dir");
let binary_path = create_test_binary("test_no_sysinfo", test_code, binary_dir.path())
.expect("Failed to create test binary");
// Execute in sandbox
let executor = SandboxExecutor::new(profile, test_fs.project_path.clone());
match executor.execute_sandboxed_spawn(
&binary_path.to_string_lossy(),
&[],
&test_fs.project_path,
) {
Ok(mut child) => {
let status = child.wait().expect("Failed to wait for child");
// System info might not be blocked on all platforms
if status.success() {
eprintln!("WARNING: System info read was not blocked - checking platform");
// On FreeBSD, system info is always allowed
if std::env::consts::OS == "freebsd" {
eprintln!("System info is always allowed on FreeBSD");
} else if std::env::consts::OS == "macos" {
// macOS might allow some system info reads
eprintln!("System info read allowed on macOS (platform limitation)");
} else {
panic!("System info read should have been blocked on Linux");
}
}
}
Err(e) => {
eprintln!("Sandbox execution failed: {} (may be expected in CI)", e);
}
}
}
/// Test platform-specific system info behavior
#[test]
#[serial]
fn test_platform_specific_system_info() {
skip_if_unsupported!();
let platform = PlatformConfig::current();
match std::env::consts::OS {
"linux" => {
// On Linux, system info is never allowed
assert!(
!platform.supports_system_info,
"Linux should not support system info read"
);
}
"macos" => {
// On macOS, system info can be allowed
assert!(
platform.supports_system_info,
"macOS should support system info read"
);
}
"freebsd" => {
// On FreeBSD, system info is always allowed (can't be restricted)
assert!(
platform.supports_system_info,
"FreeBSD always allows system info read"
);
}
_ => {
eprintln!("Unknown platform behavior for system info");
}
}
}

View File

@@ -1,297 +0,0 @@
//! Integration tests for sandbox violation detection and logging
use crate::sandbox::common::*;
use crate::skip_if_unsupported;
use claudia_lib::sandbox::executor::SandboxExecutor;
use gaol::profile::{Operation, PathPattern, Profile};
use serial_test::serial;
use std::sync::{Arc, Mutex};
use tempfile::TempDir;
/// Mock violation collector for testing
#[derive(Clone)]
struct ViolationCollector {
violations: Arc<Mutex<Vec<ViolationEvent>>>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
struct ViolationEvent {
operation_type: String,
pattern_value: Option<String>,
process_name: String,
}
impl ViolationCollector {
fn new() -> Self {
Self {
violations: Arc::new(Mutex::new(Vec::new())),
}
}
fn record(&self, operation_type: &str, pattern_value: Option<&str>, process_name: &str) {
let event = ViolationEvent {
operation_type: operation_type.to_string(),
pattern_value: pattern_value.map(|s| s.to_string()),
process_name: process_name.to_string(),
};
if let Ok(mut violations) = self.violations.lock() {
violations.push(event);
}
}
fn get_violations(&self) -> Vec<ViolationEvent> {
self.violations.lock().unwrap().clone()
}
}
/// Test that violations are detected for forbidden operations
#[test]
#[serial]
fn test_violation_detection() {
skip_if_unsupported!();
let platform = PlatformConfig::current();
if !platform.supports_file_read {
eprintln!("Skipping test: file read not supported on this platform");
return;
}
// Create test file system
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
let collector = ViolationCollector::new();
// Create profile allowing only project path
let operations = vec![Operation::FileReadAll(PathPattern::Subpath(
test_fs.project_path.clone(),
))];
let profile = match Profile::new(operations) {
Ok(p) => p,
Err(_) => {
eprintln!("Failed to create profile - operation not supported");
return;
}
};
// Test various forbidden operations
let test_cases = vec![
(
"file_read",
test_code::file_read(&test_fs.forbidden_path.join("secret.txt").to_string_lossy()),
"file_read_forbidden",
),
(
"file_write",
test_code::file_write(&test_fs.project_path.join("new.txt").to_string_lossy()),
"file_write_forbidden",
),
(
"process_spawn",
test_code::spawn_process().to_string(),
"process_spawn_forbidden",
),
];
for (op_type, test_code, binary_name) in test_cases {
let binary_dir = TempDir::new().expect("Failed to create temp dir");
let binary_path = create_test_binary(binary_name, &test_code, binary_dir.path())
.expect("Failed to create test binary");
let executor = SandboxExecutor::new(profile.clone(), test_fs.project_path.clone());
match executor.execute_sandboxed_spawn(
&binary_path.to_string_lossy(),
&[],
&test_fs.project_path,
) {
Ok(mut child) => {
let status = child.wait().expect("Failed to wait for child");
if !status.success() {
// Record violation
collector.record(op_type, None, binary_name);
}
}
Err(_) => {
// Sandbox setup failure, not a violation
}
}
}
// Verify violations were detected
let violations = collector.get_violations();
// On some platforms (like macOS), sandbox might not block all operations
if violations.is_empty() {
eprintln!("WARNING: No violations detected - this might be a platform limitation");
// On Linux, we expect at least some violations
if std::env::consts::OS == "linux" {
panic!("Should have detected some violations on Linux");
}
}
}
/// Test violation patterns and details
#[test]
#[serial]
fn test_violation_patterns() {
skip_if_unsupported!();
let platform = PlatformConfig::current();
if !platform.supports_file_read {
eprintln!("Skipping test: file read not supported on this platform");
return;
}
// Create test file system
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
// Create profile with specific allowed paths
let allowed_dir = test_fs.root.path().join("allowed_specific");
std::fs::create_dir_all(&allowed_dir).expect("Failed to create allowed dir");
let operations = vec![
Operation::FileReadAll(PathPattern::Subpath(test_fs.project_path.clone())),
Operation::FileReadAll(PathPattern::Literal(allowed_dir.join("file.txt"))),
];
let profile = match Profile::new(operations) {
Ok(p) => p,
Err(_) => {
eprintln!("Failed to create profile - operation not supported");
return;
}
};
// Test accessing different forbidden paths
let forbidden_db_path = test_fs
.forbidden_path
.join("data.db")
.to_string_lossy()
.to_string();
let forbidden_paths = vec![
("/etc/passwd", "system_file"),
("/tmp/test.txt", "temp_file"),
(forbidden_db_path.as_str(), "forbidden_db"),
];
for (path, test_name) in forbidden_paths {
let test_code = test_code::file_read(path);
let binary_dir = TempDir::new().expect("Failed to create temp dir");
let binary_path = create_test_binary(test_name, &test_code, binary_dir.path())
.expect("Failed to create test binary");
let executor = SandboxExecutor::new(profile.clone(), test_fs.project_path.clone());
match executor.execute_sandboxed_spawn(
&binary_path.to_string_lossy(),
&[],
&test_fs.project_path,
) {
Ok(mut child) => {
let status = child.wait().expect("Failed to wait for child");
// Some platforms might not block all file access
if status.success() {
eprintln!(
"WARNING: Access to {} was allowed (possible platform limitation)",
path
);
if std::env::consts::OS == "linux" && path.starts_with("/etc") {
panic!("Access to {} should be denied on Linux", path);
}
}
}
Err(_) => {
// Sandbox setup failure
}
}
}
}
/// Test multiple violations in sequence
#[test]
#[serial]
fn test_multiple_violations_sequence() {
skip_if_unsupported!();
// Create test file system
let test_fs = TestFileSystem::new().expect("Failed to create test filesystem");
// Create minimal profile
let operations = vec![Operation::FileReadAll(PathPattern::Subpath(
test_fs.project_path.clone(),
))];
let profile = match Profile::new(operations) {
Ok(p) => p,
Err(_) => {
eprintln!("Failed to create profile - operation not supported");
return;
}
};
// Create test binary that attempts multiple forbidden operations
let test_code = r#"
use std::fs;
use std::net::TcpStream;
use std::process::Command;
fn main() {{
let mut failures = 0;
// Try file write
if fs::write("/tmp/test.txt", "data").is_err() {{
eprintln!("File write failed (expected)");
failures += 1;
}}
// Try network connection
if TcpStream::connect("google.com:80").is_err() {{
eprintln!("Network connection failed (expected)");
failures += 1;
}}
// Try process spawn
if Command::new("ls").output().is_err() {{
eprintln!("Process spawn failed (expected)");
failures += 1;
}}
// Try forbidden file read
if fs::read_to_string("/etc/passwd").is_err() {{
eprintln!("Forbidden file read failed (expected)");
failures += 1;
}}
if failures > 0 {{
eprintln!("FAILURE: {{failures}} operations were blocked");
std::process::exit(1);
}} else {{
println!("SUCCESS: No operations were blocked (unexpected)");
}}
}}
"#;
let binary_dir = TempDir::new().expect("Failed to create temp dir");
let binary_path = create_test_binary("test_multi_violations", test_code, binary_dir.path())
.expect("Failed to create test binary");
// Execute in sandbox
let executor = SandboxExecutor::new(profile, test_fs.project_path.clone());
match executor.execute_sandboxed_spawn(
&binary_path.to_string_lossy(),
&[],
&test_fs.project_path,
) {
Ok(mut child) => {
let status = child.wait().expect("Failed to wait for child");
// Multiple operations might not be blocked on all platforms
if status.success() {
eprintln!("WARNING: Forbidden operations were not blocked (platform limitation)");
if std::env::consts::OS == "linux" {
panic!("Operations should be blocked on Linux");
}
}
}
Err(e) => {
eprintln!("Sandbox execution failed: {} (may be expected in CI)", e);
}
}
}

View File

@@ -1,17 +0,0 @@
//! Comprehensive test suite for sandbox functionality
//!
//! This test suite validates the sandboxing capabilities across different platforms,
//! ensuring that security policies are correctly enforced.
#[cfg(unix)]
#[macro_use]
pub mod common;
#[cfg(unix)]
pub mod unit;
#[cfg(unix)]
pub mod integration;
#[cfg(unix)]
pub mod e2e;

View File

@@ -1,146 +0,0 @@
//! Unit tests for SandboxExecutor
use claudia_lib::sandbox::executor::{should_activate_sandbox, SandboxExecutor};
use gaol::profile::{AddressPattern, Operation, PathPattern, Profile};
use std::env;
use std::path::PathBuf;
/// Create a simple test profile
fn create_test_profile(project_path: PathBuf) -> Profile {
let operations = vec![
Operation::FileReadAll(PathPattern::Subpath(project_path)),
Operation::NetworkOutbound(AddressPattern::All),
];
Profile::new(operations).expect("Failed to create test profile")
}
#[test]
fn test_executor_creation() {
let project_path = PathBuf::from("/test/project");
let profile = create_test_profile(project_path.clone());
let _executor = SandboxExecutor::new(profile, project_path);
// Executor should be created successfully
}
#[test]
fn test_should_activate_sandbox_env_var() {
// Test when env var is not set
env::remove_var("GAOL_SANDBOX_ACTIVE");
assert!(
!should_activate_sandbox(),
"Should not activate when env var is not set"
);
// Test when env var is set to "1"
env::set_var("GAOL_SANDBOX_ACTIVE", "1");
assert!(
should_activate_sandbox(),
"Should activate when env var is '1'"
);
// Test when env var is set to other value
env::set_var("GAOL_SANDBOX_ACTIVE", "0");
assert!(
!should_activate_sandbox(),
"Should not activate when env var is not '1'"
);
// Clean up
env::remove_var("GAOL_SANDBOX_ACTIVE");
}
#[test]
fn test_prepare_sandboxed_command() {
let project_path = PathBuf::from("/test/project");
let profile = create_test_profile(project_path.clone());
let executor = SandboxExecutor::new(profile, project_path.clone());
let _cmd = executor.prepare_sandboxed_command("echo", &["hello"], &project_path);
// The command should have sandbox environment variables set
// Note: We can't easily test Command internals, but we can verify it doesn't panic
}
#[test]
fn test_executor_with_empty_profile() {
let project_path = PathBuf::from("/test/project");
let profile = Profile::new(vec![]).expect("Failed to create empty profile");
let executor = SandboxExecutor::new(profile, project_path.clone());
let _cmd = executor.prepare_sandboxed_command("echo", &["test"], &project_path);
// Should handle empty profile gracefully
}
#[test]
fn test_executor_with_complex_profile() {
let project_path = PathBuf::from("/test/project");
let operations = vec![
Operation::FileReadAll(PathPattern::Subpath(project_path.clone())),
Operation::FileReadAll(PathPattern::Subpath(PathBuf::from("/usr/lib"))),
Operation::FileReadAll(PathPattern::Literal(PathBuf::from("/etc/hosts"))),
Operation::FileReadMetadata(PathPattern::Subpath(PathBuf::from("/"))),
Operation::NetworkOutbound(AddressPattern::All),
Operation::NetworkOutbound(AddressPattern::Tcp(443)),
Operation::SystemInfoRead,
];
// Only create profile with supported operations
let filtered_ops: Vec<_> = operations
.into_iter()
.filter(|op| {
use gaol::profile::{OperationSupport, OperationSupportLevel};
matches!(op.support(), OperationSupportLevel::CanBeAllowed)
})
.collect();
if !filtered_ops.is_empty() {
let profile = Profile::new(filtered_ops).expect("Failed to create complex profile");
let executor = SandboxExecutor::new(profile, project_path.clone());
let _cmd = executor.prepare_sandboxed_command("echo", &["test"], &project_path);
}
}
#[test]
fn test_command_environment_setup() {
let project_path = PathBuf::from("/test/project");
let profile = create_test_profile(project_path.clone());
let executor = SandboxExecutor::new(profile, project_path.clone());
// Test with various arguments
let _cmd1 = executor.prepare_sandboxed_command("ls", &[], &project_path);
let _cmd2 = executor.prepare_sandboxed_command("cat", &["file.txt"], &project_path);
let _cmd3 = executor.prepare_sandboxed_command("grep", &["-r", "pattern", "."], &project_path);
// Commands should be prepared without panic
}
#[test]
#[cfg(unix)]
fn test_spawn_sandboxed_process() {
use crate::sandbox::common::is_sandboxing_supported;
if !is_sandboxing_supported() {
return;
}
let project_path = env::current_dir().unwrap_or_else(|_| PathBuf::from("/tmp"));
let profile = create_test_profile(project_path.clone());
let executor = SandboxExecutor::new(profile, project_path.clone());
// Try to spawn a simple command
let result = executor.execute_sandboxed_spawn("echo", &["sandbox test"], &project_path);
// On supported platforms, this should either succeed or fail gracefully
match result {
Ok(mut child) => {
// If spawned successfully, wait for it to complete
let _ = child.wait();
}
Err(e) => {
// Sandboxing might fail due to permissions or platform limitations
println!("Sandbox spawn failed (expected in some environments): {e}");
}
}
}

View File

@@ -1,7 +0,0 @@
//! Unit tests for sandbox components
#[cfg(test)]
mod executor;
#[cfg(test)]
mod platform;
#[cfg(test)]
mod profile_builder;

View File

@@ -1,181 +0,0 @@
//! Unit tests for platform capabilities
use claudia_lib::sandbox::platform::{get_platform_capabilities, is_sandboxing_available};
use pretty_assertions::assert_eq;
use std::env;
#[test]
fn test_sandboxing_availability() {
let is_available = is_sandboxing_available();
let expected = matches!(env::consts::OS, "linux" | "macos" | "freebsd");
assert_eq!(
is_available, expected,
"Sandboxing availability should match platform support"
);
}
#[test]
fn test_platform_capabilities_structure() {
let caps = get_platform_capabilities();
// Verify basic structure
assert_eq!(caps.os, env::consts::OS, "OS should match current platform");
assert!(
!caps.operations.is_empty() || !caps.sandboxing_supported,
"Should have operations if sandboxing is supported"
);
assert!(
!caps.notes.is_empty(),
"Should have platform-specific notes"
);
}
#[test]
#[cfg(target_os = "linux")]
fn test_linux_capabilities() {
let caps = get_platform_capabilities();
assert_eq!(caps.os, "linux");
assert!(caps.sandboxing_supported);
// Verify Linux-specific capabilities
let file_read = caps
.operations
.iter()
.find(|op| op.operation == "file_read_all")
.expect("file_read_all should be present");
assert_eq!(file_read.support_level, "can_be_allowed");
let metadata_read = caps
.operations
.iter()
.find(|op| op.operation == "file_read_metadata")
.expect("file_read_metadata should be present");
assert_eq!(metadata_read.support_level, "cannot_be_precisely");
let network_all = caps
.operations
.iter()
.find(|op| op.operation == "network_outbound_all")
.expect("network_outbound_all should be present");
assert_eq!(network_all.support_level, "can_be_allowed");
let network_tcp = caps
.operations
.iter()
.find(|op| op.operation == "network_outbound_tcp")
.expect("network_outbound_tcp should be present");
assert_eq!(network_tcp.support_level, "cannot_be_precisely");
let system_info = caps
.operations
.iter()
.find(|op| op.operation == "system_info_read")
.expect("system_info_read should be present");
assert_eq!(system_info.support_level, "never");
}
#[test]
#[cfg(target_os = "macos")]
fn test_macos_capabilities() {
let caps = get_platform_capabilities();
assert_eq!(caps.os, "macos");
assert!(caps.sandboxing_supported);
// Verify macOS-specific capabilities
let file_read = caps
.operations
.iter()
.find(|op| op.operation == "file_read_all")
.expect("file_read_all should be present");
assert_eq!(file_read.support_level, "can_be_allowed");
let metadata_read = caps
.operations
.iter()
.find(|op| op.operation == "file_read_metadata")
.expect("file_read_metadata should be present");
assert_eq!(metadata_read.support_level, "can_be_allowed");
let network_tcp = caps
.operations
.iter()
.find(|op| op.operation == "network_outbound_tcp")
.expect("network_outbound_tcp should be present");
assert_eq!(network_tcp.support_level, "can_be_allowed");
let system_info = caps
.operations
.iter()
.find(|op| op.operation == "system_info_read")
.expect("system_info_read should be present");
assert_eq!(system_info.support_level, "can_be_allowed");
}
#[test]
#[cfg(target_os = "freebsd")]
fn test_freebsd_capabilities() {
let caps = get_platform_capabilities();
assert_eq!(caps.os, "freebsd");
assert!(caps.sandboxing_supported);
// Verify FreeBSD-specific capabilities
let file_read = caps
.operations
.iter()
.find(|op| op.operation == "file_read_all")
.expect("file_read_all should be present");
assert_eq!(file_read.support_level, "never");
let system_info = caps
.operations
.iter()
.find(|op| op.operation == "system_info_read")
.expect("system_info_read should be present");
assert_eq!(system_info.support_level, "always");
}
#[test]
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "freebsd")))]
fn test_unsupported_platform_capabilities() {
let caps = get_platform_capabilities();
assert!(!caps.sandboxing_supported);
assert_eq!(caps.operations.len(), 0);
assert!(caps.notes.iter().any(|note| note.contains("not supported")));
}
#[test]
fn test_all_operations_have_descriptions() {
let caps = get_platform_capabilities();
for op in &caps.operations {
assert!(
!op.description.is_empty(),
"Operation {} should have a description",
op.operation
);
assert!(
!op.support_level.is_empty(),
"Operation {} should have a support level",
op.operation
);
}
}
#[test]
fn test_support_level_values() {
let caps = get_platform_capabilities();
let valid_levels = ["never", "can_be_allowed", "cannot_be_precisely", "always"];
for op in &caps.operations {
assert!(
valid_levels.contains(&op.support_level.as_str()),
"Operation {} has invalid support level: {}",
op.operation,
op.support_level
);
}
}

View File

@@ -1,337 +0,0 @@
//! Unit tests for ProfileBuilder
use claudia_lib::sandbox::profile::{ProfileBuilder, SandboxRule};
use std::path::PathBuf;
use test_case::test_case;
/// Helper to create a sandbox rule
fn make_rule(
operation_type: &str,
pattern_type: &str,
pattern_value: &str,
platforms: Option<&[&str]>,
) -> SandboxRule {
SandboxRule {
id: None,
profile_id: 0,
operation_type: operation_type.to_string(),
pattern_type: pattern_type.to_string(),
pattern_value: pattern_value.to_string(),
enabled: true,
platform_support: platforms.map(|p| {
serde_json::to_string(&p.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap()
}),
created_at: String::new(),
}
}
#[test]
fn test_profile_builder_creation() {
let project_path = PathBuf::from("/test/project");
let builder = ProfileBuilder::new(project_path.clone());
assert!(
builder.is_ok(),
"ProfileBuilder should be created successfully"
);
}
#[test]
fn test_empty_rules_creates_empty_profile() {
let project_path = PathBuf::from("/test/project");
let builder = ProfileBuilder::new(project_path).unwrap();
let profile = builder.build_profile(vec![]);
assert!(
profile.is_ok(),
"Empty rules should create valid empty profile"
);
}
#[test]
fn test_file_read_rule_parsing() {
let project_path = PathBuf::from("/test/project");
let builder = ProfileBuilder::new(project_path.clone()).unwrap();
let rules = vec![
make_rule(
"file_read_all",
"literal",
"/usr/lib/test.so",
Some(&["linux", "macos"]),
),
make_rule(
"file_read_all",
"subpath",
"/usr/lib",
Some(&["linux", "macos"]),
),
];
let _profile = builder.build_profile(rules);
// Profile creation might fail on unsupported platforms, but parsing should work
if std::env::consts::OS == "linux" || std::env::consts::OS == "macos" {
assert!(
_profile.is_ok(),
"File read rules should be parsed on supported platforms"
);
}
}
#[test]
fn test_network_rule_parsing() {
let project_path = PathBuf::from("/test/project");
let builder = ProfileBuilder::new(project_path).unwrap();
let rules = vec![
make_rule("network_outbound", "all", "", Some(&["linux", "macos"])),
make_rule("network_outbound", "tcp", "8080", Some(&["macos"])),
make_rule(
"network_outbound",
"local_socket",
"/tmp/socket",
Some(&["macos"]),
),
];
let _profile = builder.build_profile(rules);
if std::env::consts::OS == "linux" || std::env::consts::OS == "macos" {
assert!(
_profile.is_ok(),
"Network rules should be parsed on supported platforms"
);
}
}
#[test]
fn test_system_info_rule_parsing() {
let project_path = PathBuf::from("/test/project");
let builder = ProfileBuilder::new(project_path).unwrap();
let rules = vec![make_rule("system_info_read", "all", "", Some(&["macos"]))];
let _profile = builder.build_profile(rules);
if std::env::consts::OS == "macos" {
assert!(
_profile.is_ok(),
"System info rule should be parsed on macOS"
);
}
}
#[test]
fn test_template_variable_replacement() {
let project_path = PathBuf::from("/test/project");
let builder = ProfileBuilder::new(project_path.clone()).unwrap();
let rules = vec![
make_rule(
"file_read_all",
"subpath",
"{{PROJECT_PATH}}/src",
Some(&["linux", "macos"]),
),
make_rule(
"file_read_all",
"subpath",
"{{HOME}}/.config",
Some(&["linux", "macos"]),
),
];
let _profile = builder.build_profile(rules);
// We can't easily verify the exact paths without inspecting the Profile internals,
// but this test ensures template replacement doesn't panic
}
#[test]
fn test_disabled_rules_are_ignored() {
let project_path = PathBuf::from("/test/project");
let builder = ProfileBuilder::new(project_path).unwrap();
let mut rule = make_rule(
"file_read_all",
"subpath",
"/usr/lib",
Some(&["linux", "macos"]),
);
rule.enabled = false;
let profile = builder.build_profile(vec![rule]);
assert!(profile.is_ok(), "Disabled rules should be ignored");
}
#[test]
fn test_platform_filtering() {
let project_path = PathBuf::from("/test/project");
let builder = ProfileBuilder::new(project_path).unwrap();
let current_os = std::env::consts::OS;
let other_os = if current_os == "linux" {
"macos"
} else {
"linux"
};
let rules = vec![
// Rule for current platform
make_rule("file_read_all", "subpath", "/test1", Some(&[current_os])),
// Rule for other platform
make_rule("file_read_all", "subpath", "/test2", Some(&[other_os])),
// Rule for both platforms
make_rule(
"file_read_all",
"subpath",
"/test3",
Some(&["linux", "macos"]),
),
// Rule with no platform specification (should be included)
make_rule("file_read_all", "subpath", "/test4", None),
];
let _profile = builder.build_profile(rules);
// Rules for other platforms should be filtered out
}
#[test]
fn test_invalid_operation_type() {
let project_path = PathBuf::from("/test/project");
let builder = ProfileBuilder::new(project_path).unwrap();
let rules = vec![make_rule(
"invalid_operation",
"subpath",
"/test",
Some(&["linux", "macos"]),
)];
let _profile = builder.build_profile(rules);
assert!(_profile.is_ok(), "Invalid operations should be skipped");
}
#[test]
fn test_invalid_pattern_type() {
let project_path = PathBuf::from("/test/project");
let builder = ProfileBuilder::new(project_path).unwrap();
let rules = vec![make_rule(
"file_read_all",
"invalid_pattern",
"/test",
Some(&["linux", "macos"]),
)];
let _profile = builder.build_profile(rules);
// Should either skip the rule or fail gracefully
}
#[test]
fn test_invalid_tcp_port() {
let project_path = PathBuf::from("/test/project");
let builder = ProfileBuilder::new(project_path).unwrap();
let rules = vec![make_rule(
"network_outbound",
"tcp",
"not_a_number",
Some(&["macos"]),
)];
let _profile = builder.build_profile(rules);
// Should handle invalid port gracefully
}
#[test_case("file_read_all", "subpath", "/test" ; "file read operation")]
#[test_case("file_read_metadata", "literal", "/test/file" ; "metadata read operation")]
#[test_case("network_outbound", "all", "" ; "network all operation")]
#[test_case("system_info_read", "all", "" ; "system info operation")]
fn test_operation_support_level(operation_type: &str, pattern_type: &str, pattern_value: &str) {
let project_path = PathBuf::from("/test/project");
let builder = ProfileBuilder::new(project_path).unwrap();
let rule = make_rule(operation_type, pattern_type, pattern_value, None);
let rules = vec![rule];
match builder.build_profile(rules) {
Ok(_) => {
// Profile created successfully - operation is supported
println!("Operation {operation_type} is supported on this platform");
}
Err(e) => {
// Profile creation failed - likely due to unsupported operation
println!("Operation {operation_type} is not supported: {e}");
}
}
}
#[test]
fn test_complex_profile_with_multiple_rules() {
let project_path = PathBuf::from("/test/project");
let builder = ProfileBuilder::new(project_path.clone()).unwrap();
let rules = vec![
// File operations
make_rule(
"file_read_all",
"subpath",
"{{PROJECT_PATH}}",
Some(&["linux", "macos"]),
),
make_rule(
"file_read_all",
"subpath",
"/usr/lib",
Some(&["linux", "macos"]),
),
make_rule(
"file_read_all",
"literal",
"/etc/hosts",
Some(&["linux", "macos"]),
),
make_rule("file_read_metadata", "subpath", "/", Some(&["macos"])),
// Network operations
make_rule("network_outbound", "all", "", Some(&["linux", "macos"])),
make_rule("network_outbound", "tcp", "443", Some(&["macos"])),
make_rule("network_outbound", "tcp", "80", Some(&["macos"])),
// System info
make_rule("system_info_read", "all", "", Some(&["macos"])),
];
let _profile = builder.build_profile(rules);
if std::env::consts::OS == "linux" || std::env::consts::OS == "macos" {
assert!(
_profile.is_ok(),
"Complex profile should be created on supported platforms"
);
}
}
#[test]
fn test_rule_order_preservation() {
let project_path = PathBuf::from("/test/project");
let builder = ProfileBuilder::new(project_path).unwrap();
// Create rules with specific order
let rules = vec![
make_rule(
"file_read_all",
"subpath",
"/first",
Some(&["linux", "macos"]),
),
make_rule("network_outbound", "all", "", Some(&["linux", "macos"])),
make_rule(
"file_read_all",
"subpath",
"/second",
Some(&["linux", "macos"]),
),
];
let _profile = builder.build_profile(rules);
// Order should be preserved in the resulting profile
}

View File

@@ -1,11 +0,0 @@
//! Main entry point for sandbox tests
//!
//! This file integrates all the sandbox test modules and provides
//! a central location for running the comprehensive test suite.
#![allow(dead_code)]
#[cfg(unix)]
mod sandbox;
// Re-export test modules to make them discoverable
pub use sandbox::*;

View File

@@ -92,6 +92,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
const fullscreenMessagesEndRef = useRef<HTMLDivElement>(null);
const unlistenRefs = useRef<UnlistenFn[]>([]);
const elapsedTimeIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [runId, setRunId] = useState<number | null>(null);
// Filter out messages that shouldn't be displayed
const displayableMessages = React.useMemo(() => {
@@ -266,24 +267,24 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
};
const handleExecute = async () => {
if (!projectPath || !task.trim()) return;
let runId: number | null = null;
try {
setIsRunning(true);
setError(null);
setExecutionStartTime(Date.now());
setMessages([]);
setRawJsonlOutput([]);
setExecutionStartTime(Date.now());
setElapsedTime(0);
setTotalTokens(0);
// Execute the agent with model override and get run ID
runId = await api.executeAgent(agent.id!, projectPath, task, model);
setRunId(null);
// Clear any existing listeners
unlistenRefs.current.forEach(unlisten => unlisten());
unlistenRefs.current = [];
// Execute the agent and get the run ID
const executionRunId = await api.executeAgent(agent.id!, projectPath, task, model);
console.log("Agent execution started with run ID:", executionRunId);
setRunId(executionRunId);
// Set up event listeners with run ID isolation
const outputUnlisten = await listen<string>(`agent-output:${runId}`, (event) => {
const outputUnlisten = await listen<string>(`agent-output:${executionRunId}`, (event) => {
try {
// Store raw JSONL
setRawJsonlOutput(prev => [...prev, event.payload]);
@@ -296,12 +297,12 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
}
});
const errorUnlisten = await listen<string>(`agent-error:${runId}`, (event) => {
const errorUnlisten = await listen<string>(`agent-error:${executionRunId}`, (event) => {
console.error("Agent error:", event.payload);
setError(event.payload);
});
const completeUnlisten = await listen<boolean>(`agent-complete:${runId}`, (event) => {
const completeUnlisten = await listen<boolean>(`agent-complete:${executionRunId}`, (event) => {
setIsRunning(false);
setExecutionStartTime(null);
if (!event.payload) {
@@ -309,7 +310,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
}
});
const cancelUnlisten = await listen<boolean>(`agent-cancelled:${runId}`, () => {
const cancelUnlisten = await listen<boolean>(`agent-cancelled:${executionRunId}`, () => {
setIsRunning(false);
setExecutionStartTime(null);
setError("Agent execution was cancelled");
@@ -318,16 +319,41 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten, cancelUnlisten];
} catch (err) {
console.error("Failed to execute agent:", err);
setError("Failed to execute agent");
setIsRunning(false);
setExecutionStartTime(null);
setRunId(null);
// Show error in messages
setMessages(prev => [...prev, {
type: "result",
subtype: "error",
is_error: true,
result: `Failed to execute agent: ${err instanceof Error ? err.message : 'Unknown error'}`,
duration_ms: 0,
usage: {
input_tokens: 0,
output_tokens: 0
}
}]);
}
};
const handleStop = async () => {
try {
// TODO: Implement actual stop functionality via API
// For now, just update the UI state
if (!runId) {
console.error("No run ID available to stop");
return;
}
// Call the API to kill the agent session
const success = await api.killAgentSession(runId);
if (success) {
console.log(`Successfully stopped agent session ${runId}`);
} else {
console.warn(`Failed to stop agent session ${runId} - it may have already finished`);
}
// Update UI state
setIsRunning(false);
setExecutionStartTime(null);
@@ -349,6 +375,22 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
}]);
} catch (err) {
console.error("Failed to stop agent:", err);
// Still update UI state even if the backend call failed
setIsRunning(false);
setExecutionStartTime(null);
// Show error message
setMessages(prev => [...prev, {
type: "result",
subtype: "error",
is_error: true,
result: `Failed to stop execution: ${err instanceof Error ? err.message : 'Unknown error'}`,
duration_ms: elapsedTime * 1000,
usage: {
input_tokens: totalTokens,
output_tokens: 0
}
}]);
}
};
@@ -453,7 +495,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
{/* Fixed container that takes full height */}
<div className="h-full flex flex-col">
{/* Sticky Header */}
<div className="sticky top-0 z-10 bg-background border-b border-border">
<div className="sticky top-0 z-20 bg-background border-b border-border">
<div className="w-full max-w-5xl mx-auto">
<motion.div
initial={{ opacity: 0, y: -20 }}

View File

@@ -1,122 +0,0 @@
import React from "react";
import { Shield, FileText, Upload, Network, AlertTriangle } from "lucide-react";
import { Card } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { type Agent } from "@/lib/api";
import { cn } from "@/lib/utils";
interface AgentSandboxSettingsProps {
agent: Agent;
onUpdate: (updates: Partial<Agent>) => void;
className?: string;
}
/**
* Component for managing per-agent sandbox permissions
* Provides simple toggles for sandbox enable/disable and file/network permissions
*/
export const AgentSandboxSettings: React.FC<AgentSandboxSettingsProps> = ({
agent,
onUpdate,
className
}) => {
const handleToggle = (field: keyof Agent, value: boolean) => {
onUpdate({ [field]: value });
};
return (
<Card className={cn("p-4 space-y-4", className)}>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-amber-500" />
<h4 className="font-semibold">Sandbox Permissions</h4>
{!agent.sandbox_enabled && (
<Badge variant="secondary" className="text-xs">
Disabled
</Badge>
)}
</div>
<div className="space-y-3">
{/* Master sandbox toggle */}
<div className="flex items-center justify-between p-3 rounded-lg border bg-muted/30">
<div className="space-y-1">
<Label className="text-sm font-medium">Enable Sandbox</Label>
<p className="text-xs text-muted-foreground">
Run this agent in a secure sandbox environment
</p>
</div>
<Switch
checked={agent.sandbox_enabled}
onCheckedChange={(checked) => handleToggle('sandbox_enabled', checked)}
/>
</div>
{/* Permission toggles - only visible when sandbox is enabled */}
{agent.sandbox_enabled && (
<div className="space-y-3 pl-4 border-l-2 border-amber-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-500" />
<div>
<Label className="text-sm font-medium">File Read Access</Label>
<p className="text-xs text-muted-foreground">
Allow reading files and directories
</p>
</div>
</div>
<Switch
checked={agent.enable_file_read}
onCheckedChange={(checked) => handleToggle('enable_file_read', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Upload className="h-4 w-4 text-green-500" />
<div>
<Label className="text-sm font-medium">File Write Access</Label>
<p className="text-xs text-muted-foreground">
Allow creating and modifying files
</p>
</div>
</div>
<Switch
checked={agent.enable_file_write}
onCheckedChange={(checked) => handleToggle('enable_file_write', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Network className="h-4 w-4 text-purple-500" />
<div>
<Label className="text-sm font-medium">Network Access</Label>
<p className="text-xs text-muted-foreground">
Allow outbound network connections
</p>
</div>
</div>
<Switch
checked={agent.enable_network}
onCheckedChange={(checked) => handleToggle('enable_network', checked)}
/>
</div>
</div>
)}
{/* Warning when sandbox is disabled */}
{!agent.sandbox_enabled && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-800 dark:bg-amber-950/50 dark:border-amber-800 dark:text-amber-200">
<AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div className="text-xs">
<p className="font-medium">Sandbox Disabled</p>
<p>This agent will run with full system access. Use with caution.</p>
</div>
</div>
)}
</div>
</Card>
);
};

View File

@@ -3,13 +3,15 @@ import { motion, AnimatePresence } from "framer-motion";
import {
ArrowLeft,
Terminal,
Loader2,
FolderOpen,
Copy,
ChevronDown,
GitBranch,
Settings,
Globe
Globe,
ChevronUp,
X,
Hash
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -22,7 +24,6 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { StreamMessage } from "./StreamMessage";
import { FloatingPromptInput, type FloatingPromptInputRef } from "./FloatingPromptInput";
import { ErrorBoundary } from "./ErrorBoundary";
import { TokenCounter } from "./TokenCounter";
import { TimelineNavigator } from "./TimelineNavigator";
import { CheckpointSettings } from "./CheckpointSettings";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
@@ -84,7 +85,9 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
const [showForkDialog, setShowForkDialog] = useState(false);
const [forkCheckpointId, setForkCheckpointId] = useState<string | null>(null);
const [forkSessionName, setForkSessionName] = useState("");
const [isCancelling, setIsCancelling] = useState(false);
// Queued prompts state
const [queuedPrompts, setQueuedPrompts] = useState<Array<{ id: string; prompt: string; model: "sonnet" | "opus" }>>([]);
// New state for preview feature
const [showPreview, setShowPreview] = useState(false);
@@ -93,10 +96,21 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
const [splitPosition, setSplitPosition] = useState(50);
const [isPreviewMaximized, setIsPreviewMaximized] = useState(false);
// Add collapsed state for queued prompts
const [queuedPromptsCollapsed, setQueuedPromptsCollapsed] = useState(false);
const parentRef = useRef<HTMLDivElement>(null);
const unlistenRefs = useRef<UnlistenFn[]>([]);
const hasActiveSessionRef = useRef(false);
const floatingPromptRef = useRef<FloatingPromptInputRef>(null);
const queuedPromptsRef = useRef<Array<{ id: string; prompt: string; model: "sonnet" | "opus" }>>([]);
const isMountedRef = useRef(true);
const isListeningRef = useRef(false);
// Keep ref in sync with state
useEffect(() => {
queuedPromptsRef.current = queuedPrompts;
}, [queuedPrompts]);
// Get effective session info (from prop or extracted) - use useMemo to ensure it updates
const effectiveSession = useMemo(() => {
@@ -197,21 +211,27 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
// Load session history if resuming
useEffect(() => {
if (session) {
loadSessionHistory();
// Set the claudeSessionId immediately when we have a session
setClaudeSessionId(session.id);
// Load session history first, then check for active session
const initializeSession = async () => {
await loadSessionHistory();
// After loading history, check if the session is still active
if (isMountedRef.current) {
await checkForActiveSession();
}
};
initializeSession();
}
}, [session]);
}, [session]); // Remove hasLoadedSession dependency to ensure it runs on mount
// Report streaming state changes
useEffect(() => {
onStreamingChange?.(isLoading, claudeSessionId);
}, [isLoading, claudeSessionId, onStreamingChange]);
// Check for active Claude sessions on mount
useEffect(() => {
checkForActiveSession();
}, []);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
if (displayableMessages.length > 0) {
@@ -276,23 +296,11 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
if (activeSession) {
// Session is still active, reconnect to its stream
console.log('[ClaudeCodeSession] Found active session, reconnecting:', session.id);
// IMPORTANT: Set claudeSessionId before reconnecting
setClaudeSessionId(session.id);
// Get any buffered output
const bufferedOutput = await api.getClaudeSessionOutput(session.id);
if (bufferedOutput) {
// Parse and add buffered messages
const lines = bufferedOutput.split('\n').filter((line: string) => line.trim());
for (const line of lines) {
try {
const message = JSON.parse(line) as ClaudeStreamMessage;
setMessages(prev => [...prev, message]);
setRawJsonlOutput(prev => [...prev, line]);
} catch (err) {
console.error('Failed to parse buffered message:', err);
}
}
}
// Don't add buffered messages here - they've already been loaded by loadSessionHistory
// Just set up listeners for new messages
// Set up listeners for the active session
reconnectToSession(session.id);
@@ -306,15 +314,29 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
const reconnectToSession = async (sessionId: string) => {
console.log('[ClaudeCodeSession] Reconnecting to session:', sessionId);
// Prevent duplicate listeners
if (isListeningRef.current) {
console.log('[ClaudeCodeSession] Already listening to session, skipping reconnect');
return;
}
// Clean up previous listeners
unlistenRefs.current.forEach(unlisten => unlisten());
unlistenRefs.current = [];
// IMPORTANT: Set the session ID before setting up listeners
setClaudeSessionId(sessionId);
// Mark as listening
isListeningRef.current = true;
// Set up session-specific listeners
const outputUnlisten = await listen<string>(`claude-output:${sessionId}`, async (event) => {
try {
console.log('[ClaudeCodeSession] Received claude-output on reconnect:', event.payload);
if (!isMountedRef.current) return;
// Store raw JSONL
setRawJsonlOutput(prev => [...prev, event.payload]);
@@ -328,20 +350,26 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
const errorUnlisten = await listen<string>(`claude-error:${sessionId}`, (event) => {
console.error("Claude error:", event.payload);
setError(event.payload);
if (isMountedRef.current) {
setError(event.payload);
}
});
const completeUnlisten = await listen<boolean>(`claude-complete:${sessionId}`, async (event) => {
console.log('[ClaudeCodeSession] Received claude-complete on reconnect:', event.payload);
setIsLoading(false);
hasActiveSessionRef.current = false;
if (isMountedRef.current) {
setIsLoading(false);
hasActiveSessionRef.current = false;
}
});
unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten];
// Mark as loading to show the session is active
setIsLoading(true);
hasActiveSessionRef.current = true;
if (isMountedRef.current) {
setIsLoading(true);
hasActiveSessionRef.current = true;
}
};
const handleSelectPath = async () => {
@@ -364,126 +392,208 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
};
const handleSendPrompt = async (prompt: string, model: "sonnet" | "opus") => {
console.log('[ClaudeCodeSession] handleSendPrompt called with:', { prompt, model, projectPath });
console.log('[ClaudeCodeSession] handleSendPrompt called with:', { prompt, model, projectPath, claudeSessionId, effectiveSession });
if (!projectPath) {
setError("Please select a project directory first");
return;
}
// If already loading, queue the prompt
if (isLoading) {
const newPrompt = {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
prompt,
model
};
setQueuedPrompts(prev => [...prev, newPrompt]);
return;
}
try {
setIsLoading(true);
setError(null);
hasActiveSessionRef.current = true;
// Clean up previous listeners
unlistenRefs.current.forEach(unlisten => unlisten());
unlistenRefs.current = [];
// For resuming sessions, ensure we have the session ID
if (effectiveSession && !claudeSessionId) {
setClaudeSessionId(effectiveSession.id);
}
// Set up event listeners before executing
console.log('[ClaudeCodeSession] Setting up event listeners...');
// If we already have a Claude session ID, use isolated listeners
const eventSuffix = claudeSessionId ? `:${claudeSessionId}` : '';
const outputUnlisten = await listen<string>(`claude-output${eventSuffix}`, async (event) => {
try {
console.log('[ClaudeCodeSession] Received claude-output:', event.payload);
// Store raw JSONL
setRawJsonlOutput(prev => [...prev, event.payload]);
// Parse and display
const message = JSON.parse(event.payload) as ClaudeStreamMessage;
console.log('[ClaudeCodeSession] Parsed message:', message);
setMessages(prev => {
console.log('[ClaudeCodeSession] Adding message to state. Previous count:', prev.length);
return [...prev, message];
});
// Extract session info from system init message
if (message.type === "system" && message.subtype === "init" && message.session_id) {
console.log('[ClaudeCodeSession] Extracting session info from init message');
// Extract project ID from the project path
const projectId = projectPath.replace(/[^a-zA-Z0-9]/g, '-');
// Set both claudeSessionId and extractedSessionInfo
if (!claudeSessionId) {
setClaudeSessionId(message.session_id);
}
if (!extractedSessionInfo) {
setExtractedSessionInfo({
sessionId: message.session_id,
projectId: projectId
});
}
}
} catch (err) {
console.error("Failed to parse message:", err, event.payload);
}
});
const errorUnlisten = await listen<string>(`claude-error${eventSuffix}`, (event) => {
console.error("Claude error:", event.payload);
setError(event.payload);
});
const completeUnlisten = await listen<boolean>(`claude-complete${eventSuffix}`, async (event) => {
console.log('[ClaudeCodeSession] Received claude-complete:', event.payload);
setIsLoading(false);
hasActiveSessionRef.current = false;
// Only clean up and set up new listeners if not already listening
if (!isListeningRef.current) {
// Clean up previous listeners
unlistenRefs.current.forEach(unlisten => unlisten());
unlistenRefs.current = [];
// Check if we should create an auto checkpoint after completion
if (effectiveSession && event.payload) {
// Mark as setting up listeners
isListeningRef.current = true;
// --------------------------------------------------------------------
// 1⃣ Event Listener Setup Strategy
// --------------------------------------------------------------------
// Claude Code may emit a *new* session_id even when we pass --resume. If
// we listen only on the old session-scoped channel we will miss the
// stream until the user navigates away & back. To avoid this we:
// • Always start with GENERIC listeners (no suffix) so we catch the
// very first "system:init" message regardless of the session id.
// • Once that init message provides the *actual* session_id, we
// dynamically switch to session-scoped listeners and stop the
// generic ones to prevent duplicate handling.
// --------------------------------------------------------------------
console.log('[ClaudeCodeSession] Setting up generic event listeners first');
let currentSessionId: string | null = claudeSessionId || effectiveSession?.id || null;
// Helper to attach session-specific listeners **once we are sure**
const attachSessionSpecificListeners = async (sid: string) => {
console.log('[ClaudeCodeSession] Attaching session-specific listeners for', sid);
const specificOutputUnlisten = await listen<string>(`claude-output:${sid}`, (evt) => {
handleStreamMessage(evt.payload);
});
const specificErrorUnlisten = await listen<string>(`claude-error:${sid}`, (evt) => {
console.error('Claude error (scoped):', evt.payload);
setError(evt.payload);
});
const specificCompleteUnlisten = await listen<boolean>(`claude-complete:${sid}`, (evt) => {
console.log('[ClaudeCodeSession] Received claude-complete (scoped):', evt.payload);
processComplete(evt.payload);
});
// Replace existing unlisten refs with these new ones (after cleaning up)
unlistenRefs.current.forEach((u) => u());
unlistenRefs.current = [specificOutputUnlisten, specificErrorUnlisten, specificCompleteUnlisten];
};
// Generic listeners (catch-all)
const genericOutputUnlisten = await listen<string>('claude-output', async (event) => {
handleStreamMessage(event.payload);
// Attempt to extract session_id on the fly (for the very first init)
try {
const settings = await api.getCheckpointSettings(
effectiveSession.id,
effectiveSession.project_id,
projectPath
);
const msg = JSON.parse(event.payload) as ClaudeStreamMessage;
if (msg.type === 'system' && msg.subtype === 'init' && msg.session_id) {
if (!currentSessionId || currentSessionId !== msg.session_id) {
console.log('[ClaudeCodeSession] Detected new session_id from generic listener:', msg.session_id);
currentSessionId = msg.session_id;
setClaudeSessionId(msg.session_id);
// If we haven't extracted session info before, do it now
if (!extractedSessionInfo) {
const projectId = projectPath.replace(/[^a-zA-Z0-9]/g, '-');
setExtractedSessionInfo({ sessionId: msg.session_id, projectId });
}
// Switch to session-specific listeners
await attachSessionSpecificListeners(msg.session_id);
}
}
} catch {
/* ignore parse errors */
}
});
// Helper to process any JSONL stream message string
function handleStreamMessage(payload: string) {
try {
// Don't process if component unmounted
if (!isMountedRef.current) return;
if (settings.auto_checkpoint_enabled) {
await api.checkAutoCheckpoint(
// Store raw JSONL
setRawJsonlOutput((prev) => [...prev, payload]);
const message = JSON.parse(payload) as ClaudeStreamMessage;
setMessages((prev) => [...prev, message]);
} catch (err) {
console.error('Failed to parse message:', err, payload);
}
}
// Helper to handle completion events (both generic and scoped)
const processComplete = async (success: boolean) => {
setIsLoading(false);
hasActiveSessionRef.current = false;
isListeningRef.current = false; // Reset listening state
if (effectiveSession && success) {
try {
const settings = await api.getCheckpointSettings(
effectiveSession.id,
effectiveSession.project_id,
projectPath,
prompt
projectPath
);
// Reload timeline to show new checkpoint
setTimelineVersion((v) => v + 1);
if (settings.auto_checkpoint_enabled) {
await api.checkAutoCheckpoint(
effectiveSession.id,
effectiveSession.project_id,
projectPath,
prompt
);
// Reload timeline to show new checkpoint
setTimelineVersion((v) => v + 1);
}
} catch (err) {
console.error('Failed to check auto checkpoint:', err);
}
} catch (err) {
console.error('Failed to check auto checkpoint:', err);
}
}
});
unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten];
// Add the user message immediately to the UI (after setting up listeners)
const userMessage: ClaudeStreamMessage = {
type: "user",
message: {
content: [
{
type: "text",
text: prompt
}
]
}
};
setMessages(prev => [...prev, userMessage]);
// Process queued prompts after completion
if (queuedPromptsRef.current.length > 0) {
const [nextPrompt, ...remainingPrompts] = queuedPromptsRef.current;
setQueuedPrompts(remainingPrompts);
// Small delay to ensure UI updates
setTimeout(() => {
handleSendPrompt(nextPrompt.prompt, nextPrompt.model);
}, 100);
}
};
// Execute the appropriate command
if (effectiveSession && !isFirstPrompt) {
console.log('[ClaudeCodeSession] Resuming session:', effectiveSession.id);
await api.resumeClaudeCode(projectPath, effectiveSession.id, prompt, model);
} else {
console.log('[ClaudeCodeSession] Starting new session');
setIsFirstPrompt(false);
await api.executeClaudeCode(projectPath, prompt, model);
const genericErrorUnlisten = await listen<string>('claude-error', (evt) => {
console.error('Claude error:', evt.payload);
setError(evt.payload);
});
const genericCompleteUnlisten = await listen<boolean>('claude-complete', (evt) => {
console.log('[ClaudeCodeSession] Received claude-complete (generic):', evt.payload);
processComplete(evt.payload);
});
// Store the generic unlisteners for now; they may be replaced later.
unlistenRefs.current = [genericOutputUnlisten, genericErrorUnlisten, genericCompleteUnlisten];
// --------------------------------------------------------------------
// 2⃣ Auto-checkpoint logic moved after listener setup (unchanged)
// --------------------------------------------------------------------
// Add the user message immediately to the UI (after setting up listeners)
const userMessage: ClaudeStreamMessage = {
type: "user",
message: {
content: [
{
type: "text",
text: prompt
}
]
}
};
setMessages(prev => [...prev, userMessage]);
// Execute the appropriate command
if (effectiveSession && !isFirstPrompt) {
console.log('[ClaudeCodeSession] Resuming session:', effectiveSession.id);
await api.resumeClaudeCode(projectPath, effectiveSession.id, prompt, model);
} else {
console.log('[ClaudeCodeSession] Starting new session');
setIsFirstPrompt(false);
await api.executeClaudeCode(projectPath, prompt, model);
}
}
} catch (err) {
console.error("Failed to send prompt:", err);
@@ -579,36 +689,54 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
};
const handleCancelExecution = async () => {
if (!isLoading || isCancelling) return;
if (!claudeSessionId || !isLoading) return;
try {
setIsCancelling(true);
// Cancel the Claude execution with session ID if available
await api.cancelClaudeExecution(claudeSessionId || undefined);
await api.cancelClaudeExecution(claudeSessionId);
// Clean up listeners
unlistenRefs.current.forEach(unlisten => unlisten());
unlistenRefs.current = [];
// Add a system message indicating cancellation
const cancelMessage: ClaudeStreamMessage = {
type: "system",
subtype: "cancelled",
result: "Execution cancelled by user",
timestamp: new Date().toISOString()
};
setMessages(prev => [...prev, cancelMessage]);
// Reset states
setIsLoading(false);
hasActiveSessionRef.current = false;
isListeningRef.current = false;
setError(null);
// Clear queued prompts
setQueuedPrompts([]);
// Add a message indicating the session was cancelled
const cancelMessage: ClaudeStreamMessage = {
type: "system",
subtype: "info",
result: "Session cancelled by user",
timestamp: new Date().toISOString()
};
setMessages(prev => [...prev, cancelMessage]);
} catch (err) {
console.error("Failed to cancel execution:", err);
setError("Failed to cancel execution");
} finally {
setIsCancelling(false);
// Even if backend fails, we should update UI to reflect stopped state
// Add error message but still stop the UI loading state
const errorMessage: ClaudeStreamMessage = {
type: "system",
subtype: "error",
result: `Failed to cancel execution: ${err instanceof Error ? err.message : 'Unknown error'}. The process may still be running in the background.`,
timestamp: new Date().toISOString()
};
setMessages(prev => [...prev, errorMessage]);
// Clean up listeners anyway
unlistenRefs.current.forEach(unlisten => unlisten());
unlistenRefs.current = [];
// Reset states to allow user to continue
setIsLoading(false);
hasActiveSessionRef.current = false;
isListeningRef.current = false;
setError(null);
}
};
@@ -664,17 +792,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
// Keep the previewUrl so it can be restored when reopening
};
const handlePreviewScreenshot = async (imagePath: string) => {
console.log("Screenshot captured:", imagePath);
// Add the screenshot to the floating prompt input
if (floatingPromptRef.current) {
floatingPromptRef.current.addImage(imagePath);
// Show a subtle animation/feedback that the image was added
// You could add a toast notification here if desired
}
};
const handlePreviewUrlChange = (url: string) => {
console.log('[ClaudeCodeSession] Preview URL changed to:', url);
@@ -689,10 +807,19 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
}
};
// Clean up listeners on component unmount
// Cleanup event listeners and track mount state
useEffect(() => {
isMountedRef.current = true;
return () => {
console.log('[ClaudeCodeSession] Component unmounting, cleaning up listeners');
isMountedRef.current = false;
isListeningRef.current = false;
// Clean up listeners
unlistenRefs.current.forEach(unlisten => unlisten());
unlistenRefs.current = [];
// Clear checkpoint manager when session ends
if (effectiveSession) {
api.clearCheckpointManager(effectiveSession.id).catch(err => {
@@ -700,20 +827,21 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
});
}
};
}, []);
}, [effectiveSession]);
const messagesList = (
<div
ref={parentRef}
className="flex-1 overflow-y-auto relative"
className="flex-1 overflow-y-auto relative pb-40"
style={{
contain: 'strict',
}}
>
<div
className="relative w-full max-w-5xl mx-auto px-4 py-4"
className="relative w-full max-w-5xl mx-auto px-4 pt-8 pb-4"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
height: `${Math.max(rowVirtualizer.getTotalSize(), 100)}px`,
minHeight: '100px',
}}
>
<AnimatePresence>
@@ -744,28 +872,27 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
</AnimatePresence>
</div>
{/* Loading and Error indicators positioned relative to the scroll container */}
<div className="sticky bottom-0 w-full flex flex-col items-center pb-40">
{isLoading && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center justify-center py-4 mt-4"
>
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</motion.div>
)}
{error && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive mt-4 w-full max-w-5xl mx-auto"
>
{error}
</motion.div>
)}
</div>
{/* Loading indicator under the latest message */}
{isLoading && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center justify-center py-4 mb-40"
>
<div className="rotating-symbol text-primary text-2xl" />
</motion.div>
)}
{/* Error indicator */}
{error && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive mb-40 w-full max-w-5xl mx-auto"
>
{error}
</motion.div>
)}
</div>
);
@@ -814,7 +941,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
<WebviewPreview
initialUrl={previewUrl}
onClose={handleClosePreview}
onScreenshot={handlePreviewScreenshot}
isMaximized={isPreviewMaximized}
onToggleMaximize={handleTogglePreviewMaximize}
onUrlChange={handlePreviewUrlChange}
@@ -841,7 +967,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
size="icon"
onClick={onBack}
className="h-8 w-8"
disabled={isLoading}
>
<ArrowLeft className="h-4 w-4" />
</Button>
@@ -947,13 +1072,14 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
onOpenChange={setCopyPopoverOpen}
/>
)}
<TokenCounter tokens={totalTokens} />
</div>
</motion.div>
{/* Main Content Area */}
<div className="flex-1 overflow-hidden">
<div className={cn(
"flex-1 overflow-hidden transition-all duration-300",
showTimeline && "sm:mr-96"
)}>
{showPreview ? (
// Split pane layout when preview is active
<SplitPane
@@ -967,7 +1093,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
<WebviewPreview
initialUrl={previewUrl}
onClose={handleClosePreview}
onScreenshot={handlePreviewScreenshot}
isMaximized={isPreviewMaximized}
onToggleMaximize={handleTogglePreviewMaximize}
onUrlChange={handlePreviewUrlChange}
@@ -984,45 +1109,218 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
<div className="h-full flex flex-col max-w-5xl mx-auto">
{projectPathInput}
{messagesList}
</div>
)}
{isLoading && messages.length === 0 && (
<div className="flex items-center justify-center h-full">
<div className="flex items-center gap-3">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-sm text-muted-foreground">
{session ? "Loading session history..." : "Initializing Claude Code..."}
</span>
</div>
{isLoading && messages.length === 0 && (
<div className="flex items-center justify-center h-full">
<div className="flex items-center gap-3">
<div className="rotating-symbol text-primary text-2xl" />
<span className="text-sm text-muted-foreground">
{session ? "Loading session history..." : "Initializing Claude Code..."}
</span>
</div>
</div>
)}
</div>
)}
</div>
{/* Floating Prompt Input - Always visible */}
<ErrorBoundary>
<FloatingPromptInput
ref={floatingPromptRef}
onSend={handleSendPrompt}
onCancel={handleCancelExecution}
isLoading={isLoading}
disabled={!projectPath}
projectPath={projectPath}
/>
{/* Queued Prompts Display */}
<AnimatePresence>
{queuedPrompts.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="fixed bottom-24 left-1/2 -translate-x-1/2 z-30 w-full max-w-3xl px-4"
>
<div className="bg-background/95 backdrop-blur-md border rounded-lg shadow-lg p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-muted-foreground mb-1">
Queued Prompts ({queuedPrompts.length})
</div>
<Button variant="ghost" size="icon" onClick={() => setQueuedPromptsCollapsed(prev => !prev)}>
{queuedPromptsCollapsed ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</Button>
</div>
{!queuedPromptsCollapsed && queuedPrompts.map((queuedPrompt, index) => (
<motion.div
key={queuedPrompt.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ delay: index * 0.05 }}
className="flex items-start gap-2 bg-muted/50 rounded-md p-2"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-muted-foreground">#{index + 1}</span>
<span className="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">
{queuedPrompt.model === "opus" ? "Opus" : "Sonnet"}
</span>
</div>
<p className="text-sm line-clamp-2 break-words">{queuedPrompt.prompt}</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0"
onClick={() => setQueuedPrompts(prev => prev.filter(p => p.id !== queuedPrompt.id))}
>
<X className="h-3 w-3" />
</Button>
</motion.div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Navigation Arrows - positioned above prompt bar with spacing */}
{displayableMessages.length > 5 && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ delay: 0.5 }}
className="fixed bottom-32 right-6 z-50"
>
<div className="flex items-center bg-background/95 backdrop-blur-md border rounded-full shadow-lg overflow-hidden">
<Button
variant="ghost"
size="sm"
onClick={() => {
// Use virtualizer to scroll to the first item
if (displayableMessages.length > 0) {
// Scroll to top of the container
parentRef.current?.scrollTo({
top: 0,
behavior: 'smooth'
});
// After smooth scroll completes, trigger a small scroll to ensure rendering
setTimeout(() => {
if (parentRef.current) {
// Scroll down 1px then back to 0 to trigger virtualizer update
parentRef.current.scrollTop = 1;
requestAnimationFrame(() => {
if (parentRef.current) {
parentRef.current.scrollTop = 0;
}
});
}
}, 500); // Wait for smooth scroll to complete
}
}}
className="px-3 py-2 hover:bg-accent rounded-none"
title="Scroll to top"
>
<ChevronUp className="h-4 w-4" />
</Button>
<div className="w-px h-4 bg-border" />
<Button
variant="ghost"
size="sm"
onClick={() => {
// Use virtualizer to scroll to the last item
if (displayableMessages.length > 0) {
// Scroll to bottom of the container
const scrollElement = parentRef.current;
if (scrollElement) {
scrollElement.scrollTo({
top: scrollElement.scrollHeight,
behavior: 'smooth'
});
}
}
}}
className="px-3 py-2 hover:bg-accent rounded-none"
title="Scroll to bottom"
>
<ChevronDown className="h-4 w-4" />
</Button>
</div>
</motion.div>
)}
<div className={cn(
"fixed bottom-0 left-0 right-0 transition-all duration-300 z-50",
showTimeline && "sm:right-96"
)}>
<FloatingPromptInput
ref={floatingPromptRef}
onSend={handleSendPrompt}
onCancel={handleCancelExecution}
isLoading={isLoading}
disabled={!projectPath}
projectPath={projectPath}
/>
</div>
{/* Token Counter - positioned under the Send button */}
{totalTokens > 0 && (
<div className="fixed bottom-0 left-0 right-0 z-30 pointer-events-none">
<div className="max-w-5xl mx-auto">
<div className="flex justify-end px-4 pb-2">
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="bg-background/95 backdrop-blur-md border rounded-full px-3 py-1 shadow-lg pointer-events-auto"
>
<div className="flex items-center gap-1.5 text-xs">
<Hash className="h-3 w-3 text-muted-foreground" />
<span className="font-mono">{totalTokens.toLocaleString()}</span>
<span className="text-muted-foreground">tokens</span>
</div>
</motion.div>
</div>
</div>
</div>
)}
</ErrorBoundary>
{/* Timeline */}
{showTimeline && effectiveSession && (
<TimelineNavigator
sessionId={effectiveSession.id}
projectId={effectiveSession.project_id}
projectPath={projectPath}
currentMessageIndex={messages.length - 1}
onCheckpointSelect={handleCheckpointSelect}
onFork={handleFork}
refreshVersion={timelineVersion}
/>
)}
<AnimatePresence>
{showTimeline && effectiveSession && (
<motion.div
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", damping: 20, stiffness: 300 }}
className="fixed right-0 top-0 h-full w-full sm:w-96 bg-background border-l border-border shadow-xl z-30 overflow-hidden"
>
<div className="h-full flex flex-col">
{/* Timeline Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<h3 className="text-lg font-semibold">Session Timeline</h3>
<Button
variant="ghost"
size="icon"
onClick={() => setShowTimeline(false)}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Timeline Content */}
<div className="flex-1 overflow-y-auto p-4">
<TimelineNavigator
sessionId={effectiveSession.id}
projectId={effectiveSession.project_id}
projectPath={projectPath}
currentMessageIndex={messages.length - 1}
onCheckpointSelect={handleCheckpointSelect}
onFork={handleFork}
refreshVersion={timelineVersion}
/>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Fork Dialog */}

View File

@@ -9,7 +9,6 @@ import { api, type Agent } from "@/lib/api";
import { cn } from "@/lib/utils";
import MDEditor from "@uiw/react-md-editor";
import { type AgentIconName } from "./CCAgents";
import { AgentSandboxSettings } from "./AgentSandboxSettings";
import { IconPicker, ICON_MAP } from "./IconPicker";
interface CreateAgentProps {
@@ -48,10 +47,6 @@ export const CreateAgent: React.FC<CreateAgentProps> = ({
const [systemPrompt, setSystemPrompt] = useState(agent?.system_prompt || "");
const [defaultTask, setDefaultTask] = useState(agent?.default_task || "");
const [model, setModel] = useState(agent?.model || "sonnet");
const [sandboxEnabled, setSandboxEnabled] = useState(agent?.sandbox_enabled ?? true);
const [enableFileRead, setEnableFileRead] = useState(agent?.enable_file_read ?? true);
const [enableFileWrite, setEnableFileWrite] = useState(agent?.enable_file_write ?? true);
const [enableNetwork, setEnableNetwork] = useState(agent?.enable_network ?? false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
@@ -81,11 +76,7 @@ export const CreateAgent: React.FC<CreateAgentProps> = ({
selectedIcon,
systemPrompt,
defaultTask || undefined,
model,
sandboxEnabled,
enableFileRead,
enableFileWrite,
enableNetwork
model
);
} else {
await api.createAgent(
@@ -93,11 +84,7 @@ export const CreateAgent: React.FC<CreateAgentProps> = ({
selectedIcon,
systemPrompt,
defaultTask || undefined,
model,
sandboxEnabled,
enableFileRead,
enableFileWrite,
enableNetwork
model
);
}
@@ -119,11 +106,7 @@ export const CreateAgent: React.FC<CreateAgentProps> = ({
selectedIcon !== (agent?.icon || "bot") ||
systemPrompt !== (agent?.system_prompt || "") ||
defaultTask !== (agent?.default_task || "") ||
model !== (agent?.model || "sonnet") ||
sandboxEnabled !== (agent?.sandbox_enabled ?? true) ||
enableFileRead !== (agent?.enable_file_read ?? true) ||
enableFileWrite !== (agent?.enable_file_write ?? true) ||
enableNetwork !== (agent?.enable_network ?? false)) &&
model !== (agent?.model || "sonnet")) &&
!confirm("You have unsaved changes. Are you sure you want to leave?")) {
return;
}
@@ -309,29 +292,7 @@ export const CreateAgent: React.FC<CreateAgentProps> = ({
</p>
</div>
{/* Sandbox Settings */}
<AgentSandboxSettings
agent={{
id: agent?.id,
name,
icon: selectedIcon,
system_prompt: systemPrompt,
default_task: defaultTask || undefined,
model,
sandbox_enabled: sandboxEnabled,
enable_file_read: enableFileRead,
enable_file_write: enableFileWrite,
enable_network: enableNetwork,
created_at: agent?.created_at || "",
updated_at: agent?.updated_at || ""
}}
onUpdate={(updates) => {
if ('sandbox_enabled' in updates) setSandboxEnabled(updates.sandbox_enabled!);
if ('enable_file_read' in updates) setEnableFileRead(updates.enable_file_read!);
if ('enable_file_write' in updates) setEnableFileWrite(updates.enable_file_write!);
if ('enable_network' in updates) setEnableNetwork(updates.enable_network!);
}}
/>
{/* System Prompt Editor */}
<div className="space-y-2">
@@ -377,4 +338,4 @@ export const CreateAgent: React.FC<CreateAgentProps> = ({
/>
</div>
);
};
};

View File

@@ -335,13 +335,13 @@ const FloatingPromptInputInner = (
}, [isExpanded]);
const handleSend = () => {
if (prompt.trim() && !isLoading && !disabled) {
if (prompt.trim() && !disabled) {
let finalPrompt = prompt.trim();
// Append thinking phrase if not auto mode
const thinkingMode = THINKING_MODES.find(m => m.id === selectedThinkingMode);
if (thinkingMode && thinkingMode.phrase) {
finalPrompt = `${finalPrompt}\n\n${thinkingMode.phrase}.`;
finalPrompt = `${finalPrompt}.\n\n${thinkingMode.phrase}.`;
}
onSend(finalPrompt, selectedModel);
@@ -516,7 +516,7 @@ const FloatingPromptInputInner = (
onChange={handleTextChange}
placeholder="Type your prompt here..."
className="min-h-[200px] resize-none"
disabled={isLoading || disabled}
disabled={disabled}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
@@ -603,7 +603,7 @@ const FloatingPromptInputInner = (
<Button
onClick={handleSend}
disabled={!prompt.trim() || isLoading || disabled}
disabled={!prompt.trim() || disabled}
size="default"
className="min-w-[60px]"
>
@@ -649,7 +649,7 @@ const FloatingPromptInputInner = (
<Button
variant="outline"
size="default"
disabled={isLoading || disabled}
disabled={disabled}
className="gap-2 min-w-[180px] justify-start"
>
{selectedModelData.icon}
@@ -698,7 +698,7 @@ const FloatingPromptInputInner = (
<Button
variant="outline"
size="default"
disabled={isLoading || disabled}
disabled={disabled}
className="gap-2"
>
<Brain className="h-4 w-4" />
@@ -757,7 +757,7 @@ const FloatingPromptInputInner = (
onChange={handleTextChange}
onKeyDown={handleKeyDown}
placeholder={dragActive ? "Drop images here..." : "Ask Claude anything..."}
disabled={isLoading || disabled}
disabled={disabled}
className={cn(
"min-h-[44px] max-h-[120px] resize-none pr-10",
dragActive && "border-primary"
@@ -769,7 +769,7 @@ const FloatingPromptInputInner = (
variant="ghost"
size="icon"
onClick={() => setIsExpanded(true)}
disabled={isLoading || disabled}
disabled={disabled}
className="absolute right-1 bottom-1 h-8 w-8"
>
<Maximize2 className="h-4 w-4" />

View File

@@ -314,9 +314,6 @@ export const GitHubAgentBrowser: React.FC<GitHubAgentBrowserProps> = ({
</h3>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline">{selectedAgent.data.agent.model}</Badge>
{selectedAgent.data.agent.sandbox_enabled && (
<Badge variant="secondary">Sandbox</Badge>
)}
</div>
</div>
</div>
@@ -341,21 +338,7 @@ export const GitHubAgentBrowser: React.FC<GitHubAgentBrowserProps> = ({
</div>
)}
{/* Permissions */}
<div>
<h4 className="text-sm font-medium mb-2">Permissions</h4>
<div className="flex flex-wrap gap-2">
<Badge variant={selectedAgent.data.agent.enable_file_read ? "default" : "secondary"}>
File Read: {selectedAgent.data.agent.enable_file_read ? "Yes" : "No"}
</Badge>
<Badge variant={selectedAgent.data.agent.enable_file_write ? "default" : "secondary"}>
File Write: {selectedAgent.data.agent.enable_file_write ? "Yes" : "No"}
</Badge>
<Badge variant={selectedAgent.data.agent.enable_network ? "default" : "secondary"}>
Network: {selectedAgent.data.agent.enable_network ? "Yes" : "No"}
</Badge>
</div>
</div>
{/* Metadata */}
<div className="text-xs text-muted-foreground">

View File

@@ -35,7 +35,8 @@ import {
TaskWidget,
LSResultWidget,
ThinkingWidget,
WebSearchWidget
WebSearchWidget,
WebFetchWidget
} from "./ToolWidgets";
interface StreamMessageProps {
@@ -246,6 +247,12 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
return <WebSearchWidget query={input.query} result={toolResult} />;
}
// WebFetch tool
if (toolName === "webfetch" && input?.url) {
renderedSomething = true;
return <WebFetchWidget url={input.url} prompt={input.prompt} result={toolResult} />;
}
// Default - return null
return null;
};
@@ -361,7 +368,7 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
const toolUse = prevMsg.message.content.find((c: any) => c.type === 'tool_use' && c.id === content.tool_use_id);
if (toolUse) {
const toolName = toolUse.name?.toLowerCase();
const toolsWithWidgets = ['task','edit','multiedit','todowrite','ls','read','glob','bash','write','grep','websearch'];
const toolsWithWidgets = ['task','edit','multiedit','todowrite','ls','read','glob','bash','write','grep','websearch','webfetch'];
if (toolsWithWidgets.includes(toolName) || toolUse.name?.startsWith('mcp__')) {
hasCorrespondingWidget = true;
}

View File

@@ -580,4 +580,4 @@ export const TimelineNavigator: React.FC<TimelineNavigatorProps> = ({
</Dialog>
</div>
);
};
};

View File

@@ -2288,3 +2288,187 @@ export const ThinkingWidget: React.FC<{
</div>
);
};
/**
* Widget for WebFetch tool - displays URL fetching with optional prompts
*/
export const WebFetchWidget: React.FC<{
url: string;
prompt?: string;
result?: any;
}> = ({ url, prompt, result }) => {
const [isExpanded, setIsExpanded] = useState(false);
const [showFullContent, setShowFullContent] = useState(false);
// Extract result content if available
let fetchedContent = '';
let isLoading = !result;
let hasError = false;
if (result) {
if (typeof result.content === 'string') {
fetchedContent = result.content;
} else if (result.content && typeof result.content === 'object') {
if (result.content.text) {
fetchedContent = result.content.text;
} else if (Array.isArray(result.content)) {
fetchedContent = result.content
.map((c: any) => (typeof c === 'string' ? c : c.text || JSON.stringify(c)))
.join('\n');
} else {
fetchedContent = JSON.stringify(result.content, null, 2);
}
}
// Check if there's an error
hasError = result.is_error ||
fetchedContent.toLowerCase().includes('error') ||
fetchedContent.toLowerCase().includes('failed');
}
// Truncate content for preview
const maxPreviewLength = 500;
const isTruncated = fetchedContent.length > maxPreviewLength;
const previewContent = isTruncated && !showFullContent
? fetchedContent.substring(0, maxPreviewLength) + '...'
: fetchedContent;
// Extract domain from URL for display
const getDomain = (urlString: string) => {
try {
const urlObj = new URL(urlString);
return urlObj.hostname;
} catch {
return urlString;
}
};
const handleUrlClick = async () => {
try {
await open(url);
} catch (error) {
console.error('Failed to open URL:', error);
}
};
return (
<div className="flex flex-col gap-2">
{/* Header with URL and optional prompt */}
<div className="space-y-2">
{/* URL Display */}
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-purple-500/5 border border-purple-500/10">
<Globe className="h-4 w-4 text-purple-500/70" />
<span className="text-xs font-medium uppercase tracking-wider text-purple-600/70 dark:text-purple-400/70">Fetching</span>
<button
onClick={handleUrlClick}
className="text-sm text-foreground/80 hover:text-foreground flex-1 truncate text-left hover:underline decoration-purple-500/50"
>
{url}
</button>
</div>
{/* Prompt Display */}
{prompt && (
<div className="ml-6 space-y-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight className={cn("h-3 w-3 transition-transform", isExpanded && "rotate-90")} />
<Info className="h-3 w-3" />
<span>Analysis Prompt</span>
</button>
{isExpanded && (
<div className="rounded-lg border bg-muted/30 p-3 ml-4">
<p className="text-sm text-foreground/90">
{prompt}
</p>
</div>
)}
</div>
)}
</div>
{/* Results */}
{isLoading ? (
<div className="rounded-lg border bg-background/50 backdrop-blur-sm overflow-hidden">
<div className="px-3 py-2 flex items-center gap-2 text-muted-foreground">
<div className="animate-pulse flex items-center gap-1">
<div className="h-1 w-1 bg-purple-500 rounded-full animate-bounce [animation-delay:-0.3s]"></div>
<div className="h-1 w-1 bg-purple-500 rounded-full animate-bounce [animation-delay:-0.15s]"></div>
<div className="h-1 w-1 bg-purple-500 rounded-full animate-bounce"></div>
</div>
<span className="text-sm">Fetching content from {getDomain(url)}...</span>
</div>
</div>
) : fetchedContent ? (
<div className="rounded-lg border bg-background/50 backdrop-blur-sm overflow-hidden">
{hasError ? (
<div className="px-3 py-2">
<div className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-4 w-4" />
<span className="text-sm font-medium">Failed to fetch content</span>
</div>
<pre className="mt-2 text-xs font-mono text-muted-foreground whitespace-pre-wrap">
{fetchedContent}
</pre>
</div>
) : (
<div className="p-3 space-y-2">
{/* Content Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FileText className="h-3.5 w-3.5" />
<span>Content from {getDomain(url)}</span>
</div>
{isTruncated && (
<button
onClick={() => setShowFullContent(!showFullContent)}
className="text-xs text-purple-500 hover:text-purple-600 transition-colors flex items-center gap-1"
>
{showFullContent ? (
<>
<ChevronUp className="h-3 w-3" />
Show less
</>
) : (
<>
<ChevronDown className="h-3 w-3" />
Show full content
</>
)}
</button>
)}
</div>
{/* Fetched Content */}
<div className="relative">
<div className={cn(
"rounded-lg bg-muted/30 p-3 overflow-hidden",
!showFullContent && isTruncated && "max-h-[300px]"
)}>
<pre className="text-sm font-mono text-foreground/90 whitespace-pre-wrap">
{previewContent}
</pre>
{!showFullContent && isTruncated && (
<div className="absolute bottom-0 left-0 right-0 h-20 bg-gradient-to-t from-muted/30 to-transparent pointer-events-none" />
)}
</div>
</div>
</div>
)}
</div>
) : (
<div className="rounded-lg border bg-background/50 backdrop-blur-sm overflow-hidden">
<div className="px-3 py-2">
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="h-4 w-4" />
<span className="text-sm">No content returned</span>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -7,7 +7,6 @@ import {
X,
Minimize2,
Maximize2,
Camera,
Loader2,
AlertCircle,
Globe,
@@ -17,7 +16,6 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
// TODO: These imports will be used when implementing actual Tauri webview
// import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
// import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
@@ -31,10 +29,6 @@ interface WebviewPreviewProps {
* Callback when close is clicked
*/
onClose: () => void;
/**
* Callback when screenshot is requested
*/
onScreenshot?: (imagePath: string) => void;
/**
* Whether the webview is maximized
*/
@@ -60,13 +54,11 @@ interface WebviewPreviewProps {
* <WebviewPreview
* initialUrl="http://localhost:3000"
* onClose={() => setShowPreview(false)}
* onScreenshot={(path) => attachImage(path)}
* />
*/
const WebviewPreviewComponent: React.FC<WebviewPreviewProps> = ({
initialUrl,
onClose,
onScreenshot,
isMaximized = false,
onToggleMaximize,
onUrlChange,
@@ -80,8 +72,6 @@ const WebviewPreviewComponent: React.FC<WebviewPreviewProps> = ({
// TODO: These will be implemented with actual webview navigation
// const [canGoBack, setCanGoBack] = useState(false);
// const [canGoForward, setCanGoForward] = useState(false);
const [isCapturing, setIsCapturing] = useState(false);
const [showShutterAnimation, setShowShutterAnimation] = useState(false);
// TODO: These will be used for actual Tauri webview implementation
// const webviewRef = useRef<WebviewWindow | null>(null);
@@ -181,39 +171,6 @@ const WebviewPreviewComponent: React.FC<WebviewPreviewProps> = ({
navigate(initialUrl);
};
const handleScreenshot = async () => {
if (isCapturing || !currentUrl) return;
try {
setIsCapturing(true);
setShowShutterAnimation(true);
// Wait for shutter animation to start
await new Promise(resolve => setTimeout(resolve, 100));
// Capture the current URL using headless Chrome
const filePath = await api.captureUrlScreenshot(
currentUrl,
null, // No specific selector - capture the whole viewport
false // Not full page, just viewport
);
console.log("Screenshot captured and saved to:", filePath);
// Continue shutter animation
await new Promise(resolve => setTimeout(resolve, 200));
setShowShutterAnimation(false);
// Trigger callback with animation
onScreenshot?.(filePath);
} catch (err) {
console.error("Failed to capture screenshot:", err);
setShowShutterAnimation(false);
} finally {
setIsCapturing(false);
}
};
return (
<div
ref={containerRef}
@@ -330,49 +287,11 @@ const WebviewPreviewComponent: React.FC<WebviewPreviewProps> = ({
</Button>
)}
</div>
{/* Screenshot Button */}
<Button
variant="outline"
size="sm"
onClick={handleScreenshot}
disabled={isCapturing || hasError}
className="gap-2"
>
{isCapturing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Camera className="h-4 w-4" />
)}
Send to Claude
</Button>
</div>
</div>
{/* Webview Content */}
<div className="flex-1 relative bg-background" ref={contentRef}>
{/* Shutter Animation */}
<AnimatePresence>
{showShutterAnimation && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="absolute inset-0 z-20 pointer-events-none"
>
<motion.div
initial={{ borderWidth: 0 }}
animate={{ borderWidth: 8 }}
exit={{ borderWidth: 0 }}
transition={{ duration: 0.3 }}
className="absolute inset-0 border-white shadow-lg"
style={{ boxShadow: 'inset 0 0 20px rgba(255, 255, 255, 0.8)' }}
/>
</motion.div>
)}
</AnimatePresence>
{/* Loading Overlay */}
<AnimatePresence>
{isLoading && (

View File

@@ -106,83 +106,6 @@ export interface ClaudeInstallation {
source: string;
}
// Sandbox API types
export interface SandboxProfile {
id?: number;
name: string;
description?: string;
is_active: boolean;
is_default: boolean;
created_at: string;
updated_at: string;
}
export interface SandboxRule {
id?: number;
profile_id: number;
operation_type: string;
pattern_type: string;
pattern_value: string;
enabled: boolean;
platform_support?: string;
created_at: string;
}
export interface PlatformCapabilities {
os: string;
sandboxing_supported: boolean;
operations: OperationSupport[];
notes: string[];
}
export interface OperationSupport {
operation: string;
support_level: string;
description: string;
}
// Sandbox violation types
export interface SandboxViolation {
id?: number;
profile_id?: number;
agent_id?: number;
agent_run_id?: number;
operation_type: string;
pattern_value?: string;
process_name?: string;
pid?: number;
denied_at: string;
}
export interface SandboxViolationStats {
total: number;
recent_24h: number;
by_operation: Array<{
operation: string;
count: number;
}>;
}
// Import/Export types
export interface SandboxProfileExport {
version: number;
exported_at: string;
platform: string;
profiles: SandboxProfileWithRules[];
}
export interface SandboxProfileWithRules {
profile: SandboxProfile;
rules: SandboxRule[];
}
export interface ImportResult {
profile_name: string;
imported: boolean;
reason?: string;
new_name?: string;
}
// Agent API types
export interface Agent {
id?: number;
@@ -191,10 +114,6 @@ export interface Agent {
system_prompt: string;
default_task?: string;
model: string;
sandbox_enabled: boolean;
enable_file_read: boolean;
enable_file_write: boolean;
enable_network: boolean;
created_at: string;
updated_at: string;
}
@@ -208,10 +127,6 @@ export interface AgentExport {
system_prompt: string;
default_task?: string;
model: string;
sandbox_enabled: boolean;
enable_file_read: boolean;
enable_file_write: boolean;
enable_network: boolean;
};
}
@@ -718,10 +633,6 @@ export const api = {
* @param system_prompt - The system prompt for the agent
* @param default_task - Optional default task
* @param model - Optional model (defaults to 'sonnet')
* @param sandbox_enabled - Optional sandbox enable flag
* @param enable_file_read - Optional file read permission
* @param enable_file_write - Optional file write permission
* @param enable_network - Optional network permission
* @returns Promise resolving to the created agent
*/
async createAgent(
@@ -729,11 +640,7 @@ export const api = {
icon: string,
system_prompt: string,
default_task?: string,
model?: string,
sandbox_enabled?: boolean,
enable_file_read?: boolean,
enable_file_write?: boolean,
enable_network?: boolean
model?: string
): Promise<Agent> {
try {
return await invoke<Agent>('create_agent', {
@@ -741,11 +648,7 @@ export const api = {
icon,
systemPrompt: system_prompt,
defaultTask: default_task,
model,
sandboxEnabled: sandbox_enabled,
enableFileRead: enable_file_read,
enableFileWrite: enable_file_write,
enableNetwork: enable_network
model
});
} catch (error) {
console.error("Failed to create agent:", error);
@@ -761,10 +664,6 @@ export const api = {
* @param system_prompt - The updated system prompt
* @param default_task - Optional default task
* @param model - Optional model
* @param sandbox_enabled - Optional sandbox enable flag
* @param enable_file_read - Optional file read permission
* @param enable_file_write - Optional file write permission
* @param enable_network - Optional network permission
* @returns Promise resolving to the updated agent
*/
async updateAgent(
@@ -773,11 +672,7 @@ export const api = {
icon: string,
system_prompt: string,
default_task?: string,
model?: string,
sandbox_enabled?: boolean,
enable_file_read?: boolean,
enable_file_write?: boolean,
enable_network?: boolean
model?: string
): Promise<Agent> {
try {
return await invoke<Agent>('update_agent', {
@@ -786,11 +681,7 @@ export const api = {
icon,
systemPrompt: system_prompt,
defaultTask: default_task,
model,
sandboxEnabled: sandbox_enabled,
enableFileRead: enable_file_read,
enableFileWrite: enable_file_write,
enableNetwork: enable_network
model
});
} catch (error) {
console.error("Failed to update agent:", error);
@@ -1092,337 +983,6 @@ export const api = {
return invoke("search_files", { basePath, query });
},
// Sandbox API methods
/**
* Lists all sandbox profiles
* @returns Promise resolving to an array of sandbox profiles
*/
async listSandboxProfiles(): Promise<SandboxProfile[]> {
try {
return await invoke<SandboxProfile[]>('list_sandbox_profiles');
} catch (error) {
console.error("Failed to list sandbox profiles:", error);
throw error;
}
},
/**
* Creates a new sandbox profile
* @param name - The profile name
* @param description - Optional description
* @returns Promise resolving to the created profile
*/
async createSandboxProfile(name: string, description?: string): Promise<SandboxProfile> {
try {
return await invoke<SandboxProfile>('create_sandbox_profile', { name, description });
} catch (error) {
console.error("Failed to create sandbox profile:", error);
throw error;
}
},
/**
* Updates a sandbox profile
* @param id - The profile ID
* @param name - The updated name
* @param description - Optional description
* @param is_active - Whether the profile is active
* @param is_default - Whether the profile is the default
* @returns Promise resolving to the updated profile
*/
async updateSandboxProfile(
id: number,
name: string,
description: string | undefined,
is_active: boolean,
is_default: boolean
): Promise<SandboxProfile> {
try {
return await invoke<SandboxProfile>('update_sandbox_profile', {
id,
name,
description,
is_active,
is_default
});
} catch (error) {
console.error("Failed to update sandbox profile:", error);
throw error;
}
},
/**
* Deletes a sandbox profile
* @param id - The profile ID to delete
* @returns Promise resolving when the profile is deleted
*/
async deleteSandboxProfile(id: number): Promise<void> {
try {
return await invoke('delete_sandbox_profile', { id });
} catch (error) {
console.error("Failed to delete sandbox profile:", error);
throw error;
}
},
/**
* Gets a single sandbox profile by ID
* @param id - The profile ID
* @returns Promise resolving to the profile
*/
async getSandboxProfile(id: number): Promise<SandboxProfile> {
try {
return await invoke<SandboxProfile>('get_sandbox_profile', { id });
} catch (error) {
console.error("Failed to get sandbox profile:", error);
throw error;
}
},
/**
* Lists rules for a sandbox profile
* @param profileId - The profile ID
* @returns Promise resolving to an array of rules
*/
async listSandboxRules(profileId: number): Promise<SandboxRule[]> {
try {
return await invoke<SandboxRule[]>('list_sandbox_rules', { profile_id: profileId });
} catch (error) {
console.error("Failed to list sandbox rules:", error);
throw error;
}
},
/**
* Creates a new sandbox rule
* @param profileId - The profile ID
* @param operation_type - The operation type
* @param pattern_type - The pattern type
* @param pattern_value - The pattern value
* @param enabled - Whether the rule is enabled
* @param platform_support - Optional platform support JSON
* @returns Promise resolving to the created rule
*/
async createSandboxRule(
profileId: number,
operation_type: string,
pattern_type: string,
pattern_value: string,
enabled: boolean,
platform_support?: string
): Promise<SandboxRule> {
try {
return await invoke<SandboxRule>('create_sandbox_rule', {
profile_id: profileId,
operation_type,
pattern_type,
pattern_value,
enabled,
platform_support
});
} catch (error) {
console.error("Failed to create sandbox rule:", error);
throw error;
}
},
/**
* Updates a sandbox rule
* @param id - The rule ID
* @param operation_type - The operation type
* @param pattern_type - The pattern type
* @param pattern_value - The pattern value
* @param enabled - Whether the rule is enabled
* @param platform_support - Optional platform support JSON
* @returns Promise resolving to the updated rule
*/
async updateSandboxRule(
id: number,
operation_type: string,
pattern_type: string,
pattern_value: string,
enabled: boolean,
platform_support?: string
): Promise<SandboxRule> {
try {
return await invoke<SandboxRule>('update_sandbox_rule', {
id,
operation_type,
pattern_type,
pattern_value,
enabled,
platform_support
});
} catch (error) {
console.error("Failed to update sandbox rule:", error);
throw error;
}
},
/**
* Deletes a sandbox rule
* @param id - The rule ID to delete
* @returns Promise resolving when the rule is deleted
*/
async deleteSandboxRule(id: number): Promise<void> {
try {
return await invoke('delete_sandbox_rule', { id });
} catch (error) {
console.error("Failed to delete sandbox rule:", error);
throw error;
}
},
/**
* Gets platform capabilities for sandbox configuration
* @returns Promise resolving to platform capabilities
*/
async getPlatformCapabilities(): Promise<PlatformCapabilities> {
try {
return await invoke<PlatformCapabilities>('get_platform_capabilities');
} catch (error) {
console.error("Failed to get platform capabilities:", error);
throw error;
}
},
/**
* Tests a sandbox profile
* @param profileId - The profile ID to test
* @returns Promise resolving to test result message
*/
async testSandboxProfile(profileId: number): Promise<string> {
try {
return await invoke<string>('test_sandbox_profile', { profile_id: profileId });
} catch (error) {
console.error("Failed to test sandbox profile:", error);
throw error;
}
},
// Sandbox violation methods
/**
* Lists sandbox violations with optional filtering
* @param profileId - Optional profile ID to filter by
* @param agentId - Optional agent ID to filter by
* @param limit - Optional limit on number of results
* @returns Promise resolving to array of violations
*/
async listSandboxViolations(profileId?: number, agentId?: number, limit?: number): Promise<SandboxViolation[]> {
try {
return await invoke<SandboxViolation[]>('list_sandbox_violations', {
profile_id: profileId,
agent_id: agentId,
limit
});
} catch (error) {
console.error("Failed to list sandbox violations:", error);
throw error;
}
},
/**
* Logs a sandbox violation
* @param violation - The violation details
* @returns Promise resolving when logged
*/
async logSandboxViolation(violation: {
profileId?: number;
agentId?: number;
agentRunId?: number;
operationType: string;
patternValue?: string;
processName?: string;
pid?: number;
}): Promise<void> {
try {
return await invoke('log_sandbox_violation', {
profile_id: violation.profileId,
agent_id: violation.agentId,
agent_run_id: violation.agentRunId,
operation_type: violation.operationType,
pattern_value: violation.patternValue,
process_name: violation.processName,
pid: violation.pid
});
} catch (error) {
console.error("Failed to log sandbox violation:", error);
throw error;
}
},
/**
* Clears old sandbox violations
* @param olderThanDays - Optional days to keep (clears all if not specified)
* @returns Promise resolving to number of deleted violations
*/
async clearSandboxViolations(olderThanDays?: number): Promise<number> {
try {
return await invoke<number>('clear_sandbox_violations', { older_than_days: olderThanDays });
} catch (error) {
console.error("Failed to clear sandbox violations:", error);
throw error;
}
},
/**
* Gets sandbox violation statistics
* @returns Promise resolving to violation stats
*/
async getSandboxViolationStats(): Promise<SandboxViolationStats> {
try {
return await invoke<SandboxViolationStats>('get_sandbox_violation_stats');
} catch (error) {
console.error("Failed to get sandbox violation stats:", error);
throw error;
}
},
// Import/Export methods
/**
* Exports a single sandbox profile with its rules
* @param profileId - The profile ID to export
* @returns Promise resolving to export data
*/
async exportSandboxProfile(profileId: number): Promise<SandboxProfileExport> {
try {
return await invoke<SandboxProfileExport>('export_sandbox_profile', { profile_id: profileId });
} catch (error) {
console.error("Failed to export sandbox profile:", error);
throw error;
}
},
/**
* Exports all sandbox profiles
* @returns Promise resolving to export data
*/
async exportAllSandboxProfiles(): Promise<SandboxProfileExport> {
try {
return await invoke<SandboxProfileExport>('export_all_sandbox_profiles');
} catch (error) {
console.error("Failed to export all sandbox profiles:", error);
throw error;
}
},
/**
* Imports sandbox profiles from export data
* @param exportData - The export data to import
* @returns Promise resolving to import results
*/
async importSandboxProfiles(exportData: SandboxProfileExport): Promise<ImportResult[]> {
try {
return await invoke<ImportResult[]>('import_sandbox_profiles', { export_data: exportData });
} catch (error) {
console.error("Failed to import sandbox profiles:", error);
throw error;
}
},
/**
* Gets overall usage statistics
* @returns Promise resolving to usage statistics
@@ -1921,38 +1481,7 @@ export const api = {
}
},
/**
* Captures a screenshot of a specific region in the window
* @param url - The URL to capture
* @param selector - Optional selector to capture
* @param fullPage - Whether to capture the full page
* @returns Promise resolving to the path of the saved screenshot
*/
async captureUrlScreenshot(
url: string,
selector?: string | null,
fullPage: boolean = false
): Promise<string> {
return await invoke<string>("capture_url_screenshot", {
url,
selector,
fullPage,
});
},
/**
* Cleans up old screenshot files from the temporary directory
* @param olderThanMinutes - Remove files older than this many minutes (default: 60)
* @returns Promise resolving to the number of files deleted
*/
async cleanupScreenshotTempFiles(olderThanMinutes?: number): Promise<number> {
try {
return await invoke<number>("cleanup_screenshot_temp_files", { olderThanMinutes });
} catch (error) {
console.error("Failed to cleanup screenshot files:", error);
throw error;
}
},
/**
* List all available Claude installations on the system

View File

@@ -2,8 +2,8 @@ import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { ErrorBoundary } from "./components/ErrorBoundary";
import "./styles.css";
import "./assets/shimmer.css";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>

View File

@@ -423,9 +423,9 @@ button:focus-visible,
/* Rotating symbol animation */
@keyframes rotate-symbol {
0% { content: "◐"; transform: scale(1); }
25% { content: "◓"; transform: scale(10); }
25% { content: "◓"; transform: scale(1); }
50% { content: "◑"; transform: scale(1); }
75% { content: "◒"; transform: scale(10); }
75% { content: "◒"; transform: scale(1); }
100% { content: "◐"; transform: scale(1); }
}