diff --git a/.repository-projection.json b/.repository-projection.json index ed9a36ed9..24dd5cc34 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "deixic-code", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "201e106555d8af4ac94c597d49f3d78fd73afbd0", + "sourceSha": "6477558fb453744e10ae520a48f32bac3396e262", "destinationRepository": "dx-corp/code", - "priorProjectedBase": "a97cfc3b262fa2430cd1d04becfea2642973bc0d", + "priorProjectedBase": "0d3003357ee3e926c2ec525e099c7179465f3081", "definitionDigest": "93eb3fb9be07d3644538c5519cf38973b4246cdcf2f8d7b60ccd593d7ca73033", "toolDigest": "fdfeaca00676828c28baab97981d7b47d418788d43c4396350b1998d6a8eb3b4", - "contentDigest": "ee915b5c9f059ace7994fbdb5772d7e2216d68928f6cd364655a33e9d4d62519", + "contentDigest": "b59bb63f13dece4416ac3cce13f223fd656e144034f84f93dd3311d8b6fefe3d", "publicationEligible": true } diff --git a/packages/tui-rs/src/entrypoint.rs b/packages/tui-rs/src/entrypoint.rs index ec6005997..3de3b20cb 100644 --- a/packages/tui-rs/src/entrypoint.rs +++ b/packages/tui-rs/src/entrypoint.rs @@ -530,6 +530,7 @@ struct Args { #[derive(Debug, Default, PartialEq, Eq)] struct NativeExecOptions { + help: bool, specialist: Option, json: bool, model: Option, @@ -561,6 +562,8 @@ fn parse_native_exec_options(raw_args: &[std::ffi::OsString]) -> NativeExecOptio prompt_parts.push(arg.into_owned()); } else if arg == "--" { positional_only = true; + } else if arg == "--help" || arg == "-h" { + options.help = true; } else if arg == "--json" || arg == "--mode=json" { options.json = true; } else if arg == "--list-tools" { @@ -1124,8 +1127,62 @@ async fn run_agent(raw_args: Vec) -> Result { // maestro print|exec [--json] [--model X] [--output-last-message P] // [--output-schema S] let mut options = parse_native_exec_options(&raw_args[2..]); + if options.help { + println!( + r#"Usage: deixic-code exec [options] + deixic-code print [options] + +Run one coding task without the interactive terminal interface. +Prompt arguments are joined with spaces; use -- before literal flag-like text. + +Options: + -h, --help Show this help and exit without inference + -m, --model Select a model, optionally as provider/model + --provider Select the provider for the model + --specialist Apply a named specialist profile + --prompt-stdin Read the prompt from standard input + --json Emit newline-delimited JSON events + --output-last-message + Write the final answer to a file on success + --output-schema + Check final JSON types and required properties + --list-tools List native tools and exit without inference + --only-tools Restrict tools (comma- or space-separated) + --add-tools Add tools within the configured tool ceiling + --remove-tools Remove tools from the selection + --approval-mode fail Reject calls requiring approval + --sandbox Subprocess sandbox: read-only, workspace-write, + native, or danger-full-access + --plugin Load a plugin for this process; repeatable + --tag Attach run metadata; repeatable + --log-group-id Correlate related runs + -w, --worktree Run in an isolated Git worktree + +Safety: + Tool selection cannot widen MAESTRO_PRINT_ALLOWED_TOOLS. + Exec has no interactive approval dialog. Use --approval-mode fail to reject + calls requiring approval; subprocess sandbox bypass requests are rejected. + +Examples: + deixic-code exec --only-tools read,grep,glob "Review this repository" + deixic-code exec --approval-mode fail --json "Fix the failing test" + cat task.txt | deixic-code exec --prompt-stdin + deixic-code exec --output-last-message answer.txt "Explain the parser" + +Exit status: + 0 on success; nonzero for execution or validation failures. + Missing prompts and invalid sandbox modes exit with status 2."# + ); + return Ok(0); + } if options.list_tools { - crate::print_mode::print_available_tools(options.json)?; + crate::print_mode::print_available_tools( + options.json, + &options.only_tools, + &options.add_tools, + &options.remove_tools, + options.specialist.as_deref(), + )?; return Ok(0); } let env_sandbox = std::env::var("MAESTRO_SANDBOX_MODE").ok(); @@ -1303,7 +1360,13 @@ async fn run_agent(raw_args: Vec) -> Result { } if args.list_tools { - crate::print_mode::print_available_tools(args.json)?; + crate::print_mode::print_available_tools( + args.json, + &args.only_tools, + &args.add_tools, + &args.remove_tools, + args.specialist.as_deref(), + )?; return Ok(0); } diff --git a/packages/tui-rs/src/print_mode.rs b/packages/tui-rs/src/print_mode.rs index f45db74bf..2b7e08c8e 100644 --- a/packages/tui-rs/src/print_mode.rs +++ b/packages/tui-rs/src/print_mode.rs @@ -233,7 +233,22 @@ fn effective_run_tools( Ok(Some(effective)) } -pub(crate) fn print_available_tools(json: bool) -> Result<()> { +/// Report native run selection, not authorization for individual tool calls. +pub(crate) fn print_available_tools( + json: bool, + only: &[String], + add: &[String], + remove: &[String], + specialist: Option<&str>, +) -> Result<()> { + let mut selected = effective_run_tools(allowed_tools_from_env()?, only, add, remove)?; + if let Some(name) = specialist { + let workspace = dunce::canonicalize(std::env::current_dir()?)?; + let profile = crate::agents_cli::resolve_specialist(name, &workspace)?; + if let Some(tools) = profile.tools.as_ref() { + selected = Some(specialist_tool_ceiling(selected, tools)); + } + } let registry = crate::tools::ToolRegistry::new(); let mut tools = registry .tools() @@ -241,6 +256,9 @@ pub(crate) fn print_available_tools(json: bool) -> Result<()> { serde_json::json!({ "name": definition.tool.name, "requiresApproval": definition.requires_approval, + "selected": selected.as_ref().is_none_or(|tools| { + tools.contains(&definition.tool.name.to_ascii_lowercase()) + }), }) }) .collect::>(); @@ -251,11 +269,14 @@ pub(crate) fn print_available_tools(json: bool) -> Result<()> { serde_json::to_string_pretty(&serde_json::json!({"tools": tools}))? ); } else { + println!("Native run selection (approval and sandbox checks still apply):"); for tool in tools { println!( "{:<28} {}", tool["name"].as_str().unwrap_or_default(), - if tool["requiresApproval"].as_bool().unwrap_or(false) { + if tool["selected"] == false { + "not selected" + } else if tool["requiresApproval"].as_bool().unwrap_or(false) { "approval may be required" } else { "available" diff --git a/packages/tui-rs/tests/direct_cli.rs b/packages/tui-rs/tests/direct_cli.rs index 465b9cba6..9bb3dfaf4 100644 --- a/packages/tui-rs/tests/direct_cli.rs +++ b/packages/tui-rs/tests/direct_cli.rs @@ -22,6 +22,8 @@ fn direct_binary_help_remains_a_successful_early_exit() { fn direct_binary_supported_noninteractive_commands_exit_successfully() { let cases: &[(&[&str], &str)] = &[ (&["a2a", "--help"], "a2a"), + (&["exec", "--help"], "deixic-code exec"), + (&["print", "-h"], "deixic-code print"), (&["hosted-runner", "--help"], "hosted-runner"), (&["init", "--help"], "init"), (&["remote", "--help"], "remote"), @@ -43,6 +45,36 @@ fn direct_binary_supported_noninteractive_commands_exit_successfully() { } } +#[test] +fn exec_help_precedes_execution_options_but_respects_literal_prompts() { + let output = Command::new(maestro_tui_binary()) + .args(["exec", "--help", "--prompt-stdin"]) + .env("MAESTRO_SANDBOX_MODE", "invalid-help-probe") + .env("OPENAI_API_KEY", "help-must-not-print-this-value") + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + let stdout = String::from_utf8_lossy(&output.stdout); + for marker in [ + "--only-tools", + "--approval-mode fail", + "Examples:", + "Exit status:", + ] { + assert!(stdout.contains(marker), "missing {marker}: {stdout}"); + } + assert!(!stdout.contains("help-must-not-print-this-value")); + assert!(output.stderr.is_empty(), "{output:?}"); + + let literal = Command::new(maestro_tui_binary()) + .args(["exec", "--sandbox", "invalid-help-probe", "--", "--help"]) + .output() + .unwrap(); + assert_eq!(literal.status.code(), Some(2), "{literal:?}"); + assert!(String::from_utf8_lossy(&literal.stderr).contains("Unsupported exec sandbox")); + assert!(!String::from_utf8_lossy(&literal.stdout).contains("Examples:")); +} + #[test] fn specialists_are_discoverable_and_unknown_selection_fails_before_inference() { let home = tempfile::tempdir().unwrap(); @@ -73,6 +105,124 @@ fn specialists_are_discoverable_and_unknown_selection_fails_before_inference() { assert!(String::from_utf8_lossy(&output.stderr).contains("authorized scope")); } +#[test] +fn tool_listing_reports_run_selection_without_widening_policy() { + let home = tempfile::tempdir().unwrap(); + let run = |args: &[&str]| { + Command::new(maestro_tui_binary()) + .args(args) + .env("MAESTRO_HOME", home.path()) + .env("MAESTRO_PRINT_ALLOWED_TOOLS", "read,grep,write") + .current_dir(home.path()) + .output() + .unwrap() + }; + for entry in ["exec", "--print"] { + let output = run(&[ + entry, + "--list-tools", + "--json", + "--only-tools", + "READ,write", + "--add-tools", + "grep,bash", + "--remove-tools", + "write", + ]); + assert!(output.status.success(), "{entry}: {output:?}"); + let catalog: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let tools = catalog["tools"].as_array().unwrap(); + let selected: Vec<_> = tools + .iter() + .filter(|tool| tool["selected"] == true) + .map(|tool| tool["name"].as_str().unwrap()) + .collect(); + assert_eq!(selected, ["grep", "read"], "{entry}: {catalog}"); + assert!( + tools + .iter() + .any(|tool| tool["name"] == "bash" && tool["selected"] == false) + ); + assert!( + tools + .iter() + .all(|tool| tool["requiresApproval"].is_boolean()) + ); + } + + let output = run(&[ + "exec", + "--list-tools", + "--json", + "--only-tools", + "read", + "--remove-tools", + "read", + ]); + assert!(output.status.success(), "{output:?}"); + let catalog: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!( + catalog["tools"] + .as_array() + .unwrap() + .iter() + .all(|tool| tool["selected"] == false) + ); + + let output = run(&["exec", "--list-tools", "--only-tools", "read"]); + assert!(output.status.success(), "{output:?}"); + let text = String::from_utf8_lossy(&output.stdout); + assert!(text.contains("approval and sandbox checks still apply")); + assert!( + text.lines() + .any(|line| line.starts_with("write ") && line.ends_with("not selected")) + ); + + let output = run(&[ + "exec", + "--list-tools", + "--json", + "--only-tools", + "missing-tool-9f64", + ]); + assert!(!output.status.success(), "{output:?}"); + assert!(String::from_utf8_lossy(&output.stderr).contains("missing-tool-9f64")); +} + +#[test] +fn tool_listing_honors_specialist_limits_and_rejects_unknown_profiles() { + let home = tempfile::tempdir().unwrap(); + std::fs::create_dir(home.path().join("agent-profiles")).unwrap(); + std::fs::write( + home.path().join("agent-profiles/listing-probe.md"), + "---\nname: listing-probe\ntools: [read,write]\n---\nInspect the repository.\n", + ) + .unwrap(); + for (profile, success) in [("listing-probe", true), ("missing-profile-9f64", false)] { + let output = Command::new(maestro_tui_binary()) + .args(["exec", "--list-tools", "--json", "--specialist", profile]) + .env("MAESTRO_HOME", home.path()) + .env("MAESTRO_PRINT_ALLOWED_TOOLS", "read,grep") + .current_dir(home.path()) + .output() + .unwrap(); + assert_eq!(output.status.success(), success, "{output:?}"); + if success { + let catalog: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let selected: Vec<_> = catalog["tools"] + .as_array() + .unwrap() + .iter() + .filter(|tool| tool["selected"] == true) + .map(|tool| tool["name"].as_str().unwrap()) + .collect(); + assert_eq!(selected, ["read"]); + } else { + assert!(String::from_utf8_lossy(&output.stderr).contains("authorized scope")); + } + } +} + #[test] fn experiments_cli_persists_consent_and_never_exports_the_installation_seed() { let home = tempfile::tempdir().unwrap();