diff --git a/docs/cli-usage.md b/docs/cli-usage.md index 0e34347..d189faf 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -329,6 +329,9 @@ Rules: - Prefer `-o json` for agent-readable output. - Use `--file`, `--set`, or `--set-str` according to the command detail body contract. +- If a command detail flag exposes `input_modes`, prefer `---env NAME`, + `---file path`, or `---stdin` over passing secrets directly in + shell arguments. ## Example Paths diff --git a/internal/codegen/render/skill.go b/internal/codegen/render/skill.go index 9f4147c..e27655b 100644 --- a/internal/codegen/render/skill.go +++ b/internal/codegen/render/skill.go @@ -518,6 +518,7 @@ func renderSkillMD(manifest *config.Manifest, refs []moduleRef) string { b.WriteString("- Do not execute directly from search results; confirm with `commands show` first.\n") b.WriteString("- Prefer `-o json` for machine-readable command output unless the user asks for human-readable output.\n") b.WriteString("- Use `--file`, `--set`, or `--set-str` for JSON request bodies according to `commands show` body requirements.\n") + b.WriteString("- For sensitive flags, prefer safe modes from `flags[].input_modes`: `---env`, `---file`, or `---stdin`.\n") return b.String() } @@ -545,7 +546,7 @@ func renderCatalogReference(manifest *config.Manifest) string { b.WriteString("- `shortcuts`: root-level commands that execute the same operation with preset flag values.\n") b.WriteString("- `http`: HTTP method and path template.\n") fmt.Fprintf(&b, "- `http.default_hostname`: optional source-level host selected after explicit `--hostname` and `$%s`; when present it is used before the single-host fallback from `hosts.yml`.\n", manifest.CLI.HostEnv) - b.WriteString("- `flags`: CLI flags, parameter location, type, required state, defaults, enum values, format, and help.\n") + b.WriteString("- `flags`: CLI flags, parameter location, type, required state, defaults, enum values, format, input modes, and help.\n") b.WriteString("- `body`: request body requirement and media type.\n") b.WriteString("- `auth`: whether auth is required and which scopes are declared.\n") b.WriteString("- `examples`: runnable examples with optional body shape, output hints, and follow-up commands.\n") @@ -555,6 +556,13 @@ func renderCatalogReference(manifest *config.Manifest) string { fmt.Fprintf(&b, "Run `%s commands show --json` before executing an unfamiliar command. This is the source of truth for flags, body, auth, HTTP path, and output hints.\n\n", cli) b.WriteString("## Schema\n\n") fmt.Fprintf(&b, "Run `%s commands schema --json` to read the catalog schema version before parsing catalog JSON with durable tooling.\n\n", cli) + b.WriteString("## Sensitive Flags\n\n") + b.WriteString("When a flag entry has `input_modes`, prefer safe modes over putting secrets directly in shell arguments.\n\n") + b.WriteString("- `flag`: pass the direct `--` value; keep this for compatibility or non-secret values.\n") + b.WriteString("- `env`: pass `---env NAME` to read the value from an environment variable.\n") + b.WriteString("- `file`: pass `---file path` to read the value from a file.\n") + b.WriteString("- `stdin`: pass `---stdin` to read the value from stdin.\n") + b.WriteString("- Use only one input mode for the same flag.\n\n") b.WriteString("## Request Bodies\n\n") b.WriteString("- `--file path`: read a JSON body from a file.\n") b.WriteString("- `--file -`: read a JSON body from stdin.\n") diff --git a/internal/codegen/render/skill_test.go b/internal/codegen/render/skill_test.go index 626214b..7777da6 100644 --- a/internal/codegen/render/skill_test.go +++ b/internal/codegen/render/skill_test.go @@ -93,6 +93,7 @@ func TestRenderSkillDirectory_GeneratesSkillStructure(t *testing.T) { "acmectl commands schema --json", "auth.required=true", "references/modules/users.md", + "flags[].input_modes", } { if !strings.Contains(skill, want) { t.Errorf("SKILL.md missing %q", want) @@ -113,7 +114,7 @@ func TestRenderSkillDirectory_GeneratesSkillStructure(t *testing.T) { } catalog := readFile(t, dir, "skills/acmectl/references/catalog.md") - for _, want := range []string{"## Search", "## Full Catalog", "## Command Detail", "## Schema", "--set-str", "-o json"} { + for _, want := range []string{"## Search", "## Full Catalog", "## Command Detail", "## Sensitive Flags", "## Schema", "input_modes", "---env", "---file", "---stdin", "--set-str", "-o json"} { if !strings.Contains(catalog, want) { t.Errorf("catalog.md missing %q", want) } diff --git a/pkg/runtime/build.go b/pkg/runtime/build.go index c6c68e4..5c1c915 100644 --- a/pkg/runtime/build.go +++ b/pkg/runtime/build.go @@ -3,6 +3,7 @@ package runtime import ( "encoding/json" "fmt" + "io" "net/url" "os" "strconv" @@ -98,6 +99,13 @@ func buildCmd(s CommandSpec) *cobra.Command { Long: s.Long, Example: s.Example, RunE: func(cmd *cobra.Command, _ []string) error { + if err := resolveSafeInputFlags(cmd, s.Params, vals); err != nil { + return err + } + if err := validateRequiredSafeParams(cmd, s.Params, s.RequestBody != nil); err != nil { + return err + } + var hostname string var clientOpts ClientOptions var err error @@ -290,7 +298,8 @@ func buildCmd(s CommandSpec) *cobra.Command { v := new(string) vals[p.Name] = v cmd.Flags().StringVar(v, p.Flag, p.Default, p.Help) - if p.Default == "" { + addSafeInputFlags(cmd, p) + if p.Default == "" && !isSensitiveStringParam(p) { _ = cmd.MarkFlagRequired(p.Flag) } if p.Deprecated { @@ -340,8 +349,9 @@ func buildCmd(s CommandSpec) *cobra.Command { v := new(string) vals[p.Name] = v cmd.Flags().StringVar(v, p.Flag, p.Default, p.Help) + addSafeInputFlags(cmd, p) } - if p.Required && p.Default == "" && (p.In != InVariable || s.RequestBody == nil) { + if p.Required && p.Default == "" && (p.In != InVariable || s.RequestBody == nil) && !isSensitiveStringParam(p) { _ = cmd.MarkFlagRequired(p.Flag) } if p.Deprecated { @@ -379,6 +389,80 @@ func buildCmd(s CommandSpec) *cobra.Command { return cmd } +func addSafeInputFlags(cmd *cobra.Command, p ParamSpec) { + if !isSensitiveStringParam(p) { + return + } + cmd.Flags().String(p.Flag+"-env", "", "read --"+p.Flag+" from an environment variable") + cmd.Flags().String(p.Flag+"-file", "", "read --"+p.Flag+" from a file") + cmd.Flags().Bool(p.Flag+"-stdin", false, "read --"+p.Flag+" from stdin") +} + +func resolveSafeInputFlags(cmd *cobra.Command, params []ParamSpec, vals map[string]any) error { + for _, p := range params { + if !isSensitiveStringParam(p) { + continue + } + changed := 0 + for _, flag := range []string{p.Flag, p.Flag + "-env", p.Flag + "-file", p.Flag + "-stdin"} { + if cmd.Flags().Changed(flag) { + changed++ + } + } + if changed == 0 { + continue + } + if changed > 1 { + return fmt.Errorf("use only one of --%s, --%s-env, --%s-file, or --%s-stdin", p.Flag, p.Flag, p.Flag, p.Flag) + } + var value string + switch { + case cmd.Flags().Changed(p.Flag): + continue + case cmd.Flags().Changed(p.Flag + "-env"): + name, _ := cmd.Flags().GetString(p.Flag + "-env") + value = os.Getenv(name) + if value == "" { + return fmt.Errorf("environment variable %s is empty", name) + } + case cmd.Flags().Changed(p.Flag + "-file"): + path, _ := cmd.Flags().GetString(p.Flag + "-file") + data, err := os.ReadFile(path) + if err != nil { + return err + } + value = string(data) + case cmd.Flags().Changed(p.Flag + "-stdin"): + data, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + value = string(data) + } + value = strings.TrimSpace(value) + if value == "" { + return fmt.Errorf("--%s value is empty", p.Flag) + } + *vals[p.Name].(*string) = value + } + return nil +} + +func validateRequiredSafeParams(cmd *cobra.Command, params []ParamSpec, hasRequestBody bool) error { + for _, p := range params { + if !p.Required || p.Default != "" || !isSensitiveStringParam(p) { + continue + } + if p.In == InVariable && hasRequestBody { + continue + } + if !flagChangedOrDefault(cmd, p) { + return fmt.Errorf("required flag(s) \"%s\" not set", p.Flag) + } + } + return nil +} + func mountShortcuts(root *cobra.Command, specs []CommandSpec) error { for _, spec := range specs { for _, shortcut := range spec.Shortcuts { @@ -540,7 +624,39 @@ func validateShortcutParamValue(p ParamSpec, value string) error { } func flagChangedOrDefault(cmd *cobra.Command, p ParamSpec) bool { - return cmd.Flags().Changed(p.Flag) || p.Default != "" + if cmd.Flags().Changed(p.Flag) || p.Default != "" { + return true + } + if !isSensitiveStringParam(p) { + return false + } + return cmd.Flags().Changed(p.Flag+"-env") || cmd.Flags().Changed(p.Flag+"-file") || cmd.Flags().Changed(p.Flag+"-stdin") +} + +func isSensitiveStringParam(p ParamSpec) bool { + if p.GoType != "string" { + return false + } + if strings.EqualFold(p.Format, "password") { + return true + } + name := sensitiveNameKey(p.Name + " " + p.Flag) + for _, marker := range []string{"password", "secret", "credential", "apikey", "privatekey", "accesstoken", "refreshtoken", "bearertoken", "authtoken"} { + if strings.Contains(name, marker) { + return true + } + } + return sensitiveNameKey(p.Name) == "token" || sensitiveNameKey(p.Flag) == "token" +} + +func sensitiveNameKey(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { + b.WriteRune(r) + } + } + return b.String() } func flagStringValue(v any) string { diff --git a/pkg/runtime/build_test.go b/pkg/runtime/build_test.go index 5c6a1b9..542284a 100644 --- a/pkg/runtime/build_test.go +++ b/pkg/runtime/build_test.go @@ -265,6 +265,36 @@ func TestBuild_VariableFlagsMergeIntoEnvelope(t *testing.T) { } } +func TestBuild_SensitiveVariableSafeInputModes(t *testing.T) { + root, url, recorded := newRecordingGraphQLRoot(t, createCredentialSpec()) + t.Setenv("OPENAI_API_KEY", "sk-env") + + cmd := mustFindChild(t, mustFindChild(t, mustFindChild(t, root, "demo"), "credentials"), "create-credential") + for _, flag := range []string{"input-api-key-env", "input-api-key-file", "input-api-key-stdin"} { + if cmd.Flag(flag) == nil { + t.Fatalf("missing --%s", flag) + } + } + + root.SetArgs([]string{"--hostname", url, "demo", "credentials", "create-credential", "--input-api-key-env", "OPENAI_API_KEY"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + body, called := recorded() + if !called { + t.Fatal("request was not sent") + } + var got map[string]any + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("invalid request JSON %q: %v", string(body), err) + } + vars, _ := got["variables"].(map[string]any) + input, _ := vars["input"].(map[string]any) + if input["apiKey"] != "sk-env" { + t.Fatalf("apiKey = %#v, want sk-env", input["apiKey"]) + } +} + func TestBuild_RequiredVariableCanComeFromBodyInput(t *testing.T) { cases := []struct { name string @@ -336,6 +366,25 @@ func TestBuild_RequiredVariableCanComeFromBodyInput(t *testing.T) { } } +func createCredentialSpec() CommandSpec { + return CommandSpec{ + Group: "Credentials", + Use: "create-credential", + Method: "POST", + PathTpl: "/graphql", + Params: []ParamSpec{ + {Name: "input.apiKey", Flag: "input-api-key", In: InVariable, GoType: "string", Required: true, Help: "API key"}, + }, + RequestBody: &RequestBody{ + Required: true, + MediaType: "application/json", + Template: `{"query":"mutation createCredential($input: CredentialInput!) { createCredential(input: $input) { id } }","variables":{"input":{}}}`, + MergePath: "variables", + }, + Security: &SecurityHint{Public: true}, + } +} + func createAppSpec() CommandSpec { return CommandSpec{ Group: "Apps", diff --git a/pkg/runtime/catalog.go b/pkg/runtime/catalog.go index 78be9c8..000e6ab 100644 --- a/pkg/runtime/catalog.go +++ b/pkg/runtime/catalog.go @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -const CatalogSchemaVersion = 7 +const CatalogSchemaVersion = 8 const DefaultSearchLimit = 20 const catalogCommandAnnotation = "lathe.catalog.command" @@ -95,6 +95,7 @@ type CatalogFlag struct { Default string `json:"default,omitempty"` Enum []string `json:"enum,omitempty"` Format string `json:"format,omitempty"` + InputModes []string `json:"input_modes,omitempty"` Deprecated bool `json:"deprecated"` Help string `json:"help,omitempty"` } @@ -259,6 +260,10 @@ func findChildCommand(parent *cobra.Command, name string) *cobra.Command { func catalogCommand(service string, spec CommandSpec, path []string) CatalogCommand { flags := make([]CatalogFlag, 0, len(spec.Params)) for _, p := range spec.Params { + var inputModes []string + if isSensitiveStringParam(p) { + inputModes = []string{"flag", "env", "file", "stdin"} + } flags = append(flags, CatalogFlag{ Name: p.Name, Flag: p.Flag, @@ -268,6 +273,7 @@ func catalogCommand(service string, spec CommandSpec, path []string) CatalogComm Default: p.Default, Enum: append([]string(nil), p.Enum...), Format: p.Format, + InputModes: inputModes, Deprecated: p.Deprecated, Help: p.Help, }) diff --git a/pkg/runtime/catalog_test.go b/pkg/runtime/catalog_test.go index d0be655..aafb021 100644 --- a/pkg/runtime/catalog_test.go +++ b/pkg/runtime/catalog_test.go @@ -201,6 +201,34 @@ func TestBuildCatalog_RequestBodyEnvelope(t *testing.T) { } } +func TestBuildCatalog_SensitiveFlagInputModes(t *testing.T) { + root := newRootWithModuleGroup() + Build(root, "demo", []CommandSpec{{ + Group: "Credentials", + Use: "create-credential", + Short: "Create credential", + OperationID: "Credentials_Create", + Method: "POST", + PathTpl: "/credentials", + Params: []ParamSpec{ + {Name: "apiKey", Flag: "api-key", In: InQuery, GoType: "string", Required: true, Help: "API key"}, + {Name: "name", Flag: "name", In: InQuery, GoType: "string", Required: true, Help: "Name"}, + }, + }}) + + catalog := BuildCatalog(root, CatalogOptions{CLIName: "myctl"}) + if len(catalog.Commands) != 1 { + t.Fatalf("commands = %d, want 1", len(catalog.Commands)) + } + flags := catalog.Commands[0].Flags + if !reflect.DeepEqual(flags[0].InputModes, []string{"flag", "env", "file", "stdin"}) { + t.Fatalf("api-key input modes = %#v", flags[0].InputModes) + } + if flags[1].InputModes != nil { + t.Fatalf("name input modes = %#v", flags[1].InputModes) + } +} + func TestBuildCatalog_HiddenCommands(t *testing.T) { root := newRootWithModuleGroup() Build(root, "demo", []CommandSpec{