Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ gog --account you@gmail.com \
```

See [Automation](docs/automation.md) for output and exit-code contracts, and
[Safety Profiles](docs/safety-profiles.md) for binaries with command policy
baked in at build time.
[Safety Profiles](docs/safety-profiles.md) for binaries with command policy and
locked flag values baked in at build time.

## Accounts and authentication

Expand Down
54 changes: 54 additions & 0 deletions cmd/bake-safety-profile/locked_flags_test.go
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)
}
}
28 changes: 28 additions & 0 deletions cmd/bake-safety-profile/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,38 @@ func generate(profile *safetyprofile.Profile) []byte {
writeMatcher(&out, "bakedSafetyAllowMatch", profile.AllowRules, profile.AllowAll)
out.WriteString("\n")
writeMatcher(&out, "bakedSafetyDenyMatch", profile.DenyRules, false)
out.WriteString("\n")
writeLockedFlags(&out, profile.LockedFlags)

return out.Bytes()
}

// writeLockedFlags emits the locked flag lookup. Names are hashed like the command
// rules; the locked values are literals because the flag parser consumes them. The
// count travels alongside so the runtime can tell that every lock matched a real
// flag without the names being written down.
func writeLockedFlags(out *bytes.Buffer, flags []safetyprofile.LockedFlag) {
out.WriteString("func bakedSafetyLockedFlag(name string) (string, bool) {\n")
if len(flags) == 0 {
out.WriteString("\treturn \"\", false\n}\n")
out.WriteString("\nfunc bakedSafetyLockedFlagCount() int { return 0 }\n")
return
}
out.WriteString("\tswitch bakedSafetyHashPath([]string{name}) {\n")
seen := make(map[uint64]string, len(flags))
for _, flag := range flags {
h := safetyprofile.HashRule(flag.Name)
if existing, dup := seen[h]; dup {
fmt.Fprintf(os.Stderr, "bake-safety-profile: hash collision between locked flags %q and %q (FNV-64a=%#x)\n", existing, flag.Name, h)
os.Exit(1)
}
seen[h] = flag.Name
fmt.Fprintf(out, "\tcase 0x%016x:\n\t\treturn %s, true\n", h, strconv.Quote(flag.Value))
}
out.WriteString("\t}\n\treturn \"\", false\n}\n")
fmt.Fprintf(out, "\nfunc bakedSafetyLockedFlagCount() int { return %d }\n", len(flags))
}

func writeMatcher(out *bytes.Buffer, name string, rules []string, matchAll bool) {
fmt.Fprintf(out, "func %s(path []string) bool {\n", name)
if matchAll {
Expand Down
96 changes: 96 additions & 0 deletions docs/safety-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ Runtime guards such as `--enable-commands`, `--disable-commands`, and
stronger: the policy is compiled into the binary and cannot be changed with
flags, environment variables, config files, or shell arguments.

A profile controls which commands may run. Through `locked-flags` it can also fix
the value of individual flags, so settings such as sanitized output do not depend
on the caller passing them. See [Locked Flags](#locked-flags).

## Quick Start

Build an agent-safe binary:
Expand Down Expand Up @@ -111,6 +115,20 @@ Good for:
- monitoring
- read-only agent context gathering

`safety-profiles/agent-safe-locked.yaml` and `safety-profiles/readonly-locked.yaml`

The same command rules as `agent-safe` and `readonly`, plus locked
`sanitize-content`, `wrap-untrusted`, and `no-input`. The read-only one also locks
`readonly`, which rejects a mutating request made inside an allowed command. Note
that locking `sanitize-content` makes `gmail get --format raw` unavailable, since
that combination is rejected.

Good for:

- agents that must not be able to request unsanitized or unmarked output
- unattended jobs where a prompt would hang the caller
- handing a binary to a caller whose command line you do not control

`safety-profiles/full.yaml`

Allows everything. This is mostly useful for smoke testing the build path or for
Expand Down Expand Up @@ -142,6 +160,7 @@ Rules:
- unlisted commands are blocked when the profile has any allow rules.
- command names are written as dot paths internally, such as `gmail.drafts.create`.
- `aliases:` controls root shortcuts such as `send`, `ls`, `search`, and `upload`.
- `locked-flags:` is not a command path; it locks flag values, see [Locked Flags](#locked-flags).

Parent rules are prefix matches. For example, `drive: true` allows every `drive`
subcommand unless a child is explicitly blocked. For restrictive profiles, prefer
Expand All @@ -155,6 +174,83 @@ gmail:
modify: false
```

## Locked Flags

Command rules decide what may run. They do not decide how it runs. Sanitized
output, for example, happens only when the caller passes `--sanitize-content`,
and a flag that does take its value from the environment is still overridden by
the command line. When the command line is written by a model rather than a
person, neither is a setting you can rely on.

`locked-flags` fixes a flag's value in the profile:

```yaml
locked-flags:
sanitize-content: true
wrap-untrusted: true
no-input: true
inline-max-bytes: 8388608
```

Values may be booleans, integers, or strings, and are parsed exactly as the flag
itself parses them.

A locked flag behaves as follows:

- the value is applied before the command runs, so the caller need not pass it.
- setting that flag on the command line is an error, not an override.
- aliases are covered, since the check is on the canonical flag name.
- a locked flag that the selected command does not define is ignored rather than
an error, so per-command flags can be locked without breaking other commands.
- a locked output mode wins over the competing one. With `json` locked, `GOG_PLAIN`
gives way silently, since an environment default is a preference rather than a
request, while an explicit `--plain` is refused for asking output the profile
forbids.
- commands that build partial requests from which flags were given treat a locked
flag as given, so the locked value reaches the request.

Two kinds of lock are refused rather than half-applied, because their value is
consumed before locks run: `--home`, which the layout resolver reads straight from
argv to choose config and credential roots, and any flag marked required, which Kong
validates during parsing. A name matching no flag at all is refused too. In each case
the binary declines to run instead of reporting a guarantee it does not have.

Setting a locked flag fails before the command handler runs:

```text
flag --sanitize-content is locked by baked safety profile "agent-safe-locked"
```

Because a locked value can make a command reject a combination the caller never
asked for, usage errors name the locked flags:

```text
--sanitize-content cannot be used with --format raw
note: --no-input, --sanitize-content, --wrap-untrusted locked by baked safety profile "agent-safe-locked"
```

Lock deliberately. A flag that participates in a conflict or a requirement removes
the command paths that contradict it. Locking `sanitize-content` makes
`gmail get --format raw` unavailable, which is an acceptable trade for an agent
profile because raw is the unsanitized dump.

Locking a flag that requires another one is the case to avoid. `--reply-all` needs
a message or thread to reply to, so `locked-flags: {reply-all: true}` breaks an
ordinary draft:

```bash
bin/gog-my-agent gmail drafts create --to you@example.com --subject Hi --body Hi
```

The command is allowed by the profile and the caller passed nothing wrong, yet it
fails because the locked flag demands an argument they had no reason to supply:

```text
--reply-all requires --reply-to-message-id or --thread-id
```

Flags that only shape output are the safe candidates.

## Choosing A Profile

Use `readonly` when the caller should never change Google or local `gog` state.
Expand Down
11 changes: 11 additions & 0 deletions internal/cmd/kong_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,18 @@ package cmd

import "github.com/alecthomas/kong"

// flagProvided reports whether a command should treat the flag as supplied. A value
// a baked profile locked counts: commands that build partial requests from which
// flags were given would otherwise drop a locked value on the floor, leaving the
// lock set on the struct but absent from the request.
func flagProvided(kctx *kong.Context, name string) bool {
return flagOnCommandLine(kctx, name) || lockedFlagNames[name]
}

// flagOnCommandLine reports only what the caller typed. Lock enforcement and
// output-mode precedence need that narrower question: one to detect an override
// attempt, the other to rank an explicit flag against an environment default.
func flagOnCommandLine(kctx *kong.Context, name string) bool {
if kctx == nil {
return false
}
Expand Down
58 changes: 51 additions & 7 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply a locked home before resolving runtime paths

When a profile locks home and the caller omits --home, this hook updates cli.Home only after preScanHomeArg() and bindRuntimeLayoutResolver() 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 👍 / 👎.

Comment on lines +187 to +188

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply locked values before required-flag validation

Locked values are injected only after parser.Parse(args) has completed, so a profile cannot supply a flag tagged required:"": Kong rejects the command as missing that flag before this code runs, while explicitly supplying it is also rejected as an override. Thus locks such as title, parent, or state-file can never satisfy commands that require them, contrary to the stated behavior that callers need not pass locked values. Inject locks before parse validation or reject required flags during profile generation.

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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
Loading
Loading