Skip to content
Open
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
19 changes: 16 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# make check Run clippy and format check
# make release Build release binaries

.PHONY: all build build-lib build-cli build-ffi build-arm64 test test-lib test-cli test-ffi check clippy fmt clean install audit help
.PHONY: all build build-lib build-cli build-ffi build-arm64 build-sideload test test-lib test-cli test-ffi test-sideload check clippy fmt clean install audit help

# Default target
all: build
Expand All @@ -23,6 +23,12 @@ build-cli:
build-ffi:
cargo build -p nono-ffi

# Build nono-cli with the sideload feature for development/testing.
# WARNING: the resulting binary has integrity protections disabled.
# Do NOT ship this binary.
build-sideload:
cargo build -p nono-cli --features sideload

build-release:
cargo build --release

Expand All @@ -48,6 +54,11 @@ test-lib:
test-cli:
cargo test -p nono-cli

# Run the sideload-gated integration tests. Requires --features sideload so
# both the test binary and the nono binary under test have the feature active.
test-sideload:
cargo test -p nono-cli --features sideload

test-ffi:
cargo test -p nono-ffi

Expand Down Expand Up @@ -118,7 +129,7 @@ lint-docs:
bash scripts/lint-docs.sh

# CI simulation (what CI would run)
ci: check test audit lint-aliases lint-docs
ci: check test test-sideload audit lint-aliases lint-docs
@echo "CI checks passed"

# Help
Expand All @@ -130,13 +141,15 @@ help:
@echo " make build-lib Build library only"
@echo " make build-cli Build CLI only"
@echo " make build-ffi Build C FFI bindings"
@echo " make build-sideload Build CLI with --features sideload (dev/test only)"
@echo " make build-release Build release binaries"
@echo " make build-arm64 Build CLI for Linux ARM64 (cargo on Linux ARM64; cross elsewhere)"
@echo ""
@echo "Test:"
@echo " make test Run all tests"
@echo " make test-lib Run library tests only"
@echo " make test-cli Run CLI tests only"
@echo " make test-cli Run CLI tests only (production build)"
@echo " make test-sideload Run CLI tests with --features sideload"
@echo " make test-ffi Run C FFI tests only"
@echo " make test-doc Run doc tests only"
@echo ""
Expand Down
5 changes: 5 additions & 0 deletions crates/nono-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ default = ["system-keyring"]
system-keyring = ["dep:keyring", "nono/system-keyring", "nono-proxy/system-keyring"]
# Enables test-only trust root overrides used by the integration harness.
test-trust-overrides = []
# Enables the `nono sideload` subcommand which installs packs from a local
# directory without registry attestation or cryptographic verification.
# WARNING: integrity protections are DISABLED when this feature is active.
# Do NOT ship production binaries built with this flag.
sideload = []

[dependencies]
nono = { version = "0.68.0", path = "../nono", default-features = false }
Expand Down
4 changes: 4 additions & 0 deletions crates/nono-cli/src/app_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ fn dispatch_command(
Commands::Outdated(args) => {
run_command_with_update(update_handle, silent, || package_cmd::run_outdated(args))
}
#[cfg(feature = "sideload")]
Commands::Sideload(args) => {
run_command_with_update(update_handle, silent, || package_cmd::run_sideload(args))
}
Commands::OpenUrlHelper(args) => run_open_url_helper(args),
Commands::PackUpdateHintHelper(args) => crate::pack_update_hint::run_refresh_helper(args),
Commands::Completions(args) => run_completions(args),
Expand Down
45 changes: 45 additions & 0 deletions crates/nono-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const STYLES: Styles = Styles::plain().header(Style::new().bold());
unpin Unpin a pack to re-include it in updates
search Search the registry for nono packs
list List installed nono packs
sideload Install a pack from a local directory (--features sideload builds only)

\x1b[1mPOLICY & PROFILES\x1b[0m
policy [deprecated] Use 'nono profile' instead
Expand Down Expand Up @@ -686,6 +687,25 @@ IN-BAND DETACH:
")]
Completions(CompletionsArgs),

/// Install a nono pack from a local directory, bypassing registry attestation.
///
/// WARNING: integrity protections are DISABLED for sideloaded packs.
/// Use `nono remove` to uninstall. Do NOT use on production systems.
#[cfg(feature = "sideload")]
#[command(help_template = "\
{about}

\x1b[1mUSAGE\x1b[0m
nono sideload <path>

{all-args}
{after-help}")]
#[command(after_help = "\x1b[1mEXAMPLES\x1b[0m
nono sideload ./my-pack # Install from local directory
nono sideload /abs/path/to/pack # Install from absolute path
")]
Sideload(SideloadArgs),

/// Internal: open a URL via supervisor IPC
#[command(hide = true)]
OpenUrlHelper(OpenUrlHelperArgs),
Expand Down Expand Up @@ -866,6 +886,28 @@ pub struct OpenUrlHelperArgs {
pub url: String,
}

/// Arguments for `nono sideload`. Only present when the binary is compiled
/// with `--features sideload`. Do NOT use on production systems.
#[cfg(feature = "sideload")]
#[derive(Parser, Debug)]
#[command(disable_help_flag = true)]
pub struct SideloadArgs {
/// Path to the local pack directory (must contain package.json)
pub path: PathBuf,

/// Namespace (registry owner) for the pack, e.g. `acme-corp`.
///
/// If omitted, the namespace is inferred from the `origin` git remote of
/// the pack directory (the GitHub owner extracted from the remote URL).
/// Errors if the namespace cannot be determined from either source.
#[arg(long, value_name = "NAMESPACE", help_heading = "OPTIONS")]
pub namespace: Option<String>,

/// Print help
#[arg(long, short = 'h', action = clap::ArgAction::Help, help_heading = "OPTIONS")]
pub help: Option<bool>,
}

/// Arguments for the hidden pack-update-hint-helper subcommand.
///
/// Invoked by `nono run` to refresh stale pack update hint cache entries in a
Expand Down Expand Up @@ -4408,6 +4450,9 @@ mod tests {
"search",
"list",
"completion",
// Only present when compiled with --features sideload.
#[cfg(feature = "sideload")]
"sideload",
];

#[test]
Expand Down
2 changes: 2 additions & 0 deletions crates/nono-cli/src/cli_bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ fn cli_verbosity(cli: &Cli) -> u8 {
| Commands::OpenUrlHelper(_)
| Commands::PackUpdateHintHelper(_)
| Commands::Completions(_) => 0,
#[cfg(feature = "sideload")]
Commands::Sideload(_) => 0,
}
}

Expand Down
25 changes: 25 additions & 0 deletions crates/nono-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@
//!
//! This is the CLI binary that uses the nono library for OS-level sandboxing.

// The sideload feature disables integrity protections and must never appear in
// a release binary. `debug_assertions` is false in `--release` builds, so this
// fires exactly when someone runs `cargo build --release --features sideload`.
#[cfg(all(feature = "sideload", not(debug_assertions)))]
compile_error!(
"The `sideload` feature must not be enabled in release builds. \
Remove `--features sideload` from the release build command. \
Sideload binaries are for development and testing only."
);

mod app_runtime;
mod approval_runtime;
mod audit_attestation;
Expand Down Expand Up @@ -129,6 +139,21 @@ fn main() {
let command_blocking_warnings = collect_cli_warnings(&cli);
print_deprecation_warnings(&command_blocking_warnings, cli.silent);

// Global sideload warning: printed before every command so there is no
// ambiguity about which binary is running. One banner per invocation,
// always on stderr, never suppressed by --silent (the risk is the reason
// for the warning).
#[cfg(feature = "sideload")]
{
use colored::Colorize;
eprintln!(
"{}",
"warning: nono was compiled with --features sideload, which means integrity \
protections are DISABLED. Do NOT use this binary on production systems."
.red()
);
}

let cli_result = run_cli(cli);
tool_sandbox::log_main_total();
if let Err(e) = cli_result {
Expand Down
40 changes: 38 additions & 2 deletions crates/nono-cli/src/package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,20 @@ pub struct LockedPackage {
/// removed from the registry between install and uninstall.
#[serde(default)]
pub wiring_record: Vec<WiringRecord>,
/// True when the pack was installed via `nono sideload` rather than pulled
/// from the registry. Sideloaded packs have no attestation, no bundle, and
/// no registry provenance. They are skipped by `nono update` / `nono
/// outdated` and their files are used as-is at runtime without signature
/// verification.
///
/// Defense-in-depth: `read_lockfile` hard-errors on any entry with
/// `sideload: true` when the binary was compiled without the `sideload`
/// feature, so production binaries never silently accept unverified packs.
///
/// Omitted from JSON output when `false` to avoid bloating the lockfile for
/// the common (registry-installed) case.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub sideload: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -160,6 +174,7 @@ impl Default for LockedPackage {
provenance: None,
artifacts: BTreeMap::new(),
wiring_record: Vec::new(),
sideload: false,
}
}
}
Expand Down Expand Up @@ -347,8 +362,29 @@ pub fn read_lockfile() -> Result<Lockfile> {
source: e,
})?;

serde_json::from_str(&content)
.map_err(|e| NonoError::ConfigParse(format!("failed to parse {}: {e}", path.display())))
let lockfile: Lockfile = serde_json::from_str(&content)
.map_err(|e| NonoError::ConfigParse(format!("failed to parse {}: {e}", path.display())))?;

// Defense-in-depth: reject sideloaded packs on production binaries.
// A production binary compiled without `--features sideload` must never
// silently accept packs that bypassed attestation and signature checks.
#[cfg(not(feature = "sideload"))]
{
let sideloaded: Vec<&str> = lockfile
.packages
.iter()
.filter(|(_, pkg)| pkg.sideload)
.map(|(key, _)| key.as_str())
.collect();
if !sideloaded.is_empty() {
let names = sideloaded.join(", ");
return Err(NonoError::PackageInstall(format!(
"lockfile contains sideloaded pack(s) [{names}]; remove with `nono remove <pack>`."
)));
}
}

Ok(lockfile)
}

pub fn write_lockfile(lockfile: &Lockfile) -> Result<()> {
Expand Down
Loading