Skip to content

Feat/python sdk cipher - #1453

Open
yujiyokoo wants to merge 3 commits into
feat/python-sdk-declaration-meaningfrom
feat/python-sdk-cipher
Open

yujiyokoo wants to merge 3 commits into
feat/python-sdk-declaration-meaningfrom
feat/python-sdk-cipher

Conversation

@yujiyokoo

Copy link
Copy Markdown
Contributor

Summary

A declared class no longer prints plaintext: repr, str and rich's __rich_repr__ show <sealed> for every sealed field. Cipher(User) now exists. It checks that the class is declared and unchanged, then finds the credentials the way every SDK does, with no request. encrypt, decrypt and query come in later tickets.

Changes

Redaction (_redact.py). @stackencrypt.model replaces __repr__, __str__ and __rich_repr__. A passthrough field shows its value, a sealed field shows <sealed>, and an Ignore field is left out. A value is read from the instance's __dict__ or a storage slot only, so a SQLAlchemy column that is not loaded runs no SELECT. A generated method is replaced. A hand-written one is refused with declaration_repr_present, unless the decorator is given redact=False.

Cipher (_cipher.py). Cipher(cls) refuses an undeclared class, a subclass of a declared class, or a non-class with declaration_not_decorated. A class changed after decoration is declaration_class_changed, with reason methods, fields or constructor. Then it calls the engine for the credentials. A refusal is an AuthenticationError with the engine's code, such as stack_auth_not_authenticated.

Credentials (crates/core/src/credentials.rs). stack-auth's AutoStrategy reads CS_CLIENT_ACCESS_KEY and CS_WORKSPACE_CRN, then the profile stash auth login writes. It is given a transport that sends nothing, so no HTTP client is built and the http feature stays off. Each refusal names the variable or file to fix.

Placeholder. Python prints <sealed>, not Go's [sealed]. [sealed] is a valid tag in rich's markup, so rich.print(repr(user)) would show the field as empty. The ADR and the spec record it.

Verification

  • pytest: 228 passed and 2 skipped, on Python 3.14 and 3.9. The redaction tests run on a dataclass, attrs, Pydantic, NamedTuple and SQLAlchemy.
  • cargo test --locked, clippy, cargo fmt --check, mypy strict, and pyright are clean.
  • pnpm run test:scripts: 83 of 83 files pass.
  • The six platform wheels are built by CI only. This is the first branch with stack-auth in them.

Related

Closes #1388. Part of #1341.

Review notes

  • Start with _redact.hand_written and _value, then check_unchanged in _declaration.py.
  • Serialisation is not printing: dataclasses.asdict, model_dump and vars still return plaintext, by design.
  • A SQLAlchemy Ignore field under Annotated loads as one object, not a list. That is a spec problem, noted on Python SDK: core call over psycopg, on EQL v4 and Stack Encrypt #1341.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 11ee027

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 30, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI; Enforced overrides: Organization
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 10b7d536-2ddc-40ef-bdb1-f20324b8dccd

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review

@yujiyokoo
yujiyokoo added this pull request to stack #1520 November 4, 2026 02:58
yujiyokoo and others added 3 commits November 5, 2026 00:19
repr and str show [sealed] for sealed fields. Cipher(cls) checks the
class again and finds the credentials, with no request and no HTTP
client.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Credentials are found without building an HTTP client, and each
refusal names the variable or file to fix. Redaction reads the class's
own dict, covers rich, and runs no descriptor that may do work.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
repr and str show [sealed] for sealed fields. Cipher(cls) checks the
class again and finds the credentials, with no request.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@yujiyokoo
yujiyokoo force-pushed the feat/python-sdk-cipher branch from 5b792d5 to 11ee027 Compare November 4, 2026 15:24
@yujiyokoo
yujiyokoo marked this pull request as ready for review November 10, 2026 00:40
@yujiyokoo
yujiyokoo requested a review from a team as a code owner November 10, 2026 00:40

@cipherstash-agent cipherstash-agent left a comment

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.

Review attestation — commit reviewed: 11ee027aa1b1c5fbce339a6a0c2a23c1017d0a23. Host: Claude Code (scheduled routine). Model: claude-sonnet-5-5. Skills: code-review (medium, performed inline), review-cross-check (done manually: no existing review threads; only changeset-bot and CodeRabbit-skipped notices), review-post.

Blocking issues: none.
Findings: 1 inline comment (non-blocking, medium confidence) on hand_written in _redact.py.
Not reviewed in depth: Rust engine/refusal/plan changes and test files were skimmed from the file list only; tests were not run.


Generated by Claude Code

filename = code.co_filename
return (
not filename.startswith(_ATTRS_GENERATED)
and os.path.realpath(filename) not in _GENERATOR_FILES

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.

[code-review, medium confidence] hand_written can misclassify a hand-written method as generated, so the decorator silently drops the user's code instead of raising declaration_repr_present.

A user's __repr__ wrapped in reprlib.recursive_repr() (a common idiom) has a wrapper whose __code__.co_filename is reprlib.py. recursive_repr copies __module__ from the wrapped function, so the __module__ == cls.__module__ check passes. The filename then matches _GENERATOR_FILES, and the method is treated as generated and replaced without the refusal the docs promise.

Suggested fix: unwrap via __wrapped__ (or compare __qualname__ / __code__.co_firstlineno against the class's own source) before deciding. Also add a test with a hand-written @reprlib.recursive_repr() __repr__.

Source: code-review (medium)


Generated by Claude Code

@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 8 review job(s) failed)

infracode — claude (claude-opus-5-5)

Nothing in the infrastructure part of this change needs to be fixed before merge. This PR changes no Terraform, CloudFormation, mise tasks, CI workflows or observability configs. Its only build-config changes are a new stack-auth path dependency in languages/python/Cargo.toml, crates/core/Cargo.toml and one line in languages/python/Cargo.lock.

I checked three things:

  • CI path filter. tests-python.yml already lists packages/stack-auth/**, because stack-kms's profile feature pulls in that crate. A change to stack-auth already reruns the Python suite.
  • http feature stays off. The new dependency is default-features = false. stack-encrypt is built with default-features = false, features = ["dynamic"], and dynamic does not turn on stack-auth/http or stack-kms/http. Cargo's feature unification does not compile reqwest or TLS into the wheel, so the comments in Cargo.toml and credentials.rs are correct.
  • Lock file. stack-auth was already in the workspace's Cargo.lock, through stack-kms. The diff adds only the new edge from stackencrypt-core, so cargo ... --locked still resolves.

I found no infrastructure issues.


python — claude (claude-opus-5-5)

Nothing must change before merging. Two small follow-ups can wait: hand_written can replace some hand-written methods with no error, and Pydantic's devtools hook still prints plaintext.

I did not repeat the open thread on reprlib.recursive_repr. The first finding below is a different input with a related cause.

Severity Finding Location
Follow-up A hand-written method with no __code__, or from another module, is replaced with no error languages/python/python/stackencrypt/_redact.py:73
Optional Pydantic's __pretty__ (devtools) still prints sealed fields languages/python/python/stackencrypt/_redact.py:60

I could not run the toolchain: this checkout has no .venv, and Pydantic and SQLAlchemy are not installed. I ran hand_written and install alone on Python 3.12, outside the package. They gave the results below. Rust files were out of scope.


rust — claude (claude-opus-5-5)

No Rust change has to be fixed before merging. The two follow-ups are both about the messages a Python user reads. The new Rust code does not use unsafe, has no panic sites, and holds no lock across an .await. The no-request transport keeps stack-auth's http feature off, as the PR description says. The one gap is that cargo clippy and cargo test were not run, because this environment has no Rust toolchain.

Severity Finding Location
Fix in a follow-up Refusal::of joins the message and the help with a space only, so the text reads as one broken sentence. This now applies to plan refusals too. languages/python/crates/core/src/refusal.rs:24
Fix in a follow-up When a profile exists but its token has no region or client id, the user sees "no credentials were found" languages/python/crates/core/src/credentials.rs:47
Optional The default message is cloned on every refusal, even when a mapped message replaces it languages/python/crates/core/src/credentials.rs:37

test-gap — claude (claude-opus-5-5)

Two coverage gaps should be fixed before merging: repr on a real SQLAlchemy instance with a column that is not loaded, and a Cipher built from a valid stash auth login profile. Both are the main promises of this PR, and today only a stand-in or the refusal path is tested. The other four comments are optional and can wait.

The tests that exist are clear and follow the repo's pytest style. The sketches below use the same helpers (declared, refusal, the credentials fixture, SECRET). I could not run the sketches here, because no .venv is built in this checkout.

Not repeated here: the existing review thread on hand_written and reprlib.recursive_repr already asks for a test of a hand-written @reprlib.recursive_repr() __repr__.

Out of scope, noted only: hand_written returns False for a method whose __module__ differs from the class's module. So a __repr__ defined in a helper module and assigned in the class body (__repr__ = helpers.show) is replaced with no error. This is the same kind of misclassification as the recursive_repr thread.


infracode — codex (gpt-5.6-terra)

This pull request contains no infrastructure-as-code changes. No infrastructure review findings.


python — codex (gpt-5.6-terra)

Redaction must avoid custom attribute access before merging. The remaining issue can wait only after this access is made storage-only.

Severity Finding Location
request-changes Reading __dict__ can run user code and trigger I/O. languages/python/python/stackencrypt/_redact.py:122

Focused tests were not run because this checkout has no Python or pytest executable.


rust — codex (gpt-5.6-terra)

I found no Rust issues that require changes before merging. The Rust credential detection builds a no-request transport and only performs local credential and profile validation.

Severity Findings
— None

Other findings not posted as comments

  • languages/python/crates/core/src/credentials.rs:56 — Optional: the Python messages for InvalidCrn, Region and InvalidAccessKey are not checked by any test.
  • languages/python/crates/core/src/credentials.rs:29 — Change before merge: no test builds a Cipher from a valid stash auth login profile.
  • languages/python/tests/test_cipher.py:151 — Optional: this test is named "names the name", but it does not check which field is named.
  • languages/python/python/stackencrypt/_redact.py:122 — Change before merge: getattr(instance, "__dict__") runs a custom __getattribute__ method.
How this review was made
Agent Model Review type Result
claude claude-opus-5-5 test-gap 6 found, 3 posted
claude claude-opus-5-5 rust 3 found, 3 posted
claude claude-opus-5-5 python 2 found, 2 posted
claude claude-opus-5-5 infracode 0 found, 0 posted
codex gpt-5.6-terra test-gap failed
codex gpt-5.6-terra rust 0 found, 0 posted
codex gpt-5.6-terra python 1 found, 0 posted
codex gpt-5.6-terra infracode 0 found, 0 posted

Plain language: the reader test did not run; only the word-and-shape check was applied.

Stack: position 7 of 15 (#1405, #1406, #1414, #1415, #1433, #1437, *️⃣ #1453, #1499, #1509, #1510, #1511, #1513, #1516, #1517, #1582). *️⃣ marks this pull request.

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

"""
method = vars(cls).get(name)
code = getattr(method, "__code__", None)
if code is None or getattr(method, "__module__", None) != cls.__module__:

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: hand_written treats a method as generated when it has no __code__ or comes from another module, so the decorator replaces the user's code with no error.

Impact: model replaces a hand-written __str__ or __repr__ without raising declaration_repr_present. The user's formatting code stops running, and nothing tells them. This is a different input from the open reprlib.recursive_repr thread. The root cause is related: the check accepts a method as generated unless it proves the method hand-written.

Evidence: I ran _redact.hand_written on Python 3.12:

class C:
    __str__ = functools.partialmethod(lambda self, x: x, "s")
hand_written(C, "__str__")  # False

These cases also return False: a callable object, a staticmethod, and __repr__ = helpers.field_repr imported from another module of the same project.

Fix: Treat a method as generated only when a known generator made it. Treat everything else in vars(cls) as hand-written:

def hand_written(cls: type, name: str) -> bool:
    method = vars(cls).get(name)
    if method is None:
        return False
    method = inspect.unwrap(method) if callable(method) else method
    code = getattr(method, "__code__", None)
    if code is None:
        return True  # partialmethod, callable object, staticmethod, ...
    filename = code.co_filename
    return not (
        filename.startswith(_ATTRS_GENERATED)
        or os.path.realpath(filename) in _GENERATOR_FILES
        or _is_namedtuple_repr(cls, method)
    )

Add one test per case: partialmethod, an imported function, and staticmethod.

Found by 1 model: claude

# attrs compiles its methods under this file name.
_ATTRS_GENERATED = "<attrs generated"

METHODS = ("__repr__", "__str__", "__rich_repr__")

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: Pydantic's __pretty__ hook, which devtools' debug() uses, still prints every sealed field in plaintext.

Impact: devtools.debug(user) on a declared Pydantic model shows the plaintext of each sealed field. The module docstring lists the print paths it covers: repr, str and rich. It does not list devtools. The user may think every print path is covered. Pydantic's Representation base class builds __pretty__, __repr_str__ and __rich_repr__ from one method, __repr_args__.

Fix: On a Pydantic class, also replace __repr_args__, so every Pydantic print hook shows the same output:

def __repr_args__(self: object) -> Iterator[Tuple[str, object]]:
    return __rich_repr__(self)

methods = (__repr__, __str__, __rich_repr__)
if hasattr(cls, "__repr_args__"):
    setattr(cls, "__repr_args__", __repr_args__)

If this is out of scope, add devtools to the docstring's list of print paths that still show plaintext.

Found by 1 model: claude

/// The code and message of `error`, with no field or reason.
pub fn of(error: &dyn Diagnostic) -> Self {
let message = match error.help() {
Some(help) => format!("{error} {help}"),

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: Refusal::of joins the engine message and its help with a single space, so the Python message has no full stop between two sentences.

Impact: Most engine messages do not end with punctuation. A Python user then reads one broken sentence. This also changes the plan refusals that plan::check raises at the class statement. Before this PR they used error.to_string() only. Now the help is added, and some of that help names the Rust/JSON plan format, not the Python declaration.

Evidence: UntypedIndex becomes record plan field "x" has a term output and no declared type Declare the field's "type" ("string", "int64", ...): its index terms derive from that type. Auth variants that credentials::message does not map, such as TokenExpired or InvalidToken, are joined the same way. The doc comment on message says "A caller whose engine help names a Rust API writes its own message", but plan.rs does not write its own message.

Fix: Add a full stop when the message does not end with one. Also decide whether plan refusals should carry the engine help. If not, keep field/reason from Refusal::of and set message: error.to_string() in plan::refusal.

let text = error.to_string();
let message = match error.help() {
    Some(help) if text.ends_with(['.', '!', '?']) => format!("{text} {help}"),
    Some(help) => format!("{text}. {help}"),
    None => text,
};

Found by 1 model: claude

/// no variable at all. `None` keeps the engine's message and help.
fn message(error: &AuthError) -> Option<String> {
let text = match error {
AuthError::NotAuthenticated(_) => "no credentials were found. Set CS_CLIENT_ACCESS_KEY \

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: NotAuthenticated does not always mean that no credentials exist, but the new message says "no credentials were found".

Impact: A user who ran stash auth login can still get this message. The message then tells them to do the thing they already did. They have no hint that the profile file is the problem.

Evidence: packages/stack-auth/src/device_session_strategy.rs (build_from_workspace_store) returns AuthError::NotAuthenticated in two more cases. One is a stored token with no region(). The other is a stored token with no client_id(). In both cases detect found the profile and loaded it.

Fix: Write a message that covers both cases. Or name the profile in the message, the same way the Store arm already does.

AuthError::NotAuthenticated(_) => "no usable credentials were found. Set CS_CLIENT_ACCESS_KEY \
     and CS_WORKSPACE_CRN, or log in again with `stash auth login` (the profile is in \
     ~/.cipherstash, or in CS_CONFIG_PATH when set)."
    .to_owned(),

Found by 1 model: claude

.map_err(|error| {
let refused = Refusal::of(&error);
Refusal {
message: message(&error).unwrap_or(refused.message.clone()),

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: unwrap_or(refused.message.clone()) clones the default message every time, also when message(&error) returns a replacement.

Impact: This is a small, needless allocation on a cold path. The bigger cost is that the closure is harder to read than it has to be.

Fix: Move the message out of refused instead of cloning it.

.map_err(|error| {
    let mut refused = Refusal::of(&error);
    if let Some(text) = message(&error) {
        refused.message = text;
    }
    refused
})

Found by 1 model: claude

return UNSET
except Exception: # noqa: BLE001 A repr never raises.
return UNAVAILABLE
return UNSET

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 runs repr on a real SQLAlchemy instance whose column is not loaded, which is the main reason _value exists.

Impact: The docstring says a SQLAlchemy column that is not loaded must run no SELECT. The only test for this uses a hand-written __getattr__, and that test passes through the except AttributeError: return UNSET branch at line 128. It never reaches this line, where a non-storage descriptor such as SQLAlchemy's InstrumentedAttribute returns UNSET. If someone changes this branch to call getattr, every repr of an expired or detached row runs a query or raises DetachedInstanceError, and no test fails. The MappedUser test in KINDS builds a transient instance with every column set, so it reads only __dict__.

Evidence: tests/test_redaction.py has test_a_passthrough_value_is_read_without_running_a_descriptor, which uses a plain class. No test uses a Session.

Fix: Add a test to tests/test_redaction.py that commits a MappedUser to an in-memory SQLite database and counts the SQL statements while repr runs:

from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session


def test_an_expired_sqlalchemy_column_runs_no_select():
    engine = create_engine("sqlite://")
    Base.metadata.create_all(engine)
    statements = []
    event.listen(engine, "before_cursor_execute", lambda *args: statements.append(args[2]))
    with Session(engine) as session:
        user = MappedUser(email=SECRET, age=42)
        session.add(user)
        session.commit()  # expires every attribute
        statements.clear()

        assert repr(user) == "MappedUser(email=<sealed>, age=<unset>)"
    assert statements == []
    # Detached: the read would raise, and repr must not.
    assert repr(user) == "MappedUser(email=<sealed>, age=<unset>)"

The test expects repr to show <unset> for the expired column and to send no statement to the database.

Found by 1 model: claude

setattr(cls, DECLARATION_ATTRIBUTE, Declaration(table=table, fields=fields))
methods: Tuple[Callable[..., Any], ...] = ()
if redact:
_check_no_hand_written_methods(cls)

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 hand-written __rich_repr__ is refused by the code, but no test checks it.

Impact: METHODS has three names. The tests check the refusal for __repr__ and __str__ only. If __rich_repr__ is dropped from METHODS, or hand_written stops matching it, the decorator replaces the user's __rich_repr__ with no error. The user's code then never runs, and nothing tells them.

Evidence: tests/test_redaction.py has test_a_hand_written_repr_is_refused and test_a_hand_written_str_is_refused. No test defines __rich_repr__.

Fix: Replace the two tests with one test over all three names:

@pytest.mark.parametrize("name", ["__repr__", "__str__", "__rich_repr__"])
def test_a_hand_written_method_is_refused(name):
    def method(self):
        return "plaintext"

    namespace = {
        "__module__": __name__,
        "__annotations__": {"email": Annotated[str, TextEq]},
        name: method,
    }
    User = dataclasses.dataclass(type("User", (), namespace))

    error = pytest.raises(DeclarationError, stackencrypt.model("users"), User).value
    assert error.code == "declaration_repr_present"
    assert name in str(error)

The test expects each of the three methods, written on the class, to raise declaration_repr_present and to name the method.

Found by 1 model: claude

),
default=None,
)
if changed is not None:

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: check_unchanged is tested only for an added annotation and a replaced __repr__.

Impact: The code compares the union of the old and new hint names, so it also catches a changed hint and a removed hint. It also checks __str__ and __rich_repr__. No test covers these cases. If the comparison is changed to loop over hints only, a deleted field passes Cipher with no error. If the zip with _redact.METHODS gets out of order, a replaced __str__ is not found.

Evidence: tests/test_cipher.py has test_an_annotation_added_after_decoration_is_refused and test_a_replaced_repr_is_refused only.

Fix: Add two parametrized tests to tests/test_cipher.py:

@pytest.mark.parametrize(
    "change",
    [
        lambda hints: hints.__setitem__("age", Annotated[str, Passthrough]),
        lambda hints: hints.pop("age"),
    ],
    ids=["changed", "removed"],
)
def test_a_changed_or_removed_hint_is_refused(credentials, change):
    User = declared()
    change(User.__annotations__)

    error = refusal(lambda: Cipher(User))
    assert error.code == "declaration_class_changed"
    assert error.reason == "fields"
    assert error.field == "age"


@pytest.mark.parametrize("name", ["__str__", "__rich_repr__"])
def test_a_replaced_str_or_rich_repr_is_refused(credentials, name):
    User = declared()
    setattr(User, name, lambda self: self.email)

    error = refusal(lambda: Cipher(User))
    assert error.reason == "methods"
    assert f"{name} replaced" in str(error)

The tests expect each change to raise declaration_class_changed with the right reason, and with the right field for a hint.

Found by 1 model: claude

This branch has not been deployed

No deployments
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.

3 participants