procyon 0.3.0

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
//! The one place that decides whether a tool call may run.
//!
//! Before this module the decision was spread over two mechanisms that did not know about each
//! other: `tools::approval::requires_approval`, a `matches!` over builtin tool *names*, and
//! `tools::mainnet`, an out-of-band opt-in. Two consequences followed from the name match, and
//! both are what this replaces.
//!
//! The first is that it was fail-*open*. MCP tools are registered as `server__tool` and plugin
//! tools carry whatever a user-authored manifest calls them, so neither could ever match a
//! literal in that list — an MCP tool that submits a transaction, or a plugin that runs an
//! arbitrary command, ran with no question asked. Here a tool declares its own capability through
//! [`Capability`] and the default is [`Capability::Write`], so a tool nobody classified asks
//! rather than proceeds. Adding a read-only tool and forgetting to say so costs a prompt; the
//! other direction cost the user their files.
//!
//! The second is that policy lived at the call site. This module keeps the two halves apart: the
//! tool says *what it does* (a fact about the tool, next to its code), and [`assess`] decides
//! *what that means here* (a policy, in one place, reading the current configuration). Risk is
//! graded — `allow`, `confirm`, `deny` — rather than a boolean, so a refusal no human can override
//! from inside the session is expressible without being confused for a prompt.
//!
//! Nothing here can be reached by the model: `assess` takes the tool and its arguments and reads
//! process configuration, and there is no tool that writes any of its inputs.

use serde_json::Value;

/// What a tool does, declared by the tool itself.
///
/// A fact about the implementation, not a permission: the mapping from capability to decision is
/// [`assess`]'s, and it depends on configuration the tool cannot see.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Capability {
    /// Reads the workspace or the network. Changes nothing.
    ReadOnly,
    /// Compiles or tests locally. It writes build output and runs the project's own code, but
    /// touches no source the user wrote and reaches no network that costs anything. Allowed
    /// because these are bound to advertised shortcuts (Ctrl+B, Ctrl+T): a prompt on a key the
    /// user just pressed teaches them to approve without reading, which is worse than no prompt.
    Build,
    /// Runs a nested agent — a skill, a persona, a roundtable. Allowed on its own account because
    /// it changes nothing directly, and every tool the nested agent reaches comes back through
    /// `ToolRegistry::execute`, so it is assessed there on its own terms.
    Delegating,
    /// Changes the user's files, or anything else Procyon cannot undo for them.
    ///
    /// The default for an unclassified tool, which is what makes the engine fail closed.
    Write,
    /// Signs and submits to a network. The only capability the mainnet gate applies to.
    Signing,
}

impl Default for Capability {
    /// The unclassified case, and deliberately the cautious one — see the module docs.
    fn default() -> Self {
        Capability::Write
    }
}

impl Capability {
    /// How much a capability can do, for comparing one against a limit.
    ///
    /// `Build` and `Delegating` rank with `ReadOnly` rather than above it: none of them touches
    /// source the user wrote or reaches a network that costs anything, and a specialist restricted
    /// to reading is not made more dangerous by being able to run the test suite.
    fn rank(self) -> u8 {
        match self {
            Capability::ReadOnly | Capability::Build | Capability::Delegating => 0,
            Capability::Write => 1,
            Capability::Signing => 2,
        }
    }

    /// Whether this capability is inside a ceiling.
    ///
    /// Used to build a specialist's tool set: a persona declared read-only is handed a registry
    /// that does not contain the tools it may not use, rather than a prompt asking it not to. See
    /// `crate::personas`.
    pub fn within(self, ceiling: Capability) -> bool {
        self.rank() <= ceiling.rank()
    }
}

/// What the engine decided.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
    /// Run it without asking.
    Allow,
    /// Ask the user first.
    Confirm,
    /// Refuse. The string is what the model is told, so it reads as an outcome rather than as an
    /// internal state, and it names what a human would have to change — never something the model
    /// could arrange for itself.
    Deny(String),
}

/// The decision plus the two strings the rest of the app needs to act on it.
#[derive(Debug, Clone)]
pub struct Assessment {
    pub decision: Decision,
    /// One line saying what this call would actually do, in the user's terms.
    pub detail: String,
    /// What "always allow" would cover. See [`scope`].
    pub scope: String,
}

/// Fields whose *name* says the value is a secret, whatever it happens to contain.
const SECRET_KEYS: [&str; 6] = [
    "secret",
    "secret_key",
    "seed",
    "seed_phrase",
    "mnemonic",
    "private_key",
];

/// Whether a string looks like a Stellar secret seed: `S` plus 55 uppercase base32 characters.
///
/// Matched by shape rather than by checksum. A near-miss is still secret material the user did not
/// mean to put on a command line, and refusing one is cheaper than leaking one.
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())
}

/// Finds secret material anywhere in a tool's arguments, and says where.
///
/// Recursive because a nested object is not a hiding place: a plugin or MCP tool takes whatever
/// shape its own schema declares, and the model composes it.
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();
            // A field *named* for a secret is refused on the strength of its name: a passphrase
            // has no recognisable shape, and this is the field where one would go.
            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,
    }
}

/// Assesses one call.
///
/// Order matters: a denial outranks everything, because a call that must not happen must not be
/// put to the user as a question either — a prompt implies the answer could be yes.
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(),
    };

    // Secrets first, and for every capability including the read-only ones. Signing is by CLI
    // identity alias, so a secret in the arguments is never the only way to do what was asked —
    // and once it has been passed it is in the process list and the session log for good.
    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,
    }
}

/// One line saying what this call would actually do.
///
/// A bare tool name is not something anyone can answer on: "allow write_file?" and "allow
/// write_file to overwrite src/lib.rs?" are different questions. Falls back to the tool name when
/// the arguments carry nothing recognisable, rather than inventing detail.
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(),
        },
        _ => {
            // Everything else is described by the nouns that decide what it costs: the thing acted
            // on, the entry point, and the network. Not keyed on the tool name, so an MCP or plugin
            // tool that happens to speak the same vocabulary is described just as well.
            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));
            }
            // The network is the part that decides whether this costs real money.
            if let Some(network) = network {
                out.push_str(&format!(" on {}", network));
            }
            out
        }
    }
}

/// What "always allow" covers.
///
/// Keyed on the arguments that decide the consequence, not on the tool name alone. Approving
/// `write_file → src/lib.rs` used to hand over `write_file` for every path for the rest of the
/// session, which is not what the person answering that question was shown or asked.
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());
    }
    // Kept in the key on purpose: "always allow invoking counter" must not silently extend from
    // testnet to the network that costs money.
    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));
        // Running the tests is not a way to change anything, so a read-only specialist keeps it.
        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
            );
        }
    }

    // The bug this module exists for: an unclassified tool — every MCP tool named `server__tool`,
    // every plugin tool named by its manifest — used to run with no question asked.
    #[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);
        }
    }

    // A passphrase has no recognisable shape, so the field name has to be enough.
    #[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
        );
    }

    // A refusal must not be dressed up as a question: a prompt implies the answer could be yes.
    #[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() {
        // SAFETY of the assumption, not of the call: the variable is process-wide, so this is one
        // test rather than several that would race each other.
        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
        );

        // A read on mainnet costs nothing, and gating it would push the agent toward `invoke` for
        // questions `read` answers for free.
        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);
    }

    // The network is what decides whether a call costs real money, so it cannot be the part that
    // is left off.
    #[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);
    }

    // An unclassified tool is the one most in need of a describable prompt, and it is exactly the
    // one a name-keyed table could not describe.
    #[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");
    }
}