15 KiB
Public Package Cleanup And Hardening Plan
This plan converts the public package audit into staged implementation work. The target audience is an LLM coding agent implementing each stage in order.
The goals are:
- make public-facing code concise, idiomatic, and predictable;
- harden all public boundary inputs against accidental secret exposure, mutation side effects, and malformed caller-provided values;
- preserve the current library user story: a downstream Go application can provide prompt/profile sources, choose built-ins or in-memory profiles, pass a request-scoped API key, and receive prepared or generated results;
- avoid broad feature work or unrelated refactors.
Unless a stage explicitly says otherwise, do not change public method names, request/result shapes, CLI behavior, HTTP behavior, or built-in profile semantics.
Stage 1: Make Load Error Classification Deterministic
Problem:
internal/usecase.ErrProfileLoad currently wraps both prompt-definition load failures and execution-profile load failures. Public mapping then infers ErrPromptLoad or ErrProfileLoad from wrapped causes. This is brittle because some profile repository failures, such as profile directory read failures, are not wrapped with a profile sentinel and can still map to scriptorium.ErrPromptLoad.
Implementation:
- In
internal/usecase, split the current load sentinel into two explicit sentinels:ErrPromptLoad = errors.New("failed to load prompt definition")ErrProfileLoad = errors.New("failed to load execution profile")
- Update
Runner.Prepare:- wrap
promptDefs.GetPromptDefinitionfailures withErrPromptLoad; - wrap prompt-definition hashing failures with
ErrPromptLoad; - wrap
profiles.GetProfilefailures withErrProfileLoad.
- wrap
- Update internal tests that currently expect prompt load failures to use
ErrProfileLoad. - Simplify public error mapping in
errors.go:- map
usecase.ErrPromptLoadto publicErrPromptLoad; - map
usecase.ErrProfileLoadto publicErrProfileLoad; - retain direct mappings for
promptdef.ErrPromptDefinitionNotFound,profile.ErrProfileNotFound, profile YAML/profile validation causes, and prompt YAML/prompt validation causes.
- map
- Keep adapter behavior stable:
- HTTP prompt-load failures should still return the existing prompt-load status/code;
- HTTP profile-load failures should still return the existing profile-load status/code;
- CLI errors should remain concise and must not include raw secrets.
Tests:
- Add public package tests proving:
- prompt-definition repository load errors map to
scriptorium.ErrPromptLoad; - selected profile repository read/list errors map to
scriptorium.ErrProfileLoad; - selected malformed profile YAML and raw
api_keyrejection still map toErrProfileLoad; - prompt-not-found and profile-not-found sentinels still map to
ErrPromptNotFoundandErrProfileNotFound.
- prompt-definition repository load errors map to
- Add or update internal usecase tests for the new
ErrPromptLoadandErrProfileLoadsplit. - Run
go test ./....
Stage 2: Stop Mutating Caller-Owned HTTP Clients
Problem:
The default OpenAI-compatible client currently stores the caller-provided *http.Client and mutates Timeout when it is zero. Because the public scriptorium.Config.HTTPClient accepts this client, engine construction can unexpectedly modify caller-owned shared clients, including http.DefaultClient.
Implementation:
- In
internal/llm.NewOpenAICompatibleClient, clone any suppliedcfg.HTTPClientbefore applying timeout defaults:- if
cfg.HTTPClient != nil, copy the struct value withcloned := *cfg.HTTPClientand use&cloned; - if
cloned.Timeout == 0, setcloned.Timeoutto the resolved default timeout; - do not mutate
cfg.HTTPClient.
- if
- Preserve existing request-level timeout behavior:
- positive
timeout_secondsstill overrides the client timeout for that request; - explicit
timeout_seconds: 0with target presence still disables client timeout for that request; - omitted timeout still uses the client/default timeout.
- positive
- Do not clone or replace shared internals such as
Transport; shallowhttp.Clientcopying is the idiomatic choice here.
Tests:
- Add LLM client tests proving:
- a supplied zero-timeout
http.Clientremains unchanged afterNewOpenAICompatibleClient; - the constructed client still uses the default timeout internally;
- a supplied nonzero-timeout
http.Clientremains unchanged; - request-level timeout override behavior remains unchanged.
- a supplied zero-timeout
- Run
go test ./internal/llm ./....
Stage 3: Harden Public JSON-Like Inputs
Problem:
Public ExtraParams and structured-output schema values are copied with a permissive reflection helper. It deep-copies useful typed JSON-like maps/slices, but it accepts arbitrary values, pointers, functions, channels, and cyclic structures. A cyclic value can recurse indefinitely and crash the process.
Decision:
Replace permissive copying at public boundaries with validating JSON-compatible copying. This is more correct and maintainable than trying to copy arbitrary Go object graphs. ExtraParams and schema payloads are provider JSON payloads; accepting non-JSON values only delays failure and makes security behavior harder to reason about.
Implementation:
- Introduce an internal root-package helper for public boundary JSON values, for example:
copyJSONMap(src map[string]any) (map[string]any, error)copyJSONValue(value any, path string, seen map[visit]struct{}) (any, error)
- Accept only JSON-compatible value kinds:
nilstringbool- signed and unsigned integer types that can be represented safely enough for JSON provider payloads;
- floating-point values excluding
NaNand infinities; - maps with string keys;
- slices and arrays;
- values behind
interface; - optionally pointers only by dereferencing them, with cycle detection.
- Reject unsupported values with clear public errors:
WithProfilesprofileExtraParamserrors returnErrInvalidConfig;OpenAICompatibleProfileshould not silently accept invalidExtraParams; because it currently returns onlyProfile, keep it a pure field-copy constructor and perform validation inWithProfiles;- request
ExecutionTargetOverride.ExtraParamserrors returnErrInvalidRequest.
- Track visited map/slice/pointer identities to reject cycles instead of recursing indefinitely.
- Keep typed JSON-compatible values isolated from caller mutation:
map[string]string,map[string]int,[]int,[]float64, nestedmap[string]any, and values behindanyshould still be copied.
- Preserve internal-only copy helpers where they are operating on trusted domain data, but use validating helpers at public-to-domain boundaries.
- Ensure any provider
extra_paramsvalidation in the LLM adapter still rejects reserved keys and empty keys as it does today.
Tests:
- Add public package tests for
ExecutionTargetOverride.ExtraParams:- typed JSON-compatible maps/slices are accepted and isolated from caller mutation;
- function/channel/struct values return
ErrInvalidRequest; - maps with non-string keys return
ErrInvalidRequest; - cyclic maps/slices return
ErrInvalidRequestwithout panic or hang; NaNand infinities returnErrInvalidRequest.
- Add public package tests for
WithProfiles:- invalid profile
ExtraParamsreturnErrInvalidConfig; - cyclic profile
ExtraParamsreturnErrInvalidConfig; - valid typed maps/slices remain deep-copied.
- invalid profile
- Run
go test ./....
Stage 4: Redact Public Secret Fields In String Formatting
Problem:
RunRequest.APIKey and GenerateRequest.APIKey use json:"-", so JSON marshaling does not emit direct API keys. Ordinary Go formatting such as fmt.Printf("%+v", req) can still print exported string fields.
Decision:
Add redacting formatting methods to the public request types that directly carry raw secrets. This does not protect against reflection-based dumpers, but it materially improves safety for normal fmt, structured logs that call String, and test diagnostics. Avoid replacing raw strings with a new secret wrapper type in this cleanup pass because that would add API complexity and likely require downstream changes.
Implementation:
- Add
String() stringandGoString() stringmethods for:RunRequestGenerateRequest
- Formatting behavior:
- include useful non-secret context such as prompt/profile IDs and whether an API key is set;
- never include the raw
APIKeyvalue; - avoid dumping full rendered prompt or output content unless existing public behavior explicitly needs it.
- Consider adding redaction helpers for nested public types only if needed by these methods.
- Do not alter JSON tags or existing JSON output shapes.
- Update consumer docs to warn that reflection-based debug dumpers can still expose exported fields and that callers should avoid logging raw request structs with reflection dump libraries.
Tests:
- Add public package tests proving:
fmt.Sprint(req),fmt.Sprintf("%+v", req), andfmt.Sprintf("%#v", req)do not contain the direct API key forRunRequest;- the same holds for
GenerateRequest; - JSON marshaling continues to omit the direct API key.
- Run
go test ./....
Stage 5: Polish The Public Option Type
Problem:
The public API currently exposes type Option func(*engineOptions) error. This works, but go doc shows an exported type whose signature references an unexported implementation type. That is legal Go, but it is not a polished public API.
Decision:
Change Option to an exported interface with an unexported method, backed by an unexported function adapter. This is the standard pattern when a package wants an opaque option type while preserving functional option ergonomics for package-provided options.
Implementation:
-
Replace the exported function type with:
type Option interface { apply(*engineOptions) error } type optionFunc func(*engineOptions) error func (f optionFunc) apply(options *engineOptions) error { return f(options) } -
Update all option constructors to return
optionFunc(...). -
Update
NewEngineto callopt.apply(&options). -
Preserve nil-option behavior if practical:
- because
Optionis now an interface, a nil interface can still be skipped; - typed nil option values are not expected from package users and do not need special support.
- because
-
This is a public source compatibility change only for callers that manually construct custom
Optionvalues. The package does not currently provide a documented way for callers to create custom options, so this is acceptable before API freeze.
Tests:
- Existing option tests should continue passing.
- Add a small compile-time or runtime test proving package-provided options still compose as before.
- Run
go test ./...andgo doc .to verify the public docs no longer expose*engineOptionsin theOptionsignature.
Stage 6: Review And Tighten Redundant Public Surface
Problem:
OpenAICompatibleProfileConfig currently duplicates Profile almost exactly, and OpenAICompatibleProfile is a shallow convenience constructor plus deep copy. This is not harmful, but it may be unnecessary public surface unless it provides provider-specific clarity or future extension space.
Decision:
Keep OpenAICompatibleProfile and OpenAICompatibleProfileConfig for now, but tighten documentation and tests around their purpose. The constructor should remain a convenience for building a normal in-memory Profile; it must not register global state, maintain a model catalog, or hide credential resolution. Removing it would reduce surface area, but it would also undo the recently documented library ergonomics without a strong correctness benefit.
Implementation:
- Add or improve doc comments for all exported option functions and source helpers:
WithPromptFSWithPromptFileWithProfileFSWithProfileFileWithSchemaFSWithSchemaFile
- Improve doc comments for
Profile,OpenAICompatibleProfileConfig, andOpenAICompatibleProfile:- explain that the constructor returns an ordinary in-memory profile;
- explain that
APIKeyRequiredis satisfied byRunRequest.APIKey; - state that raw API keys do not belong in profiles.
- Re-run
go doc .and confirm the public API reads cleanly. - Do not remove or rename existing exported types in this stage.
Tests:
- No new behavior tests are required unless comments reveal behavior drift.
- Run
go test ./...andgo vet ./....
Stage 7: Final Security And Compatibility Pass
Implementation:
-
Search for raw secret emission paths:
rg "APIKey|api_key|Authorization|%\\+v|GoString|String".
-
Confirm:
- config, profile YAML, CLI flags, and HTTP JSON still reject raw API keys;
- public
RunRequest.APIKeyremains request-scoped and JSON-omitted; PreparedRun,RunResult, and formatter output do not include direct API keys;- injected
LLMClientstill receivesGenerateRequest.APIKeywhen provided.
-
Run:
go test ./... go vet ./... go run ./examples/go-library/prepare go run ./cmd/scriptorium render \ --config ./examples/config.yml \ --prompt generic.markdown_summary \ --input transcript=./examples/fixtures/transcript.md \ --input glossary=./examples/fixtures/glossary.yml \ --format json -
Update implemented-behavior docs only after code changes:
docs/consumers/pkg-scriptorium.mdfor redaction and JSON-compatibleExtraParamsvalidation;docs/internal/runner.mdif internal error sentinel names or load classification are documented there;docs/internal/adapters.mdif adapter error mapping documentation references the old shared sentinel.
Non-Goals
- Do not add a credential resolver.
- Do not add raw API-key support to config, profile YAML, CLI flags, or HTTP JSON.
- Do not introduce new third-party dependencies.
- Do not add new model/profile catalog behavior.
- Do not redesign prompt/profile/schema source loading beyond the explicit cleanup items above.
- Do not change CLI or HTTP request/response shapes unless required to preserve current documented behavior.
Open Questions
None. The plan chooses the more idiomatic and maintainable options where the audit identified tradeoffs:
- split internal load sentinels rather than expanding public inference heuristics;
- clone caller-owned
http.Clientvalues rather than documenting mutation; - validate JSON-like inputs rather than copying arbitrary Go object graphs;
- redact public secret-bearing request structs rather than replacing API-key strings with a larger credential abstraction;
- keep
OpenAICompatibleProfileas documented ergonomic surface for now rather than removing recently added API.