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
55 changes: 35 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,19 +362,33 @@ preflight, before any action starts. Create the configured target first or prote
an existing ancestor. Merry never creates host paths to install these masks;
optional, absent development mounts remain skippable.

`ssh_agent`, `gpg_agent`, and `dbus` independently enable outer-sandbox forwarding;
they do not preauthorize inner actions. An inner action must request the matching
host-integration capability and receive runtime approval before using the forwarded
socket or automatically imported client files. Explicit trusted path rules may
separately expose regular files, but do not approve protected agent sockets.
A missing agent does not prevent ordinary actions
from starting; explicitly requesting an unavailable endpoint reports an error. Native GPG
socket discovery uses `gpgconf --list-dirs` and honors `GNUPGHOME`; it does not
assume sockets live in `.gnupg` or `/run`. Outer scaffolding preserves private
socket-directory permissions without importing their other contents.

After approval, the SSH integration also exposes `~/.ssh/known_hosts` and
`known_hosts2` read-only to inner actions,
`ssh_agent`, `gpg_agent`, and `dbus` independently enable outer-sandbox forwarding
and preauthorize the matching inner capability: when the validated endpoint exists,
ordinary actions use the forwarded socket and automatically imported client files
without a separate request, like trusted `readonly_paths` and `readwrite_paths`.
`review_paths` still mask a configured endpoint until its exact path is approved
for that action, and `deny_paths` always mask it; explicit trusted path rules that
expose regular files do not thereby approve protected agent sockets. An
integration that trusted configuration did not enable can still be requested for
one permissioned action when its endpoint is visible. A missing agent does not
prevent ordinary actions from starting; explicitly requesting an unavailable
endpoint reports an error. Native GPG socket discovery uses `gpgconf --list-dirs`
and honors `GNUPGHOME`; it does not assume sockets live in `.gnupg` or `/run`. Outer
scaffolding preserves private socket-directory permissions without importing their
other contents.

Path policy decides whether an enabled endpoint is reachable, not whether it is
announced. `SSH_AUTH_SOCK`, `DBUS_SESSION_BUS_ADDRESS`, and `GNUPGHOME` name the
configured endpoints for every action, while a `deny_paths` or `review_paths`
entry covering the socket, the keyring, or one of their parent directories masks
the mount itself. A broad deny therefore also hides the agent even though
`ssh_agent`/`gpg_agent`/`dbus` is enabled: the client sees the endpoint and fails
when it connects. Keep those denies narrow (or outside the endpoint tree) when the
integration should stay usable, and remember that `deny_paths` is never reopened
by an approval.

Once the SSH integration is available, `~/.ssh/known_hosts` and
`known_hosts2` are exposed read-only to inner actions,
without granting access to private keys, `~/.ssh/config`, or the network. These
files still obey `deny_paths` and `review_paths`; an explicit deny of the entire
`.ssh` directory blocks them as well. Existing host identities can be checked,
Expand Down Expand Up @@ -420,13 +434,14 @@ Runtime owns the session capability store and retention decisions. Process adapt
consume read-only snapshots and report normalized path constraints; they do not
record grants. Each preparation captures a fresh mount-alias view, shared by path
review, masking, and client-resource discovery, rather than caching the filesystem
for an entire session. Approved host integrations may be retained within that
session; configuration flags alone never create a runtime grant. Separately
reviewed paths still require per-action approval.

`gpg_agent = true` makes the conventional public-key stores available for reviewed
use. After GPG integration approval, inner actions import `pubring.kbx` and legacy
`pubring.gpg` read-only, without additional `readonly_paths`. Each approved inner
for an entire session. Trusted configuration flags are the preauthorized baseline
for paths and host integrations; capabilities approved through a request may be
retained within that session. Separately reviewed paths still require per-action
approval.

`gpg_agent = true` makes the conventional public-key stores available to inner
actions. When the integration is available, inner actions import `pubring.kbx` and
legacy `pubring.gpg` read-only, without additional `readonly_paths`. Each such
action gets a private, temporary `GNUPGHOME` view
at the original path for locks and a fresh trust database. These client writes
never modify the host keyring, even when the host directory is declared read-only.
Expand Down
4 changes: 4 additions & 0 deletions crates/merry-cli/src/coding/tests/composition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ async fn headless_runtime_uses_coding_agent_profile() {
.collect::<Vec<_>>()
.join("\n");
assert!(request_text.contains("Coding file capabilities"));
assert!(
request_text.contains("Network is withheld from every action that does not request it")
);
assert!(request_text.contains("Action PATH search tools:"));
assert!(request_text.contains("user's current input language"));
assert!(
request
Expand Down
9 changes: 7 additions & 2 deletions crates/merry-cli/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,13 @@ impl MerryConfig {
}

/// Returns host IPC integrations explicitly enabled by trusted global
/// configuration. These form the outer sandbox capability ceiling and are
/// forwarded to inner process sandboxes when their endpoints are present.
/// configuration.
///
/// The same flags are the outer sandbox capability ceiling and the inner
/// action preauthorization: when a validated endpoint exists, ordinary
/// inner actions use it without another permission request. `deny_paths`
/// and `review_paths` still mask a configured endpoint until the exact
/// path is approved for that action.
pub fn host_integrations(&self) -> Vec<HostIntegration> {
let Some(permissions) = self.raw.permissions.as_ref() else {
return Vec::new();
Expand Down
2 changes: 1 addition & 1 deletion crates/merry-cli/src/config/tests/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ fn rejects_removed_no_sandbox_review_key() {
}

#[test]
fn parses_host_integrations_for_outer_sandbox_ceiling() {
fn parses_host_integrations_for_outer_ceiling_and_inner_preauthorization() {
let paths = XdgPaths::from_parts(home(), None, None);
let config = MerryConfig::load_optional_from_text(
Some(
Expand Down
44 changes: 39 additions & 5 deletions crates/merry-cli/src/runtime_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,14 @@ pub(crate) fn main_reasoning_effort(
.and_then(|provider| provider.reasoning_effort))
}

pub(crate) fn action_process_backend_options(
/// Builds the action backend inputs that need no asynchronous discovery.
///
/// GnuPG socket discovery has to run on the async runtime, so this is the
/// synchronous base for [`prepared_action_process_backend_options`], which every
/// product surface uses. Trusted global configuration preauthorizes the inner
/// action sandbox here; the per-action endpoint checks still happen in the
/// process backend.
fn action_process_backend_options(
config: Option<&MerryConfig>,
) -> Result<ActionProcessBackendOptions, config::ConfigError> {
let home = config
Expand Down Expand Up @@ -124,7 +131,14 @@ pub(crate) fn action_process_backend_options(
Ok(ActionProcessBackendOptions::new()
.with_path_rules(path_rules)
.with_network_requests_allowed(config.is_none_or(MerryConfig::network_requests_allowed))
.with_environment_overrides(environment_overrides))
.with_environment_overrides(environment_overrides)
// Trusted global config is the user's own preauthorization for the
// inner action sandbox; the endpoints are still validated per action.
.with_host_integrations(
config
.map(MerryConfig::host_integrations)
.unwrap_or_default(),
))
}

pub(crate) fn configured_runtime_builder(
Expand Down Expand Up @@ -189,17 +203,37 @@ mod tests {
use std::{fs, path::PathBuf, sync::Arc};

#[test]
fn configured_host_integrations_do_not_preauthorize_inner_actions() {
fn configured_host_integrations_preauthorize_inner_actions() {
use merry_runtime::HostIntegration;

let paths = XdgPaths::from_parts(PathBuf::from("/home/alice"), None, None);
let config = MerryConfig::load_optional_from_text(
Some("[permissions]\nssh_agent = true\ngpg_agent = true\ndbus = true\n"),
&paths,
)
.unwrap()
.unwrap();
assert_eq!(config.host_integrations().len(), 3);
// `MerryConfig::host_integrations()` parsing is covered by the config
// module; this test owns the mapping into the action backend options.
let options = action_process_backend_options(Some(&config)).unwrap();
assert!(options.host_integrations().is_empty());
assert_eq!(
options.host_integrations(),
[
HostIntegration::SshAgent,
HostIntegration::SessionBus,
HostIntegration::GpgAgent,
]
);

let unconfigured =
MerryConfig::load_optional_from_text(Some("[permissions]\nnetwork = true\n"), &paths)
.unwrap()
.unwrap();
let options = action_process_backend_options(Some(&unconfigured)).unwrap();
assert!(
options.host_integrations().is_empty(),
"only integrations enabled by trusted config may be preauthorized"
);
}

#[test]
Expand Down
47 changes: 47 additions & 0 deletions crates/merry-cli/src/sandbox/tests.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#[cfg(target_os = "linux")]
use crate::{config::MerryConfig, sandbox::host::current_process_uid};
use crate::{
config::XdgPaths,
sandbox::{
Expand All @@ -7,10 +9,13 @@ use crate::{
os, plan_bootstrap_with_file_exists, plan_bootstrap_with_probe,
},
};
use merry_runtime::{PathAccess, PathAccessRule, PathAccessRuleSource};
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};
#[cfg(target_os = "linux")]
use std::{env, fs};

#[derive(Default)]
struct FakeHostProbe {
Expand Down Expand Up @@ -94,6 +99,45 @@ fn path_is_fake_bwrap(path: &Path) -> bool {
path == Path::new("/custom/bin/bwrap")
}

/// Builds the host fixture an integration test re-enters with: a private home
/// holding `permissions`, the current test binary as the sandbox command, and
/// the same config the child half reloads through its own XDG paths.
#[cfg(target_os = "linux")]
fn integration_host(home: &Path, workspace: &Path, permissions: &str) -> Host {
let mut host = sandbox_host();
host.cwd = workspace.to_path_buf();
host.current_exe = env::current_exe().unwrap();
host.path = Some(os("/usr/bin:/bin"));
host.args.clear();
host.current_uid = current_process_uid().unwrap();
host.xdg_paths = XdgPaths::from_parts(home.to_path_buf(), None, None);
fs::create_dir_all(host.xdg_paths.config_dir()).unwrap();
fs::write(host.xdg_paths.config_file(), permissions).unwrap();
let config = MerryConfig::load_optional(&host.xdg_paths)
.unwrap()
.unwrap();
host.host_integrations = config.host_integrations();
host.trusted_path_rules = config.trusted_global_path_rules().unwrap();
host.trusted_path_rules.push(PathAccessRule::new(
&host.current_exe,
PathAccess::ReadOnly,
PathAccessRuleSource::TrustedGlobalConfig,
));
host
}

/// Re-enters the test binary inside `plan`'s sandbox and asserts the named child
/// test reported exactly one passing test.
#[cfg(target_os = "linux")]
fn assert_sandbox_child_ran(plan: &mut Plan, host: &Host, marker: &str, test_path: &str) {
plan.args.extend(reentry::sandboxed_reentry_arguments(
marker,
&host.current_exe,
test_path,
));
reentry::assert_child_passed(&reentry::run_plan(plan), test_path);
}

fn plan_sandbox(with_sandbox: bool, host: &Host) -> Result<Bootstrap, Error> {
plan_bootstrap_with_file_exists(with_sandbox, host, path_is_fake_bwrap)
}
Expand Down Expand Up @@ -154,6 +198,9 @@ mod runtime_evidence;
#[cfg(target_os = "linux")]
mod mount_execution;

#[cfg(target_os = "linux")]
mod reentry;

#[cfg(target_os = "linux")]
mod gpg_public;

Expand Down
Loading
Loading