Skip to content

Commit ed1e7f9

Browse files
7schmiedeclaude
andcommitted
fix: three backlog items from v0.4.0 session hand-off
#1 cron scanner default-off `is_scanner_enabled` now defaults to false for "cron" — it reads `crontab -l` (host-state, not repo-state) and produced spurious cron_job resources on CI runners that have their own crontab. Opt back in with `scan { scanners { cron = true } }`. #2 composit init refuses to overwrite without --force run_init now errors with a clear message when Compositfile already exists. Pass --force to overwrite; a timestamped .backup file is written first. Removes the interactive y/N prompt that blocked non-interactive (agent) callers. #3 resolution_disabled info is silenceable via resolvable = [] `scan.resolvable` is now Option<Vec<String>>: None = absent (diff still emits the info), Some([]) = explicit opt-out (diff stays silent), Some([...]) = resolution enabled. Repos that deliberately don't commit .env files can set `resolvable = []` once and never see the noise again. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4e2af3b commit ed1e7f9

7 files changed

Lines changed: 224 additions & 42 deletions

File tree

src/cli.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ pub enum Commands {
6060
/// Generate a minimal template without running a scan
6161
#[arg(long)]
6262
minimal: bool,
63+
64+
/// Overwrite an existing Compositfile (writes a timestamped .backup first)
65+
#[arg(long)]
66+
force: bool,
6367
},
6468
/// Compare Compositfile governance against scan report
6569
Diff {

src/commands/diff.rs

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
88

99
use crate::cli::DiffOutputFormat;
1010
use crate::core::compositfile::parse_compositfile;
11-
use crate::core::governance::{Governance, Predicate, ProviderRule, Role};
11+
use crate::core::governance::{Governance, Predicate, ProviderRule, Role, ScanSettings};
1212
use crate::core::types::{AuthMode, Provider, Report, Resource, ScanMode};
1313

1414
// ─────────────────────────────────────────────────────────
@@ -142,7 +142,7 @@ pub fn compute_diff_opts(
142142
check_providers(governance, report, offline),
143143
check_budgets(governance, report),
144144
check_resources(governance, report),
145-
check_resolution(report),
145+
check_resolution(report, &governance.scan),
146146
check_policies(governance, base_dir, report),
147147
];
148148

@@ -762,12 +762,14 @@ fn check_resources(governance: &Governance, report: &Report) -> ViolationCategor
762762
}
763763

764764
/// Surface RFC 006 resolution artefacts as diff signals. Emits:
765-
/// - `resolution_disabled` (Info) once, when the report carries no
766-
/// resolution metadata but scanners found `${VAR}` references that
767-
/// could benefit from it. Points the operator at `scan.resolvable`.
768-
/// - `unresolved_variable` (Info) per variable that couldn't be filled
769-
/// in by the resolver.
770-
fn check_resolution(report: &Report) -> ViolationCategory {
765+
/// - `resolution_disabled` (Info) once, when the report carries no resolution
766+
/// metadata, `${VAR}` references were found, AND the Compositfile has no
767+
/// `scan { resolvable = [...] }` block (i.e. `scan.resolvable` is `None`).
768+
/// Silenced when `resolvable = []` is declared explicitly — that signals a
769+
/// deliberate opt-out.
770+
/// - `unresolved_variable` (Info) per variable that couldn't be filled in by
771+
/// the resolver.
772+
fn check_resolution(report: &Report, scan: &ScanSettings) -> ViolationCategory {
771773
let mut violations = Vec::new();
772774
let mut passed = 0;
773775

@@ -787,7 +789,9 @@ fn check_resolution(report: &Report) -> ViolationCategory {
787789

788790
match &report.resolution {
789791
None => {
790-
if has_templated {
792+
// Only nag when resolvable is absent entirely — `resolvable = []`
793+
// means the operator deliberately opted out and wants silence.
794+
if has_templated && scan.resolvable.is_none() {
791795
violations.push(Violation {
792796
severity: Severity::Info,
793797
rule: "resolution_disabled".to_string(),
@@ -798,7 +802,8 @@ fn check_resolution(report: &Report) -> ViolationCategory {
798802
block when env files are detected — uncomment it to enable RFC 006 \
799803
variable substitution. If your Compositfile is hand-written, add the \
800804
block at the top of the workspace block. Default redaction applies to \
801-
*_KEY, *_SECRET, *_TOKEN, *_PASSWORD."
805+
*_KEY, *_SECRET, *_TOKEN, *_PASSWORD. \
806+
To silence this diagnostic permanently, set `scan { resolvable = [] }`."
802807
.to_string(),
803808
),
804809
expected: Some("resolvable = [\".env\"]".to_string()),
@@ -2895,6 +2900,23 @@ mod tests {
28952900
);
28962901
}
28972902

2903+
#[test]
2904+
fn resolution_disabled_silenced_when_resolvable_explicitly_empty() {
2905+
// `scan { resolvable = [] }` means "I know, I deliberately opted out".
2906+
// The info must not fire even when ${VAR} refs are present.
2907+
let report = report_with_templated_image();
2908+
let mut gov = make_governance(vec![], "500 EUR");
2909+
gov.scan.resolvable = Some(vec![]); // explicit opt-out
2910+
let diff = compute_diff(&gov, &report, Path::new("."));
2911+
let cat = find_category(&diff, "resolution");
2912+
assert!(
2913+
cat.violations
2914+
.iter()
2915+
.all(|v| v.rule != "resolution_disabled"),
2916+
"resolution_disabled must be silent when resolvable = [] (explicit opt-out)"
2917+
);
2918+
}
2919+
28982920
#[test]
28992921
fn resolution_unresolved_variable_surfaces_per_variable() {
29002922
use crate::core::scanner::{ResolutionInfo, UnresolvedVariable};

src/commands/init.rs

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::collections::{BTreeSet, HashMap};
22
use std::path::Path;
33

4-
use anyhow::Result;
4+
use anyhow::{bail, Result};
55
use colored::Colorize;
66

77
use crate::core::types::{Report, Resource};
@@ -23,15 +23,38 @@ const AGENT_HEADER: &str = "\
2323
2424
";
2525

26-
pub fn run_init(dir: &Path, workspace_name: Option<String>, report: Option<&Report>) -> Result<()> {
26+
pub fn run_init(
27+
dir: &Path,
28+
workspace_name: Option<String>,
29+
report: Option<&Report>,
30+
force: bool,
31+
) -> Result<()> {
32+
let compositfile_path = dir.join("Compositfile");
33+
34+
if compositfile_path.exists() {
35+
if !force {
36+
bail!(
37+
"Compositfile already exists at {}; pass --force to overwrite",
38+
compositfile_path.display()
39+
);
40+
}
41+
let ts = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
42+
let backup_path = dir.join(format!("Compositfile.backup.{ts}"));
43+
std::fs::copy(&compositfile_path, &backup_path)?;
44+
println!(
45+
" {} {}",
46+
"Backup written:".yellow(),
47+
backup_path.display()
48+
);
49+
}
50+
2751
let workspace = workspace_name.unwrap_or_else(|| workspace_from_dir(dir));
2852

2953
let content = match report {
3054
Some(r) => generate_from_report(&workspace, r),
3155
None => generate_minimal(&workspace),
3256
};
3357

34-
let compositfile_path = dir.join("Compositfile");
3558
std::fs::write(&compositfile_path, &content)?;
3659

3760
let display_path = std::env::current_dir()
@@ -511,6 +534,61 @@ mod tests {
511534
}
512535
}
513536

537+
#[test]
538+
fn init_refuses_if_compositfile_exists_without_force() {
539+
let dir = tempfile::tempdir().unwrap();
540+
let path = dir.path().join("Compositfile");
541+
std::fs::write(&path, "old content").unwrap();
542+
543+
let err = run_init(dir.path(), None, None, false).unwrap_err();
544+
assert!(
545+
err.to_string().contains("--force"),
546+
"error should mention --force: {}",
547+
err
548+
);
549+
assert_eq!(
550+
std::fs::read_to_string(&path).unwrap(),
551+
"old content",
552+
"existing file must not be modified"
553+
);
554+
}
555+
556+
#[test]
557+
fn init_force_writes_backup_and_overwrites() {
558+
let dir = tempfile::tempdir().unwrap();
559+
let path = dir.path().join("Compositfile");
560+
std::fs::write(&path, "old content").unwrap();
561+
562+
run_init(dir.path(), Some("test-ws".to_string()), None, true).unwrap();
563+
564+
let new_content = std::fs::read_to_string(&path).unwrap();
565+
assert!(!new_content.contains("old content"), "old content must be gone");
566+
assert!(new_content.contains("test-ws"), "new file must use workspace name");
567+
568+
let backups: Vec<_> = std::fs::read_dir(dir.path())
569+
.unwrap()
570+
.filter_map(|e| e.ok())
571+
.filter(|e| {
572+
e.file_name()
573+
.to_string_lossy()
574+
.starts_with("Compositfile.backup.")
575+
})
576+
.collect();
577+
assert_eq!(backups.len(), 1, "expected exactly one backup file");
578+
assert_eq!(
579+
std::fs::read_to_string(backups[0].path()).unwrap(),
580+
"old content"
581+
);
582+
}
583+
584+
#[test]
585+
fn init_without_existing_file_does_not_need_force() {
586+
let dir = tempfile::tempdir().unwrap();
587+
run_init(dir.path(), Some("fresh".to_string()), None, false).unwrap();
588+
let content = std::fs::read_to_string(dir.path().join("Compositfile")).unwrap();
589+
assert!(content.contains("fresh"));
590+
}
591+
514592
#[test]
515593
fn database_role_stub_emitted_for_postgres_image() {
516594
let report = mk_report(vec![mk_resource(

src/core/compositfile.rs

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ pub fn parse_compositfile(path: &Path) -> Result<Governance> {
6161
/// valid no-op.
6262
fn parse_scan_block(block: &hcl::Block) -> Result<ScanSettings> {
6363
let exclude_paths = get_string_array_attr(&block.body, "exclude");
64-
let resolvable = get_string_array_attr(&block.body, "resolvable");
64+
let resolvable = get_optional_string_array_attr(&block.body, "resolvable");
6565
let redact = get_string_array_attr(&block.body, "redact");
6666

6767
let mut extra_patterns = Vec::new();
@@ -515,6 +515,12 @@ fn get_string_attr(body: &Body, key: &str) -> Option<String> {
515515

516516
/// Extract a string array attribute from an HCL body.
517517
fn get_string_array_attr(body: &Body, key: &str) -> Vec<String> {
518+
get_optional_string_array_attr(body, key).unwrap_or_default()
519+
}
520+
521+
/// Like `get_string_array_attr` but returns `None` when the attribute is absent.
522+
/// Use this when the caller needs to distinguish "not declared" from "declared as []".
523+
fn get_optional_string_array_attr(body: &Body, key: &str) -> Option<Vec<String>> {
518524
body.attributes()
519525
.find(|a| a.key.as_str() == key)
520526
.map(|a| match &a.expr {
@@ -527,7 +533,6 @@ fn get_string_array_attr(body: &Body, key: &str) -> Vec<String> {
527533
.collect(),
528534
_ => vec![],
529535
})
530-
.unwrap_or_default()
531536
}
532537

533538
/// Extract an unsigned integer attribute from an HCL body.
@@ -1095,8 +1100,6 @@ mod tests {
10951100

10961101
#[test]
10971102
fn test_missing_scan_block_yields_default_empty_settings() {
1098-
// A Compositfile without a scan block must still produce a valid
1099-
// Governance — governance and scan tuning are independently optional.
11001103
let gov = parse_hcl(
11011104
r#"
11021105
workspace "test" {
@@ -1108,5 +1111,87 @@ mod tests {
11081111
assert!(gov.scan.exclude_paths.is_empty());
11091112
assert!(gov.scan.extra_patterns.is_empty());
11101113
assert!(gov.scan.scanners.is_empty());
1114+
// No scan block → resolvable absent → None (not opted out)
1115+
assert!(gov.scan.resolvable.is_none());
1116+
}
1117+
1118+
#[test]
1119+
fn test_resolvable_absent_is_none() {
1120+
// No resolvable key → None (diff should suggest it)
1121+
let gov = parse_hcl(
1122+
r#"
1123+
workspace "test" {
1124+
scan { exclude = [] }
1125+
}
1126+
"#,
1127+
)
1128+
.unwrap();
1129+
assert!(gov.scan.resolvable.is_none());
1130+
}
1131+
1132+
#[test]
1133+
fn test_resolvable_empty_array_is_some_empty() {
1134+
// resolvable = [] → Some([]) — explicit opt-out, diff stays silent.
1135+
let gov = parse_hcl(
1136+
r#"
1137+
workspace "test" {
1138+
scan { resolvable = [] }
1139+
}
1140+
"#,
1141+
)
1142+
.unwrap();
1143+
assert_eq!(gov.scan.resolvable, Some(vec![]));
1144+
}
1145+
1146+
#[test]
1147+
fn test_resolvable_with_values() {
1148+
let gov = parse_hcl(
1149+
r#"
1150+
workspace "test" {
1151+
scan { resolvable = [".env", ".env.local"] }
1152+
}
1153+
"#,
1154+
)
1155+
.unwrap();
1156+
assert_eq!(
1157+
gov.scan.resolvable,
1158+
Some(vec![".env".to_string(), ".env.local".to_string()])
1159+
);
1160+
}
1161+
1162+
#[test]
1163+
fn test_cron_scanner_disabled_by_default() {
1164+
let gov = parse_hcl(
1165+
r#"
1166+
workspace "test" {}
1167+
"#,
1168+
)
1169+
.unwrap();
1170+
assert!(
1171+
!gov.scan.is_scanner_enabled("cron"),
1172+
"cron scanner must be opt-in (host-state, not repo-state)"
1173+
);
1174+
assert!(
1175+
gov.scan.is_scanner_enabled("docker"),
1176+
"docker scanner must remain enabled by default"
1177+
);
1178+
}
1179+
1180+
#[test]
1181+
fn test_cron_scanner_opt_in_via_scanners_block() {
1182+
let gov = parse_hcl(
1183+
r#"
1184+
workspace "test" {
1185+
scan {
1186+
scanners { cron = true }
1187+
}
1188+
}
1189+
"#,
1190+
)
1191+
.unwrap();
1192+
assert!(
1193+
gov.scan.is_scanner_enabled("cron"),
1194+
"cron must be enabled when explicitly set to true"
1195+
);
11111196
}
11121197
}

src/core/governance.rs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,14 @@ pub struct ScanSettings {
4040
pub scanners: HashMap<String, bool>,
4141

4242
/// RFC 006: env-file globs whose values may be substituted into other
43-
/// resources (e.g. `${VAR}` in docker-compose). Empty = no resolution;
44-
/// values never leave disk. Keys matching any `redact` pattern are
45-
/// replaced with `<redacted>` before substitution so secret-looking
46-
/// variables don't end up in the report.
43+
/// resources (e.g. `${VAR}` in docker-compose). Values never leave disk.
44+
/// Keys matching any `redact` pattern are replaced with `<redacted>`.
45+
///
46+
/// `None` = field absent from Compositfile → diff suggests it when `${VAR}` found.
47+
/// `Some([])` = deliberately opted out → diff stays silent.
48+
/// `Some([..])` = resolution enabled with these env-file globs.
4749
#[serde(default)]
48-
pub resolvable: Vec<String>,
50+
pub resolvable: Option<Vec<String>>,
4951

5052
/// RFC 006: glob-style patterns on env-var *keys* whose values MUST
5153
/// be redacted even when `resolvable` allows substitution. Matched
@@ -94,7 +96,11 @@ pub struct ExtraPattern {
9496

9597
impl ScanSettings {
9698
pub fn is_scanner_enabled(&self, scanner_id: &str) -> bool {
97-
self.scanners.get(scanner_id).copied().unwrap_or(true)
99+
match self.scanners.get(scanner_id).copied() {
100+
Some(v) => v,
101+
// cron reads host-state (`crontab -l`), not repo-state — opt-in only.
102+
None => scanner_id != "cron",
103+
}
98104
}
99105
}
100106

src/core/registry.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,9 @@ impl ScannerRegistry {
114114
// which env-file globs are allowed to supply values. Without it,
115115
// `${VAR}` stays literal in the report and shows up as
116116
// `unresolved_variable` in the diff.
117-
let resolvable = scan.map(|s| s.resolvable.as_slice()).unwrap_or(&[]);
117+
let resolvable = scan
118+
.and_then(|s| s.resolvable.as_deref())
119+
.unwrap_or(&[]);
118120
let redact = scan.map(|s| s.redact.as_slice()).unwrap_or(&[]);
119121
let resolution =
120122
resolve_docker_service_variables(&mut all_resources, &context.dir, resolvable, redact);

0 commit comments

Comments
 (0)