Add language SDKs (Python, Ruby, Go, Node, Haskell) over a JSON-over-FFI core#105
Open
domenkozar wants to merge 50 commits into
Open
Add language SDKs (Python, Ruby, Go, Node, Haskell) over a JSON-over-FFI core#105domenkozar wants to merge 50 commits into
domenkozar wants to merge 50 commits into
Conversation
Surface the resolution waterfall the resolver already computes as a stable, versioned, machine-readable contract that never carries secret values. This is phase 1 of polyglot language support: the per-secret provenance type every later phase (FFI, codegen, SDKs) depends on, shipped standalone as the check --explain/--json quick win. - New public ResolutionReport / SecretResolution / ResolutionStatus types (schema_version 1), serde-serializable, with to_explain_string() and all_required_present() helpers. - validate_audited now records per-secret provenance (status, the serving provider's credential-free URI, generated, default_applied, as_path) instead of discarding it; entries sorted by name for deterministic output. - ValidatedSecrets and ValidationErrors carry the resolution and expose report(), so the report is available on both success and missing-required. - check --json (versioned JSON) and check --explain (human trace) skip the prompt-for-missing flow and exit non-zero when a required secret is missing, so CI can gate on them. No secret values are ever printed. - Canonical JSON Schema committed at schema/resolution-report.schema.json. - Golden wire-format test plus an end-to-end provenance test through a real dotenv backend; CLI reference and CHANGELOG updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2, slice 1: the authoritative value-carrying resolution output the C ABI and other-language SDKs consume. The FFI crate (next slice) is a thin wrapper over this, keeping resolution logic in one place. - New Secrets::resolve() -> ResolveResponse, building on phase 1 provenance: per-secret value (or persisted temp-file path for as_path), source (provider/generated/default), and the serving provider's credential-free URI. On a missing required secret it returns an empty secrets map plus missing_required, mirroring the derive crate's load(). - New public ResolveResponse / ResolvedSecret / ResolvedSource types (schema_version 1, BTreeMap for deterministic key order) with is_ok() and without_values(). - secretspec resolve --json prints the payload (values to stdout, meant to be piped); --no-values emits the same structure value-free. Exits non-zero when a required secret is missing. - Canonical JSON Schema at schema/resolve-response.schema.json; CLI reference and CHANGELOG updated; tests cover values, provenance, missing-required, and as_path path persistence. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2, slice 2: the in-process boundary other-language SDKs bind to. A
deliberately narrow, JSON-in/JSON-out C ABI keeps every binding thin and keeps
resolution logic in the secretspec crate alone.
- Three-function surface: secretspec_resolve(request_json) -> response_json,
secretspec_free(ptr), secretspec_abi_version(). Request and response are the
versioned JSON contract; the response envelope separates transport failure
({"ok":false,"error":{kind,message}}) from a successful resolution
({"ok":true,"response":ResolveResponse}) that still reports missing_required.
- Panics are caught at the boundary (never unwind across FFI); returned strings
are caller-owned and freed via secretspec_free; null and bad input are handled.
- Hand-written C header at secretspec-ffi/include/secretspec.h; crate builds as
cdylib + staticlib + rlib.
- SecretSpecError::kind() promoted to pub for typed SDK error handling.
- Tests drive the real extern \"C\" entry points (values, no_values,
missing-required, invalid input, missing manifest); a committed smoke.c plus a
drafted per-platform ffi-build workflow build and smoke-test the cdylib on
linux/macos/windows (native per-runner; portable packaging is follow-up).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 3, slice 1: the single brain for typed-accessor generation. Every generator (the Rust derive macro and the future TS/Python/Go/Ruby emitters) needs the same manifest decisions; computing them in one place stops drift. - build_ir(&Config) -> CodegenIr reduces a manifest to a language-neutral IR: project name, sorted profile list, the union field set (optional if optional-in or missing-from any profile, a path if as_path in any profile), and the per-profile raw (non-merged) field sets. - Faithfully reproduces derive macro semantics, including the long-standing quirk that an unspecified `required` is treated as optional (differs from the runtime resolver) so generated output stays stable. - IR types are serde-serializable for emitters and tooling. Unit tests cover union optionality, missing-in-profile, as_path-in-any, per-profile exactness, descriptions, and the empty-profiles default case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 3, slice 2: validate the IR against the known-good consumer and remove the duplicated typing logic, so the derive macro and the future TS/Python/Go/Ruby emitters share one brain. - declare_secrets now calls secretspec::codegen::build_ir(&config) once and sources every typing decision from it: the union struct fields, per-profile enum variants, the Profile list, and the load_profile arms. The empty-profiles special case disappears because the IR already models it. - Removed the derive's own is_secret_optional / is_field_optional_across_profiles / is_field_as_path / analyze_field_types / get_profile_variants; the token emitters now read optional/as_path straight off the IR (only the Rust type mapping stays local). Dropped the unit tests that covered those moved helpers (now tested in secretspec::codegen). - Generated API is unchanged: 15 derive unit + 3 trybuild UI + 12 integration tests pass, and the example crate resolves through the generated builder. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 4: the first non-Rust consumer, proving the whole stack from a generic dlopen caller (the same C ABI path Go/purego and Ruby/ffi will reuse). - secretspec-py binds secretspec-ffi via cffi: marshals a JSON request to secretspec_resolve, parses the envelope, frees the buffer. No resolution logic in Python; every provider is inherited from the Rust core. - Mirrors the derive crate's vocabulary: SecretSpec.builder().with_provider() .with_profile().with_reason().load() -> Resolved(.secrets/.provider/.profile), plus set_as_env(). MissingRequiredError vs SecretSpecError(.kind) separate a missing required secret from a transport failure. as_path yields a file path. - Library discovery via SECRETSPEC_FFI_LIB, a wheel-bundled copy, or a Cargo target dir. pyproject packages it; README documents it. - pytest suite (6 tests) drives the real cdylib end to end: values, default source, missing-optional, set_as_env, missing-required, as_path, invalid input. conftest builds the crate and locates the library automatically. - devenv.nix now provides Python + cffi + pytest + maturin. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes phase 4: the codegen half of the Python reference SDK, so typed accessors and the runtime SDK ship together. The emitter is a thin template over the shared codegen IR, so it cannot drift from the derive macro or future language emitters. - codegen::python::emit(&CodegenIr) -> String generates a module that mirrors the derive crate's shape over the runtime SDK: a SecretSpec union dataclass plus one <Profile>Secrets dataclass per profile, each with a builder-style load(). Idiomatic Python: snake_case attributes typed str / Optional[str] / Path, required pulled directly, optional guarded, as_path wrapped in Path. - New `secretspec codegen --lang python [-o FILE]` CLI command (value-free; reads only the manifest via the now-non-test-gated Secrets::config()). - Rust test asserts the emitted types/assignments; two Python e2e tests generate a module via the CLI, import it, and resolve through the generated accessors (union + profile-pinned, including as_path). conftest builds the CLI too. - Generated code avoids `from __future__ import annotations` so it is robust when imported/exec'd in any context. CLI reference and CHANGELOG updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 5: the Go binding over the same C ABI, reusing the generic dlopen path the
Python SDK validated (here in a static language, for the devops/k8s audience).
- secretspec-go binds secretspec-ffi via purego (dlopen, no cgo): marshals a
JSON request to secretspec_resolve, reads the C string, frees it. No
resolution logic in Go; every provider comes from the Rust core.
- Mirrors the derive vocabulary with idiomatic Go (PascalCase): New()
.WithProvider().WithProfile().WithReason().Load() -> *Resolved with
Provider/Profile/Secrets and SetAsEnv(). *MissingRequiredError vs *Error{Kind}
separate a missing required secret from a transport failure. as_path yields a
readable file path.
- Library discovery via SECRETSPEC_FFI_LIB or a Cargo target dir. Tests (gofmt
clean) drive the real cdylib end to end via a TestMain that builds and locates
it: abi version, values+provenance, missing-required, as_path, invalid input.
- devenv.nix now provides Go.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 5: the Ruby binding over the same C ABI. Uses stdlib Fiddle (dlopen) rather than the ffi gem, so there is no native gem build; same generic C ABI path as Python/Go. - secretspec-rb binds secretspec-ffi via Fiddle: marshals a JSON request to secretspec_resolve, reads the C string, frees it. No resolution logic in Ruby. - Mirrors the derive vocabulary idiomatically: Secretspec::SecretSpec.builder.with_provider.with_profile.with_reason.load -> Resolved(#provider/#profile/#secrets) plus set_as_env!. MissingRequiredError vs Error(#kind) separate a missing required secret from a transport failure. as_path yields a readable file path. - Library discovery via SECRETSPEC_FFI_LIB or a Cargo target dir. minitest suite (6 tests) drives the real cdylib end to end, building/locating it first. - gemspec + README; devenv.nix now provides Ruby. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 5: the Node binding over the same C ABI, via koffi (dlopen), keeping Node on the identical generic C ABI path as Python/Go/Ruby. (napi-rs remains the future production-distribution option; koffi keeps the reference uniform.) - secretspec-node binds secretspec-ffi via koffi: marshals a JSON request to secretspec_resolve, decodes the C string, frees it. No resolution logic in JS. - Mirrors the derive vocabulary idiomatically (camelCase): SecretSpec.builder().withProvider().withProfile().withReason().load() -> Resolved(provider/profile/secrets) plus setAsEnv(). MissingRequiredError vs SecretSpecError(.kind) separate a missing required secret from a transport failure. as_path yields a readable file path. TypeScript types in index.d.ts. - Library discovery via SECRETSPEC_FFI_LIB or a Cargo target dir. node:test suite (6 tests) drives the real cdylib end to end, building/locating it first. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The capstone of phase 5: prove the Python, Go, Ruby, and Node SDKs agree. They
are all thin clients over one C ABI, so the risk is in each SDK's parsing and
exposure, not the resolver. This suite locks that down.
- conformance/fixtures/* are self-contained cases (manifest + .env +
expected.json). Each SDK resolves them and projects its result to a canonical
shape (profile, per-secret {value, source, as_path}, missing lists), then
asserts equality with expected.json. For as_path secrets the canonical value
is the materialized file's contents, so it is deterministic across languages.
- Each SDK runs the fixtures inside its own native runner (pytest, go test,
minitest, node:test), reading conformance/ relative to the repo root. All four
pass the same two fixtures (basic: provider + default + optional-missing;
as_path).
- Fixtures cover successful resolutions; error behavior stays in per-SDK suites.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
conformance/run.sh builds the secretspec-ffi cdylib once, points every SDK at it via SECRETSPEC_FFI_LIB (so they don't each rebuild), runs all four conformance suites in their native runners, and prints a combined PASS/FAIL/SKIP summary. Exits non-zero if any language fails; a missing toolchain is SKIP, not FAIL. README documents it as the one-command entry point. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…language emitters
Replaces the hand-written per-language codegen emitter approach (the
`codegen --lang python` command and the WIP Go/Ruby/TypeScript emitters) with a
single JSON Schema emitter, so we no longer maintain a typed-accessor generator
per language. quicktype turns the schema into idiomatic types AND deserializers
for any language; we maintain only a tiny generic `fields()` helper per SDK.
- `secretspec codegen --lang ...` becomes `secretspec schema`: emits a JSON
Schema (draft-06) with a `SecretSpec` union type plus one `<Profile>Secrets`
per profile, property names = secret names, optionals nullable. Driven by the
same shared IR (so it can't drift from the derive macro).
- Each runtime SDK gains a generic `fields()` returning a flat
`{SECRET_NAME: value}` map (the file path for as_path): Python/Ruby return the
map, Go/Node also expose a JSON variant (FieldsJSON / fieldsJson) for
quicktype's bytes/string deserializers.
- The loader the user writes is one line, e.g.
`SecretSpec.from_dict(resolved.fields())`. quicktype owns naming, optionality,
and converters for all current and future languages.
- Deleted codegen::{python,go,ruby,typescript} and their emitter tests; added a
schema emitter test. Python e2e now drives the real pipeline:
`secretspec schema | quicktype --lang python` then
`SecretSpec.from_dict(resolved.fields())`. CLI reference + CHANGELOG updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a SDKs workflow that builds the cdylib + CLI once and runs every SDK's full test suite (unit + cross-language conformance + the schema/quicktype codegen pipeline) via scripts/ci-sdks.sh, so the Python/Go/Ruby/Node bindings cannot silently rot. The prior CI (`devenv test` -> cargo test --all) covered only the Rust crates, including secretspec-ffi, but none of the language SDKs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The polyglot SDKs were undocumented (the docs site had only a Rust SDK page and the README never mentioned them). Adds a docs page per SDK (quick start, error model, the schema/quicktype typed-access pattern, library discovery), wires them into the sidebar, and adds a "Language SDKs" section to the README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 6 / pillar A, Python: make the Python SDK installable without a separate native build by bundling the secretspec-ffi cdylib into the wheel. The SDK already prefers a bundled secretspec/_lib/ over SECRETSPEC_FFI_LIB / a Cargo target dir, so this wires up the build. - scripts/stage-cdylib.sh builds the cdylib (release) and stages it into secretspec/_lib/ (gitignored) per OS, including the Windows .dll case. - setup.py forces a platform (non-pure) wheel tagged py3-none-<platform> so pip installs the right native library per OS/arch; metadata stays in pyproject. - Verified locally: the produced wheel is py3-none-linux_x86_64, contains secretspec/_lib/libsecretspec_ffi.so, and a clean install (no env var, outside the repo) loads the bundled lib and resolves a secret. - Drafted python-wheels.yml: a per-platform matrix (linux x86_64/aarch64, macOS x86_64/aarch64, windows) that stages the cdylib, builds the wheel, and smoke tests it. NOTE: Linux wheels are tagged linux_*, not manylinux_*; PyPI publishing still needs an auditwheel-repair step to vendor the cdylib's system deps (libdbus from keyring) and make glibc portable. That is the remaining follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 6 / pillar A, Ruby: make the Ruby SDK installable without a separate native build by bundling the secretspec-ffi cdylib into a platform gem. - The SDK now prefers a vendored vendor/<lib> (staged into a platform gem) over SECRETSPEC_FFI_LIB / a Cargo target dir. - scripts/stage-cdylib.sh builds the cdylib (release) and stages it into vendor/ (gitignored) per OS, incl. the Windows .dll case. - The gemspec includes vendor/* and sets Gem::Platform::CURRENT when the lib is staged, so `gem build` produces a platform gem (else a pure-Ruby gem). - Verified locally: built secretspec-0.12.0-x86_64-linux.gem, and a clean install (no env var, outside the repo) loaded the bundled lib and resolved a secret. Existing Ruby suite still green. - Drafted ruby-gems.yml: per-platform matrix that stages, builds, and smoke tests the gem. NOTE: like the wheels, a portable Linux gem needs a baseline build (e.g. rake-compiler-dock) to vendor the cdylib's system deps (libdbus) and glibc; that is the remaining follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 6 / pillar A, Go: let `go get` work with no native build by embedding the secretspec-ffi cdylib per platform (go:embed) and extracting it to a temp file at first use for purego to dlopen. - Per-platform embedded_<os>_<arch>.go files (linux/darwin/windows x amd64/arm64) embed lib/secretspec_ffi_<os>_<arch>.<ext>; embedded.go extracts it to a content-addressed temp path. findLibrary prefers SECRETSPEC_FFI_LIB, then the embedded lib, then a Cargo target dir. - Gated behind the `embed_lib` build tag: the default build (CI, `go test`, a plain checkout) compiles a nil-stub and needs no staged binary, so nothing breaks; only release/distribution builds pass `-tags embed_lib` with the libraries staged. - scripts/stage-cdylib.sh builds + stages the lib under the build-tagged name. Verified locally: default `go test` green, and a tagged build outside the repo with no SECRETSPEC_FFI_LIB embeds the lib and resolves a secret. - Drafted go-embed.yml (per-platform build + embedded smoke test). NOTE: the embedded libs are ~34 MB each; a release commits them via git-LFS (they are gitignored here) and flips embedding on by default. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 6 / pillar A, Node: replace the koffi (dlopen) binding with a napi-rs native addon that embeds the resolver, so `npm install` needs no cdylib or SECRETSPEC_FFI_LIB. This is the standard way to ship a Rust-backed npm package. - New `secretspec::resolve_json(&str) -> String` in the core: the shared JSON-in/JSON-out boundary (request -> response envelope). secretspec-ffi is refactored into a thin wrapper over it (and drops its serde deps), so the C ABI and the napi addon define the envelope contract in exactly one place. - New secretspec-node-native crate (napi-rs) exposing resolve()/abiVersion() over resolve_json; a napi cdylib is a valid Node addon, so scripts/build-addon.sh is just `cargo build` + rename to secretspec.node. - index.js now requires ./secretspec.node instead of koffi; the JS API (builder, Resolved, fields/fieldsJson, errors) is unchanged. Dropped the koffi and unused typescript deps; the package has no runtime npm dependencies. - Test harness builds the addon instead of the cdylib; all 8 Node tests (incl. conformance) pass, and the full cross-language suite stays green. - Drafted node-addon.yml (per-platform addon build + smoke test). NOTE: full npm distribution publishes per-platform addon packages (the pattern @napi-rs/cli automates); that publish wiring is the remaining follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 6 / pillar A follow-up: turn the per-platform build workflows into release pipelines that produce portable artifacts and publish on a version tag, plus a RELEASE.md runbook. - Python (python-wheels.yml): build Linux wheels inside a manylinux_2_28 container and repair with auditwheel (vendors the cdylib's libdbus/glibc), native macOS/Windows wheels, and publish to PyPI via Trusted Publishing (OIDC). - Ruby (ruby-gems.yml): add a publish job that `gem push`es the platform gems (RUBYGEMS_API_KEY). Portable-Linux gem build noted as a follow-up. - Go: add secretspec-go/.gitattributes so the embedded libs are git-LFS tracked when a release commits them. - RELEASE.md documents each ecosystem's build approach, publish mechanism, required secrets, and known gaps (Ruby portable build; Go git-LFS + manual commit; Node multi-platform npm via @napi-rs/cli optional packages). UNVALIDATED: these are cross-platform CI + registry-credential pipelines that have not been run; they need a CI iteration and the documented repo secrets. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the loose end where only Python had an automated codegen test (Go/Ruby/TS were verified by hand). Each SDK now runs the full pipeline in its native runner: secretspec schema -> quicktype -> typed deserializer over the SDK's fields(). - Schema emitter reworked to a single-root object (the union by default, or a profile's fields via `schema --profile`). quicktype only emits a converter for the ROOT type, so the previous Manifest wrapper / $ref root gave JS/Go no usable `toSecretSpec`/`UnmarshalSecretSpec` and mis-named the type. Pair with `quicktype --top-level SecretSpec`. - New e2e tests: Go (temp module, UnmarshalSecretSpec(FieldsJSON)); Ruby (dry-struct, from_dynamic!(fields)); Node (quicktype --lang javascript, toSecretSpec(fieldsJson())); Python updated to --top-level. All gated on npx. - ci-sdks.sh runs all Ruby test files; CLI docs + SDK pages + CHANGELOG updated for --top-level and --profile. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SDK section jumped straight into per-language pages with no explanation of how the polyglot stack works. Adds an Overview page (one Rust resolver, thin clients over the C ABI / napi addon, the shared runtime API and error model, typed access via schema+quicktype, and the bundled-library distribution model) and wires it as the first item in the SDK sidebar. Docs site builds clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The landing page only showcased the Rust SDK. Adds a "Use it from any language" showcase section after it, with the shared builder API in Python, Node.js, Go, and Ruby, plus the one-resolver/thin-client framing and links to the SDK overview and the schema+quicktype typed-access path. Landing page builds clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The landing page had a standalone "Compile-time secrets in Rust" section adjacent to the new "Language SDKs" section, so Rust read as separate from the SDKs when it is one of them. Folded the Rust main.rs example into the single Language SDKs section as its compile-time highlight, after the Python/Node/Go/ Ruby snippets. One coherent SDK section; landing page builds clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Per-profile JSON Schema (`schema --profile <p>`) now allows additional
properties: `resolve --profile <p>` returns the profile's own secrets plus
those inherited from `default`, which the per-profile type intentionally does
not list (matching the derive macro), so a strict quicktype deserializer would
otherwise reject a valid resolve result. The union schema stays exhaustive.
- `resolve_json` now catches panics itself, so both native boundaries that
funnel through it (the C ABI and the napi-rs Node addon) return the same
`{"ok":false,"error":...}` envelope on an internal panic.
- `secretspec::codegen` exposes one shared `capitalize`, used by both the schema
emitter and the derive macro (was a byte-identical copy in each), so profile
type-name casing can never drift.
- `build_ir` computes the union field set in a single pass instead of
re-scanning every profile per field; `validate`/`resolve` resolve each
secret's merged config once per pass instead of twice.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Load nil-checks the response and validates `schema_version` against the version this SDK was built for, so a skewed library is reported rather than nil-panicked or silently misparsed. - SetAsEnv skips secrets with no usable value (e.g. under no_values) instead of exporting an empty string, via a new `usable()` helper. - extractEmbedded uses an owner-only (0o700) temp dir and reuses the cached cdylib only when its content hash matches, not just its size, closing a predictable-path load and a stale-file reuse. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- _resolve_response checks the response is present and validates schema_version, raising SecretSpecError on mismatch instead of KeyError / silent misparse. - _load uses double-checked locking so concurrent first callers do not race to dlopen. - Dropped the divergent `source = "provider"` default (other SDKs pass it through); added with_no_values to the builder for parity. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- set_as_env! skips secrets with no usable value instead of `ENV[name] = nil`, which would delete the variable. - load nil-checks the response and validates schema_version, raising Secretspec::Error on mismatch. - ensure_loaded guards the one-time dlopen with a Mutex and re-checks @loaded inside the lock. - Added with_no_values to the builder for parity. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- load nil-checks the response and validates schema_version, throwing SecretSpecError on mismatch. - setAsEnv skips secrets with no usable value instead of coercing null to the string "null". - Added withNoValues to the builder (and index.d.ts) for parity. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
uname under git-bash/msys reports MINGW*/MSYS*/CYGWIN*; map those to secretspec_ffi.dll so the cross-language conformance gate can run on Windows, where the FFI artifact already ships. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
validation_report_provider_uri returned the override and per-secret alias URIs verbatim, and this branch newly serializes that into the provider field of the resolution report (check --json/--explain) and the resolve response (resolve --json, every SDK's response.provider). A user-authored alias or --provider override embedding a credential (vault+token:s3cr3t@host, vault://host?token=...) therefore leaked into machine-readable output and across the FFI boundary, even though the sibling source_provider and the warn path already redact it. Route both raw returns through redact_uri_strict; add a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The napi resolve binding was synchronous, so resolving from a network-backed provider (1Password, LastPass) blocked the Node event loop for the whole round-trip. Add a resolveAsync binding that runs resolve_json on the libuv threadpool (napi AsyncTask) and a Builder.loadAsync() that awaits it. The synchronous load() is unchanged; loadAsync() reports a clear error against an older addon that lacks the binding. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When SECRETSPEC_FFI_LIB is unset, the Go, Python, and Ruby SDKs walk up to a Cargo target/ directory to find the library. They preferred release over debug, so a stale release build silently shadowed the debug build a developer had just produced (surfacing later as a confusing schema-version mismatch). Within the nearest target/, pick the candidate with the newest mtime instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
package.json ships no prebuilt addon and has no per-platform publish wiring (the CI workflow flags this as a follow-up), so the "npm install needs no native build" claim in the changelog and SDK docs was unbacked. Reword to say the addon is built from the Rust core via scripts/build-addon.sh and that prebuilt per-platform npm packages are a follow-up. Also record the credential redaction, loadAsync, and cdylib-discovery fixes under Unreleased. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
no_values now routes through a new Secrets::resolve_without_values, which never exposes a secret value or persists an as_path temp file, so no secret byte crosses the boundary and as_path resolution leaves nothing on disk. Previously the resolver fully materialized every value (and persisted every as_path temp file) and only then stripped them. Adds Secrets::report() and a mode:"report" request on the shared resolve_json boundary: a value-free ResolutionReport (per-secret status and provenance) that, unlike resolve, reports a missing required secret as a status rather than failing the call. This is the inventory/preflight view the CLI exposes as check --json, now reachable from every language SDK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Go Fields()/FieldsJSON() now emit JSON null for a value-less secret instead of the empty string "", matching Python/Ruby/Node; Fields() returns map[string]*string and a new Usable() distinguishes absent from empty. Node index.d.ts types fields() as Record<string, string | null>. - Every SDK gains a cleanup affordance for the persisted as_path temp files: Go Resolved.Close(), Python close()/context manager, Ruby close()/load block, Node dispose()/Symbol.dispose. - Every SDK gains report() (Node also reportAsync()) over the value-free report, which never fails on a missing required secret. - Conformance gains no_values and report dimensions (the latter asserts source_provider presence), locking the cross-language contract so a divergence like the Go ""-vs-null one cannot ship again. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A thin client over the secretspec-ffi C ABI, linked at build time via the GHC FFI. Mirrors the other SDKs: a builder (withPath/withProvider/withProfile/ withReason/withNoValues) plus load/report, returning a Resolved (get/fields/fieldsJson/setAsEnv/close) or a value-free Report. A missing required secret throws MissingRequiredError; other failures throw SecretSpecError with a stable errorKind. as_path secrets come back as a readable file path. Wires GHC into devenv, the cross-language conformance runner (all three dimensions), and ci-sdks.sh; adds a schema -> quicktype -> typed codegen e2e test (quicktype's Haskell target); and a Haskell SDK CI workflow that builds/tests on PR and publishes to Hackage on a version tag. Adds docs (SDK page, sidebar, overview, landing) and README entries. The cdylib is linked at build time (--extra-lib-dirs) and must be on the runtime loader path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e chain errors The value-free surfaces (Secrets::report(), resolve_without_values(), the FFI no_values/report requests, and check --json/--explain) ran the full resolution, so they minted and stored a brand new secret in the provider for any declared generate secret that was not yet set, and failed outright on a read-only provider. Thread a Materialize flag through validate_audited so those entry points share the identical resolution logic but skip its two side effects: a generatable-but-absent secret is reported as it would resolve (generated) without being created, and no as_path secret is written to a temp file. Also: a per-secret provider chain whose primary provider errors and whose fallback chain has no value now surfaces that provider error, exactly as a single-provider failure already did, instead of silently reporting the secret as missing_required, so machine consumers can tell an outage from an unprovisioned secret. Route check --json/--explain through report() (removing a duplicated inline copy of its construction). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… model Three robustness fixes plus a distribution correction: * Embedded (-tags embed_lib) cdylibs are extracted into a per-user, owner-only cache directory (os.UserCacheDir) whose privacy is verified before use, instead of a predictably named directory under the shared system temp dir. This closes a local attacker file swap (TOCTOU) that could run attacker code in the process on a shared host, and avoids noexec temp mounts. An embedded git LFS pointer (from a botched release) is rejected with a clear error rather than fed to dlopen. * A missing symbol in the loaded library no longer panics: purego.RegisterLibFunc panics are recovered and returned as a load error, so an incompatible cdylib does not escape sync.Once and leave the loader poisoned with nil pointers. * A zero-value Builder (var b Builder, not via New()) no longer panics with a nil-map write in its WithX setters; the request map initializes lazily. Distribution moves to the system library model: git LFS plus the module proxy cannot ship a working library (the proxy serves LFS pointer text), so it is no longer prescribed. Consumers provide the cdylib via SECRETSPEC_FFI_LIB or vendor it for an embed_lib build. RELEASE.md, .gitattributes, .gitignore, the workflow, README, and docs updated accordingly. Tests release as_path temp files so repeated runs leave no secret files behind. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
callNative copied the response out and freed it in straight-line IO, so an asynchronous exception (e.g. a System.Timeout.timeout around load/report) arriving between the call returning and the free leaked the native, secret bearing response buffer. Install the free under mask and run it via finally so it always executes. The conformance test now closes its value-carrying Resolved so as_path temp files do not accumulate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The value-carrying as_path and conformance tests in the Python, Ruby, and Node suites resolved the as_path fixture but never disposed the result, so each run left another 0400 secret-bearing temp file behind (only the no_values variants cleaned up). Close/dispose the result, matching the no_values tests: Python via try/finally close(), Ruby via the block form of load that closes in ensure, and Node via try/finally dispose(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
secretspec | 65a2cc2 | Commit Preview URL Branch Preview URL |
Jun 12 2026, 04:49 PM |
The value-carrying ResolveResponse stays the SDK boundary, but only over the secretspec-ffi C ABI. Not shipping it as a CLI verb keeps the command surface verb-level auditable: check never prints a value, get prints exactly one (per-key audited and reason-gated), and bulk value extraction never becomes a pipeable plaintext artifact. Adding the subcommand back later is backwards compatible; removing it after people script against it would not be. Secrets::resolve()/resolve_without_values()/resolve_json(), the FFI crate, the SDKs, and the committed JSON Schema are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e review devenv Pin the Rust version in a single rust-toolchain.toml consumed by both devenv (languages.rust.toolchainFile) and the native CI runners via rustup, so released artifacts build with the same compiler CI tests against. Scope the artifact workflows (ffi, node, python, ruby, go) to PR changes in their own directories; core resolver changes are already verified on PRs by the devenv-based test.yml and sdks.yml, and the full matrices still run on tags and manual dispatch. Install devenv in the Claude review workflow and allowlist devenv commands so the reviewer can build and run tests instead of reviewing blind. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The SDKs job fills the ~14GB free on a hosted ubuntu runner (Nix store + full debug build with the AWS/GCP/Bitwarden provider stacks) and dies with ENOSPC, so drop the preinstalled toolchains we never use. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same ENOSPC as the SDKs job: the cdylib + CLI debug build with the full provider stacks plus the Nix store overflows the hosted runner's disk, this time so badly the runner could not even write its own logs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The macos-13 label is no longer provisioned by GitHub, so every x86_64-apple-darwin artifact job sat queued forever while the rest of the matrix passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three Windows breakages surfaced by the first complete artifact CI runs: - Provider specs like dotenv://C:\path\.env failed with "invalid port number" because C: parsed as a URL host:port. Drive-letter paths are now carried in the URL path component (forward-slash separators) and the dotenv provider strips the URL's leading slash from them. - The Go SDK never compiled on Windows: purego.Dlopen and RTLD_* exist only on Unix. The library open is now split into build-tagged files, using syscall.LoadLibrary on Windows. - The Node codegen test spawned npx, which is npx.cmd on Windows and unreachable without a shell. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Go SDK segfaults in the purego call path on x86_64 darwin and Apple has moved the platform on; stop building Intel macOS artifacts (FFI cdylib, wheels, gems, Node addon, Go embed lib) rather than debugging a dying target. Intel mac users can still build from source via the system-library path. Pin the remaining macOS runners to macos-latest instead of macos-14 so the next image retirement does not silently strand jobs in the queue like macos-13 did; artifact compatibility comes from rustc's deployment target, not the runner OS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
node --test runs the three test files in parallel processes; each one's ensureAddon() kicked off build-addon.sh when secretspec.node was absent, and the final `cp` truncated the addon in place while a sibling process could already have it mapped, killing it with SIGBUS (seen once in the SDKs workflow). Install via temp file + rename so an existing mapping keeps its inode, and build once up front in ci-sdks.sh so the test processes never race to build at all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…un green Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds first-class SDKs for Python, Ruby, Go, Node.js, and Haskell so non-Rust apps inherit every secretspec provider, profile, chain, generation, and
as_pathbehavior with no language-side resolution logic. All resolution stays in the Rust core; each SDK is a thin client over a single JSON-in / JSON-out boundary.How it fits together
secretspec-ffi— a narrow C ABI cdylib exposingsecretspec_resolve/secretspec_free/secretspec_abi_version, funneling through oneresolve_json(&str) -> Stringboundary that catches panics and returns a uniform{"ok": …}envelope.secretspec resolve, prints JSON) and a value-free resolution report (secretspec check --json/--explain), both versioned with a canonical JSON Schema underschema/.secretspec::codegen) — the derive macro and every SDK's typed accessors are generated from one JSON Schema via quicktype, instead of per-language emitters.conformance/) — shared fixtures every SDK must reduce to an identical canonical result, run inside each SDK's own test runner.Review-driven hardening (final commits)
The branch closes the highest-impact findings from a multi-agent review of itself:
report(),no_values,check --json) are now side-effect-free. They no longer mint and store a brand-new secret in the provider for an unsetgeneratesecret, and no longer writeas_pathsecrets to disk. A provider-chain primary outage now surfaces the provider error instead of silently reporting the secret as missing.Buildersafety, and a switch to the system-library distribution model (git-LFS + the module proxy cannot ship a working library).mask/finally, closing an async-exception leak.Test plan
cargo fmt/cargo clippyclean (no new lints);gofmtclean.Known follow-ups (not blocking)
Lower-severity items remain tracked: Go loader retry on transient miss, Node stale-addon staleness check,
schemahonoringSECRETSPEC_PROFILE, aresolve --jsondoc-flag sweep, a stale Node README, a__proto__key edge case, and Windows support (URI parsing, CI leg,purego.Dlopencompile).🤖 Generated with Claude Code