use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Capability {
ReadOnly,
Build,
Delegating,
Write,
Signing,
}
impl Default for Capability {
fn default() -> Self {
Capability::Write
}
}
impl Capability {
fn rank(self) -> u8 {
match self {
Capability::ReadOnly | Capability::Build | Capability::Delegating => 0,
Capability::Write => 1,
Capability::Signing => 2,
}
}
pub fn within(self, ceiling: Capability) -> bool {
self.rank() <= ceiling.rank()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
Allow,
Confirm,
Deny(String),
}
#[derive(Debug, Clone)]
pub struct Assessment {
pub decision: Decision,
pub detail: String,
pub scope: String,
}
const SECRET_KEYS: [&str; 6] = [
"secret",
"secret_key",
"seed",
"seed_phrase",
"mnemonic",
"private_key",
];
fn looks_like_secret_seed(candidate: &str) -> bool {
let candidate = candidate.trim();
candidate.len() == 56
&& candidate.starts_with('S')
&& candidate
.bytes()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
}
fn find_secret(input: &Value) -> Option<String> {
match input {
Value::String(text) => looks_like_secret_seed(text).then(|| "a secret seed".to_string()),
Value::Array(items) => items.iter().find_map(find_secret),
Value::Object(map) => map.iter().find_map(|(key, value)| {
let lowered = key.to_lowercase();
if SECRET_KEYS.contains(&lowered.as_str())
&& value.as_str().is_some_and(|v| !v.trim().is_empty())
{
return Some(format!("'{}'", key));
}
find_secret(value)
}),
_ => None,
}
}
pub fn assess(tool: &str, capability: Capability, input: &Value) -> Assessment {
let detail = describe(tool, input);
let scope = scope(tool, input);
let deny = |reason: String| Assessment {
decision: Decision::Deny(reason),
detail: detail.clone(),
scope: scope.clone(),
};
if let Some(where_) = find_secret(input) {
return deny(format!(
"Refusing to run {} with a secret key in {}: it would reach the process list and the \
session log. Sign by Stellar CLI identity alias (e.g. `alice`) instead. Nothing ran.",
tool, where_
));
}
if capability == Capability::Signing {
if let Some(network) = input.get("network").and_then(Value::as_str) {
if crate::tools::mainnet::is_public_network(network)
&& !crate::tools::mainnet::mainnet_allowed()
{
return deny(format!(
"Refusing to sign on '{}': mainnet operations are disabled. This spends real \
funds, so it is off unless the operator turns it on out of band — set \
`allow_mainnet = true` in ~/.config/procyon/config.toml, or \
PROCYON_ALLOW_MAINNET=1 in the environment. Ask the user to do it; you cannot \
enable it yourself. Nothing was submitted.",
network
));
}
}
}
let decision = match capability {
Capability::ReadOnly | Capability::Build | Capability::Delegating => Decision::Allow,
Capability::Write | Capability::Signing => Decision::Confirm,
};
Assessment {
decision,
detail,
scope,
}
}
pub fn describe(tool: &str, input: &Value) -> String {
let field = |key: &str| input.get(key).and_then(Value::as_str);
match tool {
"write_file" | "edit_file" => match field("path") {
Some(path) => format!("{} → {}", tool, path),
None => tool.to_string(),
},
"account_create" => match field("name") {
Some(name) => format!("account_create → {}", name),
None => tool.to_string(),
},
_ => {
let target = field("contract")
.or_else(|| field("contract_id"))
.or_else(|| field("path"));
let method = field("method").or_else(|| field("function"));
let network = field("network");
let mut out = tool.to_string();
if let Some(target) = target {
out.push_str(&format!(" → {}", target));
}
if let Some(method) = method {
out.push_str(&format!(".{}()", method));
}
if let Some(network) = network {
out.push_str(&format!(" on {}", network));
}
out
}
}
}
pub fn scope(tool: &str, input: &Value) -> String {
let field = |key: &str| input.get(key).and_then(Value::as_str);
let mut parts = vec![tool.to_string()];
if let Some(target) = field("path")
.or_else(|| field("contract"))
.or_else(|| field("contract_id"))
{
parts.push(target.to_string());
}
if let Some(network) = field("network") {
parts.push(network.to_string());
}
parts.join(":")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn decide(tool: &str, capability: Capability, input: Value) -> Decision {
assess(tool, capability, &input).decision
}
#[test]
fn reads_builds_and_delegation_run_without_asking() {
for capability in [
Capability::ReadOnly,
Capability::Build,
Capability::Delegating,
] {
assert_eq!(
decide("whatever", capability, json!({})),
Decision::Allow,
"{:?} should not ask",
capability
);
}
}
#[test]
fn a_ceiling_admits_less_than_itself_and_refuses_more() {
assert!(Capability::ReadOnly.within(Capability::ReadOnly));
assert!(Capability::Build.within(Capability::ReadOnly));
assert!(Capability::Delegating.within(Capability::ReadOnly));
assert!(!Capability::Write.within(Capability::ReadOnly));
assert!(!Capability::Signing.within(Capability::Write));
assert!(Capability::Write.within(Capability::Signing));
assert!(Capability::Signing.within(Capability::Signing));
}
#[test]
fn writes_and_signatures_ask() {
for capability in [Capability::Write, Capability::Signing] {
assert_eq!(
decide("whatever", capability, json!({"network": "testnet"})),
Decision::Confirm,
"{:?} must ask",
capability
);
}
}
#[test]
fn an_unclassified_tool_asks_rather_than_proceeds() {
assert_eq!(
decide(
"raven__submit_transaction",
Capability::default(),
json!({})
),
Decision::Confirm,
"the default must fail closed"
);
}
#[test]
fn a_secret_seed_is_refused_wherever_it_appears() {
let seed = "S".repeat(56);
for input in [
json!({"source": seed}),
json!({"args": [seed]}),
json!({"signer": {"key": seed}}),
] {
let Decision::Deny(reason) = decide("stellar_invoke", Capability::Signing, input)
else {
panic!("a secret seed must be refused");
};
assert!(reason.contains("identity alias"), "{}", reason);
assert!(reason.contains("Nothing ran"), "{}", reason);
}
}
#[test]
fn a_field_named_for_a_secret_is_refused_on_its_name() {
for key in SECRET_KEYS {
let input = json!({ key: "correct horse battery staple" });
assert!(
matches!(
decide("plugin_sign", Capability::Write, input),
Decision::Deny(_)
),
"'{}' must be refused",
key
);
}
}
#[test]
fn an_empty_secret_field_is_not_treated_as_one() {
assert_eq!(
decide("account_create", Capability::Write, json!({"secret": ""})),
Decision::Confirm
);
}
#[test]
fn a_secret_is_denied_rather_than_put_to_the_user() {
let seed = "S".repeat(56);
assert!(matches!(
decide("read_file", Capability::ReadOnly, json!({"path": seed})),
Decision::Deny(_)
));
}
#[test]
fn mainnet_signing_is_refused_unless_the_operator_enabled_it() {
let restore = std::env::var("PROCYON_ALLOW_MAINNET").ok();
std::env::set_var("PROCYON_ALLOW_MAINNET", "0");
let Decision::Deny(reason) = decide(
"caatinga_deploy",
Capability::Signing,
json!({"network": "mainnet"}),
) else {
panic!("mainnet signing must be refused when it is off");
};
assert!(reason.contains("real funds"), "{}", reason);
assert!(
reason.contains("cannot enable it yourself"),
"the message must close the door on the model asking itself: {}",
reason
);
assert_eq!(
decide(
"caatinga_read",
Capability::ReadOnly,
json!({"network": "mainnet"})
),
Decision::Allow
);
std::env::set_var("PROCYON_ALLOW_MAINNET", "1");
assert_eq!(
decide(
"caatinga_deploy",
Capability::Signing,
json!({"network": "mainnet"})
),
Decision::Confirm,
"enabling mainnet grants the operation, not the approval"
);
match restore {
Some(value) => std::env::set_var("PROCYON_ALLOW_MAINNET", value),
None => std::env::remove_var("PROCYON_ALLOW_MAINNET"),
}
}
#[test]
fn the_question_names_the_file_a_write_would_touch() {
let detail = describe("write_file", &json!({"path": "src/lib.rs", "content": "x"}));
assert!(detail.contains("src/lib.rs"), "got {}", detail);
}
#[test]
fn the_question_names_the_network_a_signature_would_reach() {
let detail = describe(
"caatinga_invoke",
&json!({"contract": "counter", "method": "increment", "network": "mainnet"}),
);
assert!(detail.contains("counter"), "got {}", detail);
assert!(detail.contains("increment"), "got {}", detail);
assert!(detail.contains("mainnet"), "got {}", detail);
}
#[test]
fn an_unknown_tool_is_still_described_by_its_arguments() {
let detail = describe(
"raven__invoke",
&json!({"contract_id": "CDLZ", "function": "burn", "network": "mainnet"}),
);
assert!(detail.contains("CDLZ"), "got {}", detail);
assert!(detail.contains("burn"), "got {}", detail);
assert!(detail.contains("mainnet"), "got {}", detail);
}
#[test]
fn unrecognised_arguments_fall_back_to_the_name_rather_than_inventing_detail() {
assert_eq!(describe("write_file", &json!({})), "write_file");
assert_eq!(describe("project_init", &json!({"x": 1})), "project_init");
}
#[test]
fn always_is_scoped_to_the_file_it_was_granted_for() {
let a = scope("write_file", &json!({"path": "src/lib.rs"}));
let b = scope("write_file", &json!({"path": "src/main.rs"}));
assert_ne!(
a, b,
"'always' must not generalise from one path to another"
);
}
#[test]
fn always_is_scoped_to_the_network_it_was_granted_for() {
let testnet = scope(
"caatinga_invoke",
&json!({"contract": "c", "network": "testnet"}),
);
let mainnet = scope(
"caatinga_invoke",
&json!({"contract": "c", "network": "mainnet"}),
);
assert_ne!(
testnet, mainnet,
"'always' on testnet must not extend to the network that costs money"
);
}
#[test]
fn a_tool_without_recognisable_arguments_is_scoped_by_name() {
assert_eq!(scope("project_init", &json!({})), "project_init");
}
}