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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,15 @@ VEX-aware vulnerability scanner.

| Mode | Command | What to commit | What installs need |
| --- | --- | --- | --- |
| Hosted (default) | `socket-patch scan` | Changed lockfiles, manifests, and registry configuration | Access to Socket's patch server |
| Hosted (default for a new project) | `socket-patch scan` | Changed lockfiles, manifests, and registry configuration | Access to Socket's patch server |
| Vendored | `socket-patch scan --mode vendored` | Changed dependency files and `.socket/vendor/` (artifacts and ledger) | The committed patched packages |
| Agent | `socket-patch scan --mode agent` | `.socket/manifest.json` and patch data; Go also uses a committed patched tree | `socket-patch apply` after dependency installs |

Vendored mode stores **patched dependencies**, not the entire dependency graph.
Other dependencies still need their normal registry, mirror, or offline cache.
Hosted and vendored installs do not need an install hook or the Socket Patch CLI.
Once a project holds vendored or agent-mode patches, a bare `scan` or `get` keeps
that mode; pass `--mode` to switch.

The CLI supports npm, PyPI, Cargo, Go, RubyGems, Maven (including sbt, Mill and
scala-cli builds), Composer, NuGet, and Deno.
Expand Down
11 changes: 6 additions & 5 deletions crates/socket-patch-cli/CLI_CONTRACT.md

Large diffs are not rendered by default.

41 changes: 30 additions & 11 deletions crates/socket-patch-cli/src/commands/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,8 @@ pub struct GetArgs {
pub all_releases: bool,

/// How to consume the patches: the same modes as `scan --mode`
/// [default: hosted; agent with `--save-only` or `--global`]
/// [default: the mode the project's patch state already records, else
/// hosted; agent with `--save-only` or `--global`]
// agent = record in .socket/manifest.json + blobs and apply in place;
// hosted = rewrite lockfiles so the patched deps resolve to Socket's
// hosted patch server (no manifest, no blobs, no ledger: the lockfile
Expand Down Expand Up @@ -1016,16 +1017,34 @@ pub async fn run(args: GetArgs) -> i32 {
"Only one of --id, --cve, --ghsa, or --package can be specified",
);
}
// v5: hosted by default, like scan. `--save-only` (records a manifest
// entry) and global installs (no project lockfile) mean agent mode.
// Usage errors exit 2, like clap's and scan's (v5.0).
let mode = args
.mode
.unwrap_or(if args.save_only || args.common.is_global() {
super::scan::ScanMode::Agent
} else {
super::scan::ScanMode::Hosted
});
// v5: with no `--mode`, like scan, the project keeps the mode its
// state already records (#1088) and a project with no state is hosted.
// `--save-only` (records a manifest entry) and global installs (no
// project lockfile) mean agent mode. Usage errors exit 2, like clap's
// and scan's (v5.0).
let mode = match args.mode {
Some(mode) => mode,
None if args.save_only || args.common.is_global() => super::scan::ScanMode::Agent,
None => match super::mode_from_project_state(&args.common).await {
Ok(mode) => {
if !args.common.json && !args.common.silent {
if let Some(note) = super::kept_mode_note(mode) {
eprintln!("{note}");
}
}
mode
}
Err(message) => {
return usage_error(
JsonCommand::Get,
args.common.json,
args.common.dry_run,
"mode_ambiguous",
&message,
);
}
},
};
// Global installs have no project lockfile: an explicit hosted or
// vendored mode would rewire the cwd project, not the global copy.
if let Some(conflict) = super::global_mode_conflict(&args.common, mode) {
Expand Down
221 changes: 221 additions & 0 deletions crates/socket-patch-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,87 @@ pub(crate) fn project_state_in_scope(common: &crate::args::GlobalArgs) -> bool {
!common.is_global()
}

/// The mode a `scan`/`get` run with no `--mode` uses for the project
/// (CLI_CONTRACT.md, Mode resolution): the mode the project's own patch
/// state already records, so a bare run never converts the project to
/// another mode. Hosted is the default for a project with no state.
///
/// * a non-empty vendor ledger (`.socket/vendor/state.json`) → vendored;
/// * a manifest (`.socket/manifest.json`) holding patches → agent;
/// * neither → hosted (hosted mode keeps no ledger of its own);
/// * both → `Err(usage message)`: the run cannot tell which mode to keep,
/// so it asks for an explicit `--mode` rather than guess.
///
/// Both stores are read from [`GlobalArgs::project_root`], so a
/// `--manifest-path` into another project consults that project's ledger,
/// never a ledger left in `--cwd`.
///
/// A manifest record the ledger already covers (same key or base purl) is
/// vendored state, not agent evidence: the documented `get --save-only`
/// then `vendor` flow leaves the record in the manifest beside its ledger
/// entry, and that project is vendored.
///
/// An unreadable or malformed vendor ledger counts as vendored, so the
/// vendored flow reports the corruption instead of a hosted takeover
/// running over it; with no readable ledger no manifest record counts as
/// covered. Likewise an unreadable or malformed manifest counts as agent
/// state, so the agent flow reports it rather than a bare run converting
/// the project to hosted mode (#1088). A missing manifest is no evidence.
/// Only called in project scope.
///
/// [`GlobalArgs::project_root`]: crate::args::GlobalArgs::project_root
pub(crate) async fn mode_from_project_state(
common: &crate::args::GlobalArgs,
) -> Result<scan::ScanMode, String> {
let manifest_path = common.resolved_manifest_path();
let project_root = common.project_root();
let (manifest, vendor) = tokio::join!(
socket_patch_core::manifest::operations::read_manifest(&manifest_path),
socket_patch_core::vendor::load_state(&project_root),
);
Comment thread
mikolalysenko marked this conversation as resolved.
let vendored_keys = match &vendor {
Ok(state) => state.purl_keys(),
Err(_) => Default::default(),
};
let vendored = !matches!(&vendor, Ok(state) if state.entries.is_empty());
let agent = match &manifest {
Ok(Some(m)) => m
.patches
.keys()
.any(|purl| !socket_patch_core::vendor::state::purl_keys_cover(&vendored_keys, purl)),
Ok(None) => false,
Err(_) => true,
};
match (vendored, agent) {
(true, true) => Err(format!(
Comment thread
mikolalysenko marked this conversation as resolved.
"{} holds both agent-mode patches ({}) and vendored patches \
(.socket/vendor/state.json): pass --mode agent, --mode vendored or \
--mode hosted to choose the mode this run uses",
project_root.display(),
manifest_path.display(),
)),
(true, false) => Ok(scan::ScanMode::Vendored),
(false, true) => Ok(scan::ScanMode::Agent),
(false, false) => Ok(scan::ScanMode::Hosted),
}
}

/// The stderr note a human-mode `scan`/`get` prints when it kept a
/// non-default mode from the project's state.
pub(crate) fn kept_mode_note(mode: scan::ScanMode) -> Option<String> {
let store = match mode {
scan::ScanMode::Hosted => return None,
scan::ScanMode::Vendored => ".socket/vendor/state.json",
scan::ScanMode::Agent => "the manifest",
};
Some(format!(
"Note: using --mode {} because {store} already holds {} patches; pass \
--mode explicitly to switch modes",
mode.cli_name(),
mode.cli_name(),
))
}

/// The usage error for a mode that rewires the project (`hosted`,
/// `vendored`) under global scope, or `None` when `mode` is allowed.
/// Shared by `scan` and `get` so both refuse the same combinations with
Expand Down Expand Up @@ -201,3 +282,143 @@ pub(crate) fn vendor_state_lenient(
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;

const LEDGER_PURL: &str = "pkg:npm/left-pad@1.3.0";
const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f";

/// A one-entry vendor ledger at `root` for `LEDGER_PURL`, checked to
/// load, so a test never passes on the malformed-ledger branch.
async fn write_ledger(root: &Path) {
let path = root.join(socket_patch_core::vendor::VENDOR_STATE_REL);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let ledger = serde_json::json!({
"version": 1,
"entries": {
LEDGER_PURL: {
"ecosystem": "npm",
"basePurl": LEDGER_PURL,
"uuid": UUID,
"artifact": {
"path": format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"),
"sha256": "ab".repeat(32),
},
"wiring": [],
}
}
});
std::fs::write(&path, serde_json::to_vec_pretty(&ledger).unwrap()).unwrap();
let loaded = socket_patch_core::vendor::load_state(root)
.await
.expect("the fixture ledger must load");
assert_eq!(loaded.entries.len(), 1);
}

/// An agent manifest at `<root>/.socket/manifest.json` holding `purls`.
fn write_manifest(root: &Path, purls: &[&str]) {
let dir = root.join(".socket");
std::fs::create_dir_all(&dir).unwrap();
let patches: serde_json::Map<String, serde_json::Value> = purls
.iter()
.map(|purl| {
(
purl.to_string(),
serde_json::json!({
"uuid": UUID,
"exportedAt": "2026-01-01T00:00:00Z",
"files": {},
"vulnerabilities": {},
"description": "fixture",
"license": "MIT",
"tier": "free",
}),
)
})
.collect();
std::fs::write(
dir.join("manifest.json"),
serde_json::to_vec_pretty(&serde_json::json!({ "patches": patches })).unwrap(),
)
.unwrap();
}

fn args(cwd: &Path, manifest_path: &str) -> crate::args::GlobalArgs {
crate::args::GlobalArgs {
cwd: PathBuf::from(cwd),
manifest_path: manifest_path.to_string(),
..crate::args::GlobalArgs::default()
}
}

/// `get --save-only` then `vendor` leaves the vendored record in the
/// manifest beside its ledger entry: that project is vendored, not
/// ambiguous.
#[tokio::test]
async fn manifest_records_the_ledger_covers_are_vendored_state() {
let tmp = tempfile::tempdir().unwrap();
write_ledger(tmp.path()).await;
write_manifest(tmp.path(), &[LEDGER_PURL]);
let mode = mode_from_project_state(&args(tmp.path(), ".socket/manifest.json")).await;
assert_eq!(mode, Ok(scan::ScanMode::Vendored));
}

/// A manifest record the ledger does not cover is agent state, so a
/// project holding it beside a ledger is still ambiguous.
#[tokio::test]
async fn an_uncovered_manifest_record_beside_a_ledger_is_ambiguous() {
let tmp = tempfile::tempdir().unwrap();
write_ledger(tmp.path()).await;
write_manifest(tmp.path(), &[LEDGER_PURL, "pkg:npm/is-odd@3.0.1"]);
let mode = mode_from_project_state(&args(tmp.path(), ".socket/manifest.json")).await;
let err = mode.expect_err("agent and vendored state together");
assert!(err.contains("--mode"), "{err}");
}

/// A malformed manifest is agent state: a bare run must not take the
/// project over in hosted mode, so the agent flow reports the error.
#[tokio::test]
async fn a_malformed_manifest_is_not_a_hosted_project() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".socket")).unwrap();
std::fs::write(tmp.path().join(".socket/manifest.json"), b"{ not json").unwrap();
let common = args(tmp.path(), ".socket/manifest.json");
assert_eq!(
mode_from_project_state(&common).await,
Ok(scan::ScanMode::Agent)
);

// Beside a vendor ledger it is ambiguous, not silently vendored.
write_ledger(tmp.path()).await;
assert!(mode_from_project_state(&common).await.is_err());
}

/// With `--manifest-path` into another project, the ledger is read
/// from that project, not from `--cwd`: a ledger left in `--cwd` is
/// not consulted, and the other project's ledger is.
#[tokio::test]
async fn the_ledger_is_read_from_the_manifest_project_root() {
let tmp = tempfile::tempdir().unwrap();
let cwd = tmp.path().join("cwd");
let other = tmp.path().join("other");
std::fs::create_dir_all(&cwd).unwrap();
std::fs::create_dir_all(&other).unwrap();
let manifest_path = other.join(".socket/manifest.json");
let manifest_path = manifest_path.to_str().unwrap();

// A ledger in --cwd only: the manifest's project is agent.
write_ledger(&cwd).await;
write_manifest(&other, &["pkg:npm/is-odd@3.0.1"]);
let mode = mode_from_project_state(&args(&cwd, manifest_path)).await;
assert_eq!(mode, Ok(scan::ScanMode::Agent));

// The manifest's project vendored the record: vendored.
write_ledger(&other).await;
write_manifest(&other, &[LEDGER_PURL]);
let mode = mode_from_project_state(&args(&cwd, manifest_path)).await;
assert_eq!(mode, Ok(scan::ScanMode::Vendored));
}
}
36 changes: 33 additions & 3 deletions crates/socket-patch-cli/src/commands/scan/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,10 @@ pub struct ScanArgs {
#[arg(long, default_value_t = false)]
pub sync: bool,

/// How discovered patches are consumed [default: hosted]. A `--prune`
/// or `--global` scan with no mode only reports
/// How discovered patches are consumed [default: the mode the
/// project's patch state already records, else hosted]. Switching an
/// existing vendored or agent-mode project to another mode needs this
/// flag. A `--prune` or `--global` scan with no mode only reports
// `--sync` also selects agent; combining it with a different `--mode`
// is rejected in `resolve_mode_flags`.
#[arg(long = "mode", value_enum)]
Expand Down Expand Up @@ -1545,10 +1547,14 @@ fn project_dirs(
/// name, as if each were `--cwd`. The exit code is the worst of the runs.
/// `--json` takes one directory, so stdout stays one document. Every
/// directory must be inside the repository root the policy was read from.
///
/// `mode_inferred`: the mode came from `--cwd`'s state rather than
/// `--mode`, so each directory takes its mode from its own state.
async fn run_project_dirs(
args: ScanArgs,
telemetry: &mut PendingTelemetry,
invocation: &InvocationPolicy,
mode_inferred: bool,
) -> i32 {
let usage = |code: &str, message: &str| {
usage_error(
Expand Down Expand Up @@ -1622,6 +1628,9 @@ async fn run_project_dirs(
child.paths.clear();
child.common.cwd = dir.clone();
child.rollout.carry = Some(carry.clone());
if mode_inferred {
child.mode = None;
}
code = code.max(Box::pin(run_scan(child, telemetry, Some(invocation), *explicit)).await);
}
code
Expand Down Expand Up @@ -1659,6 +1668,9 @@ async fn run_scan(
) -> i32 {
apply_env_toggles(&args.common);

// Whether the user chose the mode (`--mode`, or `--sync` = agent).
// Without it, the mode comes from the project's own state below.
let mode_explicit = args.mode.is_some() || args.sync;
// Resolve `--mode`/`--sync` into `args.mode` (see
// `resolve_mode_flags`). `--sync` with another mode is a usage error
// (exit 2); under --json it prints the coded error on stdout.
Expand All @@ -1676,6 +1688,24 @@ async fn run_scan(
};
return scan_usage_error(&args, code, &message);
}
// A bare scan keeps the mode the project already has (#1088): only an
// explicit `--mode` converts a vendored or agent-mode project. The
// hosted default above is the only one this replaces (a `--prune` or
// global scan with no mode stays report-only).
let mode_inferred = !mode_explicit && args.mode == Some(ScanMode::Hosted);
if mode_inferred {
match crate::commands::mode_from_project_state(&args.common).await {
Ok(mode) => {
if !args.common.json && !args.common.silent {
if let Some(note) = crate::commands::kept_mode_note(mode) {
eprintln!("{note}");
}
}
args.mode = Some(mode);
}
Err(message) => return scan_usage_error(&args, "mode_ambiguous", &message),
}
}

// The repo's socket.yml policy, read once per invocation before any
// write (an invalid file fails the run closed).
Expand All @@ -1699,7 +1729,7 @@ async fn run_scan(
if matches!(args.mode, Some(ScanMode::Hosted) | Some(ScanMode::Vendored))
&& !args.paths.is_empty()
{
return Box::pin(run_project_dirs(args, telemetry, invocation)).await;
return Box::pin(run_project_dirs(args, telemetry, invocation, mode_inferred)).await;
}

let mut policy = Box::new(ScanPolicy::for_root(
Expand Down
Loading
Loading