Skip to content

feat(golang): every failure a guest reports is a Diagnostic that unwraps to its sentinel - #1110

Open
coderdan wants to merge 1 commit into
claude/gracious-einstein-ywrwim-guest-errorsfrom
claude/gracious-einstein-ywrwim-go-diagnostic
Open

coderdan wants to merge 1 commit into
claude/gracious-einstein-ywrwim-guest-errorsfrom
claude/gracious-einstein-ywrwim-go-diagnostic

Conversation

@coderdan

@coderdan coderdan commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Go callers can now inspect detailed Rust runtime errors with errors.As, while existing errors.Is checks continue to identify the same error kinds. Both encrypt and auth expose a *Diagnostic containing a stable code, message, help, structured fields, and causes. The runtime reads these details through the se_last_error export added in #1104 and falls back to the previous plain error when details are unavailable or invalid.

This is the third of three PRs for #1098, completing the path from Rust error details to the Go API.

Changes

  • Public error type: Adds shared Diagnostic and Cause types, exposed as aliases from both encrypt and auth. Error() returns the Rust message, and Unwrap() returns the existing sentinel error used by errors.Is.
  • Useful accessors: Exposes expected/found keyset identifiers and field/reason details. Structured fields decode into ordinary Go values, including nested maps and lists.
  • Runtime integration: Reads se_last_error after a nonzero status, decodes the details, and wipes both the host copy and runtime buffer. The export is optional for compatibility with older runtimes.
  • Failure handling: Missing exports, empty details, malformed data, or details without a message return the original sentinel. A trap while retrieving details also matches ErrTrap, so the client closes the runtime instance while preserving the original error kind.
  • Documentation: Adds examples of errors.Is for the kind and errors.As for details, documents allowed error contents, and removes stale comments saying only status numbers cross the boundary.
  • Upstream fix discovered by tests: A refused token exchange could echo an access key through its raw response body. The fix is in feat(stack-encrypt)!: codes, help and fields on every error in stack-encrypt, stack-kms, stack-auth and stack-profile #1103; these Go HTTP tests verify the body is absent from diagnostic details.

Verification

The existing PR description reports the following checks. This description edit did not rerun them or check current CI status.

  • Go: CGO_ENABLED=0 go test ./... from languages/golang passed against freshly built authentication and all four encryption runtimes. GOARCH=386 tests passed for internal/... and auth/...; go vet ./... and gofmt -l were clean.
  • Lint: golangci-lint run ./... with pinned version 2.14.0 reported 0 issues. A compatible binary was installed because the image's Go 1.25-built binary could not check this module.
  • Transport tests: A minimal test WebAssembly module covers all diagnostic fields, nested values, causes, keyset accessors, buffer wiping, each fallback, retrieval traps, and unknown statuses.
  • Encryption tests: HTTP outcomes (401, 403, 404, 409, 500, HTML, invalid JSON, and connection refusal) check error kinds, codes, and response-body exclusion. Publicly reachable engine failures check codes, including wrong keysets, tampering, short match text, truncated ciphertext, malformed input, calls before initialization, and untyped indexes.
  • Authentication tests: Profile-storage and authentication failures check codes and preserve existing assertions. JSON error line details are checked. All existing errors.Is assertions passed unchanged, and the README example compiled in a temporary test.
  • Reported CI at 8faaf44: Go lint, WebAssembly/runtime checks, macOS/Windows bindings, and Go live tests passed. The original description reported CI pending at rebased head d18bfbc; only two base dependency files changed. This is historical status, not a claim about current CI.
  • Skipped locally: TestLiveForeignKeysetIsRefusedBeforeRetrieval, which requires credentials. A deterministic test checks both keyset identifiers on every PR.

Related

Review notes

  • Start with internal/guest/diagnostic.go, then the integration in internal/guest/call.go.
  • Scope: Diagnostics describe failures reported by the Rust runtime. Go-side failures, such as a closed client, memory-lock failure, missing profile, rejected arguments, or a runtime trap itself, retain their existing errors. Adding codes for those would require a Go-specific namespace.
  • HTTP message suffix: Authentication retains its : HTTP 403 suffix because the Rust error does not always contain the status. Some messages therefore repeat it, such as Server error: 403: HTTP 403.
  • Field location: An empty-match-text error still has no engine-provided field. Go's existing record recheck adds the record index and field name.
  • No changeset: This PR changes only the Go module, which has no release process yet; no npm package changes.

Summary by CodeRabbit

  • New Features

    • Go authentication and encryption errors now include structured diagnostic details, such as error codes, messages, help, and relevant field or keyset information.
    • Diagnostics remain compatible with standard error matching, so callers can identify error kinds while inspecting additional details.
    • Guests without diagnostic support continue to return the existing bare errors.
  • Documentation

    • Updated Go package guidance to explain how to inspect error kinds and diagnostic details, and clarify what information errors may contain.

@changeset-bot

changeset-bot Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: f2c025d

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 15d8c335-c14d-4b7a-aeb6-dcd617749c9f
📥 Commits

Reviewing files that changed from the base of the PR and between fcac251 and f2c025d.

📒 Files selected for processing (20)
  • languages/golang/auth/diagnostic_test.go
  • languages/golang/auth/errors.go
  • languages/golang/auth/guest.go
  • languages/golang/auth/strategy_test.go
  • languages/golang/auth/transport.go
  • languages/golang/encrypt/README.md
  • languages/golang/encrypt/diagnostic_test.go
  • languages/golang/encrypt/doc.go
  • languages/golang/encrypt/errors.go
  • languages/golang/encrypt/errors_test.go
  • languages/golang/encrypt/export_test.go
  • languages/golang/encrypt/guest.go
  • languages/golang/encrypt/guest_test.go
  • languages/golang/encrypt/live_test.go
  • languages/golang/encrypt/records.go
  • languages/golang/internal/guest/call.go
  • languages/golang/internal/guest/diagnostic.go
  • languages/golang/internal/guest/diagnostic_test.go
  • languages/golang/internal/guest/doc.go
  • languages/golang/internal/guest/errors.go

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The shared Go guest package now retrieves optional error details after guest-call failures and returns diagnostics that unwrap to existing sentinel errors. The auth and encrypt packages expose the diagnostic types. Tests and documentation cover diagnostic content, sentinel matching, and compatibility with guests that omit the export.

Changes

Guest error diagnostics

Layer / File(s) Summary
Diagnostic data and decoding
languages/golang/internal/guest/diagnostic.go
Adds Diagnostic and Cause types, accessors, keyset ID parsing, and decoding for guest-provided error details.
Guest call integration and public API
languages/golang/internal/guest/call.go, languages/golang/internal/guest/errors.go, languages/golang/auth/guest.go, languages/golang/auth/errors.go, languages/golang/encrypt/guest.go, languages/golang/encrypt/errors.go, languages/golang/encrypt/doc.go, languages/golang/encrypt/README.md
The call path resolves optional se_last_error details and returns a diagnostic that unwraps to the status sentinel. Auth and encrypt expose aliases for the diagnostic types. Documentation describes error-kind and diagnostic access.
Package behavior and compatibility tests
languages/golang/internal/guest/diagnostic_test.go, languages/golang/auth/*test.go, languages/golang/encrypt/*test.go, languages/golang/auth/transport.go, languages/golang/encrypt/records.go
Tests check diagnostic codes and fields, sentinel matching, absent or invalid details, guest traps, and transport outcomes. Comments describe transport and term-failure details.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant GuestCall as guest.Call
  participant Diagnose as Exports.diagnose
  participant LastError as se_last_error
  participant Memory as Guest memory
  GuestCall->>Diagnose: Pass PackedResult error
  Diagnose->>LastError: Request error details
  LastError-->>Diagnose: Return packed guest-buffer location
  Diagnose->>Memory: Read and copy detail bytes
  Diagnose->>Memory: Wipe detail bytes
  Diagnose-->>GuestCall: Return Diagnostic or status sentinel
Loading

Merge Risk: ⚪ Minimal · up to f2c02

No actionable issue remains before merge. Guest errors retain their existing error-kind checks while exposing details when available.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to f2c02

Detailed errors preserve existing error classification and compatibility, but they also expose server-provided text through ordinary Go errors. The guarantee that diagnostics never contain credentials depends on an upstream description contract that was not established. No actual credential disclosure was verified.

Retained concerns

  • Medium · security · inferred: The new public error path inherits auth-server error_description without confidentiality enforcement. If an upstream response echoes a credential in that JSON field, it reaches Diagnostic.Message, Error(), and downstream error logging despite the documented no-credential guarantee. The Rust description handling predates this PR, but its exposure through ordinary Go errors is new. Raw-body exclusion is meaningful counterevidence; secret-bearing descriptions and low-privilege attacker control were not verified.
Security review details

Security Blast Radius

  • inferred — The shared contract affects auth and encrypt callers. The concrete description-content concern is bounded to consumers of an affected auth exchange and their downstream error handling or logs; cross-tenant access, additional privileges, and credential theft were not demonstrated.

Security Findings and Attack Paths

  • inferred — A conditional disclosure path exists from a credential-bearing JSON error_description through ServerError, the guest encoder, Diagnostic.Message, and ordinary error output. Control of or secret reflection by the responding service is required; the supplied security assessment retained no verified findings.

Trust Boundaries and Controls

  • observed — Auth diagnostics exclude raw HTML bodies and replace unparseable error-body text with a safe description. Tests cover credential-like text in HTML and ordinary JSON descriptions, but do not establish confidentiality for arbitrary error_description content.

Resilience and Maintainability Implications

  • observed — Retrieval and deallocation use cancellation-independent contexts, so cancellation after a guest failure does not skip those operations. Deallocation errors are ignored; the valid repository producer supplies registered pointer-length pairs, but malformed-buffer and deallocation-trap behavior lacks explicit test coverage.

Hardening Proposals

  • proposed — Establish an enforceable safe-description contract for auth responses, or substitute fixed descriptions before public diagnostic encoding. Validate credential-bearing JSON descriptions as well as raw-body exclusion before treating the no-credential guarantee as complete.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 42 functions across 19 files. (1 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: guest errors gain Diagnostic details while preserving sentinel matching through unwrapping.
Linked Issues check ✅ Passed #1101's shared diagnostic objective is implemented. internal/guest/diagnostic.go decodes se_last_error, exposes the detail fields and accessors, and unwraps to the existing sentinel. `internal/gue…
Out of Scope Changes check ✅ Passed The changes support #1101. Public documentation, typed accessors, runtime integration, and tests all implement or verify the diagnostic API. The HTTP response-body checks verify that diagnostics do no…
Full details: Docstring Coverage

Explanation

Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 42 functions across 19 files. (1 skipped: 1 unsupported.)

  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@coderdan

coderdan commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto #1094 at 6efc22b, which fixes the second cipherstash-bot review there (index-only fields and nil interface passthroughs now round-trip). Two conflicts came up, in gensupport/declaration.go (#1095) and stack-encrypt/Cargo.toml (#1103, where sha2 is now gone and serde_json sits in [dependencies]). At the top of the stack the Go suite passes with all five guests built, and so do cargo nextest --workspace --all-features and clippy.

https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc

@coderdan
coderdan force-pushed the claude/gracious-einstein-ywrwim-go-diagnostic branch 4 times, most recently from 2705d06 to 20b9c50 Compare October 7, 2026 02:40
@coderdan

coderdan commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto the new #1104. There was one conflict, in auth/transport.go and auth/strategy_test.go, with #1094's renames. I resolved it in this PR's own commit (20b9c50), which now uses auth.ErrTransport, ErrConfig and ErrOther and auth.WithBaseURL. Go tests and golangci-lint pass locally.

https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc

@coderdan
coderdan force-pushed the claude/gracious-einstein-ywrwim-go-diagnostic branch from 20b9c50 to 8e95495 Compare October 7, 2026 03:44
@coderdan
coderdan force-pushed the claude/gracious-einstein-ywrwim-go-diagnostic branch from 8e95495 to 8968ef1 Compare October 7, 2026 03:54
@coderdan
coderdan force-pushed the claude/gracious-einstein-ywrwim-go-diagnostic branch from 8968ef1 to f6bb5db Compare October 7, 2026 04:00
@coderdan
coderdan force-pushed the claude/gracious-einstein-ywrwim-go-diagnostic branch 2 times, most recently from 2732d61 to 245bb8d Compare October 7, 2026 04:25
…aps to its sentinel

A Go caller could check an error's kind with errors.Is and nothing more:
the guest handed back a status number, and errors.As had no type to
target. #1100 gave both guests se_last_error, which hands over the full
error behind a failed export. This reads it.

guest.Call fetches it after any non-zero status and returns a
*Diagnostic: Code, Message, Help, URL, Severity, Fields and Causes, with
Unwrap returning the sentinel the status already mapped to, so every
errors.Is check keeps working and Error() is the Rust message. The
buffer is wiped on both sides once read. A guest with no se_last_error,
nothing recorded, or bytes that do not decode into an error with a
message all give the bare sentinel, as before; a trap fetching it also
wraps ErrTrap, so the caller closes the instance as after any trap.

encrypt and auth expose the type as Diagnostic (and Cause) by alias, as
they do the sentinels. ExpectedKeyset and FoundKeyset read a
foreign-keyset refusal's two keysets, and Field and Reason a refused
plan, record or value's field and reason.

Tests: a hand-assembled guest drives each missing or broken detail and
checks the wipe; each ZeroKMS outcome, each engine refusal reachable
through the public API, and each profile-store kind asserts its code;
a guest with se_last_error unbound fails with the bare sentinel; the
foreign-keyset refusal carries both ids, hermetically and live. The
README's Errors section explains errors.Is for the kind and errors.As for
the detail, and the rule for what an error may contain.

Closes #1101

Claude-Session: https://claude.ai/code/session_01URtfKsTToFUCRwq3g7gCUf
@coderdan
coderdan force-pushed the claude/gracious-einstein-ywrwim-go-diagnostic branch from 245bb8d to f2c025d Compare October 7, 2026 04:35
@coderdan
coderdan marked this pull request as ready for review October 7, 2026 04:52
@coderdan
coderdan requested a review from a team as a code owner October 7, 2026 04:52
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-07T04:55:46.521376Z f2c025d Draft marked ready
🔒 Security Review ✅ Completed 2026-10-07T04:57:21.268499Z f2c025d Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderdan
coderdan requested a review from auxesis October 7, 2026 04:52
Comment on lines +164 to +165
d.kind = kind
return d

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For an unknown status, kind is StatusError's wrapper "cipherstash: internal guest failure (unrecognized guest status N)". Error() returns only d.Message, so that text is lost from every log line. StatusError keeps the number so that a guest/host version skew is easy to diagnose. A newer guest that adds status 28 and records its own error now prints only that error's message. I checked this with the stub guest: status 28 with detail gives Error() = "a new kind of failure", and without detail gives "... (unrecognized guest status 28)".

The skew signal shows only when the guest gives no detail, which is the less likely case for a newer guest. Possible fix: when kind is not one of the known sentinels, return fmt.Errorf("%w: %w", kind, d). Then errors.As still finds d, errors.Is(err, ErrInternal) still matches, and the number stays in the text.

Comment on lines +300 to +310
// An unknown status keeps its number, with or without detail behind it.
func TestAnUnknownStatusIsStillInternal(t *testing.T) {
detail := encode(t, map[string]any{"code": "stack_guest_abi::status", "message": "the call failed with status 99"})
_, err := callStub(t, 99, detail, withLastError)
var d *guest.Diagnostic
if !errors.Is(err, guest.ErrInternal) || !errors.As(err, &d) || d.Code != "stack_guest_abi::status" {
t.Fatalf("err = %#v, want a Diagnostic over ErrInternal", err)
}
if !errors.Is(d.Unwrap(), guest.ErrInternal) {
t.Fatalf("Unwrap() = %v", d.Unwrap())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says that an unknown status keeps its number, but the test does not assert the number. It checks only d.Code and that Unwrap() matches ErrInternal. The number is in err.Error() here only because the stub's message is GuestError::Status's text. Add an assertion that err.Error() contains 99, and use a stub message without the number. Then the test catches the gap in the comment on diagnose.

@cipherstash-bot cipherstash-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommendation: 🟡 merge after changes (1 of 4 review job(s) failed)

One change before merge: add a real-guest test for Diagnostic.Reason(). The public accessor reads a key that only a stub test checks. The other items can wait. The core path is correct: diagnose, its fallbacks, the wipe of the host copy and the guest buffer, and the trap handling. Every existing errors.Is check still matches.

The most useful follow-up is the order of the token-source error in encrypt/client.go. Today errors.As returns the generic token_get detail, not the auth detail that says why the credential failed.

One finding was checked and dropped. It proposed that decodeDiagnostic refuse a detail with no code. The Rust encoder leaves out code on purpose when an error has none (packages/stack-guest-abi/src/last_error.rs:185). Refusing such a detail would discard its message, help and fields.

Other findings not posted as comments

These lines are not in the diff, so they cannot carry inline comments.

  • Optional: the comment and failure text of TestAuthTransportErrorWithoutAResponseNamesNoStatus are now wrong. (languages/golang/auth/strategy_test.go:683 and :702) The comment says the failure "stays the bare sentinel". The t.Fatalf text says "want a bare ErrTransport". With this PR, a connection refusal is a *Diagnostic from the guest's RequestError that wraps ErrTransport. The test still passes, because it checks only errors.Is and the absence of "HTTP". Update the comment and message, or assert the Diagnostic and its code.
  • Optional: the Checker.Check doc comment is now wrong. (languages/golang/encrypt/checker.go:43-45) It says "which rule failed is the engine's to know, so a caller that wants the field named checks one field at a time". The returned error is now a *Diagnostic with Field() and Reason(), and TestCheckerRefusesAnIndexedUntypedField asserts d.Field() == "age". Update the comment so that callers, such as stashgen, read the field from the Diagnostic instead.
How this review was made
Agent Model Review type Result
claude claude-opus-5-5 test-gap 5 found, 5 posted
claude claude-opus-5-5 golang 2 found, 2 posted
codex gpt-5.6-terra test-gap 2 found, 1 posted
codex gpt-5.6-terra golang failed

Synthesis: claude-opus-5-5 merged the findings, removed duplicates and dropped findings it could not confirm in the code. 1 posted finding(s) were raised by two or more models.

Plain language: claude-opus-5-5 read every comment as a new reader would. 5 comment(s) had a problem that stopped the reader acting; it rewrote 5.

Stack: position 8 of 8 (#1090, #1093, #1094, #1095, #1096, #1103, #1104, *️⃣ #1110). *️⃣ marks this pull request.

Context loaded: the description, 4 linked issue(s) and 5 discussion entries.

// the snake_case name stack-encrypt's dynamic::Reason gives it
// ("field_missing", "unknown_key", "field_type", ...), or "" for an error
// that gives none. New reasons may appear: keep a fallback.
func (d *Diagnostic) Reason() string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change before merge: No test checks Diagnostic.Reason() against a real guest, so no test fails if Go and Rust stop using the same key.

Impact: Reason() reads Fields["reason"]. stack-encrypt writes that key in the payload of dynamic::Error (packages/stack-encrypt/src/dynamic/mod.rs:402). If either side renames the key or changes the value format, Reason() returns "" for every error. No test fails. The README documents Reason as a public accessor.

Evidence: The only Reason() test is TestAFailureCarriesItsDiagnostic in internal/guest/diagnostic_test.go. That test uses the stub guest (a test guest, not the real one) with a hand-written detail. The other accessors have tests against the real guest:

  • ExpectedKeyset and FoundKeyset: TestAForeignKeysetNamesBothKeysets in encrypt/errors_test.go.
  • Field: encrypt/guest_test.go:786.

The engine refuses a field with outputs ["c", "c"]. It returns Error::Plan { field, reason: DuplicateOutput } (packages/stack-encrypt/src/dynamic/record.rs:267). The test in the fix builds that field.

Fix: Add the test below to encrypt/guest_test.go. The test sends the plan to the guest directly, so the Go check does not refuse the plan first. TestCheckerRefusesAnIndexedUntypedField sends its plan in the same way. The test expects ErrEncoding, code stack_encrypt::dynamic_plan, Field() equal to "age", and Reason() equal to "duplicate_output".

The test uses names that are not exported:

  • checker.c.call runs a function on a guest instance.
  • inst.call(ctx, inst.planCheck, buf(encoded)) calls the guest's plan check with the encoded plan.
  • These are the same calls that TestCheckerRefusesAnIndexedUntypedField makes.

The test also calls a helper wantDiagnostic(t, err, code). The helper must fail the test unless err contains a *Diagnostic with that code. It must return that *Diagnostic. If the package has no helper with this name, add one.

// The engine's reason reaches Go under the key Reason reads.
func TestAPlanRefusalNamesItsReason(t *testing.T) {
	ctx := context.Background()
	checker, err := NewChecker(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer checker.Close()
	p := &record.Plan{Context: []string{"users"}, Fields: []record.Field{
		{Name: "age", Kind: record.Uint32, Outputs: []record.Output{record.Ciphertext, record.Ciphertext}},
	}}
	encoded, err := vcffi.Marshal(p.Wire())
	if err != nil {
		t.Fatal(err)
	}
	_, err = checker.c.call(ctx, func(inst *instance) ([]byte, error) {
		return inst.call(ctx, inst.planCheck, buf(encoded))
	})
	if !errors.Is(err, ErrEncoding) {
		t.Fatalf("err = %v, want ErrEncoding", err)
	}
	d := wantDiagnostic(t, err, "stack_encrypt::dynamic_plan")
	if d.Field() != "age" || d.Reason() != "duplicate_output" {
		t.Errorf("Field() = %q, Reason() = %q", d.Field(), d.Reason())
	}
}

Found by 1 model: claude


// Diagnostic is the full error behind a failure the guest reports, beside
// the kind: every such failure is a *Diagnostic wrapping one of the
// sentinels above, so errors.Is matches the kind and errors.As reads the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix in a follow-up: When the token source fails, errors.As returns the encrypt guest's generic token_get Diagnostic, not the auth Diagnostic that says why.

Impact: A program that uses the documented errors.As(err, &d) pattern on a credential failure gets the wrong detail. Examples are an expired device session or a refused access key. d.Message is host token_get failed with status 1, and d.Help is empty. The auth *Diagnostic with the real code and help (for example stack_auth::invalid_grant) is in the error chain, but errors.As never reaches it. Credential failures are a common first-run error, so many users see this case.

Evidence: encrypt/client.go:432 builds fmt.Errorf("%w (token source: %w)", err, c.transport.tokenErr). errors.As checks the operands of a multi-%w error in order and stops at the first match. err comes from diagnose, so it is already a *Diagnostic and always matches first. tokenErr comes from the token source (encrypt/transport.go:217). When the source is an auth strategy, this PR makes it a *Diagnostic too. On the guest side, HostTokenStrategy records CustomError("host token_get failed with status {status}") (encrypt/guest/src/host.rs:161).

Fix: When the token source returned a *Diagnostic, put it first, so errors.As finds the cause. Keep both errors in the chain, so errors.Is still matches both kinds. Add a test: a refused token exchange gives the auth code through errors.As on a NewClient error.

if err != nil && c.transport != nil && c.transport.tokenErr != nil {
	var cause *guest.Diagnostic
	if errors.As(c.transport.tokenErr, &cause) {
		// The guest knows only that token_get failed; the token source knows why.
		err = fmt.Errorf("token source: %w (%w)", c.transport.tokenErr, err)
	} else {
		err = fmt.Errorf("%w (token source: %w)", err, c.transport.tokenErr)
	}
}

Found by 1 model: claude

if !errors.As(err, &d) || d.Code != "stack_auth::server_error" {
t.Fatalf("Token error = %#v, want a stack_auth::server_error Diagnostic", err)
}
if shown := fmt.Sprintf("%+v", *d); strings.Contains(shown, "nginx") || strings.Contains(shown, "testKeySecret") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix in a follow-up: No Go test checks that the host's transport error text stays out of the auth Diagnostic.

Impact: When the RoundTripper returns an error, auth/transport.go:129 copies err.Error() into the guest. The guest puts that text in an io::Error inside RequestError (auth/guest/src/host.rs:41). A custom RoundTripper or a proxy error can put a URL with a query string, or proxy credentials, in that text. This PR now gives the guest's recorded error to Go callers. If the code that encodes that error starts to copy the io::Error message, the text reaches Diagnostic.Message or Causes. No test fails.

Evidence:

  • The Rust leak test cannot run host.rs, because that module exists only on wasm32 (auth/guest/src/lib.rs:101-104).
  • TestAuthTransportErrorNamesTheHTTPStatusNotTheBody checks only the body of a 403 response.
  • TestAuthTransportErrorWithoutAResponseNamesNoStatus checks only errors.Is and that the error has no "HTTP" text.

Fix: Add the test below next to TestAuthTransportErrorNamesTheHTTPStatusNotTheBody. Its RoundTripper returns an error whose text contains a marker string. The test expects ErrTransport, a *Diagnostic, and no marker text in the error string or in any part of the Diagnostic.

The test uses roundTripFunc, which this file already has. It also uses these names, and expects them to work like this:

  • guestOrSkip(t) skips the test when the guest is not available.
  • testCRN is a valid CRN for tests.
  • Open(ctx, dir, options...) opens a profile in dir.
  • WithRoundTripper(rt) makes the profile send its HTTP requests through rt.
  • WithBaseURL(url) sets the auth server URL for the strategy.

If the package uses different names for these jobs, change the test to use them.

func TestAuthTransportErrorTextIsNotInTheDiagnostic(t *testing.T) {
	guestOrSkip(t)
	ctx := context.Background()
	const marker = "leak-marker-transport"
	rt := roundTripFunc(func(*http.Request) (*http.Response, error) {
		return nil, errors.New(`Post "https://cts.invalid/token?secret=` + marker + `": proxyconnect tcp: refused`)
	})
	profile, err := Open(ctx, t.TempDir(), WithRoundTripper(rt))
	if err != nil {
		t.Fatal(err)
	}
	defer profile.Close()
	strategy, err := profile.AccessKey(ctx, testCRN, "CSAKtestKeyId.testKeySecret", WithBaseURL("https://cts.invalid"))
	if err != nil {
		t.Fatal(err)
	}
	defer strategy.Close()
	_, err = strategy.Token(ctx)
	var d *Diagnostic
	if !errors.Is(err, ErrTransport) || !errors.As(err, &d) {
		t.Fatalf("Token error = %#v, want a Diagnostic over ErrTransport", err)
	}
	if shown := fmt.Sprintf("%v %+v", err, *d); strings.Contains(shown, marker) {
		t.Fatalf("the host's transport error is in the Diagnostic: %s", shown)
	}
}

Found by 1 model: claude

if _, err := profile.AccessKey(context.Background(), "invalid", "CSAKtestKeyId.testKeySecret"); !errors.Is(err, ErrConfig) {
t.Fatalf("malformed CRN for access key: error = %v, want %v", err, ErrConfig)
} else {
wantCode(t, err, "stack_auth::invalid_crn")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix in a follow-up: The malformed access key check at lines 457-460 does not check the Diagnostic code or look for the key text.

Impact: profile.Auto sends CS_CLIENT_ACCESS_KEY to the guest in the strategy config. The guest's create refuses a key that does not parse. This is the only guest call that receives an access key and fails before any HTTP request. If a later change puts the key text in that error, the text reaches Diagnostic.Message or Fields. No test fails.

Evidence:

  • The Rust leak test cannot run create, because the auth module exists only on wasm32 (auth/guest/src/lib.rs:101-102). So only this Go test runs the real code.
  • The check at lines 457-460 uses the value "not-a-key". It checks only errors.Is(err, ErrConfig).
  • In this PR, the malformed CRN check after it gets a wantCode call. The access key check does not.

Fix: Replace the check at lines 457-460 with the code below. The new code sets CS_CLIENT_ACCESS_KEY to a marker string and calls profile.Auto. It expects ErrConfig, code stack_auth::invalid_access_key, and no marker text in the error string or the Diagnostic.

Before you paste the code, compare it with lines 457-460:

  • The code assigns with err =. If err is not declared before this point, change it to err :=.
  • If the old check sets other environment variables, keep them.

The code also calls a helper wantDiagnostic(t, err, code). The helper must fail the test unless err contains a *Diagnostic with that code. It must return that *Diagnostic. If the auth tests have no helper with this name, add one next to wantCode.

	const marker = "leak-marker-access-key"
	t.Setenv("CS_CLIENT_ACCESS_KEY", marker)
	_, err = profile.Auto(context.Background())
	if !errors.Is(err, ErrConfig) {
		t.Fatalf("malformed access key: error = %v, want %v", err, ErrConfig)
	}
	d := wantDiagnostic(t, err, "stack_auth::invalid_access_key")
	if shown := fmt.Sprintf("%v %+v", err, *d); strings.Contains(shown, marker) {
		t.Fatalf("the access key is in the Diagnostic: %s", shown)
	}

Found by 1 model: claude

out := Buf{Ptr: uint32(res[0] >> 32), Len: uint32(res[0])} //nolint:gosec // splits the packed u64 into its two u32 halves
defer e.Free(ctx, out)
view, ok := m.Memory().Read(out.Ptr, out.Len)
if !ok {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: No test covers an se_last_error result whose buffer is outside guest memory.

Impact: For this result, m.Memory().Read fails and diagnose returns the bare sentinel kind. TestWithoutDetailTheFailureIsTheBareSentinel has a case for each other branch in diagnose that returns the bare sentinel. It has no case for this branch. A later change could make this branch return a new error. Call already does this for an output outside memory ("guest returned an out-of-range buffer"). Then a bug in a guest's se_last_error hides the real failure kind, and no test fails. The new case also shows that the deferred Free on that buffer does not make the call trap or panic.

Evidence: The stub guest in internal/guest/diagnostic_test.go returns valid detail bytes, a zero result, or a trap. It never returns a nonzero pointer outside its one page of memory.

Fix: Add a mode to the stub guest and one case to the table. The new case expects the bare guest.ErrEncoding, with no trap and no panic.

The code below uses these names:

  • mode is the lastErrorMode value that stubGuest receives.
  • packed is the packed result that the stub's se_last_error returns. diagnose reads the buffer pointer from the high 32 bits and the length from the low 32 bits.
  • good is the valid detail that the other cases in the table use.

Put the new if statement in stubGuest after the line that sets packed. The new value has pointer 1<<16 and length 16. One page of guest memory has 1<<16 bytes, so the buffer starts at the end of memory.

const (
	withLastError lastErrorMode = iota
	noLastError
	trappingLastError
	outOfRangeLastError
)

// In stubGuest, after packed is computed:
if mode == outOfRangeLastError {
	packed = int64(1<<16)<<32 | 16 // starts at the end of the one-page memory
}

// In TestWithoutDetailTheFailureIsTheBareSentinel's cases:
{"a buffer past the end of guest memory", good, outOfRangeLastError},

In that test, the if c.mode == withLastError check confirms that the detail is wiped. That check does not apply to the new mode, so it needs no change.

Found by 2 models: claude, codex

if err != nil {
return fmt.Errorf("%w; %w: fetching the error's detail: %w", kind, ErrTrap, err)
}
if res[0]>>32 == 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: A guest whose se_last_error export returns no value makes diagnose panic with an index out of range.

Impact: auth.WithGuest is public, so a program can load an auth guest built from other source. newInstance accepts any export named se_last_error and checks only the name. If that export has no result, LastError.Call returns an empty slice, and res[0] panics on the first failed call. The panic stops the caller's program instead of returning the sentinel. The Diagnostic doc comment promises that missing detail never hides the failure.

Evidence: auth/guest.go:180 and encrypt/guest.go:188 assign module.ExportedFunction("se_last_error") with no type check. This line reads res[0] with no length check. An export that takes parameters already fails safely: wazero returns an error, and the code takes the ErrTrap path.

Fix: Accept the export only when its type is () -> i64. Put the check in internal/guest, so both packages use the same rule. A length check here is a second check.

// LastErrorExport is se_last_error when the module exports it with the
// ABI's type, else nil: the guest then reports the status alone.
func LastErrorExport(m api.Module) api.Function {
	fn := m.ExportedFunction("se_last_error")
	if fn == nil {
		return nil
	}
	def := fn.Definition()
	if len(def.ParamTypes()) != 0 || len(def.ResultTypes()) != 1 || def.ResultTypes()[0] != api.ValueTypeI64 {
		return nil
	}
	return fn
}

// in diagnose:
if len(res) == 0 || res[0]>>32 == 0 {
	return kind
}

Found by 1 model: claude

if !ok {
return nil, false
}
d := &Diagnostic{Severity: "error", Fields: map[string]any{}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: No test covers the defaults that decodeDiagnostic applies when the detail has only some of its keys.

Impact: decodeDiagnostic applies three defaults:

  • It sets Severity to "error" when the detail has no "severity" key.
  • It keeps Fields as an empty map when "fields" is not an object.
  • It ignores a text key whose value is not a string.

The only test that checks Severity sets it to "warning". So if a change removes the "error" default, Severity is empty and no test fails. Also, no case in TestWithoutDetailTheFailureIsTheBareSentinel covers a "message" that is not a string.

Fix: Add the test below to internal/guest/diagnostic_test.go. It expects Severity equal to "error", an empty Code, an empty Fields that is not nil, and no causes.

The code uses these names, and expects them to work like this:

  • encode(t, value) encodes a value as detail bytes.
  • callStub(t, status, detail, mode) runs the stub guest (the test guest in this file) with that status, detail and se_last_error mode.
  • guest.StatusEncoding is the status that decodes to guest.ErrEncoding.
// A detail with only a message decodes with the defaults: Severity
// "error", Fields empty and not nil, and a non-string key ignored.
func TestAPartialDetailDecodesWithDefaults(t *testing.T) {
	detail := encode(t, map[string]any{
		"message": "failed",
		"code":    uint64(7),
		"fields":  "not an object",
	})
	_, err := callStub(t, guest.StatusEncoding, detail, withLastError)
	var d *guest.Diagnostic
	if !errors.As(err, &d) {
		t.Fatalf("err = %#v, want a *Diagnostic", err)
	}
	if d.Severity != "error" || d.Code != "" || d.Fields == nil || len(d.Fields) != 0 || d.Causes != nil {
		t.Errorf("decoded %+v", d)
	}
}

Also add this case to TestWithoutDetailTheFailureIsTheBareSentinel. It expects the bare guest.ErrEncoding:

{"a message that is not a string", encode(t, map[string]any{"message": uint64(1)}), withLastError},

Found by 1 model: claude

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Go: errors.As has no target — add a Diagnostic error type that unwraps to today's sentinels

4 participants