Skip to content

Commit 01bee76

Browse files
mikolalysenkoclaude
andcommitted
feat(vex): respect setup state + manual declaration (property 7)
Close the last RED contract pin: VEX now attests a patch only for an ecosystem that is actually set up — or explicitly declared `manual` — instead of trusting the whole manifest. - schema: `SetupConfig` gains a `manual: Vec<String>` array (ecosystem cli_names the user applies by hand), serde-default + skip-empty alongside `exclude`. - setup.rs: new `configured_ecosystems(common)` runs the same on-disk hook- presence probes as `setup --check` and returns the set of set-up ecosystems (feature-gated per ecosystem). `persist_setup_excludes` now preserves any existing `manual` when rewriting. - vex.rs: `generate_vex` filters `outcome.applied` to `configured_ecosystems ∪ setup.manual` (mapping manual names via `ecosystem_from_manual_name`), in BOTH verify and `--no-verify` modes, on top of the existing --ecosystems + on-disk-verification filters. - The `vex_omits_patches_for_unconfigured_ecosystem` pin is un-ignored and green (an un-set-up pypi patch, not manual, yields no statements / exit 1). - VEX-suite migration (single point each): `e2e_vex` / `e2e_embedded_vex` `write_manifest` helpers now stamp a blanket `setup.manual` so those fixtures (which test document GENERATION, not setup state) still attest — all 18 of their tests stay green, including verify-mode omission. CLI_CONTRACT property 7 updated to implemented. All 5 setup contract-gap pins are now active (0 ignored). Verified: clippy --features all-eco -D warnings clean; e2e_vex (13) + e2e_embedded_vex (5) + core lib (933 / 969 with composer) all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5af0ebc commit 01bee76

7 files changed

Lines changed: 178 additions & 22 deletions

File tree

crates/socket-patch-cli/CLI_CONTRACT.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -138,13 +138,15 @@ in particular, are behavior changes that gate a version bump when implemented).
138138

139139
7. **Reflected in VEX.** A patch contributes a `not_affected` statement to the repo's OpenVEX document
140140
only for ecosystems that are **actually set up** — or explicitly declared **manual** (below). Patches
141-
for an ecosystem that is neither set up nor declared manual produce no VEX statement. *(Intended;
142-
**not yet implemented** — VEX currently filters by `--ecosystems` and on-disk verification but has no
143-
notion of setup state. RED-guarded.)*
144-
- **Manual declaration.** Users who run `socket-patch apply` by hand (e.g. in a CI step) can declare
145-
an ecosystem or individual hook as `manual`, so VEX still attests its patches even though the
146-
auto-install hook is intentionally not wired. Intended home: a sub-property of
147-
`.socket/manifest.json`. *(Follow-up work.)*
141+
for an ecosystem that is neither set up nor declared manual produce no VEX statement. *(Implemented —
142+
`generate_vex` filters `applied` to ecosystems returned by `commands/setup::configured_ecosystems`
143+
(on-disk hook presence) ∪ the manifest's `setup.manual`, in addition to the existing `--ecosystems`
144+
filter and on-disk verification. Applies in both verify and `--no-verify` modes.)*
145+
- **Manual declaration.** Users who run `socket-patch apply` by hand (e.g. in a CI step) declare an
146+
ecosystem as `manual` so VEX still attests its patches even though the auto-install hook is
147+
intentionally not wired. Home: the `setup.manual` array (a list of ecosystem `cli_name`s — `pypi`,
148+
`cargo`, …) in `.socket/manifest.json`. *(Implemented for the read/attest path; a `setup` flag to
149+
populate it is a future nicety — today it's hand-authored in the manifest.)*
148150

149151
8. **Graceful, exact remove.** `setup --remove` (optionally per-ecosystem via `--ecosystems`) restores
150152
the repo to its exact pre-setup state: manifests byte-for-byte, sibling scripts/dependencies

crates/socket-patch-cli/src/commands/setup.rs

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,14 +278,97 @@ async fn persist_setup_excludes(common: &GlobalArgs, excludes: &[String]) {
278278
{
279279
return; // already persisted exactly — don't rewrite
280280
}
281+
// Preserve any existing `manual` declarations (property 7) when rewriting.
282+
let manual = existing
283+
.as_ref()
284+
.and_then(|m| m.setup.as_ref())
285+
.map(|s| s.manual.clone())
286+
.unwrap_or_default();
281287
let mut manifest = existing.unwrap_or_else(PatchManifest::new);
282-
manifest.setup = Some(SetupConfig { exclude: merged });
288+
manifest.setup = Some(SetupConfig {
289+
exclude: merged,
290+
manual,
291+
});
283292
if let Some(parent) = path.parent() {
284293
let _ = tokio::fs::create_dir_all(parent).await;
285294
}
286295
let _ = write_manifest(&path, &manifest).await;
287296
}
288297

298+
/// Which ecosystems are **actually set up** at `cwd` — i.e. their auto-repatch
299+
/// hook is present on disk (the same presence checks `setup --check` runs). VEX
300+
/// uses this (∪ the manifest's `manual` declarations) to attest patches only for
301+
/// set-up-or-manual ecosystems (CLI_CONTRACT property 7). Read-only; ignores the
302+
/// `--ecosystems` filter (it reports real on-disk state).
303+
pub(crate) async fn configured_ecosystems(
304+
common: &GlobalArgs,
305+
) -> std::collections::HashSet<socket_patch_core::crawlers::Ecosystem> {
306+
use socket_patch_core::crawlers::Ecosystem;
307+
let mut set = std::collections::HashSet::new();
308+
309+
// npm: any discovered package.json whose hook scripts are present.
310+
let npm = find_package_json_files(&common.cwd).await;
311+
for loc in &npm.files {
312+
if let Ok(content) = tokio::fs::read_to_string(&loc.path).await {
313+
if !is_setup_configured_str(&content).needs_update {
314+
set.insert(Ecosystem::Npm);
315+
break;
316+
}
317+
}
318+
}
319+
320+
// pypi: a chosen python manifest carries the `socket-patch[hook]` dep.
321+
if let Some(plan) = plan_python(common).await {
322+
for (path, _) in &plan.manifests {
323+
if let Ok(content) = tokio::fs::read_to_string(path).await {
324+
if deps_contain_hook(&content) {
325+
set.insert(Ecosystem::Pypi);
326+
break;
327+
}
328+
}
329+
}
330+
}
331+
332+
// gem: the managed plugin directive is present in the Gemfile.
333+
if let Some(project) = gem_setup::discover_bundler_project(&common.cwd).await {
334+
if let Ok(content) = tokio::fs::read_to_string(&project.gemfile).await {
335+
if gem_setup::is_plugin_directive_present(&content) {
336+
set.insert(Ecosystem::Gem);
337+
}
338+
}
339+
}
340+
341+
#[cfg(feature = "cargo")]
342+
if let Some(project) = discover_cargo_project(&common.cwd).await {
343+
for member in &project.members {
344+
if let Ok(content) = tokio::fs::read_to_string(member).await {
345+
if is_guard_dep_present(&content) {
346+
set.insert(Ecosystem::Cargo);
347+
break;
348+
}
349+
}
350+
}
351+
}
352+
353+
#[cfg(feature = "golang")]
354+
if let Some(module) = go_setup::discover_go_module(&common.cwd).await {
355+
if go_setup::guard_files_present(&module.root).await {
356+
set.insert(Ecosystem::Golang);
357+
}
358+
}
359+
360+
#[cfg(feature = "composer")]
361+
if let Some(project) = composer_setup::discover_composer_project(&common.cwd).await {
362+
if let Ok(content) = tokio::fs::read_to_string(&project.composer_json).await {
363+
if composer_setup::is_hook_present(&content) {
364+
set.insert(Ecosystem::Composer);
365+
}
366+
}
367+
}
368+
369+
set
370+
}
371+
289372
// Canonical `--ecosystems` token sets per setup branch (see `eco_in_scope`).
290373
const ECO_NPM: &[&str] = &["npm"];
291374
const ECO_PYPI: &[&str] = &["pypi", "python"];

crates/socket-patch-cli/src/commands/vex.rs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use std::collections::HashMap;
1717
use std::path::{Path, PathBuf};
1818

1919
use clap::Args;
20-
use socket_patch_core::crawlers::CrawlerOptions;
20+
use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem};
2121
use socket_patch_core::manifest::operations::read_manifest;
2222
use socket_patch_core::manifest::schema::PatchManifest;
2323
use socket_patch_core::utils::telemetry::{track_vex_failed, track_vex_generated};
@@ -246,6 +246,26 @@ pub async fn run(args: VexArgs) -> i32 {
246246
/// telemetry. Returns a [`VexWriteSummary`] on success or a structured
247247
/// [`VexGenError`] (with a stable code) on failure. All `track_vex_*`
248248
/// telemetry is fired here so every caller reports consistently.
249+
/// Map a `setup.manual` entry to an `Ecosystem`. Accepts the canonical
250+
/// `cli_name` plus the friendly aliases `setup --exclude`/`--ecosystems` accept
251+
/// (`go`/`golang`, `python`/`pypi`, `ruby`/`gem`, `php`/`composer`). Names for
252+
/// ecosystems not compiled into this build (or unrecognized) yield `None` and
253+
/// are ignored.
254+
fn ecosystem_from_manual_name(name: &str) -> Option<Ecosystem> {
255+
match name.to_ascii_lowercase().as_str() {
256+
"npm" | "yarn" | "pnpm" | "bun" => Some(Ecosystem::Npm),
257+
"pypi" | "python" => Some(Ecosystem::Pypi),
258+
"gem" | "ruby" => Some(Ecosystem::Gem),
259+
#[cfg(feature = "cargo")]
260+
"cargo" | "rust" => Some(Ecosystem::Cargo),
261+
#[cfg(feature = "golang")]
262+
"golang" | "go" => Some(Ecosystem::Golang),
263+
#[cfg(feature = "composer")]
264+
"composer" | "php" => Some(Ecosystem::Composer),
265+
_ => None,
266+
}
267+
}
268+
249269
pub(crate) async fn generate_vex(
250270
common: &GlobalArgs,
251271
params: &VexBuildParams,
@@ -258,7 +278,7 @@ pub(crate) async fn generate_vex(
258278
};
259279

260280
// Partition manifest into applied / failed.
261-
let outcome = if params.no_verify {
281+
let mut outcome = if params.no_verify {
262282
VerifyOutcome {
263283
applied: manifest.patches.keys().cloned().collect(),
264284
failed: Vec::new(),
@@ -268,6 +288,31 @@ pub(crate) async fn generate_vex(
268288
socket_patch_core::vex::applied_patches(manifest, &package_paths).await
269289
};
270290

291+
// Property 7: attest a patch only for an ecosystem that is actually set up —
292+
// or explicitly declared `manual` in the manifest. Patches for an ecosystem
293+
// that is neither are dropped regardless of verification mode (so even
294+
// `--no-verify` won't attest an un-set-up ecosystem's patches).
295+
let mut allowed = crate::commands::setup::configured_ecosystems(common).await;
296+
if let Some(s) = &manifest.setup {
297+
for name in &s.manual {
298+
if let Some(e) = ecosystem_from_manual_name(name) {
299+
allowed.insert(e);
300+
}
301+
}
302+
}
303+
let before = outcome.applied.len();
304+
outcome.applied.retain(|purl| {
305+
Ecosystem::from_purl(purl)
306+
.map(|e| allowed.contains(&e))
307+
.unwrap_or(false)
308+
});
309+
if outcome.applied.len() != before && !common.silent && !common.json {
310+
eprintln!(
311+
"Note: omitting patches for ecosystems that are not set up (and not declared `manual` \
312+
in .socket/manifest.json's `setup.manual`) from VEX."
313+
);
314+
}
315+
271316
if !outcome.failed.is_empty() && !common.silent && !common.json {
272317
for f in &outcome.failed {
273318
eprintln!(

crates/socket-patch-cli/tests/e2e_embedded_vex.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,14 @@ use std::process::Command;
1717
use serde_json::Value;
1818
use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes;
1919
use socket_patch_core::manifest::schema::{
20-
PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo,
20+
PatchFileInfo, PatchManifest, PatchRecord, SetupConfig, VulnerabilityInfo,
2121
};
2222

23+
/// Declare every ecosystem `manual` in fixtures so the property-7 setup-state
24+
/// filter doesn't drop these patches — these tests exercise embedded-VEX
25+
/// generation, not setup state.
26+
const ALL_MANUAL: &[&str] = &["npm", "pypi", "cargo", "golang", "gem", "composer"];
27+
2328
fn binary() -> &'static str {
2429
env!("CARGO_BIN_EXE_socket-patch")
2530
}
@@ -53,9 +58,14 @@ fn cli() -> Command {
5358
fn write_manifest(cwd: &Path, manifest: &PatchManifest) {
5459
let dir = cwd.join(".socket");
5560
std::fs::create_dir_all(&dir).unwrap();
61+
let mut m = manifest.clone();
62+
m.setup = Some(SetupConfig {
63+
exclude: Vec::new(),
64+
manual: ALL_MANUAL.iter().map(|s| s.to_string()).collect(),
65+
});
5666
std::fs::write(
5767
dir.join("manifest.json"),
58-
serde_json::to_string_pretty(manifest).unwrap(),
68+
serde_json::to_string_pretty(&m).unwrap(),
5969
)
6070
.unwrap();
6171
}

crates/socket-patch-cli/tests/e2e_vex.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,15 @@ use std::process::Command;
2020
use serde_json::Value;
2121
use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes;
2222
use socket_patch_core::manifest::schema::{
23-
PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo,
23+
PatchFileInfo, PatchManifest, PatchRecord, SetupConfig, VulnerabilityInfo,
2424
};
2525

26+
/// Every setup-supported ecosystem, declared `manual` in test fixtures so the
27+
/// property-7 setup-state filter (`commands/setup::configured_ecosystems`) does
28+
/// not drop these patches — these tests exercise VEX document GENERATION, not
29+
/// setup state, so they opt every patch in via the `manual` escape hatch.
30+
const ALL_MANUAL: &[&str] = &["npm", "pypi", "cargo", "golang", "gem", "composer"];
31+
2632
fn binary() -> &'static str {
2733
env!("CARGO_BIN_EXE_socket-patch")
2834
}
@@ -58,9 +64,14 @@ fn cli() -> Command {
5864
fn write_manifest(cwd: &Path, manifest: &PatchManifest) {
5965
let dir = cwd.join(".socket");
6066
std::fs::create_dir_all(&dir).unwrap();
67+
let mut m = manifest.clone();
68+
m.setup = Some(SetupConfig {
69+
exclude: Vec::new(),
70+
manual: ALL_MANUAL.iter().map(|s| s.to_string()).collect(),
71+
});
6172
std::fs::write(
6273
dir.join("manifest.json"),
63-
serde_json::to_string_pretty(manifest).unwrap(),
74+
serde_json::to_string_pretty(&m).unwrap(),
6475
)
6576
.unwrap();
6677
}

crates/socket-patch-cli/tests/setup_contract_gaps.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -188,17 +188,17 @@ fn setup_check_detects_unapplied_manifest_patch() {
188188
// has a pypi patch but pypi is NOT set up (no requirements.txt / pyproject hook),
189189
// so the document must contain zero statements (exit 1, no applicable patches).
190190
//
191-
// CURRENTLY RED: VEX has no notion of setup state. With `--no-verify` it trusts
192-
// the manifest wholesale and emits the statement regardless of whether pypi was
193-
// ever set up — so it writes a 1-statement document and exits 0.
191+
// SHIPPED: VEX now filters by setup state — `generate_vex` drops patches whose
192+
// ecosystem is neither set up (`commands/setup::configured_ecosystems`) nor
193+
// declared `manual` in the manifest's `setup.manual`. With pypi un-set-up and
194+
// not manual, the only patch is dropped → no applicable patches → exit 1. This
195+
// pin is now an active (non-ignored) regression guard.
194196
//
195-
// (The converse — declaring pypi `manual` to re-include it — is follow-up work;
196-
// see the `#[ignore]`d placeholder below.)
197+
// (The converse — declaring pypi `manual` to re-include it — is exercised by the
198+
// `manual` escape hatch the e2e_vex / e2e_embedded_vex fixtures rely on.)
197199
// ===========================================================================
198200

199201
#[test]
200-
// Gap pin (non-blocking, runnable via --ignored). Un-ignore when property 7 ships.
201-
#[ignore = "gap: VEX has no notion of setup state; see CLI_CONTRACT 'Setup command contract' property 7"]
202202
fn vex_omits_patches_for_unconfigured_ecosystem() {
203203
let proj = tempfile::tempdir().unwrap();
204204
let home = tempfile::tempdir().unwrap();

crates/socket-patch-core/src/manifest/schema.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,17 @@ pub struct SetupConfig {
4444
/// needing configuration.
4545
#[serde(default, skip_serializing_if = "Vec::is_empty")]
4646
pub exclude: Vec<String>,
47+
/// Ecosystems (by `Ecosystem::cli_name`, e.g. `"pypi"`) the user runs
48+
/// `socket-patch apply` for by hand, so their patches are still attested in
49+
/// VEX even though no auto-install hook is wired (CLI_CONTRACT property 7).
50+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
51+
pub manual: Vec<String>,
4752
}
4853

4954
impl SetupConfig {
5055
/// Whether this carries no setup state (so the manifest can omit the key).
5156
pub fn is_empty(&self) -> bool {
52-
self.exclude.is_empty()
57+
self.exclude.is_empty() && self.manual.is_empty()
5358
}
5459
}
5560

0 commit comments

Comments
 (0)