-
Notifications
You must be signed in to change notification settings - Fork 672
feat(safety-profile): lock flag values with locked-flags #976
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
69b851b
a2baa26
0d1063d
7cabd50
438ad1a
49ac92b
fee967b
fd24ccc
22e8ed9
b3a42d5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "fmt" | ||
| "go/parser" | ||
| "go/token" | ||
| "testing" | ||
|
|
||
| "github.com/openclaw/gogcli/internal/safetyprofile" | ||
| ) | ||
|
|
||
| func TestGenerateLockedFlagsEmitsHashedLookup(t *testing.T) { | ||
| profile := &safetyprofile.Profile{ | ||
| Name: "test", | ||
| AllowRules: []string{"gmail.get"}, | ||
| LockedFlags: []safetyprofile.LockedFlag{ | ||
| {Name: "sanitize-content", Value: "true"}, | ||
| {Name: "inline-max-bytes", Value: "8388608"}, | ||
| }, | ||
| } | ||
|
|
||
| out := generate(profile) | ||
|
|
||
| if _, err := parser.ParseFile(token.NewFileSet(), "gen.go", out, parser.AllErrors); err != nil { | ||
| t.Fatalf("generated code does not parse as Go:\n%s\n\nerror: %v", out, err) | ||
| } | ||
|
|
||
| // The flag name must be hashed like a command rule, and the value emitted as the | ||
| // literal the flag parser consumes. | ||
| for _, flag := range profile.LockedFlags { | ||
| want := fmt.Sprintf("\tcase 0x%016x:\n\t\treturn %q, true\n", safetyprofile.HashRule(flag.Name), flag.Value) | ||
| if !bytes.Contains(out, []byte(want)) { | ||
| t.Fatalf("generated output missing case for %q:\n%s\n\nfull output:\n%s", flag.Name, want, out) | ||
| } | ||
| if bytes.Contains(out, []byte(`"`+flag.Name+`"`)) { | ||
| t.Fatalf("locked flag name %q appears verbatim; it should only be hashed\n\nfull output:\n%s", flag.Name, out) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // A profile with no locked flags still needs the lookup, so safety_profile builds | ||
| // compile whether or not the profile uses the feature. | ||
| func TestGenerateWithoutLockedFlagsStillDefinesLookup(t *testing.T) { | ||
| out := generate(&safetyprofile.Profile{Name: "test", AllowRules: []string{"gmail.get"}}) | ||
|
|
||
| if _, err := parser.ParseFile(token.NewFileSet(), "gen.go", out, parser.AllErrors); err != nil { | ||
| t.Fatalf("generated code does not parse as Go:\n%s\n\nerror: %v", out, err) | ||
| } | ||
| want := "func bakedSafetyLockedFlag(name string) (string, bool) {\n\treturn \"\", false\n}\n" | ||
| if !bytes.Contains(out, []byte(want)) { | ||
| t.Fatalf("generated output missing the empty lookup:\n%s", out) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -165,7 +165,6 @@ func executeWithRuntime(args []string, runtime *app.Runtime) (err error) { | |
| cli.diagnostics = runtimeIO.Err | ||
| cli.authOperations = runtime.Auth | ||
| cli.authMode = googleapi.ParseAuthMode(os.Getenv("GOG_AUTH_MODE")) | ||
| applyExplicitOutputModePrecedence(kctx, &cli.RootFlags) | ||
|
|
||
| // Make config-backed account and alias resolution available to the | ||
| // pre-Run enforcement hooks below (enforceGmailNoSend resolves the | ||
|
|
@@ -182,6 +181,17 @@ func executeWithRuntime(args []string, runtime *app.Runtime) (err error) { | |
| if err = enforceBakedSafetyProfile(kctx); err != nil { | ||
| return reportEarlyError(runtimeIO.Err, err) | ||
| } | ||
| if err = verifyLockedFlagsExist(kctx); err != nil { | ||
| return reportEarlyError(runtimeIO.Err, err) | ||
| } | ||
| if err = enforceLockedFlags(kctx); err != nil { | ||
| return reportEarlyError(runtimeIO.Err, err) | ||
|
Comment on lines
+187
to
+188
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Locked values are injected only after Useful? React with 👍 / 👎. |
||
| } | ||
| // After the locks, so a locked output mode is what precedence resolves around | ||
| // rather than something a competing mode can leave in conflict. | ||
| if err = applyExplicitOutputModePrecedence(kctx, &cli.RootFlags); err != nil { | ||
| return reportEarlyError(runtimeIO.Err, err) | ||
| } | ||
| if err = enforceEnabledCommands(kctx, cli.EnableCommands, cli.EnableCommandsExact); err != nil { | ||
| return reportEarlyError(runtimeIO.Err, err) | ||
| } | ||
|
|
@@ -334,13 +344,13 @@ func executeWithRuntime(args []string, runtime *app.Runtime) (err error) { | |
| err = stableExitCode(err) | ||
|
|
||
| if u := ui.FromContext(ctx); u != nil { | ||
| msg := strings.TrimSpace(errfmt.Format(err)) | ||
| msg := errorMessage(err) | ||
| if msg != "" { | ||
| u.Err().Error(msg) | ||
| } | ||
| return err | ||
| } | ||
| msg := strings.TrimSpace(errfmt.Format(err)) | ||
| msg := errorMessage(err) | ||
| if msg != "" { | ||
| _, _ = fmt.Fprintln(runtimeIO.Err, msg) | ||
| } | ||
|
|
@@ -400,19 +410,37 @@ func validateJSONTransformFlags(mode outfmt.Mode, flags *RootFlags) error { | |
| } | ||
| } | ||
|
|
||
| func applyExplicitOutputModePrecedence(kctx *kong.Context, flags *RootFlags) { | ||
| // applyExplicitOutputModePrecedence settles --json against --plain, which cannot | ||
| // both be set. A locked mode outranks the competing one: typing that competing flag | ||
| // is refused the way setting the locked flag itself is, since the caller asked for | ||
| // output the profile forbids, while an environment default gives way silently | ||
| // because it is an ambient setting rather than a request about this invocation. | ||
| func applyExplicitOutputModePrecedence(kctx *kong.Context, flags *RootFlags) error { | ||
| if flags == nil { | ||
| return | ||
| return nil | ||
| } | ||
|
|
||
| jsonSet := flagProvided(kctx, "json") | ||
| plainSet := flagProvided(kctx, "plain") | ||
| jsonLocked := lockedFlagNames["json"] | ||
| plainLocked := lockedFlagNames["plain"] | ||
| jsonSet := flagOnCommandLine(kctx, "json") | ||
| plainSet := flagOnCommandLine(kctx, "plain") | ||
| switch { | ||
| case jsonLocked && !plainLocked: | ||
| if plainSet { | ||
| return usagef("flag --plain conflicts with --json, locked by baked safety profile %q", bakedSafetyProfileName()) | ||
| } | ||
| flags.Plain = false | ||
| case plainLocked && !jsonLocked: | ||
| if jsonSet { | ||
| return usagef("flag --json conflicts with --plain, locked by baked safety profile %q", bakedSafetyProfileName()) | ||
| } | ||
| flags.JSON = false | ||
| case jsonSet && !plainSet: | ||
| flags.Plain = false | ||
| case plainSet && !jsonSet: | ||
| flags.JSON = false | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func reportEarlyError(w io.Writer, err error) error { | ||
|
|
@@ -426,6 +454,22 @@ func reportEarlyError(w io.Writer, err error) error { | |
| return err | ||
| } | ||
|
|
||
| // errorMessage renders a command's error for display. Usage errors carry the | ||
| // locked-flag note, because a baked profile can supply a value the caller never | ||
| // passed and the rejection then names a flag absent from their command line. The | ||
| // pre-run enforcement errors skip it: those name the locked flag themselves. | ||
| func errorMessage(err error) string { | ||
| msg := strings.TrimSpace(errfmt.Format(err)) | ||
| if msg == "" || ExitCode(err) != 2 { | ||
| return msg | ||
| } | ||
| note := lockedFlagsNote() | ||
| if note == "" { | ||
| return msg | ||
| } | ||
| return msg + "\n" + note | ||
| } | ||
|
|
||
| func isTerminalWriter(w io.Writer) bool { | ||
| file, ok := w.(*os.File) | ||
| return ok && termutil.IsTerminal(file) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a profile locks
homeand the caller omits--home, this hook updatescli.Homeonly afterpreScanHomeArg()andbindRuntimeLayoutResolver()have already selected the default config/data roots. Commands consequently keep reading the user's normal configuration and credentials rather than the locked directory, despite the flag target showing the locked value. The locked home must participate in layout binding before the resolver is constructed, or this flag should be rejected as unsupported.Useful? React with 👍 / 👎.