Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

solcis-sdk

The reference Policy Enforcement Point (PEP) client for Solcis, published on PyPI as solcis-sdk. The Node (@solcis/sdk on npm) and .NET (Solcis.Sdk on NuGet.org) clients carry the same API.

A PEP is the point in your application where — right before an agent does something risky (send data, call a tool, spend money) — you ask the policy evaluator "is this allowed?" and obey. This client runs the installed policy binary as a subprocess, asks it to decide one action, reads the JSON answer, and enforces it. It is all-local and fails closed: any problem is treated as BLOCK.

Install

pip install solcis-sdk

While the SDK is in alpha, that installs the newest alpha release. Pin the version you install, as with any dependency (pip install solcis-sdk==<version>). The package runs no install-time scripts, and its only runtime additions are the crypto pins the receipt verifier needs (blake3 + PyNaCl).

If you were given a wheel file instead, pip install ./solcis_sdk-<version>-py3-none-any.whl installs the same package.

The package's version is its own, set separately from the version of the Solcis platform you deploy: a lower number is still the right client. Compatibility rests on the decision contract the two were built against, which this client checks on every call and answers with a BLOCK where an engine was built to a different one.

Note: the package is branded Solcis and its environment variables use the SOLCIS_ prefix. The credentials it handles are minted under the solcis_ prefix too: a channel pre-shared key (PSK) reads solcis_pep_… and an operating credential reads solcis_ac_…. An older credential may still carry the legacy rails_ prefix, and both brands are accepted, so a rails_pep_- or rails_ac_-prefixed value is valid too, not a mismatch.

Use

import solcis_pep

result = solcis_pep.decide_and_enforce(
    {
        "action": "export",
        "enforcement_point_id": "agent-1",
        "user_id": "user-1",
        "source": "secret-db",
        "destination": "public-bucket",
        "payload": {"rows": 3},
    },
    policy_bin="/path/to/policy",  # or set SOLCIS_POLICY_BIN
    bundle="/path/to/bundle", trust="/path/to/trust",  # or SOLCIS_BUNDLE / SOLCIS_TRUST
)

if result["outcome"] == solcis_pep.PROCEED:
    ...  # let the action happen
elif result["outcome"] == solcis_pep.HOLD:
    ...  # pause; a human must decide first
else:  # solcis_pep.BLOCK
    ...  # stop the action

policy_bin defaults to the SOLCIS_POLICY_BIN environment variable (an explicit path — never a bare name found on PATH). Prefer decide_and_enforce — it decides and maps in one call, so the two steps can't be mis-wired.

With policy_bin set but neither bundle nor trust configured, decide() fails closed (no_bundle_configured) rather than silently falling back to the engine's built-in demo policy — see the reason-code table. To try the SDK with no bundle to hand, opt into the demo engine explicitly: pass demo_engine=True, or set SOLCIS_PEP_DEMO_ENGINE=true. Not for production or a shared environment — the demo engine decides from a permissive sample policy, not one you authored. demo_engine is tri-state: the default None defers to the env; True turns it on; anything else (including False) turns it off, even with the env set — precedence is per-call > env > off.

The same opt-in also covers the demo engine's receipt anchor on the serve transport. policy serve --demo signs receipts with a key anyone can reproduce, so without the opt-in the client refuses that key. Configured as SOLCIS_PDP_TRUST_ANCHOR, a serve decide fails closed with receipt_untrusted and report_outcome returns outcome_receipt_unverifiable, both before any connection; as a decide's trust-on-first-use pin, the decide fails closed before any connection; and first seen on first contact, it is never pinned and that decide fails closed once the response is read. Pass demo_engine=True to decide_via_selected_transport, enforce_action or report_outcome, or set SOLCIS_PEP_DEMO_ENGINE=true, only when rehearsing against the demo engine: the same setting turns on the process-exec demo policy, so set it only in a rehearsal shell — see the demo receipt anchor.

enforce() accepts a string or a result mapping

enforce() maps the engine's 5-outcome decision to PROCEED / BLOCK / HOLD. It accepts either the decision string or a whole decide() result mapping (it reads result["decision"]), so passing the whole result enforces the decision it carries:

result = solcis_pep.decide(action, policy_bin="/path/to/policy")
solcis_pep.enforce(result["decision"])  # the decision string
solcis_pep.enforce(result)  # the whole result mapping — same outcome
solcis_pep.result_to_outcome(result)  # the self-documenting named form

A valid but unrecognised token still maps to BLOCK (fail closed). Genuine misuse — None, a number, a list, or a mapping with no string "decision" — raises a descriptive, secret-free TypeError in development and fails closed to BLOCK in production (SOLCIS_PEP_ENV=production or python -O); pass strict= to force either mode. decide_and_enforce only ever passes the decision string, so it never raises.

Per-call enforcement mode

Set the enforcement mode for a single call with the enforcement_mode= keyword — threaded verbatim into the process-exec argv as --enforcement-mode <value>:

solcis_pep.decide_and_enforce(action, policy_bin="/path/to/policy", enforcement_mode="shadow")

Precedence: the per-call enforcement_mode (when non-empty) overrides SOLCIS_ENFORCEMENT_MODE, which overrides the engine default. It writes no os.environ, is never sourced from the request/action/payload, and governs the process-exec transport only — on serve the daemon owns the mode (no effect, no error). It never softens a real deny: routing keys solely on decision.

Non-blocking async API

decide_async and decide_and_enforce_async are non-blocking async siblings of decide / decide_and_enforce. Each thread-offloads the audited sync core verbatim via asyncio.to_thread, so it frees the event loop while the policy child is in flight and resolves to the same result mapping the sync path returns for the same input:

import asyncio
import solcis_pep


async def main():
    r = await solcis_pep.decide_async(action, policy_bin="/path/to/policy")
    result = await solcis_pep.decide_and_enforce_async(action, policy_bin="/path/to/policy")
    if result["outcome"] == solcis_pep.PROCEED:
        ...  # allowed — carry on


asyncio.run(main())

They are additive — the sync decide / decide_and_enforce are byte-for-byte unchanged — and govern the process-exec transport only (the serve transport is already async network I/O; for the config-selected front door use the sync enforce_action / decide_via_selected_transport). Because they reuse the sync core verbatim, they inherit its exact fail-closed semantics (every failure resolves to the identical coded reason_code) and the same decided-request / bound_fields binding, so assert_executed_request behaves identically on an async result. They never raise — every failure, including an async-plumbing fault, resolves to a fail-closed BLOCK (an unobserved raise could be swallowed upstream and fail open).

Threading — thread-safety matters. The offload runs on the event loop's default ThreadPoolExecutor, so the optional on_decision observer fires on a worker thread — it must be thread-safe — and many concurrent decide_async calls queue on that bounded pool (each still spawns its own policy child; the async path lifts the sync path's implicit spawn serialisation, so the host is responsible for bounding in-flight decides).

When a bundle is configured and no skip-verify handle is passed, the offloaded core runs policy verify then policy decide as two sequential spawns, so an un-skipped call costs up to ~2× the per-spawn timeout budget. Verify once at boot with verify_at_boot (below) and pass its handle as verified= to collapse this to a single spawn.

Bundle identity, skip-verify, provenance, and the executed→decided binding

Every decide result carries the engine's native-identity block — four additive, tolerant-parse string fields (absent ⇒ "", never fabricated), surfaced on both transports:

  • policy_id — the id: of the policy that decided: the policy whose rule decided, or whose own policy-level check did (such as its large-input refusal). For a deployment the console publishes, it is the policy id the console shows. It is "" when no single policy decided — an allow traced to no rule, an empty set of policies, or an engine-wide check such as intent alignment (on a deny, the rule id says which). When several policies objected, it names the one whose outcome was enforced; the decision log names them all. Identity is not an authz anchor: it is a non-authenticated author label for observability only; never grant or route on policy_id alone.
  • version_hash — blake3(policy.yaml), the revision of the combined set of policies enforced — not the version of the policy policy_id names (distinct from the skip-verify bundle_hash and the serve Health bundle_manifest_hash).
  • action — the decided action-subject the engine evaluated (e.g. bank_transfer_external).
  • verification — "verified" (the signature was checked this call) or "assumed" (skip-verify).
r = solcis_pep.decide(action, policy_bin="/path/to/policy", bundle=bundle, trust=trust)
# policy_id is "" when no single policy decided.
print("decided by", r["policy_id"] or "(no single policy)", r["action"], r["verification"])

Read the block in this order: if ok is false, ignore it; an empty version_hash means the demo engine or no bundle; an empty policy_id with a version_hash means no single policy decided.

Rules the engine could not evaluate

When the policy engine cannot evaluate a rule (an AI model it could not reach, a fact the request did not supply, a policy module that failed), it fails closed, usually to a hold. reason_code alone cannot tell that hold from one the policy asked for. engine_exceptions can: one record for each rule, or eval within a rule, that could not be evaluated, on both transports. It is [] when the engine evaluated every rule, when the engine predates the field, and when ok is false. engine_exceptions_omitted counts any further records the engine left out: an integer from 0 to 2147483647, and 0 when there are none or when the engine sent anything else.

key meaning
rule_id the rule that could not be evaluated
eval_id the eval within the rule, or None
cause why it could not be evaluated (below)
refusal when model routing refused, which check refused it: a reason token from the serve Health report's escalating_rules; otherwise None
capability the capability name the policy's author gave the rule's AI model, never an endpoint; otherwise None
policy_id the id: of the policy the rule belongs to; "" when no single policy owns it; None on the demo engine
deciding True on the one record, if any, whose failure is the outcome this decision took
observe_only True when the rule is observe-only, so its failure never decides

cause is currently one of model_disabled, model_not_configured, model_unavailable, model_no_capacity, decision_deadline_passed, model_timed_out, model_error, model_input_missing, judge_misconfigured, predicate_unevaluable, wasm_module_failed, rule_has_no_evaluator or required_registry_empty. The list can grow: treat a value you do not recognise as a rule that could not be evaluated, for a reason this version cannot name. A malformed record is skipped rather than failing the decision.

r = solcis_pep.decide_and_enforce(action, policy_bin=policy_bin, bundle=bundle, trust=trust)
for e in r["engine_exceptions"]:
    if e["deciding"]:
        logging.warning("%s could not be evaluated (%s)", e["rule_id"], e["cause"])

These records explain an outcome and never change it: route on outcome (or decision) as always. The on_decision record does not carry them, so read them from the result.

The mandatory executed→decided binding

decide reports the action it evaluated; a PEP must bind the tool call it is about to execute to that decided action. assert_executed_action(result, executed_action) is that binding, and it is the mandatory PEP pattern: decide the action you are about to execute, then assert the executed action equals result["action"] before acting. It returns PROCEED only when the decision routed to PROCEED, the result is a real engine decision, and the decided action equals the executed one — every other case (including a matched-action deny) returns BLOCK. A caller without this binding is outside the trust envelope.

r = solcis_pep.decide_and_enforce(action, policy_bin=policy_bin, bundle=bundle, trust=trust)
if (
    r["outcome"] == solcis_pep.PROCEED
    and solcis_pep.assert_executed_action(r, action["action"]) == solcis_pep.PROCEED
):
    ...  # safe to execute exactly action["action"]

When a signed policy denies on a request field (not just the action) — e.g. destination or payload.amount — the decide result lists those fields in result["bound_fields"], and the PEP must bind every one. assert_executed_request(result, executed_request) is the generalisation of assert_executed_action: it binds the action and every bound_fields entry, comparing each executed value against the decided one byte-for-byte (no trim / case-fold / unicode / numeric normalisation), so a decide-benign / execute-malicious dodge (decide destination="internal", execute "external") fails closed to BLOCK. Prefer it whenever your policies branch on request parameters. (It needs the decided request the SDK captured, so it applies to the process-exec decide result.)

r = solcis_pep.decide_and_enforce(action, policy_bin=policy_bin, bundle=bundle, trust=trust)
if (
    r["outcome"] == solcis_pep.PROCEED
    and solcis_pep.assert_executed_request(r, action) == solcis_pep.PROCEED
):
    ...  # safe to execute action: its action AND every bound field matched the decided request

Skip per-call re-verify (verify_at_boot)

Verify a bundle once at boot and skip the per-call signature re-verify (two policy spawns → one):

handle = solcis_pep.verify_at_boot(
    policy_bin, bundle, trust
)  # runs the real anchor verify, captures bundle_hash
r = solcis_pep.decide(action, policy_bin=policy_bin, bundle=bundle, trust=trust, verified=handle)

verify_at_boot runs the real Ed25519/anchor verify and, on success, captures the engine-reported bundle_hash (h) into an opaque handle; decide(verified=…) then appends --assume-verified --expect-bundle-hash <h>. The exact security trade: it skips the Ed25519 signature check but the engine still recomputes every file's blake3 and the full-signed-set h, refusing fail-closed on any mismatch. It needs the operator-pinned hash — a skip requested without a successfully-minted handle fails closed (assume_verified_requires_verify), and with no bundle configured (assume_verified_requires_bundle). A hand-built handle never enables skip; on the serve transport it is a no-op. Two-hash discipline: version_hash (blake3(policy.yaml), identity) ≠ h (the signed-set bundle_hash, integrity) — never pin version_hash as the skip hash.

The decision-provenance hook

Pass on_decision=callable to observe the finalized decision — outcome, decision, reason_code, rule_id, policy_id, version_hash, action, verification, shadow, transport, ok — without a bespoke telemetry ring buffer. It fires exactly once, after the decision is finalized, never influences routing, and a raise is swallowed (the observer must be non-blocking):

solcis_pep.decide_and_enforce(action, policy_bin=policy_bin, on_decision=lambda record: log(record))

The four security rules

  1. argv, never a shell string — action data is passed as a plain argument, never evaluated by a shell. No command injection.
  2. decision from the JSON, never the exit code — the engine exits 0 even when it denies. The outcome is read from decision.decision; the exit status is never trusted.
  3. fail closed — any problem (non-zero exit, empty/garbled output, missing decision, timeout, an exception) maps to BLOCK. A broken check must never accidentally allow.
  4. no signing key — the client verifies signatures and never produces them; it needs only the public trust anchor and the bundle. It does hold two secrets you must protect if you use those features: the serve channel's pre-shared key, and the agent credential you supply to enrolment.

The full frozen decide contract ships with the SDK sources rather than inside this package. Ask your Solcis contact for it if you are writing a client of your own.

The two transports (one API)

The distribution ships three top-level modules — solcis_pep (the process-exec transport + the unified front door), serve (the framed-TCP serve transport), and receipt (the signed-receipt verifier). The example above uses the process-exec entry point (decide_and_enforce). For the unified front door that auto-selects serve-vs-process-exec by config, use solcis_pep.enforce_action(action): it enforces over the serve transport when SOLCIS_PDP_ADDR is set (a loopback host:port), else over process-exec. SOLCIS_PDP_ADDR is the only selector — not SOLCIS_PDP (the testbed stub|api mode enum) or SOLCIS_PDP_URL (an HTTP address). The serve transport verifies a signed receipt before honouring any proceed-able decision.

Required — SOLCIS_CHANNEL_AGENT_ID. The daemon channel-overwrites request.enforcement_point_id with the channel id its PSK maps to, so the signed receipt is bound to that channel id. Set SOLCIS_CHANNEL_AGENT_ID to that channel id: the agent's id from the console, shown under the environment's name on the agent's page. There is no default. Unset or empty, every decide BLOCKs with channel_agent_id_missing before any connection is opened, and report_outcome is refused with the same code. A wrong value fails closed too: a genuine allow fails the content-bind (receipt_tampered), and a legitimate outcome report surfaces as outcome_receipt_unverifiable (recorded, but not provably the reporter's).

Serve env vocabulary (canonical names)

The serve transport is configured from the environment only (never from a request field). Each value resolves with the precedence explicit call option > canonical SOLCIS_PDP_* env, read presence-based (not truthy: the resolver tests is not None, never or, so a set-but-empty canonical stays empty and still fails closed).

Concept Canonical
Pre-shared key (channel credential) SOLCIS_PDP_PSK
Receipt-signing trust anchor SOLCIS_PDP_TRUST_ANCHOR
Connect+read timeout (ms) SOLCIS_PDP_TIMEOUT_MS
Daemon loopback host:port (selects serve) SOLCIS_PDP_ADDR

SOLCIS_PDP_TRUST_ANCHOR is the receipt-signing anchor (it verifies the signed serve receipt) — a distinct name from SOLCIS_TRUST, the bundle-verify (policy verify --trust) anchor on the process-exec path. No env name or value ever appears in a fail-closed diagnostic line (coded reason tokens only).

The full env surface and the serve wire are in ../README.md and ../SERVE_PROTOCOL.md.

What is this daemon enforcing? (serve.health)

To render "this engine is currently enforcing X" without making a decision, read the enforced-identity block off a serve Health report. serve.health() returns a parsed HealthReport — or None on any fault — and, on a bundle-serving daemon, that report carries policy_set_id — the id of the policy document enforced, composed: followed by your enforcement point id for a policy set — and the same version_hash / verification a decision carries:

from solcis_pep import serve

h = serve.health()  # None on any fault (unreachable / refused / malformed)
if h is None:
    ...  # could not reach the daemon — render "unknown"
elif h.policy_set_id is None:
    ...  # no bundle yet (demo engine, or awaiting first pull)
else:
    print(f"enforcing {h.policy_set_id} @ {h.version_hash} ({h.verification})")
    # on serve, h.verification is always serve.VERIFICATION_VERIFIED today

Three rules keep this honest:

  • Provenance, not permission. policy_set_id / version_hash / verification ride outside the signed receipt, so they are labels to display and log — never an authorization anchor, and never a substitute for the pinned receipt trust anchor the serve decide path verifies.
  • Identity present is not fresh, unstale, or non-empty. A present identity is what the engine decides with now; it does not say the bundle is current, that a reload did not fail, or that anything is enforced (a signed zero-member bundle has an identity and enforces nothing). Freshness is the operator-only bundle_manifest_hash / bundle_stale, absent (None) from a Pep report.
  • None means unknown. serve.health() collapses every fault to None. Render "unknown"; never fall back to a negative claim about enforcement.

Enrol — bind your agent's identity at startup (enrol)

A cold-path helper, separate from the decide/enforce core, wraps the control-plane POST /api/agent-environments/enrol: a running agent presents its solcis_ac_… operating credential and its runtime did, and its declared instance flips declared -> active.

from solcis_pep.enrol import enrol, EnrolError

try:
    # Each keyword falls back to its env var, so a fully-configured agent can call enrol() with no args.
    instance = enrol(
        base_url=base_url, credential=credential, did=did
    )  # EnrolResult(id, did, first_seen_at, status="active")
except EnrolError as e:
    if e.retryable:
        ...  # transient (control-plane down / rate-limited / 5xx) — safe to retry: a repeat enrol is idempotent
    else:
        raise  # a config error (wrong credential, did already claimed) — fail startup loudly
Env var Meaning
SOLCIS_CONTROL_PLANE_URL The control-plane origin (management plane — not the PDP; unrelated to SOLCIS_PDP_ADDR/SOLCIS_PDP_URL). Must be https unless loopback.
SOLCIS_AGENT_CREDENTIAL The solcis_ac_… operating credential (a bearer secret; never logged).
SOLCIS_AGENT_DID The runtime did (not SOLCIS_CHANNEL_AGENT_ID, the serve channel id).

Idempotent on the same did (a 200 no-op, first_seen_at preserved) — call it every boot; retry when e.retryable. It raises (it does not fail closed to BLOCK): EnrolUsageError on misuse (missing config or a non-https base), EnrolError (with a closed .code, a .retryable flag, and the HTTP .status) on an HTTP/transport fault. It uses only the stdlib (urllib) — no new dependency — and transits your credential to the control-plane, storing none. TLS trust uses the system OpenSSL trust store.

Migration: vendored file → installed package

If your tree still vendors this client by path — e.g. sys.path.insert(0, ".../oss/pep-sdk/python") then import solcis_pep, or a SOLCIS_PEP_PYTHON directory override — depend on the installed package instead, as described in Install above:

pip install solcis-sdk

Everything installs under the one name solcis_pep: import solcis_pep is the main entry point, and the helpers are its submodules — from solcis_pep import serve, receipt and from solcis_pep.enrol import enrol. Replace the sys.path insertion (or the SOLCIS_PEP_PYTHON override) with a normal dependency on solcis-sdk; the call sites (solcis_pep.decide_and_enforce(...), or solcis_pep.enforce_action(...) for transport auto-selection) are unchanged.

Breaking change from 1.0.0a3 and earlier: those releases installed serve, receipt, tofu, enrol and env_names as separate top-level modules, so code wrote import serve or from enrol import enrol. Those names were generic enough to clash with any other installed package that ships one, so they now live inside solcis_pep. Change import serve to from solcis_pep import serve, import receipt to from solcis_pep import receipt, and from enrol import … to from solcis_pep.enrol import …. import solcis_pep and the enforcement result are unchanged. The only other additions are the minimal crypto runtime dependencies (blake3 + PyNaCl, pulled transitively, used only by the serve/receipt path — the process-exec path is unaffected) and the new serve transport + unified front door. The SOLCIS_PEP_PYTHON override remains only as an escape hatch for containers that mount the client at a fixed path.

Licence

MIT.

Metadata

Release files for solcis-sdk 1.0.0a4

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for solcis-sdk 1.0.0a4
File Size Uploaded
solcis_sdk-1.0.0a4.tar.gz 104.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for solcis-sdk 1.0.0a4
File Interpreter ABI Platform
solcis_sdk-1.0.0a4-py3-none-any.whl Python 3 none any Details

Total release size: 200.7 kB

Release files / solcis_sdk-1.0.0a4.tar.gz

Download URL solcis_sdk-1.0.0a4.tar.gz
Size 104.0 kB
Tags Source
SHA-256 checksum
How to use checksums
14b64090da7fb659c77de11b6f2e6302f7b4565bdefd54e3874bf65c8166369a
BLAKE2b-256 checksum
How to use checksums
ff10be19217d4111266ccdb6fd07bf890ada11e1107f54fa34912b722416cb5e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Release files / solcis_sdk-1.0.0a4-py3-none-any.whl

Download URL solcis_sdk-1.0.0a4-py3-none-any.whl
Size 96.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
86344c4a92e159df9006eeb8f1709732067d3f0ecc32430bf370a892bc840aeb
BLAKE2b-256 checksum
How to use checksums
4d4f7deca1cb9bc157efc3bbc0ef8e2048bb08432e694819684af53d61893392
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Release history Release notifications | RSS feed

This release

1.0.0a4 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page