secrets

package
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 11, 2026 License: AGPL-3.0 Imports: 11 Imported by: 0

Documentation

Overview

Package secrets materializes user-supplied secrets onto the sandbox pod filesystem with strict validation and TOCTOU-safe permissions.

This package is the single source of truth for secret materialization. Both code paths use it:

  • Boot-time: `workspace-agentd materialize --from /sandbox-cfg/secrets.json` invoked from the runtime entrypoint script before opencode starts.
  • Reload: the agentd HTTP handler `/v1/reload-secrets` calls Materialize directly with the request body.

Before this package existed, materialization was duplicated across a bash script (entrypoint-common.sh) and an inline Go function inside cmd/workspace-agentd/main.go, both of which suffered from Epic 17 G2: shell-quoted interpolation that broke on a single quote in the value. They also lacked input validation, used non-atomic chmod-after-write (G20), and used naive string-contains checks for path traversal.

The Materialize function:

  • Validates every field of every Secret against an allowlist (var names, key types, hostnames, protocols, mount-path scopes).
  • Writes files using os.OpenFile with O_CREATE|O_EXCL and mode 0600 so permissions are atomic with creation — no window where the file is readable with default umask.
  • Encodes the env-file value using shellquote.Bash (single-quoted with embedded single quotes escaped) so a malicious PLAINTEXT cannot break out of the bash `source` consumer at entrypoint-opencode.sh and at agentd buildEnv().
  • Resolves mount paths via filepath.Clean + strict prefix containment against the secrets base directory.
  • Returns a typed *MaterializeResult that carries per-secret outcomes (Materialized / Skipped / Failed) along with a redacted reason. The caller decides whether to surface this to the operator (via pod status) or to logs.

Threat-model invariants this package enforces:

T1 No interpretation of secret values by the shell.
T2 No file ever exists on disk with mode > 0600 for credential material.
T3 No path written outside SecretsBasePath, $HOME/.ssh, or AgentConfigPath.
T4 No env-file line that does not round-trip cleanly through `source`.
T5 An invalid secret skips that secret only; the rest still materialize.

See `secrets_test.go` for the regression corpus, including:

  • Single-quote, dollar-sign, backtick, newline injection in PLAINTEXT.
  • Path traversal via "..", URL-encoded "..", absolute paths, symlinks.
  • Hostname injection via " IdentityFile /etc/shadow".
  • Var-name injection via reserved bash names and embedded "=" / ";".

Index

Constants

This section is empty.

Variables

View Source
var ErrPartialFailure = errors.New("secret materialization had partial failures")

ErrPartialFailure is returned by Materialize when at least one secret reached OutcomeFailed. Callers should still consider partially-applied state — files for already-materialized secrets remain on disk.

This sentinel stays a plain error (not *apierrors.APIError) because it lives in pkg/ which cannot import api/internal/ (Go internal-package visibility). It is consumed only by cmd/workspace-agentd (the agent daemon), never by an HTTP handler, so HTTP status mapping is not needed.

Functions

func FormatEnvLine

func FormatEnvLine(varName, value string) string

FormatEnvLine produces an `export VAR='value'` line suitable for `source` by bash. The single-quote escaping (`'\”`) is the canonical safe form for embedding arbitrary text in a single-quoted shell string.

Consumers MUST read the resulting file via `bash source` (or an equivalent that implements bash's quoting rules), NOT via line-based regex parsing — values may contain literal newlines inside the quoted region, and a naive split-on-newline parser will mangle them.

In this codebase, the consumer is buildEnvFrom() in cmd/workspace-agentd which delegates to a bash subprocess so the source-of-truth parser is bash itself.

Types

type Filesystem

type Filesystem interface {
	RemoveAll(path string) error
	MkdirAll(path string, perm os.FileMode) error
	OpenForCreate(path string, flag int, perm os.FileMode) (io.WriteCloser, error)
	Remove(path string) error
}

Filesystem is the minimal interface Materialize needs. Tests inject a fake; production uses RealFS which delegates to os.*.

func RealFS

func RealFS() Filesystem

RealFS returns the production Filesystem.

type LLMProviderFormatter

type LLMProviderFormatter func(providers []sec.LLMProviderData) ([]byte, error)

LLMProviderFormatter is a callback that renders staged LLM provider data into the agent-specific config format. Each agent type (opencode, Claude Code, Codex) provides its own implementation.

type MaterializeResult

type MaterializeResult struct {
	Results []SecretResult `json:"results"`
}

MaterializeResult aggregates outcomes for a Materialize call. The aggregate Error is nil when every secret was Materialized; otherwise it is a sentinel that callers can wrap. Per-secret reasons live on Results so callers can render structured status.

func (*MaterializeResult) Counts

func (r *MaterializeResult) Counts() (int, int, int)

Counts returns (materialized, skipped, failed).

func (*MaterializeResult) HasFailures

func (r *MaterializeResult) HasFailures() bool

HasFailures returns true if any secret produced an OutcomeFailed. OutcomeSkipped does not count: skipping a malformed secret is a successful security decision, not a failure.

type Materializer

type Materializer struct {
	FS    Filesystem
	Paths Paths
	// contains filtered or unexported fields
}

Materializer holds dependencies for materialization. Construct with NewMaterializer or pass a Materializer{} with field defaults filled in by the caller.

func NewMaterializer

func NewMaterializer() *Materializer

NewMaterializer returns a Materializer using the production filesystem and paths derived from $HOME.

func (*Materializer) EnrichProviders

func (m *Materializer) EnrichProviders(fn func([]sec.LLMProviderData) []sec.LLMProviderData)

EnrichProviders applies fn to the staged provider slice, replacing it with the result. Callers use this to inject additional fields (e.g. a live model list fetched from the provider's /models endpoint) after Materialize and before FlushProviders. fn must not be nil.

func (*Materializer) FlushProviders

func (m *Materializer) FlushProviders(formatter LLMProviderFormatter) error

FlushProviders calls FormatProviders and writes the result to AgentConfigPath. Used by callers that do NOT have an AgentConfigWriter (e.g. the materialize subcommand, which runs as a separate process before agentd starts). Callers inside the agentd process should use FormatProviders + AgentConfigWriter.Rebuild instead.

When formatter is nil, FlushProviders is a no-op (no agent config is written). This allows callers to conditionally skip agent-specific rendering.

func (*Materializer) FormatProviders

func (m *Materializer) FormatProviders(formatter LLMProviderFormatter) ([]byte, error)

FormatProviders calls the formatter with all staged LLM provider data and returns the formatted bytes WITHOUT writing to disk. Callers that use an external config writer (e.g. AgentConfigWriter in cmd/workspace-agentd) call this instead of FlushProviders so the writer is the sole disk writer.

Returns (nil, nil) when formatter is nil or no providers are staged, matching FlushProviders' no-op semantics. This lets callers unconditionally call FormatProviders → writer.SetProviders without branching.

func (*Materializer) Materialize

func (m *Materializer) Materialize(secrets []Secret) (*MaterializeResult, error)

Materialize processes secrets and returns per-secret outcomes. The function performs a full reset of the secrets base directory, SSH directory, env file, agent config, and git credentials before applying the new set, matching the existing reload semantics.

func (*Materializer) StagedProviders

func (m *Materializer) StagedProviders() []sec.LLMProviderData

StagedProviders returns the LLM provider data accumulated during Materialize. Returns nil if no llm-provider secrets were in the batch. This allows callers to use the structured data for direct API injection (e.g., PUT /auth/:providerID) instead of or in addition to file-based config rendering via FlushProviders.

type Outcome

type Outcome string

Outcome describes what happened to a single secret.

const (
	OutcomeMaterialized Outcome = "materialized"
	OutcomeSkipped      Outcome = "skipped"
	OutcomeFailed       Outcome = "failed"
)

type Paths

type Paths struct {
	Home            string // user home (e.g. /home/sandbox)
	SecretsBaseDir  string // secret-file root (/sandbox-runtime/rt/secrets)
	SSHDir          string // SSH config directory (/sandbox-runtime/rt/ssh)
	AgentConfigPath string // opencode config (/sandbox-runtime/agent-config.json)
	SecretsEnvPath  string // env-file (/sandbox-runtime/secrets-env)
	GitCredsPath    string // git-credentials file (/sandbox-runtime/rt/git-credentials)
}

Paths configures filesystem destinations. Defaults match agentd constants; tests override.

func DefaultPaths

func DefaultPaths(home string) Paths

DefaultPaths returns production paths derived from the agentd package constants and the given home dir.

US-35.7: SSH/git/secrets paths point to /sandbox-runtime/rt/* (tmpfs) to match loadMaterializeConfig() in cmd/workspace-agentd. The $HOME-relative PVC paths are symlinks (created by init container) pointing here.

type Secret

type Secret struct {
	Type      string            `json:"type"`
	Name      string            `json:"name"`
	Metadata  map[string]string `json:"metadata"`
	Plaintext string            `json:"plaintext"`
}

Secret is the materialization-time representation of a credential. Metadata is intentionally kept as a typed map to avoid leaking arbitrary JSON shape into the materializer; unknown keys are ignored.

func LoadSecretsFile

func LoadSecretsFile(path string) ([]Secret, error)

LoadSecretsFile reads and parses a secrets.json file.

type SecretResult

type SecretResult struct {
	Type    string  `json:"type"`
	Name    string  `json:"name"`
	Outcome Outcome `json:"outcome"`
	Reason  string  `json:"reason,omitempty"`
}

SecretResult is the per-secret outcome reported by Materialize. Reason is human-readable but MUST NOT include the secret's plaintext.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL