Skip to content

Allow command-layer library consumers to provide per-invocation UI streams #5039

Description

@joelmccoy

Description

Cosign command types such as verify.VerifyBlobCmd can be called from Go programs, but callers cannot redirect their UI output for a single invocation. Successful blob verification calls ui.Infof(ctx, "Verified OK"); without a Cosign UI environment in the context, that function writes to os.Stderr.

The default is appropriate for the cosign executable. In an application that calls VerifyBlobCmd.Exec, however, the message bypasses the application's logger, formatting, log-level controls, and configured destination.

Applications may use the command layer to stay aligned with Cosign's option validation and verification behavior:

cmd := &verify.VerifyBlobCmd{
    // Cosign verification options
}

if err := cmd.Exec(ctx, blobPath); err != nil {
    return err
}

A successful verification can therefore introduce an unformatted line among the application's own logs:

2026-08-04 10:10:05 INF processing artifact
Verified OK
2026-08-04 10:10:09 INF processing complete

Cosign already supports per-context streams internally through ui.Env and ui.WithEnv. Go prevents external consumers from importing internal/ui, and Cobra's SetErr does not affect output written through that package.

Could Cosign provide a public, supported way for command-layer consumers to supply stdin and stderr streams for one invocation?

Possible solutions

  1. Expose a narrow public context helper over the existing UI environment. This appears to be the smallest and most consistent option because command execution already passes a context through the UI calls. The public API could accept streams without exposing the rest of internal/ui:

    type IOStreams struct {
        Stdin  io.Reader
        Stderr io.Writer
    }
    
    func WithIOStreams(ctx context.Context, streams IOStreams) context.Context

    A caller could then configure the invocation before calling Exec:

    ctx = cli.WithIOStreams(ctx, cli.IOStreams{
        Stdin:  input,
        Stderr: logWriter,
    })
    
    err := cmd.Exec(ctx, blobPath)

    The public function could continue delegating to internal/ui.WithEnv, leaving the internal UI implementation private.

  2. Add shared streams to command options. An exported IOStreams value could be embedded in command structs or common command options. VerifyBlobCmd.Exec would use those streams when creating its internal UI context. This is more discoverable on the command itself, but each command that emits UI output would need to adopt the option.

Promoting all of internal/ui to a public package would also make Env and WithEnv usable, but it would expose prompt and logging implementation details that callers do not need. A narrow stream API would provide the required control with a smaller public compatibility surface.

In either design, unset streams should retain the current os.Stdin and os.Stderr defaults. Streams must remain scoped to one invocation so concurrent commands can use different destinations without replacing process-global file handles. Supplying streams must not change verification behavior or return values.

Relevant implementation:

  • VerifyBlobCmd new-bundle success output:
    _, err = cosign.VerifyNewBundle(ctx, co, artifactPolicyOption, bundle)
    if err != nil {
    return err
    }
    ui.Infof(ctx, "Verified OK")
    return nil
  • VerifyBlobCmd legacy success output:
    if _, err = cosign.VerifyBlobSignature(ctx, signature, co); err != nil {
    return err
    }
    ui.Infof(ctx, "Verified OK")
    return nil
  • Internal UI environment and context functions:
    type Env struct {
    Stderr io.Writer
    Stdin io.Reader
    }
    // defaultEnv returns the default environment (writing to os.Stderr and
    // reading from os.Stdin).
    func defaultEnv() *Env {
    return &Env{
    Stderr: os.Stderr,
    Stdin: os.Stdin,
    }
    }
    type ctxKey struct{}
    func (c ctxKey) String() string {
    return "cosign/ui:env"
    }
    var ctxKeyEnv = ctxKey{}
    // getEnv gets the environment from ctx.
    //
    // If ctx does not contain an environment, getEnv returns the default
    // environment (see defaultEnvironment).
    func getEnv(ctx context.Context) *Env {
    e, ok := ctx.Value(ctxKeyEnv).(*Env)
    if !ok {
    return defaultEnv()
    }
    return e
    }
    // WithEnv adds the environment to the context.
    func WithEnv(ctx context.Context, e *Env) context.Context {
    return context.WithValue(ctx, ctxKeyEnv, e)
    }

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions