diff --git a/docs/architecture/system-architecture.md b/docs/architecture/system-architecture.md index 3db9ea4..f755382 100644 --- a/docs/architecture/system-architecture.md +++ b/docs/architecture/system-architecture.md @@ -499,6 +499,17 @@ The supervisor authenticates the direct caller, binds the request to the session and authenticated task, signs the bounded permit, persists the receipt, and retains all transport, KMS, provider, and repository credentials. +The same roles may inspect that live, deployment-scoped catalog through the +supervisor with `multiagent ops list`, and retrieve one complete contract with +`multiagent ops describe`. List output is intentionally compact and contains +no parameter schema. It labels each operation's request path, direct +eligibility, and any direct-ineligibility reasons so a confined role does not +mistake a reviewed operation for a direct read. This is discovery metadata, not authorization. The runtime +must not substitute permit fixtures, prompt-maintained IDs, or image-local +catalog files for the live response. Exact target, runbook digest, version, +parameters, and current execution policy are still validated when a request is +bound and executed. + This is a distinct authority operation, not a relaxation of generic `ops execute`. It mechanically rejects write/execute capabilities, mutations, approval-bearing operations, arbitrary URLs, caller-selected filesystem diff --git a/prompts/orchestrator.md b/prompts/orchestrator.md index 957cfb1..bc287b7 100644 --- a/prompts/orchestrator.md +++ b/prompts/orchestrator.md @@ -50,6 +50,15 @@ write/execute/mutating external operations belong to ops and the reviewed runbook lifecycle. No role calls provider endpoints directly or receives Supervisor credentials. +Discover external operations from the live deployment with +`multiagent ops list --direct-only`, optionally narrowed by `--query TEXT`. +Use `multiagent ops describe OPERATION_ID` for the selected operation's full +schema and examples. Treat only entries with +`requestPath=supervisor-direct` and `directEligible=true` as available to a +confined role; `reviewed-ops` entries require the operations/review lifecycle. +Do not infer the available catalog from permit fixtures, runbook examples, or +files in the runtime image. + Wiki and repository reads may support a caller-facing result directly. Spawn a reader only when parallelism, isolation, or specialized analysis is useful; a reader is not a prerequisite for read-only completion. No independent reviewer diff --git a/prompts/roles/repository-reader.md b/prompts/roles/repository-reader.md index c3b5fcb..deca98d 100644 --- a/prompts/roles/repository-reader.md +++ b/prompts/roles/repository-reader.md @@ -12,6 +12,10 @@ when fresh evidence or repository materialization is necessary. The JSON request must contain exactly `operation`, `parameters`, `runbook`, and the framework-relative `runbookDocument`; the supervisor binds its task, goal, target, and runbook digest. Inspect `multiagent ops describe OPERATION_ID` first. +If the operation ID is not already known, discover it with +`multiagent ops list --direct-only [--query TEXT]`; do not infer availability +from local permit fixtures or runbook examples. Use only results marked +`requestPath=supervisor-direct` and `directEligible=true`. Create each request as a mode-`0640` JSON file under `$MULTIAGENT_ROLE_SHARED_WRITE_DIR`, run `chmod 0640 PATH`, then pass that exact path to `multiagent ops read --request-file PATH`. This is the role-confined scratch diff --git a/runtime/src/authority.rs b/runtime/src/authority.rs index 6828930..653ed54 100644 --- a/runtime/src/authority.rs +++ b/runtime/src/authority.rs @@ -45,6 +45,7 @@ enum AuthorityOperation { ValidationLeaseShow, ValidationLeaseList, GateCheck, + OpsList, OpsDescribe, OpsRead, OpsPublishBound, @@ -61,6 +62,9 @@ impl AuthorityRequest { "workflow" => (AuthorityOperation::Workflow, args), "decision" => (AuthorityOperation::Decision, args), "dag" => (AuthorityOperation::Dag, args), + "ops" if args.first().map(String::as_str) == Some("list") => { + (AuthorityOperation::OpsList, &args[1..]) + } "ops" if args.first().map(String::as_str) == Some("describe") => { (AuthorityOperation::OpsDescribe, &args[1..]) } @@ -183,7 +187,9 @@ impl AuthorityRequest { | AuthorityOperation::TodoAssign | AuthorityOperation::TodoStatus | AuthorityOperation::GateCheck => uid == config::ORCHESTRATOR_UID, - AuthorityOperation::OpsDescribe | AuthorityOperation::OpsRead => matches!( + AuthorityOperation::OpsList + | AuthorityOperation::OpsDescribe + | AuthorityOperation::OpsRead => matches!( uid, config::ORCHESTRATOR_UID | config::WRITER_UID @@ -295,6 +301,7 @@ impl AuthorityRequest { | AuthorityOperation::ValidationLeaseShow | AuthorityOperation::ValidationLeaseList | AuthorityOperation::GateCheck + | AuthorityOperation::OpsList | AuthorityOperation::OpsDescribe | AuthorityOperation::OpsRead => true, }, @@ -340,6 +347,7 @@ impl AuthorityRequest { AuthorityOperation::ValidationLeaseShow => ("subagent", Some("validation-lease-show")), AuthorityOperation::ValidationLeaseList => ("subagent", Some("validation-lease-list")), AuthorityOperation::GateCheck => ("subagent", Some("gate-check")), + AuthorityOperation::OpsList => ("ops", Some("list")), AuthorityOperation::OpsDescribe => ("ops", Some("describe")), AuthorityOperation::OpsRead => ("ops", Some("read")), AuthorityOperation::OpsPublishBound => ("ops", Some("publish-bound")), @@ -505,6 +513,30 @@ mod tests { describe.into_cli(), ("ops".to_string(), strings(&["describe", "github.read"])) ); + let list = AuthorityRequest::from_cli( + "ops", + &strings(&["list", "--direct-only", "--query", "github"]), + ) + .expect("ops list request"); + for uid in [ + config::ORCHESTRATOR_UID, + config::WRITER_UID, + config::READER_UID, + config::OPS_UID, + config::REVIEWER_UID, + ] { + assert!( + list.authorized_for(uid), + "uid {uid} should be allowed to inspect the live capability catalog" + ); + } + assert_eq!( + list.into_cli(), + ( + "ops".to_string(), + strings(&["list", "--direct-only", "--query", "github"]) + ) + ); let direct_read = AuthorityRequest::from_cli( "ops", &strings(&["read", "--request-file", "/logs/agents/reader/request.json"]), diff --git a/runtime/src/prod_ops.rs b/runtime/src/prod_ops.rs index 1a48730..1ed66fc 100644 --- a/runtime/src/prod_ops.rs +++ b/runtime/src/prod_ops.rs @@ -18,7 +18,7 @@ const MAX_RUNBOOK_BYTES: u64 = 1_048_576; const MATERIALIZATION_TIMEOUT: StdDuration = StdDuration::from_secs(120); const MAX_MATERIALIZATION_FILES: u64 = 200_000; const MAX_MATERIALIZATION_BYTES: u64 = 1024 * 1024 * 1024; -const OPS_USAGE: &str = "usage:\n multiagent ops describe OPERATION_ID\n multiagent ops read --request-file PATH\n multiagent ops template\n multiagent ops bind-runbook --request-file PATH --runbook-document PATH\n multiagent ops publish --draft-file PATH --runbook-document PATH\n multiagent ops review-bind --request-file PATH\n multiagent ops execute --request-file PATH --reviewer NAME [--reviewed-request PATH]"; +const OPS_USAGE: &str = "usage:\n multiagent ops list [--direct-only] [--query TEXT]\n multiagent ops describe OPERATION_ID\n multiagent ops read --request-file PATH\n multiagent ops template\n multiagent ops bind-runbook --request-file PATH --runbook-document PATH\n multiagent ops publish --draft-file PATH --runbook-document PATH\n multiagent ops review-bind --request-file PATH\n multiagent ops execute --request-file PATH --reviewer NAME [--reviewed-request PATH]"; pub(crate) struct PublishedRequest { artifact_path: PathBuf, @@ -48,6 +48,7 @@ struct TrustedApproval { pub fn run(args: &[String]) -> Result { match args.first().map(String::as_str) { + Some("list") => list(&args[1..]), Some("describe") => describe(&args[1..]), Some("read") => execute_direct_read(&args[1..]), Some("template") => template(&args[1..]), @@ -64,6 +65,165 @@ pub fn run(args: &[String]) -> Result { } } +fn list(args: &[String]) -> Result { + let response = call_prod_mcp_tool("operations_capabilities", json!({}))?; + let result = list_capabilities(&response, args)?; + println!( + "{}", + serde_json::to_string(&result) + .map_err(|error| format!("encode prod-mcp capability list: {error}"))? + ); + Ok(ExitCode::SUCCESS) +} + +fn list_capabilities(response: &Value, args: &[String]) -> Result { + let mut direct_only = false; + let mut query: Option = None; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--direct-only" if !direct_only => { + direct_only = true; + index += 1; + } + "--query" if query.is_none() => { + let value = args + .get(index + 1) + .filter(|value| !value.is_empty()) + .ok_or("--query requires a non-empty value")?; + query = Some(value.to_ascii_lowercase()); + index += 2; + } + "--direct-only" => return Err("duplicate option: --direct-only".into()), + "--query" => return Err("duplicate option: --query".into()), + _ => return Err("usage: multiagent ops list [--direct-only] [--query TEXT]".into()), + } + } + let result = response + .get("result") + .and_then(Value::as_object) + .ok_or("prod-mcp capabilities response has no result object")?; + if result.get("isError").and_then(Value::as_bool) == Some(true) { + return Err(format!( + "prod-mcp capabilities failed: {}", + Value::Object(result.clone()) + )); + } + let structured = result + .get("structuredContent") + .and_then(Value::as_object) + .ok_or("prod-mcp capabilities response has no structured content")?; + let operations = structured + .get("operations") + .and_then(Value::as_array) + .ok_or("prod-mcp capabilities response has no operations array")?; + let mut compact = operations + .iter() + .filter(|operation| !direct_only || direct_eligible(operation)) + .filter(|operation| { + query.as_ref().is_none_or(|query| { + [ + operation.get("id").and_then(Value::as_str), + operation.get("connector").and_then(Value::as_str), + operation.get("description").and_then(Value::as_str), + ] + .into_iter() + .flatten() + .any(|value| value.to_ascii_lowercase().contains(query)) + || operation + .get("allowedRunbooks") + .and_then(Value::as_array) + .is_some_and(|values| { + values + .iter() + .filter_map(Value::as_str) + .any(|value| value.to_ascii_lowercase().contains(query)) + }) + }) + }) + .map(|operation| { + let mut descriptor = serde_json::Map::new(); + for key in [ + "id", + "version", + "connector", + "description", + "access", + "mutation", + "allowedRunbooks", + "requiredApprovalRoles", + "requireChangeTicket", + ] { + if let Some(value) = operation.get(key) { + descriptor.insert(key.into(), value.clone()); + } + } + descriptor.insert( + "directEligible".into(), + Value::Bool(direct_eligible(operation)), + ); + descriptor.insert( + "requestPath".into(), + Value::String( + if direct_eligible(operation) { + "supervisor-direct" + } else { + "reviewed-ops" + } + .into(), + ), + ); + descriptor.insert( + "directIneligibilityReasons".into(), + Value::Array( + direct_ineligibility_reasons(operation) + .into_iter() + .map(|reason| Value::String(reason.into())) + .collect(), + ), + ); + Value::Object(descriptor) + }) + .collect::>(); + compact.sort_by(|left, right| { + left.get("id") + .and_then(Value::as_str) + .cmp(&right.get("id").and_then(Value::as_str)) + }); + Ok(json!({ + "apiVersion": "multiagent.moveindustries.io/v1", + "kind": "OperationCapabilityList", + "scope": structured.get("scope").cloned().unwrap_or(Value::Null), + "operations": compact, + })) +} + +fn direct_eligible(operation: &Value) -> bool { + direct_ineligibility_reasons(operation).is_empty() +} + +fn direct_ineligibility_reasons(operation: &Value) -> Vec<&'static str> { + let mut reasons = Vec::new(); + if !matches!( + operation.get("access").and_then(Value::as_str), + Some("read" | "materialize") + ) { + reasons.push("access-requires-reviewed-ops"); + } + if operation.get("mutation").and_then(Value::as_bool) != Some(false) { + reasons.push("mutation-requires-reviewed-ops"); + } + match operation + .get("requiredApprovalRoles") + .and_then(Value::as_array) + { + Some(roles) if roles.is_empty() => {} + Some(_) => reasons.push("approval-roles-required"), + None => reasons.push("approval-metadata-missing"), + } + reasons +} + fn describe(args: &[String]) -> Result { if args.len() != 1 || args[0].is_empty() { return Err("usage: multiagent ops describe OPERATION_ID".into()); @@ -2787,12 +2947,13 @@ mod tests { use super::{ base64_decode, base64url_encode, build_request, canonical, clone_summary, curl_command, direct_request_runbook, ecdsa_der_to_raw, execute_mode, git_auth_config, git_clone_command, - materialization_usage, operation_capability, parse_mcp_body, persist_direct_receipt, - private_temp_path, redacted_direct_receipt, reject_arbitrary_urls, review_binding_marker, - review_binding_matches, review_binding_value, review_evidence_is_bound, reviewer_accepted, - runbook_content_digest, validate_diagnosis_capability, validate_direct_capability, - validate_evidence_scope, validate_read_capability, validate_request_template, - write_mcp_headers, DirectAccess, ExecuteMode, TrustedApproval, + list_capabilities, materialization_usage, operation_capability, parse_mcp_body, + persist_direct_receipt, private_temp_path, redacted_direct_receipt, reject_arbitrary_urls, + review_binding_marker, review_binding_matches, review_binding_value, + review_evidence_is_bound, reviewer_accepted, runbook_content_digest, + validate_diagnosis_capability, validate_direct_capability, validate_evidence_scope, + validate_read_capability, validate_request_template, write_mcp_headers, DirectAccess, + ExecuteMode, TrustedApproval, }; use chrono::{TimeZone, Utc}; use serde_json::json; @@ -2822,6 +2983,80 @@ mod tests { assert_eq!(base64url_encode(&[251, 255]), "-_8"); } + #[test] + fn capability_list_is_compact_searchable_and_marks_direct_operations() { + let response = json!({ + "result": { + "structuredContent": { + "scope": "deployment-enabled", + "operations": [ + { + "id": "github.create-pr", + "version": "1.0.0", + "connector": "github", + "description": "Create a pull request", + "access": "write", + "mutation": true, + "allowedRunbooks": ["github.repository-work@1.1.0"], + "requiredApprovalRoles": ["operations-reviewer"], + "requireChangeTicket": false, + "parameterSchema": {"type": "object"} + }, + { + "id": "github.clone", + "version": "1.0.0", + "connector": "github", + "description": "Materialize a repository", + "access": "materialize", + "mutation": false, + "allowedRunbooks": ["github.repository-work@1.1.0"], + "requiredApprovalRoles": [], + "requireChangeTicket": false, + "parameterSchema": {"type": "object"} + } + ] + } + } + }); + let listed = list_capabilities( + &response, + &[ + "--direct-only".into(), + "--query".into(), + "repository".into(), + ], + ) + .unwrap(); + assert_eq!(listed["scope"], "deployment-enabled"); + assert_eq!(listed["operations"].as_array().unwrap().len(), 1); + assert_eq!(listed["operations"][0]["id"], "github.clone"); + assert_eq!(listed["operations"][0]["directEligible"], true); + assert_eq!(listed["operations"][0]["requestPath"], "supervisor-direct"); + assert_eq!( + listed["operations"][0]["directIneligibilityReasons"], + json!([]) + ); + assert!(listed["operations"][0].get("parameterSchema").is_none()); + + let all = list_capabilities(&response, &[]).unwrap(); + let create = all["operations"] + .as_array() + .unwrap() + .iter() + .find(|operation| operation["id"] == "github.create-pr") + .unwrap(); + assert_eq!(create["directEligible"], false); + assert_eq!(create["requestPath"], "reviewed-ops"); + assert_eq!( + create["directIneligibilityReasons"], + json!([ + "access-requires-reviewed-ops", + "mutation-requires-reviewed-ops", + "approval-roles-required" + ]) + ); + } + #[test] fn certified_runbook_digest_uses_prefixed_exact_bytes() { assert_eq!(