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 thesolcis_prefix too: a channel pre-shared key (PSK) readssolcis_pep_…and an operating credential readssolcis_ac_…. An older credential may still carry the legacyrails_prefix, and both brands are accepted, so arails_pep_- orrails_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— theid: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 onpolicy_idalone.version_hash—blake3(policy.yaml), the revision of the combined set of policies enforced — not the version of the policypolicy_idnames (distinct from the skip-verifybundle_hashand the serve Healthbundle_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
- argv, never a shell string — action data is passed as a plain argument, never evaluated by a shell. No command injection.
- decision from the JSON, never the exit code — the engine exits
0even when it denies. The outcome is read fromdecision.decision; the exit status is never trusted. - 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. - 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-overwritesrequest.enforcement_point_idwith the channel id its PSK maps to, so the signed receipt is bound to that channel id. SetSOLCIS_CHANNEL_AGENT_IDto 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 withchannel_agent_id_missingbefore any connection is opened, andreport_outcomeis 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 asoutcome_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/verificationride 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. Nonemeans unknown.serve.health()collapses every fault toNone. 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)
| File | Size | Uploaded | |
|---|---|---|---|
| solcis_sdk-1.0.0a4.tar.gz | 104.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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
|