Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 109 additions & 94 deletions .config/rustqual/baseline-dry.json

Large diffs are not rendered by default.

203 changes: 109 additions & 94 deletions .config/rustqual/baseline.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions crates/jp_cli/src/access/mount.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
//! - `MODE` is `ro` (default) or `rw`.
//! `rw` requires an explicit `TOOL:` prefix.
//! - `TOOL:` scopes the grant to a single tool; without it the grant expands to
//! all enabled local tools.
//! all enabled local and command plugin tools.

use camino::{Utf8Component, Utf8Path, Utf8PathBuf};
use jp_config::conversation::tool::access::FsRuleConfig;
Expand All @@ -24,7 +24,8 @@ pub enum MountMode {
/// A parsed `--mount` specification.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MountSpec {
/// Single tool the grant applies to, or `None` for all enabled local tools.
/// Single tool the grant applies to, or `None` for all enabled local and
/// command plugin tools.
pub tool: Option<String>,
/// Workspace-relative symlink location, as typed (relative to CWD).
pub name: String,
Expand Down
2 changes: 2 additions & 0 deletions crates/jp_cli/src/cmd/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//! subcommands, which need no workspace
//! - External plugin dispatch: routing an unknown subcommand to the plugin
//! whose manifest claims it, and spawning it
//! - Tool calls served by plugins (`source = "plugin.command.<name>"`)
//!
//! See: `docs/rfd/072-command-plugin-system.md`

Expand All @@ -22,6 +23,7 @@ mod process_tree;
pub(crate) mod registry;
mod revoke;
pub(crate) mod routing;
pub(crate) mod tool;
mod uninstall;
mod update;

Expand Down
26 changes: 16 additions & 10 deletions crates/jp_cli/src/cmd/plugin/admission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,9 @@ pub(crate) struct Official<'a> {
/// Decide whether `plugin` may run, asking the user where the policy says to.
///
/// Records an approval when the user answers `Y`.
/// Returns the SHA-256 of the contents the decision was made on, read once
/// before deciding: a binary replaced while the prompt is open is not the one
/// that was admitted.
///
/// # Errors
///
Expand All @@ -284,7 +287,7 @@ pub(crate) fn admit(
approvals: &mut ApprovalStore,
interactive: bool,
printer: &Printer,
) -> cmd::Output {
) -> Result<String, cmd::Error> {
check_protocol(plugin)?;

let config = plugins_config.command.get(&plugin.name);
Expand All @@ -307,7 +310,7 @@ pub(crate) fn admit(
let reason = match decide(&candidate, config) {
Verdict::Run => {
debug!(name = plugin.name, path = %plugin.path, "Plugin admitted.");
return Ok(());
return Ok(sha256);
}
Verdict::Refuse(reason) => return Err(reason.into()),
Verdict::Ask(reason) => reason,
Expand All @@ -333,14 +336,17 @@ pub(crate) fn admit(
.map_err(|e| cmd::Error::from(format!("prompt failed: {e}")))?;

match answer {
'y' => Ok(()),
'Y' => approvals.record(&plugin.name, ApprovedPlugin {
path: plugin.path.clone(),
sha256,
approved_at: Utc::now(),
installed: false,
manifest: None,
}),
'y' => Ok(sha256),
'Y' => {
approvals.record(&plugin.name, ApprovedPlugin {
path: plugin.path.clone(),
sha256: sha256.clone(),
approved_at: Utc::now(),
installed: false,
manifest: None,
})?;
Ok(sha256)
}
_ => Err("plugin execution denied".into()),
}
}
Expand Down
41 changes: 41 additions & 0 deletions crates/jp_cli/src/cmd/plugin/admission_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ fn a_sha1_pin_is_compared_with_the_binarys_sha1() {
false,
&printer
)
.map(drop)
.map_err(|error| error.message.unwrap_or_default()),
Ok(())
);
Expand All @@ -150,6 +151,7 @@ fn a_sha1_pin_is_compared_with_the_binarys_sha1() {
false,
&printer
)
.map(drop)
.map_err(|error| error.message.unwrap_or_default()),
Err(format!(
"plugin `webui` binary checksum mismatch.\nexpected: bbb\nactual: \
Expand Down Expand Up @@ -327,6 +329,7 @@ fn without_a_terminal_only_an_approved_binary_runs() {
false,
&printer,
)
.map(drop)
.map_err(|error| error.message.unwrap_or_default())
};

Expand Down Expand Up @@ -360,6 +363,44 @@ fn without_a_terminal_only_an_approved_binary_runs() {
);
}

/// Admission hands back the digest it decided on, so a caller that pins the
/// binary for later checks pins the approved contents, not whatever is on disk
/// by the time it reads the file again.
#[test]
fn admission_returns_the_digest_it_decided_on() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("jp-webui");
std::fs::write(&path, "v1").unwrap();
let plugin = LocalPlugin {
path: path.clone(),
..plugin("webui")
};
let approved = registry::sha256_file(&path).unwrap();
let mut approvals = ApprovalStore::load_from(Some(tmp.path().join("approvals.json")));
approvals
.record("webui", ApprovedPlugin {
path: path.clone(),
sha256: approved.clone(),
approved_at: Utc::now(),
installed: false,
manifest: None,
})
.unwrap();
let (printer, _out, _err) = Printer::memory(jp_printer::OutputFormat::Text);

let decided = admit(
&plugin,
Official::default(),
&AppConfig::new_test().plugins,
&mut approvals,
false,
&printer,
)
.map_err(|error| error.message.unwrap_or_default());

assert_eq!(decided, Ok(approved));
}

#[test]
fn without_a_terminal_the_refusal_says_how_to_approve() {
let plugin = plugin("webui");
Expand Down
25 changes: 11 additions & 14 deletions crates/jp_cli/src/cmd/plugin/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,21 +273,11 @@ fn init_message(
) -> Result<(HostToPlugin, Value), cmd::Error> {
let config_json = config_value(config)?;

let options: serde_json::Map<String, Value> = config
.plugins
.command
.get(name)
.map(|c| {
c.options
.iter()
.map(|(k, v)| (k.clone(), v.0.clone()))
.collect()
})
.unwrap_or_default();
let options = config.plugins.command_options(name);

let storage = paths.storage.ok_or("workspace has no storage configured")?;

let init = HostToPlugin::Init(InitMessage {
let init = HostToPlugin::Init(Box::new(InitMessage {
version: PROTOCOL_VERSION,
workspace: WorkspaceInfo {
root: workspace.root().to_owned(),
Expand All @@ -300,7 +290,8 @@ fn init_message(
args: args.to_vec(),
log_level,
output_format: output_format(format),
});
tool: None,
}));

Ok((init, config_json))
}
Expand Down Expand Up @@ -691,7 +682,7 @@ fn output_format(format: OutputFormat) -> PluginOutputFormat {

/// The JP directories a plugin is told about, so it needs no platform logic of
/// its own.
fn well_known_paths(user_storage_path: Option<&Utf8Path>) -> PathsInfo {
pub(crate) fn well_known_paths(user_storage_path: Option<&Utf8Path>) -> PathsInfo {
let home = std::env::home_dir().and_then(|p| camino::Utf8PathBuf::from_path_buf(p).ok());

PathsInfo {
Expand Down Expand Up @@ -1835,6 +1826,12 @@ fn handle_request(
debug!("Ignoring describe in message loop.");
}

// Only meaningful when `init` carried a tool call, which a command run
// never does.
PluginToHost::ToolOutcome(_) => {
debug!("Ignoring a tool outcome from a plugin running a command.");
}

// Answered by the caller, before the lock this runs under is taken.
PluginToHost::Compose(_)
| PluginToHost::Query(_)
Expand Down
39 changes: 39 additions & 0 deletions crates/jp_cli/src/cmd/plugin/routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,45 @@ pub(crate) fn route<'a>(
})
}

/// A plugin found by its name, for a tool whose source names it.
#[derive(Debug, PartialEq)]
pub(crate) struct Named<'a> {
/// The binary.
pub plugin: &'a LocalPlugin,

/// The official registry entry whose `id` is the plugin's name, when it is
/// one.
pub official: Option<&'a RegistryPlugin>,
}

/// Find the plugin named `name`, the way a tool's `plugin.command.<name>`
/// source names it.
///
/// A name, not a command path: a tool reaches a plugin by its identity, which
/// keys its configuration and its approval, whatever command it claims.
/// Returns `None` when no binary on this machine has that name.
///
/// # Errors
///
/// [`RouteError::SameName`] when two binaries share it.
pub(crate) fn by_name<'a>(
name: &str,
local: &'a [LocalPlugin],
registry: Option<&'a Registry>,
) -> Result<Option<Named<'a>>, RouteError> {
check_unique_name(local, name)?;

let Some(plugin) = local.iter().find(|plugin| plugin.name == name) else {
return Ok(None);
};

let official = official_entries(registry)
.into_values()
.find(|entry| entry.kind.is_command() && entry.id == name);

Ok(Some(Named { plugin, official }))
}

/// The registry's official entries, by key.
///
/// Only these claim commands; a third-party entry is a catalog entry.
Expand Down
65 changes: 65 additions & 0 deletions crates/jp_cli/src/cmd/plugin/routing_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,71 @@ fn plugin(name: &str, dir: &str, command: &[&str]) -> LocalPlugin {
}
}

/// A tool names its plugin by name, and the lookup finds that binary and
/// nothing else.
#[test]
fn by_name_finds_the_plugin_with_that_name() {
let local = [
plugin("ticket", "/bin", &["ticket"]),
plugin("metrics", "/bin", &["metrics"]),
];

let named = by_name("ticket", &local, None).unwrap().unwrap();

assert_eq!(named.plugin.path, Utf8PathBuf::from("/bin/jp-ticket"));
assert_eq!(named.official, None);
}

#[test]
fn by_name_finds_nothing_for_an_absent_plugin() {
let local = [plugin("metrics", "/bin", &["metrics"])];

assert_eq!(by_name("ticket", &local, None).unwrap(), None);
}

/// Two binaries with one name would share one approval; a tool call refuses
/// both, as a command does.
#[test]
fn by_name_refuses_a_name_two_binaries_share() {
let local = [
plugin("ticket", "/install", &["ticket"]),
plugin("ticket", "/bin", &["tickets"]),
];

assert_eq!(
by_name("ticket", &local, None).unwrap_err(),
RouteError::SameName {
name: "ticket".to_owned(),
paths: vec!["/install/jp-ticket".into(), "/bin/jp-ticket".into()],
}
);
}

/// The official entry decides whether the binary is the published release, so
/// it travels with the plugin; a third-party catalog entry with the same id
/// does not make a binary official.
#[test]
fn by_name_carries_the_official_entry() {
let local = [
plugin("serve-web", "/install", &["serve", "web"]),
plugin("metrics", "/bin", &["metrics"]),
];
let registry = registry();

let official = by_name("serve-web", &local, Some(&registry))
.unwrap()
.unwrap();
let third_party = by_name("metrics", &local, Some(&registry))
.unwrap()
.unwrap();

assert_eq!(
official.official.map(|entry| entry.id.as_str()),
Some("serve-web")
);
assert_eq!(third_party.official, None);
}

fn without_manifest(name: &str, dir: &str) -> LocalPlugin {
LocalPlugin {
manifest: ManifestState::Missing,
Expand Down
Loading
Loading