Skip to content

feat(keyring): opt-in read grants for repeated keyring reads - #65

Merged
M3gA-Mind merged 2 commits into
tinyhumansai:mainfrom
M3gA-Mind:feat/keyring-read-grant
Nov 11, 2026
Merged

M3gA-Mind merged 2 commits into
tinyhumansai:mainfrom
M3gA-Mind:feat/keyring-read-grant

Conversation

@M3gA-Mind

Copy link
Copy Markdown
Contributor

Summary

Every keyring operation asked the host for a fresh consent. A host that reads the same entry often, such as a signed-in session token, either prompted again and again, or cached the plaintext itself, where the module could neither see nor revoke it.

A host can now opt in to read grants, which the module enforces:

  • Opt-in: KeyringConfig2.read_grant { max_ttl_ms }, at most 24 hours. Once set, a read's consent request carries offer, and an Allowed reply may carry grant { ttl_ms }.
  • Clamp and cover: the module clamps the lifetime to the ceiling. Later reads of the same entry by the same agent, origin and tier then skip the prompt.
  • Purpose: not part of the key. It's free text, not an identity.
  • Where grants live: only in module memory, at most 64 at a time, with the oldest dropped first. Nothing is persisted, so a restart asks again.
  • Admission: reads under a grant are still rate-admitted.
  • Writes and deletes: never offered a grant, and they always ask.
  • What ends a grant at once, before any backend access:
    • a write or delete of the entry, including one the human then refuses;
    • a reinit;
    • the new RevokeGrants operation, for one entry or all of them;
    • process exit.

Related issue

Closes #55. The scope follows the maintainer's decision on the issue: no purpose binding, a 24 h ceiling, memory only.

API or behavior changes

These are additive and opt-in. Without read_grant, nothing changes on the wire, and consent stays one per operation.

  • KeyringConfig2.read_grant: Option<KeyringReadGrant2>
  • KeyringConsentRequest2.offer: Option<KeyringReadGrant2>
  • KeyringConsentResponse2.grant: Option<KeyringGrant2>
  • KeyringOperation2::RevokeGrants { name: Option<String> }

All four are omitted when absent. Both consent structs keep deny_unknown_fields, so an old host never sees offer, and an old module refuses a read_grant config at load.

CONTRACT_VERSION stays (2, 1). A bump would make every 2.1 host incompatible, and nothing changes for a host that doesn't opt in. Maintainers may bump it at release.

The module doesn't audit keyring operations today; the host's consent prompt is the record. This change adds no audit, and a granted read is logged nowhere new.

Validation

Commands actually run, on our Linux CI runner (rustc 1.99.1, --locked, RUSTFLAGS unset so the repo's polyval_force_soft applies) at a34ae0a:

  • cargo fmt --all -- --check: clean
  • cargo clippy --all-targets --all-features -- -D warnings: 0 warnings (also with --keep-going)
  • cargo test -p tinysecurity-bus --all-features crypto_ops: 6 passed, 0 failed
  • cargo test -p tinysecurity-module --all-features adapter::crypto::tests: 26 passed, 0 failed
  • cargo build -p tinysecurity-module --examples --all-features: ok
  • cargo test --all-features: 1371 passed, 0 failed
  • Red controls, each rerun with the module's keyring tests: with the grant check disabled, exactly the 4 tests that depend on skipping the prompt fail. With the write/delete revoke removed, only writes_and_deletes_always_ask_and_end_the_entrys_grant fails. With the reinit revoke removed, only reinit_ends_every_read_grant fails

Tests

  • a_read_grant_covers_repeat_reads_until_its_clamped_expiry: a 5 s grant clamped to the 1 s ceiling, covered at 999 ms and asked again at 1000 ms.
  • a_read_grant_is_bound_to_the_entry_agent_origin_and_tier: another entry, agent, origin or tier asks again; another purpose does not.
  • writes_and_deletes_always_ask_and_end_the_entrys_grant: only reads get an offer, and a refused write still ends the grant.
  • revoke_grants_ends_one_entry_or_all_without_consent: by name, all, an unknown name, and a bad name refused.
  • reinit_ends_every_read_grant
  • read_grants_are_capped_and_the_oldest_is_dropped_first
  • without_read_grant_a_host_grant_is_ignored_and_nothing_is_offered: no offer key on the wire, and RevokeGrants refused.
  • a_zero_grant_grants_nothing
  • Configuration bounds: max_ttl_ms 0 and 86 400 001 are refused, 86 400 000 is accepted.
  • Bus wire pins for offer, grant, RevokeGrants and read_grant.
  • Deliberately untested: the Access::Revoke arm in the backend match. It's unreachable because revocation returns earlier, and it denies if ever reached.

Documentation

docs/module-crypto.md describes read grants, and the module doc comment of adapter_crypto.rs is updated.

Checklist

  • The change is focused on one logical change
  • No new #[allow(...)], #[ignore], or relaxed lints
  • No secrets, tokens, or .env contents in the diff or the description

Every keyring operation asked the host for a fresh consent, so a host
that reads the same entry often prompted again and again, or cached the
plaintext itself where the module could not see or revoke it.

A host can now set KeyringConfig2.read_grant { max_ttl_ms } (at most
24 h). A read's consent request then carries an offer, and an Allowed
reply may carry grant { ttl_ms }. The module clamps it and lets later
reads of the same entry by the same agent, origin and tier skip the
prompt. Grants live only in module memory, at most 64 with the oldest
dropped first, and reads under one are still rate-admitted. Writes and
deletes are never offered a grant and always ask.

A write or delete of the entry, a reinit, the new RevokeGrants
operation (one entry, or all) and process exit each end a grant before
any backend access. Without read_grant nothing changes on the wire.
@coderabbitai

coderabbitai Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

Important

  • Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 3a8ba65c-87ab-4c08-b4bb-79a5dec76b69
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

@tinysweeper

tinysweeper Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

Tiny Sweeper review

Reviewed head: de31d27ce327bd1a35586a0e1b5dfc92f0a9c3d3

Findings

No active actionable findings.

Advisory notes

  • medium: Test that RevokeGrants still works after the rate window is spent (crates/tinysecurity-module/src/adapter_crypto.rs:619)
    Problem: The `Access::Revoke` arm returns before `state.admit()?`, which is what makes the comment's claim true, but no test can tell the difference. `fixture_with` raises `operations_per_m
    Suggested fix:
    No concrete fix was supplied.

Verdict: Ready for maintainer review · Priority: medium
Coverage: Reviewed 6 of 6 files.

Diagnostics and supporting details

Full summary

Pull request purpose (title): feat(keyring): opt-in read grants for repeated keyring reads. Purpose (whole-pull-request title and description): feat(keyring): opt-in read grants for repeated keyring reads, closing #55. Today every keyring operation asks the host for fresh consent; a host that reads the same entry often either re-prompts or caches the plaintext where the module cannot see or revoke it. This pull request lets a host opt in to module-enforced read grants: `KeyringConfig2.read_grant { max_ttl_ms }` (ceiling 24 h) adds `offer` to a read's consent request and lets an `Allowed` reply carry `grant { ttl_ms }`. The module clamps the lifetime to the ceiling and lets later reads of the same entry by the same agent, origin and tier skip the prompt; purpose is deliberately not part of the grant key. Grants live only in module memory, at most 64 at a time, oldest dropped first, so a restart asks again. Granted reads are still rate-admitted; writes and deletes are never offered a grant and always ask. A write or delete of the entry (including one the human then refuses), a reinit, the new `RevokeGrants` operation (one entry or all), and process exit each end the grant before any backend access. The change is described as additive and opt-in: every new field is omitted when absent, so a host that does not configure `read_grant` sees no wire change, and `CONTRACT_VERSION` is unchanged.

Review snapshot

Change surface Files Review signal Count
Production 3 Active findings 0
Tests 2 Noted findings 1
Documentation 1 Resolved findings 0
Configuration 0 Pending checks/questions 0

Completeness: Complete
Test assessment: Test coverage is assessed from changed tests and lane evidence; execution is not claimed without trusted check data.

What changed

The pull request touches six files: the bus wire types and their tests (`crates/tinysecurity-bus/src/crypto_ops.rs`, `crates/tinysecurity-bus/src/crypto_ops_tests.rs`), the module adapter and its tests (`crates/tinysecurity-module/src/adapter_crypto.rs`, `crates/tinysecurity-module/src/adapter_crypto_tests.rs`), the native-contract example (`crates/tinysecurity-module/examples/keyring_native_contract.rs`), and the module crypto documentation (`docs/module-crypto.md`). On the bus, `KeyringConfig2` gains an opt-in read-grant section, `KeyringConsentRequest2` gains `offer`, the consent response gains `grant`, and `KeyringOperation2` gains `RevokeGrants { name: Option<String> }`. Configuration validation bounds the ceiling, and reinit ends all grants. A latest-push-delta comparison is unavailable, so no statement is made about what changed since the prior reviewed head.

Features

  • Added — Opt-in read-grant configuration and wire fields: `KeyringConfig2` gains an optional read-grant section carrying `max_ttl_ms` (documented ceiling 24 hours), and `KeyringConsentRequest2` gains an optional `offer` so a read's consent prompt can carry the scope the host may grant. Both are serde-optional and omitted when absent, so a host that does not opt in sees no change on the wire. (crates/tinysecurity-bus/src/crypto_ops.rs pub struct KeyringConfig2 {, crates/tinysecurity-bus/src/crypto_ops.rs pub struct KeyringConsentRequest2 {, crates/tinysecurity-bus/src/crypto_ops.rs pub struct KeyringConsentRequest2 {)
  • Added — RevokeGrants operation: `KeyringOperation2` gains a `RevokeGrants { name: Option<String> }` variant that ends grants for one entry or, when the name is absent, for every entry. The module answers it before any consent or backend access and reports whether any grant ended; it requires the host to have configured the read-grant option and validates the name like any other keyring name. (crates/tinysecurity-bus/src/crypto_ops.rs pub enum KeyringOperation2 {, crates/tinysecurity-module/src/adapter_crypto.rs enum Access {, crates/tinysecurity-module/src/adapter_crypto.rs async fn keyring_inner()
  • Added — Module-enforced read grants: `consent` now computes an offer only for reads when the host opted in, and on `Allowed` returns the host's requested lifetime clamped to the offered ceiling, treating a zero lifetime as no grant. `keyring_inner` checks a live grant scoped by entry name, agent, origin and tier for reads (purpose is not part of the key), and stores a new grant on `Allowed`. Writes and deletes are never offered a grant and always revoke the entry's grant before asking, so a refused write still ends it. (crates/tinysecurity-module/src/adapter_crypto.rs async fn consent(, crates/tinysecurity-module/src/adapter_crypto.rs async fn keyring_inner(, crates/tinysecurity-module/src/adapter_crypto.rs async fn consent()
  • Modified — Configuration validation for the grant ceiling: Configuration validation now also rejects a read-grant ceiling outside the accepted range, alongside the existing service-name and consent-timeout checks. (crates/tinysecurity-module/src/adapter_crypto.rs fn validate(config: &CryptoConfig2) -> BusResult<()> {)
  • Modified — Reinit ends every read grant: The configure path revokes all grants when state is reused across a reinit, since grants are held only in module memory, so the host decides again what it trusts. (crates/tinysecurity-module/src/adapter_crypto.rs fn configure()

Tests

  • unit — Pins the bus wire form of the new fields: `offer` is absent from a consent request when not offered and serializes as the ceiling when offered, `grant` is absent unless the host returns it, `RevokeGrants` serializes with and without a name, and a read-grant config round-trips.: Added in this pull request; review describes it as pinning the new serde behavior. No test execution or result is claimed here. (crates/tinysecurity-bus/src/crypto_ops_tests.rs, crates/tinysecurity-bus/src/crypto_ops_tests.rs fn config_round_trips_and_omits_absent_sections() -> Result<(), Box<dyn std::err)
  • unit — Added module tests cover a grant clamped to the ceiling and expiring at the boundary, binding to entry, agent, origin and tier but not purpose, writes and deletes always prompting and ending the grant (including a refused write), `RevokeGrants` by one entry and all without consent, reinit ending every grant, the 64-entry cap dropping the oldest first, the no-opt-in path ignoring a host grant with nothing offered and refusing `RevokeGrants`, and a zero lifetime granting nothing.: Added in this pull request; the review describes these behaviors as pinned. No test execution or result is claimed here. (crates/tinysecurity-module/src/adapter_crypto_tests.rs, crates/tinysecurity-module/src/adapter_crypto_tests.rs async fn fixture(allow: bool) -> Result<(Fixture, Consenter), Box<dyn std::error)

Before merge

None.

How this fits together

flowchart LR
  n0["KeyringBackend2<br/>changed"]:::changed
  n1["KeyringConfig2<br/>changed"]:::changed
  n2["KeyringOperation2<br/>changed"]:::changed
  n3["config_round_trips_and_omits_absent_sections<br/>changed"]:::changed
  n4["Consenter<br/>changed"]:::changed
  n5["main<br/>changed"]:::changed
  n6["standalone"]:::impacted
  n7["CryptoConfig2"]:::impacted
  n8["CryptoState"]:::impacted
  n9["...que_authenticated_peer_and_valid_backends"]:::impacted
  n10["keyring_request"]:::impacted
  n1 -->|uses| n0
  n3 -->|uses| n1
  n3 -->|uses| n7
  n5 -->|uses| n1
  n5 -->|uses| n4
  n5 -->|uses| n7
  n6 -->|uses| n7
  n7 -->|uses| n1
  n8 -->|uses| n7
  n9 -->|calls| n6
  n9 -->|tests| n6
  n10 -->|uses| n2
  classDef changed fill:#0d4429,stroke:#238636,color:#e6edf3
  classDef impacted fill:#161b22,stroke:#6e7681,color:#c9d1d9
  classDef flagged fill:#5a1e02,stroke:#d93f0b,color:#ffffff
  classDef blocking fill:#67060c,stroke:#f85149,color:#ffffff
Loading
Agent review details

critique

  • Conclusion: Success
  • Scope reviewed: all assigned evidence
  • Lane summary: Reviewed 6 files; 3 findings. _The code index is behind this pull request (indexed at `b3c67225d15c`), so retrieved context may be out of date._

security

  • Conclusion: Success
  • Scope reviewed: all assigned evidence
  • Positive: The security lane reports no findings; the documentation file was not security-reviewed as prose.
  • Lane summary: Reviewed 5 files; 0 findings. 1 file was not security-reviewed: docs/module-crypto.md (prose or tabular data). _The code index is behind this pull request (indexed at `b3c67225d15c`), so retrieved context may be out of date._

tests

  • Conclusion: Success
  • Scope reviewed: all assigned evidence
  • Lane summary: The read-grant code is coherent and the added tests do real work — they pin the clamp-to-ceiling and expiry boundary, the agent/origin/tier/name binding (with purpose explicitly excluded), that writes and deletes always prompt and end the grant even when refused, that `RevokeGrants` needs no consent, that a reinit ends every grant, and the 64-grant oldest-first eviction. Two stated contracts are not pinned by any test: the comment that ending a grant "is never rate-limited away" (every grant test runs with `operations_per_minute = 4096`, so moving the revoke arm behind `state.admit()` would pass the whole suite), and the `filter(|ttl| *ttl > 0)` branch for a host that answers an offered read with `ttl_ms: 0`, which the fixture cannot produce. _The code index is behind this pull request (indexed at `b3c67225d15c`), so retrieved context may be out of date._

commits

  • Conclusion: Success
  • Scope reviewed: all assigned evidence
  • Positive: The commits lane reports no sensitive content in the committed files.
  • Lane summary: No sensitive content in committed files. _The code index is behind this pull request (indexed at `b3c67225d15c`), so retrieved context may be out of date._

description

  • Conclusion: Success
  • Scope reviewed: all assigned evidence
  • Positive: The description lane reports that the four wire additions, the clamp to the configured ceiling, the agent/origin/tier/name grant key with purpose excluded, the oldest-first cap, and the revoke-before-consent paths for writes/deletes, reinit and `RevokeGrants` are implemented where the description says, and that the documented behavior matches the diff.
  • Lane summary: The description matches the diff. All four wire additions it lists are present as described (`KeyringConfig2.read_grant`, `KeyringConsentRequest2.offer`, `KeyringConsentResponse2.grant`, `KeyringOperation2::RevokeGrants`), the clamp to the configured ceiling, the agent/origin/tier/name grant key with purpose excluded, the 64-entry oldest-first cap, and the revoke-before-consent paths for writes/deletes, reinit and `RevokeGrants` are all implemented where the body says they are. Nothing significant in the diff goes unmentioned, including the deliberate `Access::Revoke` unreachable arm and the docs update. I could not verify the reported CI/test-run output, but nothing in the diff contradicts it. _The code index is behind this pull request (indexed at `b3c67225d15c`), so retrieved context may be out of date._
Evidence and run details
  • Models: ladder/vectors-oai3, deepseek-v4.1-flash, deepseek-v4-flash
  • Spend: $0.066865
  • Completion usage: incomplete; some provider completion costs are unavailable.
  • Tokens: 1442480 input · 264082 output · 576768 cached · 832 embedding
  • Updated: 2026-11-11T13:13:44Z
Head State Pass summary
a34ae0a7cfb6 ready for maintainer review 0 active finding(s), 0 resolved finding(s) (at 2026-11-11T11:41:04Z)
de31d27ce327 ready for maintainer review 0 active finding(s), 0 resolved finding(s) (at 2026-11-11T13:13:44Z)

tinysweeper 0.1.2

@tinysweeper tinysweeper 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.

tinysweeper found nothing blocking. Approving.

Diagnostics

Total spend: $0.0641

@M3gA-Mind
M3gA-Mind merged commit 9834e38 into tinyhumansai:main Nov 11, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Keyring reads need a consent per operation, with no session-scoped grant

1 participant