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 ¶
- Variables
- func FormatEnvLine(varName, value string) string
- type Filesystem
- type LLMProviderFormatter
- type MaterializeResult
- type Materializer
- func (m *Materializer) EnrichProviders(fn func([]sec.LLMProviderData) []sec.LLMProviderData)
- func (m *Materializer) FlushProviders(formatter LLMProviderFormatter) error
- func (m *Materializer) FormatProviders(formatter LLMProviderFormatter) ([]byte, error)
- func (m *Materializer) Materialize(secrets []Secret) (*MaterializeResult, error)
- func (m *Materializer) StagedProviders() []sec.LLMProviderData
- type Outcome
- type Paths
- type Secret
- type SecretResult
Constants ¶
This section is empty.
Variables ¶
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 ¶
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.*.
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 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 ¶
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 ¶
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.