From 90868f3b6ee4c058970e58ecf26fa597e23c48b3 Mon Sep 17 00:00:00 2001 From: Bhushan Date: Mon, 13 Jul 2026 17:09:02 +0530 Subject: [PATCH] fix(cli): Fail closed on scan block and clarify CI empty diffs Scan commands exit non-zero under --mode block (or --fail-on) so shell scripts cannot install after a BLOCK decision. CI human summaries now warn when changed-only scans zero packages, and the public-boundary check falls back to grep when ripgrep is missing. --- docs/commands.md | 14 ++++ internal/ci/summary.go | 8 +++ internal/ci/summary_test.go | 30 ++++++++ pkg/cli/main.go | 115 ++++++++++++++++++++----------- pkg/cli/scan_exit.go | 108 +++++++++++++++++++++++++++++ pkg/cli/scan_exit_test.go | 71 +++++++++++++++++++ scripts/check-public-boundary.sh | 62 ++++++++++++++--- 7 files changed, 359 insertions(+), 49 deletions(-) create mode 100644 internal/ci/summary_test.go create mode 100644 pkg/cli/scan_exit.go create mode 100644 pkg/cli/scan_exit_test.go diff --git a/docs/commands.md b/docs/commands.md index 5201793..c41e2e6 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -10,10 +10,24 @@ Global patterns used widely: |------|---------| | `--json` | Machine-readable JSON (same shape MCP tools use) | | `--mode audit\|warn\|block` | Enforcement mode | +| `--fail-on none\|warn\|block` | Process exit threshold for scan commands (see below) | | `--policy ` | Custom policy file | | `--offline` | Use local cache / DB only (no registry fetch when required data is missing, fail closed) | | `--behavior disabled\|heuristic\|isolated` | Optional lifecycle execution (default: disabled) | +**Scan exit codes:** package scan commands print a decision and, by default, exit +`0` in `warn`/`audit` mode so interactive review stays usable. With +`--mode block` (or explicit `--fail-on block`), a `block` or `review_required` +decision exits `1` so scripts fail closed: + +```bash +pkgsafe scan-npm-package axois --mode block && npm install axois # blocked install never runs +pkgsafe scan-local-npm ./pkg --fail-on warn # exit 1 on warn or worse +``` + +`pkgsafe scan` (workspace) defaults to failing on block even in warn mode so it +stays a project gate. Use `--fail-on none` to override. + --- ## Health and version diff --git a/internal/ci/summary.go b/internal/ci/summary.go index e4c87a6..0b37243 100644 --- a/internal/ci/summary.go +++ b/internal/ci/summary.go @@ -31,6 +31,12 @@ func WriteHumanSummary(w io.Writer, result *ScanResult) { fmt.Fprintf(w, "Exceptions Used: %s\n", strings.Join(result.ExceptionsUsed, ", ")) } fmt.Fprintf(w, "Packages Scanned: %d\n", result.Summary.PackagesScanned) + if result.ChangedOnly && result.Summary.PackagesScanned == 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "NOTICE: changed-only scan found 0 packages.") + fmt.Fprintln(w, " Decision ALLOW means no dependency changes were gated — not that the full project is clean.") + fmt.Fprintln(w, " Run with --changed-only=false for a full lockfile scan.") + } fmt.Fprintln(w) fmt.Fprintln(w, "Summary:") fmt.Fprintf(w, "- Allow: %d\n", result.Summary.Allow) @@ -133,6 +139,8 @@ func WriteHumanSummary(w io.Writer, result *ScanResult) { fmt.Fprintln(w, "Request authorized human review before merging.") } else if result.Decision == "warn" { fmt.Fprintln(w, "Review package warnings before merging.") + } else if result.ChangedOnly && result.Summary.PackagesScanned == 0 { + fmt.Fprintln(w, "No dependency changes to gate. Use a full scan if you need whole-lockfile coverage.") } else { fmt.Fprintln(w, "Package scan completed successfully. No action required.") } diff --git a/internal/ci/summary_test.go b/internal/ci/summary_test.go new file mode 100644 index 0000000..fa4b597 --- /dev/null +++ b/internal/ci/summary_test.go @@ -0,0 +1,30 @@ +package ci + +import ( + "bytes" + "strings" + "testing" +) + +func TestWriteHumanSummaryChangedOnlyZeroPackages(t *testing.T) { + var buf bytes.Buffer + WriteHumanSummary(&buf, &ScanResult{ + Decision: "allow", + Mode: "warn", + FailOn: "block", + Lockfile: "package-lock.json", + Ecosystem: "npm", + ChangedOnly: true, + Summary: Summary{PackagesScanned: 0}, + }) + out := buf.String() + if !strings.Contains(out, "changed-only scan found 0 packages") { + t.Fatalf("expected zero-package notice, got:\n%s", out) + } + if !strings.Contains(out, "not that the full project is clean") { + t.Fatalf("expected full-project disclaimer, got:\n%s", out) + } + if !strings.Contains(out, "No dependency changes to gate") { + t.Fatalf("expected recommended action for zero changes, got:\n%s", out) + } +} diff --git a/pkg/cli/main.go b/pkg/cli/main.go index a0e0226..1b0b8d6 100644 --- a/pkg/cli/main.go +++ b/pkg/cli/main.go @@ -310,6 +310,7 @@ func cmdScanPyPIPackage(args []string) error { asJSON := fs.Bool("json", false, "write JSON output") policyPath := fs.String("policy", "", "policy YAML path") mode := fs.String("mode", "", "audit, warn, or block") + failOn := fs.String("fail-on", "", "fail process when decision reaches this threshold: none, warn, or block (default: block in --mode block, else none)") offline := fs.Bool("offline", false, "run scan offline using cached database and metadata") behavior := fs.String("behavior", "", "behavior analysis mode: disabled, heuristic, or isolated") sandbox := fs.Bool("sandbox", false, "compatibility alias for --behavior heuristic; PyPI execution remains disabled without isolated backend") @@ -344,7 +345,10 @@ func cmdScanPyPIPackage(args []string) error { } res = stripEnterprise(res, false) _ = saveResult(res) - return output.Write(os.Stdout, res, *asJSON) + if err := output.Write(os.Stdout, res, *asJSON); err != nil { + return err + } + return exitIfScanFails(res.Decision, pol.Mode, *failOn) } func cmdScanPythonDeps(args []string) error { @@ -352,6 +356,7 @@ func cmdScanPythonDeps(args []string) error { asJSON := fs.Bool("json", false, "write JSON output") policyPath := fs.String("policy", "", "policy YAML path") mode := fs.String("mode", "", "audit, warn, or block") + failOn := fs.String("fail-on", "", "fail process when decision reaches this threshold: none, warn, or block (default: block in --mode block, else none)") offline := fs.Bool("offline", false, "run scan offline using cached database and metadata") registryConfig := fs.String("registry-config", "", "path to registries.yaml") if err := fs.Parse(reorderFlags(args)); err != nil { @@ -387,17 +392,20 @@ func cmdScanPythonDeps(args []string) error { if *asJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") - return enc.Encode(results) - } - for i, res := range results { - if i > 0 { - fmt.Fprintln(os.Stdout) - } - if err := output.Write(os.Stdout, res, false); err != nil { + if err := enc.Encode(results); err != nil { return err } + } else { + for i, res := range results { + if i > 0 { + fmt.Fprintln(os.Stdout) + } + if err := output.Write(os.Stdout, res, false); err != nil { + return err + } + } } - return nil + return exitIfScanResultsFail(results, pol.Mode, *failOn) } func cmdScanGoDeps(args []string) error { @@ -405,6 +413,7 @@ func cmdScanGoDeps(args []string) error { asJSON := fs.Bool("json", false, "write JSON output") policyPath := fs.String("policy", "", "policy YAML path") mode := fs.String("mode", "", "audit, warn, or block") + failOn := fs.String("fail-on", "", "fail process when decision reaches this threshold: none, warn, or block (default: block in --mode block, else none)") offline := fs.Bool("offline", false, "run scan offline using cached database and metadata") registryConfig := fs.String("registry-config", "", "path to registries.yaml") if err := fs.Parse(reorderFlags(args)); err != nil { @@ -453,17 +462,20 @@ func cmdScanGoDeps(args []string) error { if *asJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") - return enc.Encode(results) - } - for i, res := range results { - if i > 0 { - fmt.Fprintln(os.Stdout) - } - if err := output.Write(os.Stdout, res, false); err != nil { + if err := enc.Encode(results); err != nil { return err } + } else { + for i, res := range results { + if i > 0 { + fmt.Fprintln(os.Stdout) + } + if err := output.Write(os.Stdout, res, false); err != nil { + return err + } + } } - return nil + return exitIfScanResultsFail(results, pol.Mode, *failOn) } func cmdScanCargoDeps(args []string) error { @@ -471,6 +483,7 @@ func cmdScanCargoDeps(args []string) error { asJSON := fs.Bool("json", false, "write JSON output") policyPath := fs.String("policy", "", "policy YAML path") mode := fs.String("mode", "", "audit, warn, or block") + failOn := fs.String("fail-on", "", "fail process when decision reaches this threshold: none, warn, or block (default: block in --mode block, else none)") offline := fs.Bool("offline", false, "run scan offline using cached database and metadata") registryConfig := fs.String("registry-config", "", "path to registries.yaml") if err := fs.Parse(reorderFlags(args)); err != nil { @@ -519,17 +532,20 @@ func cmdScanCargoDeps(args []string) error { if *asJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") - return enc.Encode(results) - } - for i, res := range results { - if i > 0 { - fmt.Fprintln(os.Stdout) - } - if err := output.Write(os.Stdout, res, false); err != nil { + if err := enc.Encode(results); err != nil { return err } + } else { + for i, res := range results { + if i > 0 { + fmt.Fprintln(os.Stdout) + } + if err := output.Write(os.Stdout, res, false); err != nil { + return err + } + } } - return nil + return exitIfScanResultsFail(results, pol.Mode, *failOn) } func cmdScanLocalNPM(args []string) error { @@ -537,6 +553,7 @@ func cmdScanLocalNPM(args []string) error { asJSON := fs.Bool("json", false, "write JSON output") policyPath := fs.String("policy", "", "policy YAML path") mode := fs.String("mode", "", "audit, warn, or block") + failOn := fs.String("fail-on", "", "fail process when decision reaches this threshold: none, warn, or block (default: block in --mode block, else none)") behavior := fs.String("behavior", "", "behavior analysis mode: disabled, heuristic, or isolated") sandbox := fs.Bool("sandbox", false, "compatibility alias for --behavior heuristic") timeout := fs.Duration("timeout", 10*time.Second, "behavior-analysis execution timeout") @@ -599,7 +616,10 @@ func cmdScanLocalNPM(args []string) error { } res = stripEnterprise(res, false) _ = saveResult(res) - return output.Write(os.Stdout, res, *asJSON) + if err := output.Write(os.Stdout, res, *asJSON); err != nil { + return err + } + return exitIfScanFails(res.Decision, pol.Mode, *failOn) } func cmdScanNPMPackage(args []string) error { @@ -608,6 +628,7 @@ func cmdScanNPMPackage(args []string) error { asJSON := fs.Bool("json", false, "write JSON output") policyPath := fs.String("policy", "", "policy YAML path") mode := fs.String("mode", "", "audit, warn, or block") + failOn := fs.String("fail-on", "", "fail process when decision reaches this threshold: none, warn, or block (default: block in --mode block, else none)") offline := fs.Bool("offline", false, "run scan offline using cached database and metadata") behavior := fs.String("behavior", "", "behavior analysis mode: disabled, heuristic, or isolated") sandbox := fs.Bool("sandbox", false, "compatibility alias for --behavior heuristic") @@ -676,7 +697,10 @@ func cmdScanNPMPackage(args []string) error { } res = stripEnterprise(res, false) _ = saveResult(res) - return output.Write(os.Stdout, res, *asJSON) + if err := output.Write(os.Stdout, res, *asJSON); err != nil { + return err + } + return exitIfScanFails(res.Decision, pol.Mode, *failOn) } func cmdScanLockfile(args []string) error { @@ -684,6 +708,7 @@ func cmdScanLockfile(args []string) error { asJSON := fs.Bool("json", false, "write JSON output") policyPath := fs.String("policy", "", "policy YAML path") mode := fs.String("mode", "", "audit, warn, or block") + failOn := fs.String("fail-on", "", "fail process when decision reaches this threshold: none, warn, or block (default: block in --mode block, else none)") _ = fs.Bool("offline", false, "run scan offline using cached database") registryConfig := fs.String("registry-config", "", "path to registries.yaml") if err := fs.Parse(reorderFlags(args)); err != nil { @@ -713,7 +738,10 @@ func cmdScanLockfile(args []string) error { } res = stripEnterprise(res, false) logLockfileToAudit(pol, lockPath, res) - return output.Write(os.Stdout, res, *asJSON) + if err := output.Write(os.Stdout, res, *asJSON); err != nil { + return err + } + return exitIfScanFails(res.Decision, pol.Mode, *failOn) } func detectEcosystem(pkgName string, pol policy.Policy, offline bool) (string, string) { @@ -2038,6 +2066,7 @@ func cmdScan(args []string) error { asJSON := fs.Bool("json", false, "write JSON output") policyPath := fs.String("policy", "", "policy YAML path") mode := fs.String("mode", "", "audit, warn, or block") + failOn := fs.String("fail-on", "", "fail process when decision reaches this threshold: none, warn, or block (default: block in --mode block, else none)") offline := fs.Bool("offline", false, "run scan offline using cached database") registryConfig := fs.String("registry-config", "", "path to registries.yaml") if err := fs.Parse(reorderFlags(args)); err != nil { @@ -2342,10 +2371,26 @@ func cmdScan(args []string) error { } } + // Aggregate worst decision across workspace files (decisions may be mixed case). + aggDecision := types.DecisionAllow + for _, r := range results { + aggDecision = worseDecision(aggDecision, types.Decision(strings.ToLower(r.decision))) + } + + // Workspace scan historically failed on block even in warn mode; keep that + // default so `pkgsafe scan` remains a fail-closed project gate. + workspaceFailOn := *failOn + if strings.TrimSpace(workspaceFailOn) == "" { + workspaceFailOn = "block" + } + if *asJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") - return enc.Encode(jsonResults) + if err := enc.Encode(jsonResults); err != nil { + return err + } + return exitIfScanFails(aggDecision, pol.Mode, workspaceFailOn) } var bold, reset string @@ -2389,17 +2434,7 @@ func cmdScan(args []string) error { } fmt.Println() - hasBlock := false - for _, r := range results { - if summaryDecisionIsBlocking(r.decision) { - hasBlock = true - } - } - if hasBlock { - return fmt.Errorf("scan failed: one or more project files violate policy") - } - - return nil + return exitIfScanFails(aggDecision, pol.Mode, workspaceFailOn) } func unique(in []string) []string { diff --git a/pkg/cli/scan_exit.go b/pkg/cli/scan_exit.go new file mode 100644 index 0000000..f627901 --- /dev/null +++ b/pkg/cli/scan_exit.go @@ -0,0 +1,108 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/sairintechnologycom/pkgsafe/internal/policy" + "github.com/sairintechnologycom/pkgsafe/internal/types" +) + +// resolveScanFailOn picks the fail-on threshold for scan commands. +// Explicit values win. In block mode the default is "block" so scripts like +// `pkgsafe scan-npm-package foo --mode block && npm install foo` fail closed. +// In warn/audit modes the default is "none" so interactive review stays usable. +func resolveScanFailOn(mode policy.Mode, failOn string) string { + failOn = strings.ToLower(strings.TrimSpace(failOn)) + switch failOn { + case "none", "warn", "block": + return failOn + case "": + if mode == policy.ModeBlock { + return "block" + } + return "none" + default: + return "" + } +} + +// validateScanFailOn returns an error when fail-on is set to an unknown value. +func validateScanFailOn(failOn string) error { + if strings.TrimSpace(failOn) == "" { + return nil + } + switch strings.ToLower(strings.TrimSpace(failOn)) { + case "none", "warn", "block": + return nil + default: + return fmt.Errorf("invalid --fail-on value %q (want none, warn, or block)", failOn) + } +} + +// worstDecision returns the most severe decision among the provided results. +func worstDecision(results []types.ScanResult) types.Decision { + worst := types.DecisionAllow + for _, res := range results { + worst = worseDecision(worst, res.Decision) + } + return worst +} + +func worseDecision(current, candidate types.Decision) types.Decision { + rank := func(d types.Decision) int { + switch d { + case types.DecisionBlock: + return 4 + case types.DecisionReviewRequired: + return 3 + case types.DecisionWarn: + return 2 + case types.DecisionAllow: + return 1 + default: + return 0 + } + } + if rank(candidate) > rank(current) { + return candidate + } + return current +} + +// exitIfScanFails returns a non-zero exitError when decision meets the fail-on +// threshold. review_required is treated as blocking under fail-on=block, matching +// install-guard semantics. +func exitIfScanFails(decision types.Decision, mode policy.Mode, failOn string) error { + if err := validateScanFailOn(failOn); err != nil { + return exitError{code: 2, err: err} + } + threshold := resolveScanFailOn(mode, failOn) + switch threshold { + case "none": + return nil + case "block": + if decision == types.DecisionBlock || decision == types.DecisionReviewRequired { + return exitError{ + code: 1, + err: fmt.Errorf("scan failed: decision=%s (fail-on=%s)", decision, threshold), + } + } + case "warn": + if decision == types.DecisionBlock || decision == types.DecisionReviewRequired || decision == types.DecisionWarn { + return exitError{ + code: 1, + err: fmt.Errorf("scan failed: decision=%s (fail-on=%s)", decision, threshold), + } + } + } + return nil +} + +// exitIfScanResultsFail is exitIfScanFails over an aggregate of package results. +func exitIfScanResultsFail(results []types.ScanResult, mode policy.Mode, failOn string) error { + if len(results) == 0 { + return nil + } + return exitIfScanFails(worstDecision(results), mode, failOn) +} diff --git a/pkg/cli/scan_exit_test.go b/pkg/cli/scan_exit_test.go new file mode 100644 index 0000000..beb931f --- /dev/null +++ b/pkg/cli/scan_exit_test.go @@ -0,0 +1,71 @@ +package cli + +import ( + "testing" + + "github.com/sairintechnologycom/pkgsafe/internal/policy" + "github.com/sairintechnologycom/pkgsafe/internal/types" +) + +func TestResolveScanFailOn(t *testing.T) { + cases := []struct { + mode policy.Mode + failOn string + want string + }{ + {policy.ModeBlock, "", "block"}, + {policy.ModeWarn, "", "none"}, + {policy.ModeAudit, "", "none"}, + {policy.ModeWarn, "block", "block"}, + {policy.ModeBlock, "none", "none"}, + {policy.ModeBlock, "warn", "warn"}, + } + for _, tc := range cases { + got := resolveScanFailOn(tc.mode, tc.failOn) + if got != tc.want { + t.Fatalf("resolveScanFailOn(%q, %q)=%q want %q", tc.mode, tc.failOn, got, tc.want) + } + } +} + +func TestExitIfScanFailsBlockMode(t *testing.T) { + err := exitIfScanFails(types.DecisionBlock, policy.ModeBlock, "") + eErr, ok := err.(exitError) + if !ok { + t.Fatalf("expected exitError, got %T (%v)", err, err) + } + if eErr.code != 1 { + t.Fatalf("expected exit code 1, got %d", eErr.code) + } + + if err := exitIfScanFails(types.DecisionAllow, policy.ModeBlock, ""); err != nil { + t.Fatalf("allow should not fail in block mode: %v", err) + } + if err := exitIfScanFails(types.DecisionBlock, policy.ModeWarn, ""); err != nil { + t.Fatalf("warn mode default should not fail: %v", err) + } + if err := exitIfScanFails(types.DecisionBlock, policy.ModeWarn, "block"); err == nil { + t.Fatal("explicit fail-on=block should fail on block in warn mode") + } + if err := exitIfScanFails(types.DecisionReviewRequired, policy.ModeBlock, ""); err == nil { + t.Fatal("review_required should fail under fail-on=block") + } + if err := exitIfScanFails(types.DecisionWarn, policy.ModeBlock, "warn"); err == nil { + t.Fatal("fail-on=warn should fail on warn") + } +} + +func TestWorstDecision(t *testing.T) { + results := []types.ScanResult{ + {Decision: types.DecisionAllow}, + {Decision: types.DecisionWarn}, + {Decision: types.DecisionReviewRequired}, + } + if got := worstDecision(results); got != types.DecisionReviewRequired { + t.Fatalf("got %s want review_required", got) + } + results = append(results, types.ScanResult{Decision: types.DecisionBlock}) + if got := worstDecision(results); got != types.DecisionBlock { + t.Fatalf("got %s want block", got) + } +} diff --git a/scripts/check-public-boundary.sh b/scripts/check-public-boundary.sh index 94616bb..f53cebd 100755 --- a/scripts/check-public-boundary.sh +++ b/scripts/check-public-boundary.sh @@ -7,19 +7,55 @@ cd "$ROOT_DIR" || exit 1 PATTERN='\bhosted[[:space:]]+evidence\b|\bbilling\b|\blicense[[:space:]]+server\b|\bSAML\b|\bSSO\b|\bRBAC\b|\benterprise[[:space:]]+dashboard\b|\bcommercial[[:space:]]+intelligence\b|\bprivate[[:space:]]+feed\b|\bcustomer-specific\b|\bpolicy[[:space:]]+sync[[:space:]]+service\b|\bpaid[[:space:]]+feature\b|\bpremium[[:space:]]+implementation\b' FORBIDDEN_PATHS='^(pkg/license|internal/enterprise|pkg/enterprise|private|customer|customers)/' FORBIDDEN_SYMBOLS='\b(Entitlement|LicenseResolver|EnterpriseCommandFunc|LoadSignedPolicyFunc|CIEnterpriseMode|FeatureSignedBundles|FeatureSignedPolicy|FeatureSignedEvidence)\b' -IMPL_PATHS='^(cmd|internal|pkg|scripts)/' DOC_PATHS='^(docs|README\.md|CONTRIBUTING\.md|SECURITY\.md|ROLLOUT-READINESS\.md|REMEDIATION\.md|CHANGELOG\.md|action\.yml|Makefile)' ALLOWLIST='^(docs/architecture/open-core-boundary\.md|docs/architecture/feature-classification\.md|CONTRIBUTING\.md|scripts/check-public-boundary\.sh):' -if ! command -v rg >/dev/null 2>&1; then - echo "error: ripgrep (rg) is required for public-boundary checks" >&2 - exit 2 +# Prefer ripgrep; fall back to find+grep so CI/developer machines without rg still work. +HAVE_RG=0 +if command -v rg >/dev/null 2>&1; then + HAVE_RG=1 fi -failures="$(rg -n -i -P --glob '!dist/**' --glob '!evidence/e2e/**' --glob '!graphify-out/**' "$PATTERN" cmd internal pkg scripts 2>/dev/null | grep -Ev "$ALLOWLIST" || true)" +search_content() { + # $1 = regex, $2... = roots + local regex="$1" + shift + if [ "$HAVE_RG" -eq 1 ]; then + rg -n -i -P --glob '!dist/**' --glob '!evidence/e2e/**' --glob '!graphify-out/**' "$regex" "$@" 2>/dev/null || true + else + # Portable fallback: line numbers, case-insensitive where supported. + find "$@" \( -path '*/dist/*' -o -path '*/evidence/e2e/*' -o -path '*/graphify-out/*' -o -path '*/.git/*' \) -prune -o -type f -print 2>/dev/null \ + | while IFS= read -r f; do + grep -n -i -E "$regex" "$f" 2>/dev/null | sed "s|^|${f}:|" + done || true + fi +} -path_failures="$(rg --files cmd internal pkg scripts 2>/dev/null | grep -E "$FORBIDDEN_PATHS" || true)" -symbol_failures="$(rg -n -P --glob '*.go' "$FORBIDDEN_SYMBOLS" cmd internal pkg 2>/dev/null || true)" +search_go_symbols() { + local regex="$1" + shift + if [ "$HAVE_RG" -eq 1 ]; then + rg -n -P --glob '*.go' "$regex" "$@" 2>/dev/null || true + else + find "$@" -type f -name '*.go' ! -path '*/.git/*' -print 2>/dev/null \ + | while IFS= read -r f; do + grep -n -E "$regex" "$f" 2>/dev/null | sed "s|^|${f}:|" + done || true + fi +} + +list_impl_files() { + if [ "$HAVE_RG" -eq 1 ]; then + rg --files cmd internal pkg scripts 2>/dev/null || true + else + find cmd internal pkg scripts -type f ! -path '*/.git/*' 2>/dev/null || true + fi +} + +failures="$(search_content "$PATTERN" cmd internal pkg scripts | grep -Ev "$ALLOWLIST" || true)" + +path_failures="$(list_impl_files | grep -E "$FORBIDDEN_PATHS" || true)" +symbol_failures="$(search_go_symbols "$FORBIDDEN_SYMBOLS" cmd internal pkg)" if [ -n "$path_failures" ] || [ -n "$symbol_failures" ]; then echo "Public-boundary check failed: forbidden premium path or entitlement/dispatch symbol found." >&2 @@ -37,7 +73,11 @@ if [ -n "$failures" ]; then exit 1 fi -warnings="$(rg -n -i -P --glob '!dist/**' --glob '!evidence/e2e/**' --glob '!graphify-out/**' "$PATTERN" . 2>/dev/null | grep -E "$DOC_PATHS" | grep -Ev "$ALLOWLIST" || true)" +if [ "$HAVE_RG" -eq 1 ]; then + warnings="$(rg -n -i -P --glob '!dist/**' --glob '!evidence/e2e/**' --glob '!graphify-out/**' "$PATTERN" . 2>/dev/null | grep -E "$DOC_PATHS" | grep -Ev "$ALLOWLIST" || true)" +else + warnings="$(search_content "$PATTERN" docs README.md CONTRIBUTING.md SECURITY.md ROLLOUT-READINESS.md REMEDIATION.md CHANGELOG.md action.yml Makefile 2>/dev/null | grep -E "$DOC_PATHS" | grep -Ev "$ALLOWLIST" || true)" +fi if [ -n "$warnings" ]; then echo "Public-boundary warning: review these public documentation mentions for OSS-safe wording." >&2 @@ -46,4 +86,8 @@ if [ -n "$warnings" ]; then echo >&2 fi -echo "Public-boundary check passed: no obvious premium implementation leakage found." +if [ "$HAVE_RG" -eq 1 ]; then + echo "Public-boundary check passed: no obvious premium implementation leakage found." +else + echo "Public-boundary check passed (grep fallback; install ripgrep for faster checks)." +fi