Skip to content
Merged
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
3 changes: 3 additions & 0 deletions docs/cli-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `--<flag>-env NAME`,
`--<flag>-file path`, or `--<flag>-stdin` over passing secrets directly in
shell arguments.

## Example Paths

Expand Down
10 changes: 9 additions & 1 deletion internal/codegen/render/skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`: `--<flag>-env`, `--<flag>-file`, or `--<flag>-stdin`.\n")
return b.String()
}

Expand Down Expand Up @@ -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")
Expand All @@ -555,6 +556,13 @@ func renderCatalogReference(manifest *config.Manifest) string {
fmt.Fprintf(&b, "Run `%s commands show <path...> --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 `--<flag>` value; keep this for compatibility or non-secret values.\n")
b.WriteString("- `env`: pass `--<flag>-env NAME` to read the value from an environment variable.\n")
b.WriteString("- `file`: pass `--<flag>-file path` to read the value from a file.\n")
b.WriteString("- `stdin`: pass `--<flag>-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")
Expand Down
3 changes: 2 additions & 1 deletion internal/codegen/render/skill_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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", "--<flag>-env", "--<flag>-file", "--<flag>-stdin", "--set-str", "-o json"} {
if !strings.Contains(catalog, want) {
t.Errorf("catalog.md missing %q", want)
}
Expand Down
122 changes: 119 additions & 3 deletions pkg/runtime/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package runtime
import (
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"strconv"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
49 changes: 49 additions & 0 deletions pkg/runtime/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion pkg/runtime/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
"github.com/spf13/cobra"
)

const CatalogSchemaVersion = 7
const CatalogSchemaVersion = 8
const DefaultSearchLimit = 20

const catalogCommandAnnotation = "lathe.catalog.command"
Expand Down Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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,
Expand All @@ -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,
})
Expand Down
28 changes: 28 additions & 0 deletions pkg/runtime/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down