Skip to content

Commit 8b92fa1

Browse files
authored
Merge pull request #106 from swarit-stepsecurity/swarit/chore/gate-features
chore: add feature gate to disable/enable features
2 parents a518d6b + 11d6e9d commit 8b92fa1

6 files changed

Lines changed: 175 additions & 10 deletions

File tree

cmd/stepsecurity-dev-machine-guard/main.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/step-security/dev-machine-guard/internal/detector/configaudit"
2121
"github.com/step-security/dev-machine-guard/internal/device"
2222
"github.com/step-security/dev-machine-guard/internal/executor"
23+
"github.com/step-security/dev-machine-guard/internal/featuregate"
2324
"github.com/step-security/dev-machine-guard/internal/launchd"
2425
"github.com/step-security/dev-machine-guard/internal/output"
2526
"github.com/step-security/dev-machine-guard/internal/paths"
@@ -47,6 +48,12 @@ func main() {
4748
// minimal config.Load (just enough for the upload gate) so this branch
4849
// stays free of the rest of main's setup work.
4950
if len(os.Args) >= 2 && os.Args[1] == "_hook" {
51+
// Gated: silently no-op so any pre-existing hook entry that points
52+
// at this binary stays harmless until the feature ships. Override
53+
// flows in via STEPSECURITY_OVERRIDE_GATE since Parse hasn't run.
54+
if !featuregate.IsEnabled(featuregate.FeatureAIAgentHooks) {
55+
os.Exit(0)
56+
}
5057
os.Exit(aiagentscli.RunHook(os.Stdin, os.Stdout, os.Stderr, os.Args[2:]))
5158
}
5259

@@ -59,6 +66,10 @@ func main() {
5966
os.Exit(1)
6067
}
6168

69+
if cfg.OverrideGate {
70+
featuregate.EnableOverride()
71+
}
72+
6273
// Apply saved config values if CLI didn't explicitly override them.
6374
// CLI flags always win over config file values (same as the shell script).
6475
if len(config.SearchDirs) > 0 && len(cfg.SearchDirs) == 1 && cfg.SearchDirs[0] == "$HOME" {
@@ -298,22 +309,38 @@ func main() {
298309
}
299310

300311
case "hooks install":
312+
if !featuregate.IsEnabled(featuregate.FeatureAIAgentHooks) {
313+
fmt.Fprintln(os.Stderr, featuregate.UnavailableMessage("hooks install"))
314+
os.Exit(1)
315+
}
301316
os.Exit(aiagentscli.RunInstall(context.Background(), exec, cfg.HooksAgent, os.Stdout, os.Stderr))
302317

303318
case "hooks uninstall":
319+
if !featuregate.IsEnabled(featuregate.FeatureAIAgentHooks) {
320+
fmt.Fprintln(os.Stderr, featuregate.UnavailableMessage("hooks uninstall"))
321+
os.Exit(1)
322+
}
304323
os.Exit(aiagentscli.RunUninstall(context.Background(), exec, cfg.HooksAgent, os.Stdout, os.Stderr))
305324

306325
default:
307326
// --npmrc and --pipconfig: focused, verbose pretty audits that
308327
// bypass everything else for a fast (~1s) deep dive.
309328
if cfg.NPMRCOnly {
329+
if !featuregate.IsEnabled(featuregate.FeatureNPMRCAudit) {
330+
fmt.Fprintln(os.Stderr, featuregate.UnavailableMessage("--npmrc"))
331+
os.Exit(1)
332+
}
310333
if err := runNPMRCOnly(exec, cfg); err != nil {
311334
log.Error("%v", err)
312335
os.Exit(1)
313336
}
314337
return
315338
}
316339
if cfg.PipConfigOnly {
340+
if !featuregate.IsEnabled(featuregate.FeaturePipConfigAudit) {
341+
fmt.Fprintln(os.Stderr, featuregate.UnavailableMessage("--pipconfig"))
342+
os.Exit(1)
343+
}
317344
if err := runPipConfigOnly(exec, cfg); err != nil {
318345
log.Error("%v", err)
319346
os.Exit(1)
@@ -432,6 +459,10 @@ func findLegacyLeftovers(legacy string) []string {
432459
// in community mode (enterprise config missing) — the existing scan
433460
// path stays unaffected. Failures are logged but never crash main.
434461
func runHookStateReconcile(exec executor.Executor, log *progress.Logger) {
462+
if !featuregate.IsEnabled(featuregate.FeatureAIAgentHooks) {
463+
log.Debug("hook-state reconcile: skipped (feature gated)")
464+
return
465+
}
435466
cfg, ok := ingest.Snapshot()
436467
if !ok {
437468
log.Debug("hook-state reconcile: skipped (no enterprise config)")

internal/cli/cli.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ type Config struct {
5757
// custom actions and other unattended orchestrators set this so a
5858
// transient network hiccup doesn't roll back the whole install.
5959
IgnoreTelemetryError bool
60+
61+
// OverrideGate disables feature gating for this invocation, letting
62+
// every capability run regardless of the featuregate allowlist.
63+
// Internal — not advertised in --help. Equivalent env var:
64+
// STEPSECURITY_OVERRIDE_GATE=1.
65+
OverrideGate bool
6066
}
6167

6268
// supportedHookAgents lists the agent names accepted by `hooks --agent <name>` and `_hook <agent> ...`.
@@ -211,6 +217,8 @@ func Parse(args []string) (*Config, error) {
211217
cfg.ConfigScanFrequency = strings.TrimPrefix(arg, "--scan-frequency=")
212218
case arg == "--verbose":
213219
cfg.Verbose = true
220+
case arg == "--override-gate":
221+
cfg.OverrideGate = true
214222
case strings.HasPrefix(arg, "--log-level="):
215223
level := strings.ToLower(strings.TrimPrefix(arg, "--log-level="))
216224
switch level {
@@ -312,6 +320,8 @@ func parseHooks(args []string) (*Config, error) {
312320
}
313321
cfg.InstallDir = rest[i]
314322
cfg.InstallDirSet = true
323+
case arg == "--override-gate":
324+
cfg.OverrideGate = true
315325
case arg == "-h" || arg == "--help":
316326
printHooksHelp()
317327
os.Exit(0)

internal/cli/cli_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,24 @@ func TestParse_Verbose(t *testing.T) {
6666
}
6767
}
6868

69+
func TestParse_OverrideGate(t *testing.T) {
70+
cfg, err := Parse([]string{"--override-gate"})
71+
if err != nil {
72+
t.Fatal(err)
73+
}
74+
if !cfg.OverrideGate {
75+
t.Error("expected OverrideGate=true on top-level parse")
76+
}
77+
78+
cfg, err = Parse([]string{"hooks", "install", "--override-gate"})
79+
if err != nil {
80+
t.Fatalf("parseHooks should accept --override-gate: %v", err)
81+
}
82+
if !cfg.OverrideGate {
83+
t.Error("expected OverrideGate=true on hooks parse")
84+
}
85+
}
86+
6987
func TestParse_Color(t *testing.T) {
7088
for _, mode := range []string{"auto", "always", "never"} {
7189
cfg, err := Parse([]string{"--color=" + mode})
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Package featuregate gates capabilities whose corresponding backend
2+
// support has not yet shipped. Each Feature constant maps 1:1 to a
3+
// product capability and stays inert until its entry is added to the
4+
// allowlist below.
5+
//
6+
// Bypass for internal dogfooding: pass --override-gate on the CLI or set
7+
// STEPSECURITY_OVERRIDE_GATE=1 in the environment. The env-var form is
8+
// the only way to flip the gate before cli.Parse runs, which the _hook
9+
// hot path relies on (main returns before Parse for that subcommand).
10+
package featuregate
11+
12+
import (
13+
"fmt"
14+
"os"
15+
)
16+
17+
type Feature string
18+
19+
const (
20+
FeatureAIAgentHooks Feature = "ai-agent-hooks"
21+
FeatureNPMRCAudit Feature = "npmrc-audit"
22+
FeaturePipConfigAudit Feature = "pipconfig-audit"
23+
)
24+
25+
// enabled lists features safe to ship today. Uncomment a line once its
26+
// backend support has merged.
27+
var enabled = map[Feature]bool{
28+
// FeatureAIAgentHooks: true,
29+
// FeatureNPMRCAudit: true,
30+
// FeaturePipConfigAudit: true,
31+
}
32+
33+
var override bool
34+
35+
func init() {
36+
if v := os.Getenv("STEPSECURITY_OVERRIDE_GATE"); v == "1" || v == "true" {
37+
override = true
38+
}
39+
}
40+
41+
// EnableOverride turns on the global override. main calls this when
42+
// --override-gate is present on the command line.
43+
func EnableOverride() { override = true }
44+
45+
// IsEnabled reports whether a feature should run.
46+
func IsEnabled(f Feature) bool {
47+
return override || enabled[f]
48+
}
49+
50+
// UnavailableMessage returns the user-facing string printed when a gated
51+
// command is invoked. Kept here so the wording stays identical across
52+
// every visible command site.
53+
func UnavailableMessage(command string) string {
54+
return fmt.Sprintf("%s is available only in beta and not yet generally available", command)
55+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package featuregate
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestIsEnabled_DefaultDeny(t *testing.T) {
9+
resetOverride(t)
10+
for _, f := range []Feature{FeatureAIAgentHooks, FeatureNPMRCAudit, FeaturePipConfigAudit} {
11+
if IsEnabled(f) {
12+
t.Errorf("%s should be gated by default", f)
13+
}
14+
}
15+
}
16+
17+
func TestIsEnabled_OverrideEnablesEverything(t *testing.T) {
18+
resetOverride(t)
19+
EnableOverride()
20+
for _, f := range []Feature{FeatureAIAgentHooks, FeatureNPMRCAudit, FeaturePipConfigAudit} {
21+
if !IsEnabled(f) {
22+
t.Errorf("%s should be enabled when override is set", f)
23+
}
24+
}
25+
}
26+
27+
func TestUnavailableMessage(t *testing.T) {
28+
msg := UnavailableMessage("hooks install")
29+
if !strings.Contains(msg, "hooks install") {
30+
t.Errorf("message %q should name the command", msg)
31+
}
32+
if !strings.Contains(msg, "beta") {
33+
t.Errorf("message %q should mention beta", msg)
34+
}
35+
}
36+
37+
func resetOverride(t *testing.T) {
38+
t.Helper()
39+
prev := override
40+
t.Cleanup(func() { override = prev })
41+
override = false
42+
}

internal/scan/scanner.go

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/step-security/dev-machine-guard/internal/detector/configaudit"
1212
"github.com/step-security/dev-machine-guard/internal/device"
1313
"github.com/step-security/dev-machine-guard/internal/executor"
14+
"github.com/step-security/dev-machine-guard/internal/featuregate"
1415
"github.com/step-security/dev-machine-guard/internal/model"
1516
"github.com/step-security/dev-machine-guard/internal/output"
1617
"github.com/step-security/dev-machine-guard/internal/progress"
@@ -208,20 +209,28 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error {
208209
}
209210

210211
// npm config audit — surface-only inventory of every .npmrc on the host
211-
// plus the merged effective view npm itself would resolve. Always on;
212-
// the audit is cheap (a few stat calls and at most two npm invocations).
213-
log.StepStart("Auditing npm configuration")
214-
start = time.Now()
212+
// plus the merged effective view npm itself would resolve. The audit is
213+
// cheap (a few stat calls and at most two npm invocations) but stays
214+
// inert until the feature ships; zero-value structs flow through to the
215+
// output so JSON/HTML keep emitting the audit shape.
215216
loggedInUser, _ := exec.LoggedInUser()
216-
npmrcAudit := configaudit.NewNPMRCDetector(exec).Detect(ctx, searchDirs, loggedInUser)
217-
log.StepDone(time.Since(start))
217+
var npmrcAudit model.NPMRCAudit
218+
if featuregate.IsEnabled(featuregate.FeatureNPMRCAudit) {
219+
log.StepStart("Auditing npm configuration")
220+
start = time.Now()
221+
npmrcAudit = configaudit.NewNPMRCDetector(exec).Detect(ctx, searchDirs, loggedInUser)
222+
log.StepDone(time.Since(start))
223+
}
218224

219225
// pip config audit — same shape: every pip.conf / pip.ini discovered,
220226
// merged effective view, env-var snapshot, and a fixed finding catalog.
221-
log.StepStart("Auditing pip configuration")
222-
start = time.Now()
223-
pipAudit := configaudit.NewPipConfigDetector(exec).Detect(ctx, loggedInUser)
224-
log.StepDone(time.Since(start))
227+
var pipAudit model.PipAudit
228+
if featuregate.IsEnabled(featuregate.FeaturePipConfigAudit) {
229+
log.StepStart("Auditing pip configuration")
230+
start = time.Now()
231+
pipAudit = configaudit.NewPipConfigDetector(exec).Detect(ctx, loggedInUser)
232+
log.StepDone(time.Since(start))
233+
}
225234

226235
// Ensure no nil slices (JSON must emit [] not null)
227236
if aiTools == nil {

0 commit comments

Comments
 (0)