This release is a pre-release and may not be stable for production use.
redact-secret-vault (Python)
Status: alpha / research-grade. Native Python implementation of the same
S1 server-authority contract as the JavaScript
@redact-secret/vault-server
(decision record).
It does not implement the JavaScript @redact-secret/vault API: Python has
no authority-free portable vault, so this single distribution is named
redact-secret-vault without a -server suffix (see the
naming decision's 2026-09-28 note).
The distribution was called redact-secret-vault-server (module
redact_secret_vault_server) before its first publish; that name was never on
PyPI. It provides trusted principal/tenant resolution, a source→sink/path/purpose decision
tuple, fail-closed policy evaluation, an extended denial vocabulary, and
audit events with no field capable of carrying a restored value. Storage is
in-memory only, matching @redact-secret/vault's threat boundary — nothing
here is persistent. Version 0.1.0b3 (PEP 440; the counterpart of the npm
0.1.0-beta.3 release) is published to PyPI from release.yml through
trusted publishing (see RELEASING.md); 0.1.0a3
was the first version there.
This package does not implement secret detection. @redact-secret/core has
no published Python distribution (verified against the
redact-secret/redact-secret GitHub organization on 2026-09-27: only
packages/javascript exists there). Capture therefore uses a qualified
service boundary: NodeCoreBridge
runs a small Node.js script
(boundary/core_bridge.mjs)
in a long-lived child process that calls only the core's public scan API
and returns its safe finding metadata (never a matched value). See
Bridge process below and the threat model's
Python core bridge
section for the boundary, what it protects against, and what it does not.
The original service-boundary inventory is
archived.
Requirements
- Python 3.10+
- For
NodeCoreBridge: anodeexecutable (Node.js 20, 22, or 24) onPATH, and@redact-secret/coreat exactly the pinned version (PINNED_CORE_VERSION,0.1.0-beta.12;0.1.0b2pinned0.1.0-beta.11,0.1.0b1pinned0.1.0-beta.10) installed with npm in a directory your application owns. A consumer that supplies its ownCoreClientdoes not need Node at all — the boundary is aProtocol, not a hard dependency.
Install
pip install redact-secret-vault==0.1.0b3
# In a directory of your choice, for example /srv/myapp/core:
npm install @redact-secret/core@0.1.0-beta.12
Then tell the bridge where that node_modules is, either in code or through
the environment:
bridge = NodeCoreBridge(node_modules="/srv/myapp/core/node_modules")
export REDACT_SECRET_VAULT_NODE_MODULES=/srv/myapp/core/node_modules
The explicit node_modules= argument wins over the environment variable. The
bridge then loads <node_modules>/@redact-secret/core from exactly that
directory. It never searches parent directories or the working directory, so
whoever controls the process's working directory cannot substitute the core.
A relative path is made absolute when the bridge is constructed. The reported
core version must still equal PINNED_CORE_VERSION
(CORE_VERSION_MISMATCH otherwise). A directory without the core raises
CORE_FAILURE with core_code="BRIDGE_CORE_NOT_FOUND", or
BRIDGE_CORE_LOAD_FAILED if the core is there but fails to load. Neither
error includes the path.
With neither setting, the bridge script resolves the core relative to its own
location in site-packages. That works when the virtualenv lives inside the
project that ran npm install (for example /srv/myapp/.venv with
/srv/myapp/node_modules), and in this repository. For a virtualenv anywhere
else it fails with BRIDGE_CORE_NOT_FOUND, so pass node_modules=.
From this repository (development; npm ci at the root installs the core):
cd packages/vault-py
pip install -e ".[test]"
Usage sketch
import asyncio
from redact_secret_vault import (
CaptureGrant,
CaptureOptions,
InMemoryVaultServer,
NodeCoreBridge,
PolicyDecision,
Principal,
RestoreRequest,
)
def resolve_principal(context):
# The consuming application's own authentication — never a mandated
# identity provider. Must raise, not return a partial Principal, when
# trust cannot be established.
return Principal(id=context["user_id"], tenant=context["tenant"])
def same_tenant_only(decision_input):
if decision_input.tenant == decision_input.source.issued_tenant:
return PolicyDecision(allow=True)
from redact_secret_vault import ServerDenialReason
return PolicyDecision(allow=False, reason=ServerDenialReason.TENANT_MISMATCH)
async def main() -> None:
server = InMemoryVaultServer(
core_client=NodeCoreBridge(),
principal_resolver=resolve_principal,
release_policy=same_tenant_only,
)
captured = server.capture(
"deploy with ghp_EXAMPLE_SYNTHETIC_TOKEN_0000000000 now",
CaptureOptions(
issued_tenant="tenant-acme-synthetic",
release=(CaptureGrant(sink="reply", paths=("body",)),),
),
)
result = await server.restore(
RestoreRequest(
sink="reply",
captures=(captured.capture_id,),
fields={"body": f"Use {captured.tokens[0].token} please"},
purpose="support-reply-purpose-synthetic",
tenant="tenant-acme-synthetic",
context={"user_id": "user-synthetic-1", "tenant": "tenant-acme-synthetic"},
)
)
print(result.fields["body"])
asyncio.run(main())
Bridge process
Since #89.
Earlier versions spawned a new Node.js process for every scan, which made
almost all of a capture's cost process start-up. Now each NodeCoreBridge
owns one long-lived process:
- Start. The process starts on the first
scan, not at construction, and loads and initializes the core once. That start (about 30 ms on an Apple M4) is paid once per process. Later scans cost a pipe round trip plus the scan itself (about 0.3 ms for a 1 KiB input). - Protocol. One request and one response per line (newline-delimited
JSON). Each request carries a sequential
idthat the response must echo. Parsing is as strict as before: exact keys and types, only the eight safe finding fields. A request larger thanMAX_REQUEST_FRAME_BYTES(448 MiB) raisesLIMIT_EXCEEDEDbefore it is sent; a response line longer thanMAX_RESPONSE_FRAME_BYTES(32 MiB) isBRIDGE_BAD_OUTPUT. - Threads. A lock admits one request at a time, so threads that share a bridge are serialized and never see each other's findings. Each waits for the requests ahead of it. For parallel scans, use one bridge per worker; separate bridges own separate processes.
- Failure. A request that runs past
timeout_s(default 10 s) is killed (CORE_FAILUREwithBRIDGE_TIMEOUT). A process that exits mid-request isBRIDGE_PROCESS_FAILED; malformed, oversized, or out-of-sequence output isBRIDGE_BAD_OUTPUT. After any failure, including a core error and an interrupted call, the process is killed and never used again, and the nextscanstarts a new one. A process that died while idle is replaced silently, because no request was lost. Errors carry fixed codes only, never input or process output, and the process's stderr is discarded. - Lifetime.
max_scans_per_process(default 10,000; the process is killed right after its last scan),max_process_age_s(default 600), andidle_timeout_s(default 60; the process exits by itself when idle that long) bound how long one process lives and how many inputs pass through its heap.max_scans_per_process=1gives back one process per scan. - Shutdown. Call
close()or use the bridge as a context manager. It kills and reaps the process, and a laterscanraisesCORE_FAILUREwithBRIDGE_CLOSED. Garbage collection of the bridge and interpreter exit do the same. The process also exits when its stdin closes, so it cannot outlive your Python process. Afteros.fork(), the child starts its own process and never touches the parent's. - PII. Every new process is initialized with the bridge's
piiand its activation is checked again (see below).
with NodeCoreBridge(node_modules="/srv/myapp/core/node_modules") as bridge:
server = InMemoryVaultServer(core_client=bridge, principal_resolver=resolve_principal)
...
Residual risk: earlier inputs can stay in the bridge process's heap until it is garbage-collected or the process exits, now for up to the lifetime bounds instead of one scan. Lower the bounds, or use one bridge per tenant, if that matters for your deployment.
PII selection and retention
Status: implemented since 0.1.0a2 (never published); 0.1.0a3 is the first PyPI release. PII detection needs
@redact-secret/core@0.1.0-beta.10 or later; this repository pins
0.1.0-beta.12 (PINNED_CORE_VERSION). A core without PII support (0.1.0-beta.9) gets the
fail-closed rules below. The rules are
the PII retention and activation decision record
(§1 and §3 "Python bridge"), the same ones @redact-secret/vault follows.
- Selection.
NodeCoreBridge(pii=[...])forwards the selectors verbatim to the core'sinitialize({ pii })in each bridge process. Nothing else initializes that process's core, so this list is the only selection. Omitting it (the default()) means PII off. The core judges selector grammar; its rejections surface asCORE_FAILUREwithcore_code(for examplePII_SELECTOR_INVALID). - Identity. Each scan reports the core's
piiActivation()identity asCoreScanOutcome.pii_activation, orNonewhen the core has no PII support. The bridge pins the identity from its first successful scan and raisesPII_ACTIVATION_MISMATCHif a later scan differs, including the first scan of a replacement process. Passexpected_pii_activation=to compare against a fixed identity instead. - Retention. A
redactfinding whose type starts withpii_is never retained unless its exact type is listed inCaptureOptions(pii=PiiRetention(retain=("pii_global_iban", ...))). Aneligiblecallback is not called for unlisted PII types and can only narrow the list. Unretained PII is replaced by a non-restorable placeholder and counted inunrestorable. - Fail closed. A non-empty
piiselection or anexpected_pii_activationon a core without PII support, and a capture'spiiretention when the scan reported no active PII detection, each raisePII_UNAVAILABLE.
bridge = NodeCoreBridge(pii=["pii"]) # the pinned core
options = CaptureOptions(
issued_tenant="tenant-acme-synthetic",
release=(CaptureGrant(sink="reply", paths=("body",)),),
pii=PiiRetention(retain=("pii_global_iban",)),
)
With PII on, Medium- and Low-confidence PII findings default to warn, so a
capture containing them fails with UNREDACTED_FINDINGS unless the caller
passes a policy that maps them or unredacted="pass-through". For example,
beta.10 rates a labeled seven-digit local phone number (telephone=…) as
Medium pii_global_phone. pii.retain applies only to redact findings, so
listing a warn-level type there does not retain it.
Every finding the core returns, PII included, counts toward max_findings,
which the bridge passes to the core. Exceeding it raises CORE_FAILURE with
core_code="FINDING_LIMIT_EXCEEDED" and commits nothing.
This package has no displayFormatter: it builds its own output with
<SECRET_n> placeholders and never calls the core's redact(). The core
beta.10 rule that rejects a placeholder reproducing any finding's matched text
(INVALID_PLACEHOLDER, which the JavaScript vault surfaces from a custom
displayFormatter) therefore does not apply here.
Tests
pip install -e ".[test]"
pytest
tests/test_pii_bridge.py includes four cases that need a PII-capable core;
they run against the pinned core. VAULT_SERVER_PY_PII_CORE_NODE_MODULES
points them at another node_modules (for example a local core build). Four
further cases need a core without PII support (beta.9) and skip with a reason;
the fake-core cases in the same file cover those rules.
tests/test_bridge_process.py covers the bridge process: reuse, lifetime
bounds, timeout, crash, malformed and oversized output, threads sharing a
bridge, separate bridges, close(), garbage collection, and fork().
tests/test_conformance.py runs the shared language-neutral corpus
(conformance/v1/corpus.json) against this package; tests/test_server_authority.py
covers the S1-specific adversarial cases (cross-tenant, missing purpose,
revoked-token reuse, policy-evaluation-error, principal-resolution failure)
that the corpus does not yet include (see conformance/README.md). Both
require a node executable and @redact-secret/core installed at the
repository root (npm ci from the repo root first).
What "equivalent to @redact-secret/vault-server (JS)" means
See docs/research/python-server-integration-2026-09-27.md for the full statement and the candid differences (token entropy source, marker-detection regex, capture's audit vocabulary, and the core-integration boundary itself). In short: the same decision tuple, the same nine-step preflight order, the same denial vocabulary, and audit events that structurally cannot carry a restored value — not byte-identical code or wire format.
Metadata
Release files for redact-secret-vault 0.1.0b3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| redact_secret_vault-0.1.0b3.tar.gz | 61.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| redact_secret_vault-0.1.0b3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 102.3 kB
Release files / redact_secret_vault-0.1.0b3.tar.gz
| Download URL | redact_secret_vault-0.1.0b3.tar.gz |
|---|---|
| Size | 61.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6ef0b4021a91aba44e541ea32b48d495b8fbfc6db1d6f0ed6f4a6d1f7e0d23d4
|
|
BLAKE2b-256 checksum How to use checksums |
9f9b6d5ad00b0fdae1f21068e6bb37e1bbad171e41fb02d2822cf20f06e04949
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.
Transparency logRelease files / redact_secret_vault-0.1.0b3-py3-none-any.whl
| Download URL | redact_secret_vault-0.1.0b3-py3-none-any.whl |
|---|---|
| Size | 41.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3c5b781b9e1f5f6a6d446ad082b8792fb7bc6914abbf7c39981d5e60c8f8dea6
|
|
BLAKE2b-256 checksum How to use checksums |
33ea240e488884be1aeb40f265cc8b93eb97815478320ba5fe4340284aec7d51
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.
Transparency log