use anyhow::{anyhow, Context as AnyhowContext, Result};
use async_trait::async_trait;
use serde::Deserialize;
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::mpsc;
use crate::ai::adapter::{AgentAdapter, Context, RevieweeOutput, ReviewerOutput};
use crate::ai::orchestrator::RallyEvent;
use crate::config::AiConfig;
const REVIEWER_SCHEMA: &str = include_str!("../schemas/reviewer.json");
const REVIEWEE_SCHEMA: &str = include_str!("../schemas/reviewee.json");
/// Git commands to disallow in local mode.
/// --disallowedTools overrides --allowedTools and settings.local.json.
const GIT_DISALLOWED_TOOLS: &str = concat!(
"Bash(git add:*),Bash(git commit:*),Bash(git push:*),",
"Bash(git stash:*),Bash(git switch:*),Bash(git branch:*),",
"Bash(git merge:*),Bash(git rebase:*),Bash(git reset:*),",
"Bash(git cherry-pick:*),Bash(git revert:*),Bash(git checkout:*),",
"Bash(git restore:*),Bash(git tag:*),Bash(git rm:*),Bash(git clean:*)"
);
/// Claude Code adapter
pub struct ClaudeAdapter {
/// Cached allowed tools string for reviewer (built once at initialization)
reviewer_allowed_tools: String,
/// Cached allowed tools string for reviewee (built once at initialization)
reviewee_allowed_tools: String,
reviewer_session_id: Option<String>,
reviewee_session_id: Option<String>,
event_sender: Option<mpsc::Sender<RallyEvent>>,
/// When true, git write operations are blocked via --disallowedTools
local_mode: bool,
}
impl ClaudeAdapter {
pub fn new(config: &AiConfig) -> Self {
Self {
reviewer_allowed_tools: Self::build_reviewer_allowed_tools(config),
reviewee_allowed_tools: Self::build_reviewee_allowed_tools(config),
reviewer_session_id: None,
reviewee_session_id: None,
event_sender: None,
local_mode: false,
}
}
/// Build allowed tools string for reviewer.
/// Base tools: Read, Glob, Grep, gh pr view/diff/checks, gh api GET
pub(crate) fn build_reviewer_allowed_tools(config: &AiConfig) -> String {
let base = "Read,Glob,Grep,Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr checks:*),Bash(gh api --method GET:*),Bash(gh api -X GET:*)";
if config.reviewer_additional_tools.is_empty() {
base.to_string()
} else {
format!("{},{}", base, config.reviewer_additional_tools.join(","))
}
}
/// Build allowed tools string for reviewee.
/// Base tools: File ops, git (without push), gh pr read-only, build/test commands.
/// NOTE: git push is NOT included by default (Breaking change).
/// To enable, add "Bash(git push:*)" to reviewee_additional_tools.
pub(crate) fn build_reviewee_allowed_tools(config: &AiConfig) -> String {
// NOTE: git push is NOT included by default (Breaking change from v0.1.x).
// Users must explicitly add "Bash(git push:*)" to reviewee_additional_tools to enable.
let base = concat!(
"Read,Edit,Write,Glob,Grep,",
// Git: local operations only (no push by default)
// Note: git checkout and git restore are excluded because they can discard changes
// (e.g., "git checkout -- ." or "git restore ."). Use git switch for branch operations.
"Bash(git status:*),Bash(git diff:*),Bash(git add:*),Bash(git commit:*),",
"Bash(git log:*),Bash(git show:*),Bash(git branch:*),Bash(git switch:*),",
"Bash(git stash:*),",
// GitHub CLI: Only safe, read-only PR operations (view, diff, checks)
"Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr checks:*),",
// GitHub API: Only GET requests (read-only)
"Bash(gh api --method GET:*),Bash(gh api -X GET:*),",
// Cargo: build, test, check, clippy, fmt (no publish)
"Bash(cargo build:*),Bash(cargo test:*),Bash(cargo check:*),",
"Bash(cargo clippy:*),Bash(cargo fmt:*),Bash(cargo run:*),",
// npm: install, test, build, run (no publish)
"Bash(npm install:*),Bash(npm test:*),Bash(npm run:*),Bash(npm ci:*),",
// pnpm: install, test, build, run (no publish)
"Bash(pnpm install:*),Bash(pnpm test:*),Bash(pnpm run:*),",
// bun: install, test, build, run (no publish)
"Bash(bun install:*),Bash(bun test:*),Bash(bun run:*)"
);
if config.reviewee_additional_tools.is_empty() {
base.to_string()
} else {
format!("{},{}", base, config.reviewee_additional_tools.join(","))
}
}
async fn send_event(&self, event: RallyEvent) {
if let Some(ref sender) = self.event_sender {
let _ = sender.send(event).await;
}
}
// TODO: Consider using a builder/struct pattern for parameters if more options are added.
async fn run_claude_streaming(
&self,
prompt: &str,
schema: &str,
allowed_tools: Option<&str>,
working_dir: Option<&str>,
session_id: Option<&str>,
) -> Result<ClaudeResponse> {
let mut cmd = Command::new("claude");
// Prevent nested session detection when octorus is run inside Claude Code
cmd.env_remove("CLAUDECODE");
// Use -p without prompt arg; prompt is piped via stdin to avoid OS ARG_MAX limit
cmd.arg("-p");
cmd.arg("--output-format").arg("stream-json");
cmd.arg("--verbose");
cmd.arg("--json-schema").arg(schema);
if let Some(tools) = allowed_tools {
cmd.arg("--allowedTools").arg(tools);
}
// In local mode, block git write operations via --disallowedTools.
// --disallowedTools takes precedence over --allowedTools and settings.local.json.
if self.local_mode {
cmd.arg("--disallowedTools").arg(GIT_DISALLOWED_TOOLS);
}
if let Some(session) = session_id {
cmd.arg("--resume").arg(session);
}
if let Some(dir) = working_dir {
cmd.current_dir(dir);
}
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd.spawn().with_context(|| {
format!(
"Failed to spawn claude process (command: {:?})",
cmd.as_std()
)
})?;
// Write prompt to stdin to avoid ARG_MAX limit
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
stdin
.write_all(prompt.as_bytes())
.await
.context("Failed to write prompt to claude stdin")?;
drop(stdin); // Close stdin to signal EOF
}
let stdout = child.stdout.take().expect("stdout should be available");
let stderr = child.stderr.take().expect("stderr should be available");
let mut stdout_reader = BufReader::new(stdout).lines();
let mut stderr_reader = BufReader::new(stderr).lines();
let mut final_response: Option<ClaudeResponse> = None;
let mut error_lines = Vec::new();
// Process NDJSON stream
loop {
tokio::select! {
line = stdout_reader.next_line() => {
match line {
Ok(Some(l)) => {
if l.trim().is_empty() {
continue;
}
// Try to parse as stream event
if let Ok(event) = serde_json::from_str::<StreamEvent>(&l) {
self.handle_stream_event(&event).await;
// Check if this is the final result
if event.event_type == "result" {
// --json-schema uses structured_output, otherwise use result
let result_value = event
.structured_output
.clone()
.or_else(|| event.result.clone());
if let Some(result) = result_value {
final_response = Some(ClaudeResponse {
session_id: event.session_id.unwrap_or_default(),
result: Some(result),
cost_usd: event.cost_usd,
duration_ms: event.duration_ms,
});
}
}
}
}
Ok(None) => break,
Err(e) => return Err(anyhow!("Error reading stdout: {}", e)),
}
}
line = stderr_reader.next_line() => {
match line {
Ok(Some(l)) => error_lines.push(l),
Ok(None) => {},
Err(e) => return Err(anyhow!("Error reading stderr: {}", e)),
}
}
}
}
let status = child
.wait()
.await
.context("Failed to wait for claude process")?;
if !status.success() {
let stderr_output = error_lines.join("\n");
return Err(anyhow!(
"Claude process failed with status {}: {}",
status,
stderr_output
));
}
final_response.ok_or_else(|| anyhow!("No result received from claude"))
}
async fn handle_stream_event(&self, event: &StreamEvent) {
match event.event_type.as_str() {
"assistant" => {
// Assistant message event - may contain thinking or text
if let Some(ref message) = event.message {
for content in &message.content {
match content.content_type.as_str() {
"thinking" => {
if let Some(ref thinking) = content.thinking {
self.send_event(RallyEvent::AgentThinking(thinking.clone()))
.await;
}
}
"text" => {
if let Some(ref text) = content.text {
self.send_event(RallyEvent::AgentText(text.clone())).await;
}
}
_ => {}
}
}
}
}
"content_block_start" => {
if let Some(ref content_block) = event.content_block {
match content_block.block_type.as_str() {
"tool_use" => {
if let Some(ref name) = content_block.name {
self.send_event(RallyEvent::AgentToolUse(
name.clone(),
"starting...".to_string(),
))
.await;
}
}
"thinking" => {
self.send_event(RallyEvent::AgentThinking("Thinking...".to_string()))
.await;
}
_ => {}
}
}
}
"content_block_delta" => {
if let Some(ref delta) = event.delta {
match delta.delta_type.as_str() {
"thinking_delta" => {
if let Some(ref thinking) = delta.thinking {
self.send_event(RallyEvent::AgentThinking(thinking.clone()))
.await;
}
}
"text_delta" => {
if let Some(ref text) = delta.text {
self.send_event(RallyEvent::AgentText(text.clone())).await;
}
}
"input_json_delta" => {
// Tool input being streamed - we can optionally show this
}
_ => {}
}
}
}
"content_block_stop" => {
// Content block completed
}
"tool_use" => {
// Full tool use event
if let Some(ref name) = event.tool_name {
let input_summary = event
.tool_input
.as_ref()
.map(summarize_json)
.unwrap_or_default();
self.send_event(RallyEvent::AgentToolUse(name.clone(), input_summary))
.await;
}
}
"tool_result" => {
// Tool result event
if let Some(ref name) = event.tool_name {
let result_summary = event
.tool_result
.as_ref()
.map(|s| summarize_text(s))
.unwrap_or_else(|| "completed".to_string());
self.send_event(RallyEvent::AgentToolResult(name.clone(), result_summary))
.await;
}
}
_ => {}
}
}
}
impl Default for ClaudeAdapter {
fn default() -> Self {
Self::new(&AiConfig::default())
}
}
#[async_trait]
impl AgentAdapter for ClaudeAdapter {
fn name(&self) -> &str {
"claude"
}
fn set_event_sender(&mut self, sender: mpsc::Sender<RallyEvent>) {
self.event_sender = Some(sender);
}
async fn run_reviewer(&mut self, prompt: &str, context: &Context) -> Result<ReviewerOutput> {
// Reviewer tools: read-only operations for code review
// - Read/Glob/Grep: File reading and searching
// - gh pr view/diff/checks: View PR information
// - gh api (GET only): Read-only API calls
// Note: We require explicit --method GET or -X GET to prevent POST/PUT/DELETE operations.
// The pattern `gh api repos:*` was too permissive as it allowed write operations.
//
// LIMITATION: The gh CLI does not validate flag ordering, so a malicious prompt could
// potentially craft commands like `gh api --method GET /endpoint --method POST`.
// This is considered acceptable risk as: (1) the reviewer agent has no incentive to
// do this, and (2) the model is instructed to only perform read operations.
//
// Additional tools can be configured via config.reviewer_additional_tools
let response = self
.run_claude_streaming(
prompt,
REVIEWER_SCHEMA,
Some(&self.reviewer_allowed_tools),
context.working_dir.as_deref(),
None,
)
.await?;
self.reviewer_session_id = Some(response.session_id.clone());
parse_reviewer_output(response.result.as_ref(), "claude")
}
async fn run_reviewee(&mut self, prompt: &str, context: &Context) -> Result<RevieweeOutput> {
// Reviewee tools: file editing, safe build/test commands
// Explicitly list safe subcommands to prevent dangerous operations like:
// - git push --force, git reset --hard
// - git checkout -- . (discards all changes)
// - git restore . (discards all changes)
// - npm publish, pnpm publish, bun publish
// - cargo publish
// - cargo clean (could delete build artifacts unexpectedly)
// - gh pr close/merge/edit (could modify PR state unexpectedly)
//
// NOTE: git push is NOT included by default (Breaking change from v0.1.x).
// To enable, add "Bash(git push:*)" to config.reviewee_additional_tools.
//
// GitHub CLI: Only safe, read-only PR operations (view, diff, checks) are allowed.
// Excluded dangerous commands: gh pr close, gh pr merge, gh pr edit, gh pr ready, gh pr reopen
// API calls require explicit --method GET or -X GET to prevent write operations.
//
// KNOWN RISK: Commands like `npm run`, `pnpm run`, `bun run` execute arbitrary scripts
// defined in package.json. This is an inherent risk but necessary for running tests
// and build commands. The user should review package.json scripts in the PR.
//
// Additional tools can be configured via config.reviewee_additional_tools
let response = self
.run_claude_streaming(
prompt,
REVIEWEE_SCHEMA,
Some(&self.reviewee_allowed_tools),
context.working_dir.as_deref(),
None,
)
.await?;
self.reviewee_session_id = Some(response.session_id.clone());
parse_reviewee_output(response.result.as_ref(), "claude")
}
async fn continue_reviewer(&mut self, message: &str) -> Result<ReviewerOutput> {
let session_id = self
.reviewer_session_id
.as_ref()
.ok_or_else(|| anyhow!("No reviewer session to continue"))?
.clone();
let response = self
.run_claude_streaming(
message,
REVIEWER_SCHEMA,
Some(&self.reviewer_allowed_tools),
None, // --resume restores the original session's context
Some(&session_id),
)
.await?;
parse_reviewer_output(response.result.as_ref(), "claude")
}
async fn continue_reviewee(&mut self, message: &str) -> Result<RevieweeOutput> {
let session_id = self
.reviewee_session_id
.as_ref()
.ok_or_else(|| anyhow!("No reviewee session to continue"))?
.clone();
let response = self
.run_claude_streaming(
message,
REVIEWEE_SCHEMA,
Some(&self.reviewee_allowed_tools),
None, // --resume restores the original session's context
Some(&session_id),
)
.await?;
parse_reviewee_output(response.result.as_ref(), "claude")
}
fn add_reviewee_allowed_tool(&mut self, tool: &str) {
// Use exact match on comma-separated items instead of substring match
// to prevent false dedup (e.g., "git status" matching inside "Bash(git status:*)")
if self.reviewee_allowed_tools.split(',').any(|t| t == tool) {
return;
}
self.reviewee_allowed_tools.push(',');
self.reviewee_allowed_tools.push_str(tool);
}
fn set_local_mode(&mut self, local_mode: bool) {
self.local_mode = local_mode;
}
}
/// Stream event from Claude CLI stream-json output
#[derive(Debug, Deserialize)]
struct StreamEvent {
#[serde(rename = "type")]
event_type: String,
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
message: Option<StreamMessage>,
#[serde(default)]
content_block: Option<ContentBlock>,
#[serde(default)]
delta: Option<Delta>,
#[serde(default)]
tool_name: Option<String>,
#[serde(default)]
tool_input: Option<serde_json::Value>,
#[serde(default)]
tool_result: Option<String>,
#[serde(default)]
result: Option<serde_json::Value>,
/// structured_output is used when --json-schema is specified
#[serde(default)]
structured_output: Option<serde_json::Value>,
#[serde(default)]
cost_usd: Option<f64>,
#[serde(default)]
duration_ms: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct StreamMessage {
#[serde(default)]
content: Vec<MessageContent>,
}
#[derive(Debug, Deserialize)]
struct MessageContent {
#[serde(rename = "type")]
content_type: String,
#[serde(default)]
text: Option<String>,
#[serde(default)]
thinking: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ContentBlock {
#[serde(rename = "type")]
block_type: String,
#[serde(default)]
name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Delta {
#[serde(rename = "type")]
delta_type: String,
#[serde(default)]
text: Option<String>,
#[serde(default)]
thinking: Option<String>,
}
/// Claude Code JSON output format
#[derive(Debug, Deserialize)]
struct ClaudeResponse {
session_id: String,
#[serde(default)]
result: Option<serde_json::Value>,
// For monitoring and cost analysis (future feature)
#[serde(default)]
#[allow(dead_code)]
cost_usd: Option<f64>,
// For performance monitoring (future feature)
#[serde(default)]
#[allow(dead_code)]
duration_ms: Option<u64>,
}
use super::common::{parse_reviewee_output, parse_reviewer_output, summarize_json, summarize_text};
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
#[test]
fn test_build_reviewer_allowed_tools_default() {
let config = AiConfig::default();
let tools = ClaudeAdapter::build_reviewer_allowed_tools(&config);
assert_snapshot!(tools, @"Read,Glob,Grep,Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr checks:*),Bash(gh api --method GET:*),Bash(gh api -X GET:*)");
}
#[test]
fn test_build_reviewer_allowed_tools_with_skill() {
let config = AiConfig {
reviewer_additional_tools: vec!["Skill".to_string()],
..Default::default()
};
let tools = ClaudeAdapter::build_reviewer_allowed_tools(&config);
assert!(tools.ends_with(",Skill"));
}
#[test]
fn test_build_reviewer_allowed_tools_with_multiple() {
let config = AiConfig {
reviewer_additional_tools: vec!["Skill".to_string(), "WebSearch".to_string()],
..Default::default()
};
let tools = ClaudeAdapter::build_reviewer_allowed_tools(&config);
assert_snapshot!(tools, @"Read,Glob,Grep,Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr checks:*),Bash(gh api --method GET:*),Bash(gh api -X GET:*),Skill,WebSearch");
}
#[test]
fn test_reviewee_default_no_git_push() {
let config = AiConfig::default();
let tools = ClaudeAdapter::build_reviewee_allowed_tools(&config);
assert_snapshot!(tools, @"Read,Edit,Write,Glob,Grep,Bash(git status:*),Bash(git diff:*),Bash(git add:*),Bash(git commit:*),Bash(git log:*),Bash(git show:*),Bash(git branch:*),Bash(git switch:*),Bash(git stash:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr checks:*),Bash(gh api --method GET:*),Bash(gh api -X GET:*),Bash(cargo build:*),Bash(cargo test:*),Bash(cargo check:*),Bash(cargo clippy:*),Bash(cargo fmt:*),Bash(cargo run:*),Bash(npm install:*),Bash(npm test:*),Bash(npm run:*),Bash(npm ci:*),Bash(pnpm install:*),Bash(pnpm test:*),Bash(pnpm run:*),Bash(bun install:*),Bash(bun test:*),Bash(bun run:*)");
}
#[test]
fn test_reviewee_with_git_push() {
let config = AiConfig {
reviewee_additional_tools: vec!["Bash(git push:*)".to_string()],
..Default::default()
};
let tools = ClaudeAdapter::build_reviewee_allowed_tools(&config);
assert!(tools.contains("Bash(git push:*)"));
}
#[test]
fn test_reviewee_with_multiple_tools() {
let config = AiConfig {
reviewee_additional_tools: vec!["Skill".to_string(), "Bash(git push:*)".to_string()],
..Default::default()
};
let tools = ClaudeAdapter::build_reviewee_allowed_tools(&config);
assert_snapshot!(tools, @"Read,Edit,Write,Glob,Grep,Bash(git status:*),Bash(git diff:*),Bash(git add:*),Bash(git commit:*),Bash(git log:*),Bash(git show:*),Bash(git branch:*),Bash(git switch:*),Bash(git stash:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr checks:*),Bash(gh api --method GET:*),Bash(gh api -X GET:*),Bash(cargo build:*),Bash(cargo test:*),Bash(cargo check:*),Bash(cargo clippy:*),Bash(cargo fmt:*),Bash(cargo run:*),Bash(npm install:*),Bash(npm test:*),Bash(npm run:*),Bash(npm ci:*),Bash(pnpm install:*),Bash(pnpm test:*),Bash(pnpm run:*),Bash(bun install:*),Bash(bun test:*),Bash(bun run:*),Skill,Bash(git push:*)");
}
#[test]
fn test_reviewee_base_tools_present() {
let config = AiConfig::default();
let tools = ClaudeAdapter::build_reviewee_allowed_tools(&config);
assert_snapshot!(tools, @"Read,Edit,Write,Glob,Grep,Bash(git status:*),Bash(git diff:*),Bash(git add:*),Bash(git commit:*),Bash(git log:*),Bash(git show:*),Bash(git branch:*),Bash(git switch:*),Bash(git stash:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr checks:*),Bash(gh api --method GET:*),Bash(gh api -X GET:*),Bash(cargo build:*),Bash(cargo test:*),Bash(cargo check:*),Bash(cargo clippy:*),Bash(cargo fmt:*),Bash(cargo run:*),Bash(npm install:*),Bash(npm test:*),Bash(npm run:*),Bash(npm ci:*),Bash(pnpm install:*),Bash(pnpm test:*),Bash(pnpm run:*),Bash(bun install:*),Bash(bun test:*),Bash(bun run:*)");
}
#[test]
fn test_reviewee_with_complex_bash_pattern() {
// Test that arbitrary Bash patterns can be added
let config = AiConfig {
reviewee_additional_tools: vec!["Bash(gh api --method POST:*)".to_string()],
..Default::default()
};
let tools = ClaudeAdapter::build_reviewee_allowed_tools(&config);
assert!(tools.contains("Bash(gh api --method POST:*)"));
}
#[test]
fn test_add_reviewee_allowed_tool() {
use crate::ai::adapter::AgentAdapter;
let config = AiConfig::default();
let mut adapter = ClaudeAdapter::new(&config);
// Initially, git push should not be present
assert!(!adapter.reviewee_allowed_tools.contains("Bash(git push:*)"));
// Add git push dynamically
adapter.add_reviewee_allowed_tool("Bash(git push:*)");
// Now it should be present
assert!(adapter.reviewee_allowed_tools.contains("Bash(git push:*)"));
// Adding the same tool again should not duplicate it
let tools_before = adapter.reviewee_allowed_tools.clone();
adapter.add_reviewee_allowed_tool("Bash(git push:*)");
assert_eq!(adapter.reviewee_allowed_tools, tools_before);
}
#[test]
fn test_set_local_mode() {
use crate::ai::adapter::AgentAdapter;
let config = AiConfig::default();
let mut adapter = ClaudeAdapter::new(&config);
assert!(!adapter.local_mode);
adapter.set_local_mode(true);
assert!(adapter.local_mode);
adapter.set_local_mode(false);
assert!(!adapter.local_mode);
}
#[test]
fn test_git_disallowed_tools_coverage() {
// Verify that GIT_DISALLOWED_TOOLS contains all critical git write commands
let disallowed = GIT_DISALLOWED_TOOLS;
let expected_commands = [
"git add",
"git commit",
"git push",
"git stash",
"git switch",
"git branch",
"git merge",
"git rebase",
"git reset",
"git cherry-pick",
"git revert",
"git checkout",
"git restore",
"git tag",
"git rm",
"git clean",
];
for cmd in expected_commands {
assert!(
disallowed.contains(&format!("Bash({}", cmd)),
"GIT_DISALLOWED_TOOLS should contain '{}'",
cmd
);
}
}
}