Merge pull request 'feat(casan): establish assurance kernel and harden control plane' (#12) from feat/assurance-kernel-control-plane into main
CASAN Supply Chain and Provenance / verify-scan-attest (push) Canceled after 0s
CASAN CI Gate / Frontend Tests (H3 gate) (push) Canceled after 0s
CASAN CI Gate / CASAN Security Gate + Vault KMS (H4/H5) (push) Canceled after 0s
CASAN CI Gate / Build & Deploy OKR → /opt/webapps/okr (push) Canceled after 0s
CASAN Harness CI / harness (push) Canceled after 0s

Reviewed-on: http://161.33.139.73:3000/admin/casan5/pulls/12
This commit is contained in:
admin
2026-08-03 04:56:52 +00:00
51 changed files with 4076 additions and 180 deletions
+1 -1
View File
@@ -28,6 +28,7 @@ COPY packages/casan-control-panel/frontend/package.json ./packages/casan-control
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/packages/casan-control-panel/backend/dist ./packages/casan-control-panel/backend/dist
COPY packages/casan-harness/scripts ./packages/casan-harness/scripts
COPY packages/casan-harness/kernel ./packages/casan-harness/kernel
COPY packages/casan-harness/config ./packages/casan-harness/config
COPY packages/casan-harness/security ./packages/casan-harness/security
COPY packages/casan-harness/config/project-registry.json ./packages/casan-harness/config/project-registry.json
@@ -46,7 +47,6 @@ WORKDIR /app/packages/casan-control-panel/backend
ENV NODE_ENV=production
ENV CASAN_PROFILE=prod
ENV CASAN_CP_STRICT=1
ENV CASAN_CP_TRUST_AUTH_PROXY=1
ENV CP_BIND=0.0.0.0
ENV CP_PORT=3010
+12 -3
View File
@@ -11,7 +11,12 @@ services:
environment:
CASAN_PROFILE: prod
CASAN_CP_STRICT: "1"
CASAN_CP_TRUST_AUTH_PROXY: "1"
CASAN_CP_AUTH_MODE: jwt
CASAN_CP_JWT_ISSUER: http://localhost:18082
CASAN_CP_JWT_AUDIENCE: casan-control-panel
CASAN_CP_JWT_PUBLIC_KEY_FILE: /run/casan-idp/idp-public.pem
CASAN_CP_JWT_ROLE_CLAIM: groups
CASAN_CP_JWT_CLOCK_SKEW_SECONDS: "30"
CP_BIND: 0.0.0.0
CP_PORT: "3010"
CASAN_APP_ROOT: /app
@@ -63,6 +68,7 @@ services:
- ./apps/service-desk:/app/apps/service-desk
- ./apps/projects:/app/apps/projects
- ./packages/casan-harness/config/project-registry.json:/app/packages/casan-harness/config/project-registry.json
- ./tmp/control-panel-local/idp-public.pem:/run/casan-idp/idp-public.pem:ro
expose:
- "3010"
networks:
@@ -111,8 +117,8 @@ services:
- --oidc-groups-claim=groups
- --reverse-proxy=true
- --set-xauthrequest=true
- --pass-access-token=false
- --pass-authorization-header=false
- --pass-access-token=true
- --pass-authorization-header=true
- --skip-provider-button=true
- --ssl-insecure-skip-verify=true
expose:
@@ -129,7 +135,10 @@ services:
CASAN_IDP_SUB: oidc-ops
CASAN_IDP_EMAIL: oidc-ops@example.com
CASAN_IDP_GROUPS: casan-org-admin,casan-approver,casan-project:AINative_OKR_CASAN4
CASAN_IDP_PRIVATE_KEY_FILE: /run/casan-idp/idp-private.pem
CASAN_APPROVAL_SIGNER_TOKEN: ${CASAN_APPROVAL_SIGNER_TOKEN:-local-approval-signer-secret}
volumes:
- ./tmp/control-panel-local/idp-private.pem:/run/casan-idp/idp-private.pem:ro
ports:
- "18082:8080"
networks:
+6 -6
View File
@@ -3,9 +3,9 @@
# `production-preflight.sh` validates all files, endpoints, Object Lock and
# images before `docker compose up` is allowed.
#
# oauth2-proxy must emit X-Auth-Request-User and X-Auth-Request-Groups.
# Nginx overwrites X-CASAN-* headers before proxying to the API; the API maps
# groups such as casan-approver -> approver via rbac-check.py map-claim.
# oauth2-proxy must emit a signed OIDC access token. Nginx removes caller-supplied
# identity headers and forwards only that bearer token; the API verifies issuer,
# audience, expiry and signature in-process before deriving CASAN identity.
services:
control-panel-api:
@@ -19,7 +19,6 @@ services:
environment:
CASAN_PROFILE: prod
CASAN_CP_STRICT: "1"
CASAN_CP_TRUST_AUTH_PROXY: "1"
CP_BIND: 0.0.0.0
CP_PORT: "3010"
CASAN_APP_ROOT: /app
@@ -29,6 +28,7 @@ services:
volumes:
- ${CASAN_CP_STATE_DIR:?Set CASAN_CP_STATE_DIR}:/app/.specify
- ${CASAN_CP_OUTPUT_DIR:?Set CASAN_CP_OUTPUT_DIR}:/app/docs/output:ro
- ${CASAN_CP_IDP_PUBLIC_KEY:?Set CASAN_CP_IDP_PUBLIC_KEY}:/run/casan-idp/idp-public.pem:ro
expose:
- "3010"
networks:
@@ -57,8 +57,8 @@ services:
- --http-address=0.0.0.0:4180
- --reverse-proxy=true
- --set-xauthrequest=true
- --pass-access-token=false
- --pass-authorization-header=false
- --pass-access-token=true
- --pass-authorization-header=true
- --skip-provider-button=true
- --cookie-secure=true
- --cookie-httponly=true
+141
View File
@@ -0,0 +1,141 @@
# CASAN Assurance Kernel
Status: implemented contract layer, version `1.0.0` (2026-08-02).
## Purpose and maturity
CASAN now has a framework-independent wire contract and deterministic policy
primitives outside the native Bash topology. The native harness remains
operational and is the first compatibility adapter; the Agentic Bridge is the
second adapter. This is an incremental extraction, not a rewrite of H1–H7.
CASAN may describe this layer as an **Assurance Kernel contract and policy
foundation with two conforming adapters**. It must not claim that every legacy
loop, graph, report, or third-party runtime has migrated to the kernel.
## Boundary
```text
Execution runtime
-> runtime adapter
-> canonical Assurance Kernel envelope
-> deterministic policy / verification / evidence services
-> runtime-specific enforcement mechanisms
```
The kernel does not execute models. It does not infer security decisions from
model output. Runtime-specific data is allowed only under a namespace such as
`casan.runtime.casan-native-harness` or `casan.runtime.agentic-bridge`.
## Canonical contract
The canonical source is
`packages/casan-harness/schemas/assurance-kernel.schema.json`. It is JSON Schema
Draft 2020-12 and uses `schema_version: 1.0.0`.
The envelope defines:
- `ExecutionRun`: identity, parent, scope, environment, runtime, mode, goal,
timestamps, status, risk summary, evidence manifest and correlation.
- `ExecutionStep`: causal position, actor/action/resource, context references,
policy decisions, tool invocation, verification, evidence and outcome.
- `Actor`: human, agent, child agent, model, tool, service account, runtime,
approver or policy engine with issuer, trust and authentication evidence.
- `Delegation`: authority, resource scope, expiry, maximum depth, parent,
revocation and approval.
- `ContextItem`: origin, trust, content hash, transforms, compression lineage,
instruction/data classification and injection scan.
- `Action` and `ResourceAccess`: canonical class, operation, authority,
side-effect level, resource and environment.
- `PolicyDecision`: versioned policy, structured decision, reason codes, risk
facts, enforcement point, engine identity, time and evidence reference.
- `RuntimeConstraint` and `RuntimeCapabilities`: honest limits and supported
cancellation/intervention mechanisms.
- `VerificationRequirement` and `VerificationResult`: expected versus actual
result, evidence, independence and failure severity.
- `Claim`, `EvidenceItem` and `TraceLink`: bidirectional claim support,
integrity metadata, producer, artifact, validation, retention and typed links.
- `Approval` and `Intervention`: explicit decisions and runtime support status.
- `Outcome`: four independent results for execution, assurance,
certification and business effect.
## Deterministic kernel services
The stdlib-only Python implementation is under
`packages/casan-harness/kernel/`.
`policy.py` provides:
- Machine-readable action classification from
`config/action-classes.json`.
- Effective risk as the maximum of content, action, resource, identity and
environment risk.
- H2 registry configuration validation and fail-closed dependency decisions.
- Single-step failure-policy selection with `halt`, `quarantine`,
`require_approval` and `record_only`.
- Production trust capability evaluation.
`contracts.py` provides builders and an enforcement-boundary verifier. The
verifier rejects unsupported schema versions, invalid actor/step references,
observed-only certification, broken claim/evidence links, evidence digest
tampering, delegation cycles and excess delegation depth.
`taxonomy.py` separates `RuntimeControl.Hn`, `ReadinessCheck.Hn`,
`ReportDimension.Hn` and `CertificationClaim.Hn`. A legacy `Hn` remains a
display alias only and never authorizes cross-category interpretation.
`supervision.py` negotiates interventions against declared runtime
capabilities. Unsupported pause, rollback, cancellation, authority reduction
or quarantine returns `unsupported`; it never reports a fictitious success.
## Action policy
The action registry includes read, write, delete, database mutation,
migration, deployment, release, credential access, identity/permission change,
external network side effect, infrastructure modification and unknown actions.
High-impact actions have a high minimum risk, require an identified actor,
explicit approval and evidence. Benign text cannot reduce that floor. Unknown
shell operations also fail toward high risk. H2 registry enforcement and H5
approval remain separate defense-in-depth decisions.
## Outcome semantics
The following states are deliberately distinct:
```text
execution_result = success | failed | cancelled | quarantined | ...
assurance_result = passed | failed | degraded | not_evaluated | ...
certification_result = certified | non_certified | ineligible | ...
business_result = achieved | not_achieved | partial | not_evaluated | ...
```
A telemetry append can succeed while `execution_result=failed`. A failed
side-effecting command defaults to `halt` in enforce/production mode and cannot
produce a successful completion or normal certification.
## Evidence integrity and trust
Canonical evidence metadata is linked to claims and carries a SHA-256 digest.
This detects local mutation and broken references. It is not, by itself, an
external trust root or WORM guarantee. Production certification separately
requires external signing and an external immutable anchor.
## Compatibility rules
- New envelopes use semantic version `1.0.0`; incompatible schema changes
require a new major version.
- Additive runtime fields belong under a namespaced `extensions` object.
- The native and Agentic Bridge formats remain readable and are dual-emitted
with canonical envelopes during migration.
- Legacy evidence is not silently reinterpreted as kernel-validated evidence.
- Observe-mode and unsafe-development-bypass runs are never certified.
- Audit chain format v1 remains verifiable while new records use v2 fields.
## Claims and residual limits
CASAN can claim deterministic shared action policy, canonical execution and
evidence contracts, and cross-runtime conformance for the native harness and
Agentic Bridge. It cannot yet claim universal runtime adoption, complete
multi-runtime supervision, external infrastructure availability, or
data-backed H1–H7 dossiers beyond the reports actually backed by evidence.
+113
View File
@@ -0,0 +1,113 @@
# CASAN Control Plane
Status: production-capable packaged authentication and trust configuration;
external infrastructure remains operator-provisioned.
## Deployment modes
### Local development
- Default bind: `127.0.0.1`.
- `CASAN_CP_AUTH_MODE=local` is allowed only on loopback and outside production.
- Local actor headers are explicitly development identity and are not a
production authentication claim.
### Networked or production
- `CASAN_CP_AUTH_MODE=jwt` is mandatory.
- Production requires RS256 verification, a mounted public key, exact issuer
and audience, and a clock skew from 0 through 300 seconds.
- Missing or invalid configuration refuses startup before NestJS listens.
- HS256 is retained only for deterministic non-production tests.
## Packaged identity boundary
```text
Browser
-> enterprise OIDC
-> oauth2-proxy session
-> Nginx auth_request
removes X-CASAN-* and forwarded identity headers
forwards signed Bearer access token
-> NestJS AuthProvider
verifies RS256 signature, iss, aud, sub, exp, nbf and iat
-> VerifiedClaims
-> CASAN RBAC role mapping and scoped request identity
```
Nginx is a TLS/session boundary, not the identity authority for the API. The
API ignores caller-supplied identity assertions and derives actor, role,
tenant and project only after token verification.
## Authentication implementation
`backend/src/common/auth-provider.ts` defines:
- `AuthProvider` and `JwtAuthProvider`.
- Typed `VerifiedClaims` and `AuthenticationDecision`.
- RS256/HS256 signature verification using Node's standard crypto APIs.
- Issuer, audience, expiry, not-before, issued-at and bounded-skew checks.
- Safe tenant/project syntax validation.
- Structured authentication audit evidence without raw tokens.
- Middleware that overwrites request identity only from verified claims.
`auth-context.ts` refuses direct header use in JWT mode unless the middleware
has marked the identity verified. Existing RBAC services then map verified
groups and continue to produce governance/authorization evidence.
## Production configuration
Required runtime variables:
```text
CASAN_PROFILE=prod
CASAN_CP_AUTH_MODE=jwt
CASAN_CP_JWT_ISSUER=https://...
CASAN_CP_JWT_AUDIENCE=...
CASAN_CP_JWT_PUBLIC_KEY_FILE=/run/casan-idp/idp-public.pem
CASAN_CP_JWT_ROLE_CLAIM=groups
CASAN_CP_JWT_TENANT_CLAIM=casan_tenant
CASAN_CP_JWT_PROJECT_CLAIM=casan_project
CASAN_CP_JWT_CLOCK_SKEW_SECONDS=60
```
The host path is configured as `CASAN_CP_IDP_PUBLIC_KEY` and mounted read-only.
oauth2-proxy must set xauthrequest output and pass the access token. Images for
API, UI and oauth2-proxy must be digest-pinned.
`production-preflight.sh` validates:
- TLS hostname, expiry and matching private key.
- IdP public key readability and RSA public-key format.
- HTTPS OIDC issuer, secure cookies and token forwarding.
- Matching oauth2-proxy client ID and API audience.
- Matching issuer and bounded JWT clock skew.
- External Vault/KMS signing and S3 Object Lock provider selection.
- Live short-lived non-root Vault token lookup.
- A real Object Lock COMPLIANCE anchor write.
- Digest-pinned images and valid Compose/Nginx configuration.
## Authentication evidence
Each production authentication decision appends a JSONL record under
`CASAN_STATE_ROOT/logs/auth/decisions.jsonl` with category, policy, decision,
reason, actor, issuer, scope and request method/path. Tokens and secrets are
never written. A failure to authenticate returns HTTP 401 with a stable reason
code; it is not converted to viewer access.
## Local production-like smoke
`docker-compose.control-panel.local.yml` uses the same bearer-token path. The
smoke script generates an ephemeral RSA keypair, mounts the private key into
the mock IdP and the public key into the API, and enables token forwarding.
This validates the boundary without treating the mock IdP as production.
## Residual limitations
- Public-key rotation currently requires replacing the mounted key and
restarting the API; automated JWKS discovery and rollover are not present.
- Enterprise IdP, managed TLS, Vault and S3 are not provisioned by the repo.
- Local tests validate crypto and provider contracts, but do not prove a real
tenant's claim mapping, token lifecycle, network policy or key rotation.
- The Control Plane is production-capable for this packaged deployment model;
it is not yet a universal multi-runtime scheduler or supervisor.
@@ -0,0 +1,95 @@
# CASAN Execution Adapters
## Adapter contract
An execution adapter translates runtime events into the canonical Assurance
Kernel envelope without replacing H2, H4, H5 or other existing controls.
```text
runtime event
-> adapter mapping
-> ExecutionRun / ExecutionStep / Actor / Action
-> PolicyDecision / VerificationResult / EvidenceItem / Outcome
-> common verifier
```
Adapter code lives in `packages/casan-harness/kernel/adapters.py`. The canonical
schema and policy code must not import a native runtime.
## Native harness adapter
`NativeHarnessAdapter` maps the Bash harness execution identity, action,
command, actor, environment, policy decisions, evidence and final outcome. The
native entrypoint in `scripts/bash/casan-harness.sh` continues to emit its
existing metrics and completion protocol while atomically writing a canonical
bundle to `CASAN_STATE_ROOT/logs/kernel/`.
If a phase exits non-zero, the harness best-effort emits a canonical failed
bundle before propagating the original exit code. This emission never masks
the command failure. A successful run reports execution, assurance and
certification separately.
The adapter is compatibility code: existing H gates remain the enforcement
implementation. The kernel does not duplicate them.
## Agentic Bridge adapter
`AgenticBridgeAdapter` maps lifecycle admission, pre-tool, post-tool, telemetry
and finalize events. The bridge now:
- Uses the shared action classifier and risk floors.
- Defaults H2 registry enforcement on in enforce mode.
- Converts missing, unreadable, timed-out, malformed or failed H2 dependencies
into structured policy decisions.
- Distinguishes failed tool outcome from successful telemetry recording.
- Applies the single-step failure policy.
- Restricts certification using enforcement mode, assurance strength and trust
capability.
- Dual-emits the canonical bundle alongside its v20 lifecycle records.
The Agentic Bridge remains a single-model integration: it performs admission,
policy, evidence and finalization but does not invoke a model itself.
## Cross-runtime conformance
`tests/assurance-kernel-tests.py` runs the same invariants through both
adapters. It proves:
1. Dangerous action classification is identical.
2. Missing actor identity denies the same high-impact action.
3. Missing H2 enforcement denies in enforce mode.
4. Approval and evidence requirements are identical.
5. Claim/evidence relationships use the same contract.
6. Observed-only execution cannot be certified.
7. Execution, assurance and certification outcomes remain distinct.
8. Correlation and parent causation are preserved.
9. The same verifier detects evidence tampering.
10. Runtime details are namespaced extensions rather than kernel assumptions.
The conformance suite currently covers two paths. A future adapter must pass
the same suite before being described as kernel-conforming.
## Capability negotiation
Adapters declare `canCancel`, `canPause`, `canResume`, `canRollback`,
`canReduceAuthority` and `canQuarantine`. These are conservative. The native
harness currently declares no general asynchronous intervention capability;
the Agentic Bridge declares quarantine support only where its lifecycle can
represent it. Unsupported interventions return an explicit result.
## Adding an adapter
1. Preserve the runtime's existing security controls.
2. Map stable run, step, actor, correlation and causation identities.
3. Use the shared action policy; do not create a weaker parallel classifier.
4. Emit policy decisions and evidence references, not boolean-only results.
5. Put runtime-only fields in `casan.runtime.<adapter>` extensions.
6. Validate with `validate_bundle` and the JSON Schema.
7. Add the full cross-runtime invariant matrix.
8. Do not mark legacy or observe-only records certified.
## Residual migration scope
Custom loop runners, graphs and every historical event producer have not been
rewritten. They may continue using legacy formats, but they cannot inherit
kernel-conformance claims until they receive an adapter and pass conformance.
+109
View File
@@ -0,0 +1,109 @@
# CASAN Trust Boundaries
## Trust model
CASAN separates enforcement, evidence, identity, execution isolation and
external trust. Passing one boundary never implies that another passed.
| Boundary | Development/local | Production/strict |
|---|---|---|
| Control Plane identity | Explicit loopback local identity | In-process RS256 JWT verification required |
| H2 tool authority | Enforce defaults on; explicit bypass is high-severity/non-certifiable | Enforce on; bypass configuration rejected |
| H5 high-impact action | Approval and actor required by action floor | Strict signed approval plus external trust requirements |
| Tool isolation | Static fallback only when explicitly enabled in dev/test | Container isolation required; unavailable backend denies |
| Audit signing | Local OpenSSL permitted and labelled local | Vault/KMS external signing required |
| Immutable anchor | Local hash chain permitted and labelled local | External Object Lock/WORM provider required |
| Certification | Local/observed limitations disclosed | Unsafe fallback or emergency override cannot normally certify |
## H2 enforcement dependency
H2 returns a structured `PolicyDecision`, never a boolean-only decision. Missing
file, unreadable file, timeout, malformed protocol, missing runtime and internal
execution errors have stable reason codes. In enforce mode all dependency
errors deny. In observe mode the operation may be observed, but assurance is
degraded and certification is forbidden.
Registry enforcement defaults on for side-effecting native and Agentic Bridge
paths. `CASAN_H2_REGISTRY=0` or `CASAN_AGENTIC_H2_REGISTRY=0` is an explicit
unsafe development/test bypass. Production treats the bypass as invalid.
## H5 governance and action risk
`config/action-classes.json` is the common policy source. Effective risk is the
maximum of content, action, resource, identity and environment factors.
Deployment, release, migration, database mutation, delete,
identity/permission modification and infrastructure modification cannot be
downgraded by benign wording. Credential access and unknown commands are also
high-risk. High-impact operations require actor identity, approval and
evidence.
Production governance requires strict approval and external signing/anchor
capability. Governance audit format v2 binds action class, risk factors and
evidence requirements into the hash. The verifier continues to recognize
legacy v1 records without upgrading their claims.
## Execution isolation
`sandbox-run.sh` detects its selected capability and writes structured sandbox
evidence. In enforce/production mode a side-effecting path requires the
container backend. If Docker or the required backend is unavailable, execution
is denied with `sandbox_isolation_backend_unavailable`; there is no silent
timeout/static fallback.
The implemented container contract uses:
- Read-only root filesystem.
- Explicit workspace bind mount as the writable scope.
- Network disabled by default.
- Non-root container user.
- Dropped Linux capabilities and `no-new-privileges`.
- PID, memory, CPU and timeout limits.
- Restricted working directory and filtered environment.
- Output-size and exit-code capture at the harness boundary.
- Rootless Docker requirement in production.
CASAN does not claim AppArmor, SELinux, a custom seccomp profile, per-domain
network allowlisting or complete host isolation where those mechanisms are not
configured. Development static fallback is policy filtering, not isolation.
## Control Plane identity boundary
Arbitrary `X-CASAN-*`, `X-Forwarded-User` and similar headers are untrusted.
Nginx removes them, oauth2-proxy provides a signed access token, and the API
verifies the token before creating `VerifiedClaims`. A non-loopback local mode
or an invalid production verifier refuses startup.
## Signing and immutable evidence boundary
Local SHA-256 chains and evidence digests provide tamper detection under the
local host's trust. They are not externally trusted immutability. Normal
production certification requires both:
1. `CASAN_SIGNING_PROVIDER=vault_kms` (or another explicitly supported external
trust provider) with an HTTPS endpoint and short-lived credential.
2. `CASAN_IMMUTABLE_ANCHOR_PROVIDER=s3_object_lock` (or an explicitly supported
external WORM provider) with bucket and KMS configuration.
The packaged production path verifies Vault Transit readiness and performs an
S3 Object Lock COMPLIANCE anchor write. It does not provision either service.
`CASAN_TRUST_EMERGENCY_OVERRIDE=1` is break-glass behavior. It emits critical
evidence, keeps readiness failed and makes the run non-certifiable. It is not a
normal production fallback.
## Secret and evidence handling
- Raw JWTs and authentication secrets are not logged.
- Tool and prompt evidence remains subject to existing redaction/scanning.
- Authentication evidence records identity metadata and stable reason codes.
- Canonical evidence digests detect local metadata tampering, while external
signing/anchoring supplies production trust.
- A successful evidence append never changes a failed execution into success.
## Infrastructure not proven by repository tests
Repository-local tests use cryptographic fixtures and narrow service stubs.
Docker isolation tests are skip-aware when Docker is absent. Real enterprise
OIDC traffic, Vault policy enforcement, KMS/HSM custody, S3 retention policy,
rootless Docker host hardening and managed network controls require deployment
evidence before production claims are made.
@@ -0,0 +1,221 @@
# Assurance Kernel and Control Plane Upgrade — Implementation Log
Date: 2026-08-02
## Executive implementation result
All seven P0 items and all five requested P1 items were implemented with
targeted regression coverage. The native harness remains operational. The
Assurance Kernel now has a versioned canonical contract, deterministic shared
policy, a native adapter, an Agentic Bridge adapter and a 10-invariant
cross-runtime conformance suite. The Control Plane packages in-process RS256
identity verification and refuses unsafe network startup.
P2 was intentionally limited: taxonomy and runtime capability negotiation were
implemented, but new H4/H5/H2 report dossiers were not represented as
data-backed because the shared evidence-query migration was not safe to finish
within this change.
## Verified pre-existing behavior
- H4 already blocked injection/secrets before model execution.
- The agent bridge already denied sensitive pre-tool adapter failures in many
paths and disclosed observed-only degradation.
- The native harness already propagated command exit status; the misleading
completion gap was primarily in Agentic Bridge finalize semantics, where a
later `Stop(completed)` could obscure a failed tool.
- Container isolation already implemented read-only root, no network,
non-root execution, capability dropping and resource bounds.
- Production preflight already checked live Vault/S3 prerequisites, and
`sign-audit-head.sh` already refused local fallback for `CASAN_PROFILE=prod`.
- H6 already had a data-backed report and operational test suite; other report
dimensions remained `contract_ready`.
- H5 strict signed approval, separation of duties and OIDC approval fixtures
already existed.
## Verified gaps
- Agentic H2 returned allow when the gate script was absent.
- Agentic registry enforcement defaulted off.
- H5 risk followed text risk, allowing benign deployment text to avoid the
inherent action floor.
- Agentic telemetry and finalization did not model failed execution separately
enough to prevent misleading successful completion.
- `sandbox-run.sh` could silently fall back to weaker timeout/static behavior.
- Control Plane identity trusted forwarded identity headers when a flag was set.
- Production governance/certification did not uniformly require both external
signing and immutable anchoring capabilities.
- No stable canonical execution contract or two-runtime conformance suite
existed.
- H1–H7 categories were structurally ambiguous.
## Code changed in this task
### P0.1 — H2 missing-gate behavior
Selected implementation: `evaluate_h2_gate` returns a structured
`PolicyDecision` with stable reason codes. Missing file, permission denial,
timeout, malformed response and internal exception deny in enforce mode.
Observe mode yields `observe_only`, degraded and non-certifiable evidence.
Proof: kernel unit tests and Agentic Bridge integration tests cover every error
class and both modes.
Residual: external shell gates remain a runtime dependency; the change makes
dependency failure honest and safe rather than eliminating it.
### P0.2 — registry defaults
Selected implementation: both side-effecting native and Agentic Bridge paths
use shared configuration validation and default registry enforcement on.
Development/test bypass is explicit, high-severity and non-certifiable;
production bypass is invalid.
Proof: unit, bridge integration and legacy Agentic Bridge suites.
### P0.3 — action-class risk floors
Selected implementation: `config/action-classes.json` is the deterministic
source. Effective risk is the maximum of five factors. H5 governance and both
adapters consume it; high-impact actions require actor, approval and evidence.
Proof: benign deployment regression in unit, bridge and shell integration
tests. Existing H5 strict approval remains 12/12.
### P0.4 — single-step failure semantics
Selected implementation: kernel failure-policy selection plus bridge
post-tool/finalize changes. Execution failure, telemetry recording, assurance
continuation and certification are separate. Production side-effect
`record_only` is rejected. Native phase failure emits a failed canonical bundle
and preserves the original non-zero exit.
Proof: all four policy values, invalid/production configuration, Agentic failed
write and native exit-7 regressions.
### P0.5 — isolation fallback
Selected implementation: enforce/production chooses container isolation and
denies when unavailable. Static fallback requires an explicit dev/test flag.
Capability evidence discloses the actual backend. Production requires a
digest-pinned image and rootless Docker.
Proof: unavailable-backend and explicit-development-fallback tests. Existing
container escape tests are present but were skipped locally because Docker was
unavailable.
### P0.6 — Control Plane identity
Selected implementation: typed `AuthProvider`, RS256 JWT verification,
`VerifiedClaims`, safe scope checks, audit evidence, header overwrite and
startup validation. Nginx passes only the access token after removing identity
headers. Production preflight verifies the packaged boundary.
Proof: missing/invalid/expired/wrong-audience/wrong-issuer tests, valid RS256,
header spoofing and production startup refusal. Backend has 57 passing tests.
Residual: mounted static public key requires coordinated restart for rotation;
JWKS discovery/automatic rollover is not yet implemented.
### P0.7 — trust-root enforcement
Selected implementation: shared capability evaluation, governance enforcement,
Vault signing, Object Lock anchoring and certification restrictions. Production
local fallback is refused. Emergency override is critical, readiness-failing
and non-certifiable.
Proof: unit, bridge, shell and production configuration tests.
Residual: real Vault/KMS and S3 are customer infrastructure and were not
provisioned or contacted in this local run.
### P1 — contract and adapters
Selected implementation: JSON Schema `1.0.0`, Python builders/verifier,
namespaced extensions, dual emission, native compatibility adapter and Agentic
Bridge adapter. Evidence integrity and bidirectional claim links use one
verifier. Delegation depth/cycles are validated.
Proof: 30 kernel/unit/conformance tests, including the exact 10 cross-runtime
invariants and evidence tampering.
### P1 — H taxonomy
Selected implementation: separate typed namespaces for runtime controls,
readiness checks, report dimensions and certification claims. Structured
readiness, report and certification evidence now includes a category/type.
Legacy `Hn` remains a display mapping only.
Proof: cross-category unit test and additive Control Plane report tests.
### Limited P2 — runtime supervision
Selected implementation: conservative runtime capabilities and intervention
negotiation. Unsupported actions are explicit, not silently successful.
Residual: no general scheduler, durable supervisor state, cross-process cancel,
pause/resume implementation, budget service or rollback engine was added.
## Tests added
- `assurance-kernel-tests.py`: 30 unit and conformance tests.
- `assurance-upgrade-integration-tests.py`: 7 Agentic Bridge integration tests.
- `phase-assurance-upgrade-tests.sh`: 11 shell integration groups.
- `auth-provider.test.ts`: JWT and production identity tests.
- Existing auth-context/report tests extended for spoofing and taxonomy.
- Production preflight tests extended to a full valid contract plus audience,
local trust and missing-key failures.
## Verification results
| Suite | Result |
|---|---:|
| Kernel unit + two-adapter conformance | 30/30 pass |
| Draft 2020-12 JSON Schema validation | pass for both adapters |
| Agentic upgrade integration | 7/7 pass |
| Assurance upgrade shell integration | 11/11 pass |
| Existing Agentic Bridge acceptance/threat suite | 42/42 pass |
| Control Plane backend | 57/57 pass |
| Control Plane backend TypeScript build | pass |
| Control Plane frontend typecheck/Vite build | pass |
| Existing H5 approval identity | 12/12 pass |
| Sandbox isolation suite | 8/8 accounted; live Docker cases skipped |
| Production handoff | 8/8 pass |
| Existing H6 AgentOps | 20/20 pass |
| Readiness compatibility | 5/5 pass |
| Production and local Compose config | pass |
Legacy suites that generated tracked audit/dashboard output were run with
temporary state where supported; known generated artifacts were restored after
verification. Existing user-owned `docs/evidence/` was not modified.
## Infrastructure-dependent work not possible locally
- Live rootless Docker isolation and network/filesystem escape tests.
- Enterprise IdP login and real key-rotation behavior.
- Live Vault Transit signing against a managed policy/token.
- Live S3 Object Lock retention and organization SCP/IAM enforcement.
- Managed TLS/DNS and external endpoint smoke.
The provider interfaces, fail-closed validation and deterministic local
contract tests are implemented for each missing dependency.
## Remaining gaps
- H4/H5/H2 and remaining H1/H3/H7 dossiers still need a shared canonical
evidence-query implementation before `contract_ready` can be removed.
- Static RS256 key mount lacks JWKS rollover.
- Kernel adoption is proven for two adapters, not every loop/graph/runtime.
- Runtime supervision is capability negotiation, not a full durable supervisor.
- External trust and production host hardening require deployment evidence.
- Cross-runtime policy-bundle distribution and remote adapter admission are not
yet centralized by the Control Plane.
## Current maturity and honest claims
After this change, CASAN is a strong assurance-enabled harness with an
implemented framework-independent Assurance Kernel foundation and a packaged,
production-capable Control Plane boundary. Framework independence is proven for
two adapters under the canonical contract. Full platform-wide kernel adoption,
universal supervision, complete dossiers and provisioned external trust remain
future work.
@@ -0,0 +1,126 @@
# CASAN Assurance Kernel Migration
## Scope
This migration preserves existing native and Agentic Bridge protocols while
adding canonical kernel envelopes and changing unsafe defaults. It is designed
for incremental adoption; no big-bang H1–H7 rewrite is required.
## Behavior changes
1. H2 registry enforcement defaults on in enforce mode.
2. A missing or failed H2 dependency denies in enforce mode.
3. High-impact action classes use deterministic risk floors and require actor,
approval and evidence.
4. Failed side-effecting single-step execution defaults to halt in enforce or
production mode.
5. Enforce/production sandbox execution refuses timeout/static-only fallback.
6. Networked/production Control Plane startup requires verified JWT identity;
`CASAN_CP_TRUST_AUTH_PROXY` no longer authorizes header trust.
7. Production certification requires an external signing provider and external
immutable anchor.
8. Canonical schema `1.0.0` is dual-emitted with legacy runtime records.
## Development migration
Existing loopback development works with:
```text
CASAN_PROFILE=development
CASAN_CP_AUTH_MODE=local
CP_BIND=127.0.0.1
```
For short-lived compatibility testing only, H2 can be bypassed explicitly:
```text
CASAN_H2_REGISTRY=0
CASAN_AGENTIC_H2_REGISTRY=0
```
The bypass is high-severity, observe-only/non-certifiable and rejected in
production. Prefer registering the tool instead of retaining this setting.
Failure handling can be selected with
`CASAN_SINGLE_STEP_FAILURE_POLICY=halt|quarantine|require_approval|record_only`.
Production side effects reject `record_only` and fail closed to `halt`.
## Production Control Plane migration
1. Export the enterprise IdP's RS256 public key.
2. Configure issuer, audience, claim names and bounded clock skew in
`runtime.env` using `infra/production/runtime.env.example`.
3. Configure oauth2-proxy to pass access tokens and authorization headers.
4. Set `CASAN_CP_IDP_PUBLIC_KEY` in `casan-prod.env` for the read-only mount.
5. Add a digest-pinned `CASAN_CP_OAUTH2_PROXY_IMAGE`.
6. Remove `CASAN_CP_TRUST_AUTH_PROXY` from every environment.
7. Configure Vault/KMS and Object Lock provider variables.
8. Run `production-preflight.sh` before starting Compose.
9. Run managed endpoint smoke with an authenticated enterprise session.
The current API loads a static public key at startup. Coordinate IdP rotation by
installing the next public key and restarting the API after token overlap has
been handled operationally. Automated JWKS rollover is not yet implemented.
## Adapter migration
The native harness and Agentic Bridge already dual-emit to
`CASAN_STATE_ROOT/logs/kernel/`. Existing consumers can keep reading legacy
records. New consumers should:
1. Require `schema_version=1.0.0`.
2. Run the common verifier.
3. Respect the four separate outcome fields.
4. Reject observed-only certification.
5. Treat runtime extensions as optional namespaced data.
6. Avoid certifying legacy runs that have no canonical evidence.
For a new runtime, implement an adapter and pass the complete 10-invariant
conformance matrix before advertising kernel conformance.
## Audit compatibility
New H5 audit records use v2 hashing with action class and risk factors. The
audit verifier and head signer support v1 and v2 records. This is compatibility,
not claim upgrading: v1 records do not gain v2 risk evidence retroactively.
## Verification commands
```bash
python3 packages/casan-harness/tests/assurance-kernel-tests.py
python3 packages/casan-harness/tests/assurance-upgrade-integration-tests.py
bash packages/casan-harness/tests/phase-assurance-upgrade-tests.sh
bash packages/casan-harness/tests/phase-agentic-bridge-tests.sh
npm --workspace packages/casan-control-panel/backend test
npm --workspace packages/casan-control-panel/backend run build
npm --workspace packages/casan-control-panel/frontend run build
bash packages/casan-harness/tests/phase-production-preflight-tests.sh
```
Redirect `CASAN_STATE_ROOT` to a temporary directory for legacy suites that
write runtime evidence.
## Rollback
Code rollback can restore the previous adapters because legacy emission remains
present. Before rollback:
1. Stop new runs and preserve canonical/audit evidence.
2. Record the last v2 audit head and external anchor.
3. Roll back API, UI, Nginx and oauth2-proxy as one deployment unit.
4. Do not restore trusted-header identity on a networked deployment.
5. Do not disable production registry, isolation or trust-root requirements.
6. If operational continuity requires emergency local trust, use the explicit
break-glass override, retain the critical evidence and do not certify runs.
A rollback that reintroduces production header trust, missing-gate allow, or
silent sandbox/trust fallback is not a safe compatibility rollback.
## Expected warnings
- Development registry bypass: high severity and non-certifiable.
- Missing H2 in observe mode: degraded assurance.
- Local signer/local ledger: permitted locally, untrusted for production.
- Unsupported runtime intervention: explicit `unsupported`.
- Legacy evidence without kernel envelope: compatibility-readable but not
kernel-validated.
+23 -13
View File
@@ -10,20 +10,26 @@ This document defines the boundary between the local workstation experience and
flowchart LR
B["Browser"] -->|TLS + OIDC cookie| N["Nginx"]
N -->|auth_request| O["oauth2-proxy"]
N -->|overwritten identity headers| A["Control Panel API"]
N -->|signed bearer token; identity headers removed| A["Control Panel API"]
A -->|verify RS256 + issuer + audience + time claims| I["VerifiedClaims"]
A -->|tenant-scoped encrypted store| S["CASAN state"]
A -->|random bridge token, local only| H["Mac host bridge"]
H -->|official CLI auth| P["Codex / Claude"]
```
The API must not be published directly. Nginx is the only ingress and overwrites `X-CASAN-Actor`, `X-CASAN-Groups`, and `X-CASAN-Role`. The API refuses a non-loopback strict bind unless `CASAN_CP_TRUST_AUTH_PROXY=1` is explicit.
The API must not be published directly. Nginx is the only ingress and removes
`X-CASAN-*` and forwarded identity headers. The API refuses every production or
non-loopback bind unless `CASAN_CP_AUTH_MODE=jwt` has a valid cryptographic
verifier. Proxy headers alone are never authenticated identity.
## Implemented controls
- TLS at Nginx; production accepts only TLS 1.2/1.3.
- OIDC authentication through oauth2-proxy.
- Secure, HttpOnly, SameSite=Lax session cookies with bounded expiry/refresh.
- Browser-supplied identity headers are overwritten at Nginx.
- Browser-supplied identity headers are removed at Nginx.
- The API verifies RS256 signature, issuer, audience, subject, expiry,
not-before/issued-at bounds and clock skew before deriving request identity.
- CSP, frame denial, MIME sniffing protection, referrer and browser permission restrictions.
- API request body capped at 1 MiB.
- SSE buffering disabled for trace streams; no intermediate proxy cache.
@@ -53,21 +59,26 @@ Before production deployment:
2. Do not deploy `provider-auth-bridge.py`.
3. Use managed OpenAI/Anthropic credentials from Vault/KMS or workload identity.
4. Use an enterprise IdP and explicit group-to-role mapping.
5. Use a CA-issued certificate and a fixed production hostname.
6. Set a digest-pinned `CASAN_CP_API_IMAGE`, `CASAN_CP_UI_IMAGE`, and `CASAN_CP_OAUTH2_PROXY_IMAGE`.
7. Keep the API on a private container/network segment with Nginx as its only caller.
8. Restrict egress from the API to allowlisted model providers, Vault/KMS, object storage and required observability endpoints.
9. Store state on encrypted storage; send audit heads to KMS/WORM/Object Lock.
10. Export rate-limit, auth failure, bridge-disabled and audit-chain metrics to alerting.
11. Back up and restore-test tenant state before enabling write actions.
12. Run the production preflight and security suites against the exact deployment images.
5. Mount the IdP RS256 public key and configure exact issuer/audience; coordinate
key rotation with an API restart until JWKS rollover is implemented.
6. Use a CA-issued certificate and a fixed production hostname.
7. Set a digest-pinned `CASAN_CP_API_IMAGE`, `CASAN_CP_UI_IMAGE`, and `CASAN_CP_OAUTH2_PROXY_IMAGE`.
8. Keep the API on a private container/network segment with Nginx as its only caller.
9. Restrict egress from the API to allowlisted model providers, Vault/KMS, object storage and required observability endpoints.
10. Store state on encrypted storage; send audit heads to KMS/WORM/Object Lock.
11. Export rate-limit, auth failure, bridge-disabled and audit-chain metrics to alerting.
12. Back up and restore-test tenant state before enabling write actions.
13. Run the production preflight and security suites against the exact deployment images.
## Known residual risks
- Local bridge authorization is bearer-token based; a process with access to the token file can call it.
- In-memory rate windows reset when the API or bridge restarts. Production should also rate-limit at ingress.
- Goal output is stored in tenant-scoped local state but is not currently envelope-encrypted as a whole.
- A compromised container on the private Control Panel network may attempt to forge proxy headers. Production network policy must keep unrelated workloads off that network.
- Static public-key rotation requires a coordinated file replacement and API
restart; automated JWKS rollover is not implemented.
- A compromised ingress container can interfere with availability or bearer
forwarding, but cannot mint a valid IdP signature without the IdP key.
- Developer account subscriptions have provider-specific quotas and are not an availability SLA.
- The local mock IdP and self-signed TLS do not prove enterprise SSO readiness.
@@ -83,4 +94,3 @@ packages/casan-harness/scripts/bash/local-full.sh start
This terminates the bridge, deletes the old token and creates a new one. Then inspect `tmp/control-panel-local/auth-bridge/model-audit.jsonl` for unexpected provider/status/hash activity. The log contains no raw prompts.
If a goal audit chain is suspected of tampering, stop new goal submissions, preserve `.specify/logs/audit/goal-orchestrator.jsonl` and its head, and compare each `prev_hash`/`record_hash` before restoring service.
+7 -1
View File
@@ -10,6 +10,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlencode, urlparse
import jwt
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
@@ -33,7 +34,12 @@ USERS = {
},
}
KID = "casan-local-prod-idp"
KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
KEY_FILE = os.environ.get("CASAN_IDP_PRIVATE_KEY_FILE", "")
if KEY_FILE:
with open(KEY_FILE, "rb") as key_handle:
KEY = serialization.load_pem_private_key(key_handle.read(), password=None)
else:
KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
CODES = {}
APPROVAL_SIGNER_TOKEN = os.environ.get("CASAN_APPROVAL_SIGNER_TOKEN", "")
+12 -3
View File
@@ -12,8 +12,10 @@ not be invented or committed.
it at `/opt/casan-control-panel/tls/tls.crt` and `tls.key` (mode `0600`).
2. Register `https://<fqdn>/oauth2/callback` with the enterprise IdP. Map the
`groups` claim to CASAN groups such as `casan-org-admin` and
`casan-approver`. Copy `oauth2-proxy.env.example` outside the repo and fill
it using the secret manager.
`casan-approver`. Export the IdP's RS256 verification public key to
`/opt/casan-control-panel/idp-public.pem`; rotation requires replacing this
file and restarting the API. Copy `oauth2-proxy.env.example` outside the
repo and fill it using the secret manager.
3. Create a Vault Transit key `casan-audit-key` with `exportable=false`; give a
workload identity only `transit/sign/casan-audit-key` and read-key metadata.
Render a short-lived token into `vault.env` outside Git. Never use Vault dev
@@ -35,6 +37,7 @@ sudo install -d -m 0700 /opt/casan-control-panel/tls /srv/casan/state /srv/casan
sudo install -m 0600 /dev/null /opt/casan-control-panel/oauth2-proxy.env
sudo install -m 0600 /dev/null /opt/casan-control-panel/runtime.env
sudo install -m 0600 /dev/null /opt/casan-control-panel/vault.env
sudo install -m 0644 /secure/export/idp-public.pem /opt/casan-control-panel/idp-public.pem
bash packages/casan-harness/scripts/bash/production-preflight.sh infra/production/casan-prod.env
set -a; source infra/production/casan-prod.env; set +a
@@ -43,9 +46,15 @@ docker compose -f docker-compose.control-panel.yml up -d
The preflight performs a real Object Lock anchor write. It will reject local
URLs, image tags, expired/near-expiry TLS, dev/root Vault tokens, incomplete
OIDC, missing paths, and buckets without Object Lock. After login, run
OIDC, issuer/audience mismatches, missing IdP keys, local trust providers,
missing paths, and buckets without Object Lock. After login, run
`managed-prod-smoke.sh` with an authenticated cookie jar.
The API does not trust `X-CASAN-*` or forwarded-user headers from Nginx.
oauth2-proxy returns the signed access token, Nginx removes caller-supplied
identity headers, and the API verifies RS256 signature, issuer, audience,
expiry and clock bounds before deriving actor, role, tenant and project.
## Operational anchors
Run `audit-ship-s3.sh` after every audit seal from the same workload identity.
+2
View File
@@ -9,10 +9,12 @@ CASAN_CP_RUNTIME_ENV=/opt/casan-control-panel/runtime.env
CASAN_CP_VAULT_ENV=/opt/casan-control-panel/vault.env
CASAN_CP_STATE_DIR=/srv/casan/state
CASAN_CP_OUTPUT_DIR=/srv/casan/output
CASAN_CP_IDP_PUBLIC_KEY=/opt/casan-control-panel/idp-public.pem
# CI must publish immutable image references, not tags such as :latest.
CASAN_CP_API_IMAGE=registry.example.internal/casan/control-panel-api@sha256:replace-with-64-hex-digest
CASAN_CP_UI_IMAGE=registry.example.internal/casan/control-panel-ui@sha256:replace-with-64-hex-digest
CASAN_CP_OAUTH2_PROXY_IMAGE=quay.io/oauth2-proxy/oauth2-proxy@sha256:replace-with-64-hex-digest
# WORM anchor destination. The deploy identity needs only PutObject and
# GetObjectLockConfiguration for this bucket/prefix; use workload identity,
+2 -2
View File
@@ -8,6 +8,6 @@ OAUTH2_PROXY_COOKIE_SECURE=true
OAUTH2_PROXY_REDIRECT_URL=https://control.casan.company.internal/oauth2/callback
OAUTH2_PROXY_OIDC_GROUPS_CLAIM=groups
OAUTH2_PROXY_SET_XAUTHREQUEST=true
OAUTH2_PROXY_PASS_ACCESS_TOKEN=false
OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER=false
OAUTH2_PROXY_PASS_ACCESS_TOKEN=true
OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER=true
OAUTH2_PROXY_SCOPE="openid profile email groups"
+10 -1
View File
@@ -2,10 +2,19 @@
# companion Vault file and the production preflight validation.
CASAN_PROFILE=prod
CASAN_CP_STRICT=1
CASAN_CP_TRUST_AUTH_PROXY=1
CASAN_CP_AUTH_MODE=jwt
CASAN_CP_JWT_ISSUER=https://id.example.internal/realms/casan
CASAN_CP_JWT_AUDIENCE=casan-control-plane
CASAN_CP_JWT_PUBLIC_KEY_FILE=/run/casan-idp/idp-public.pem
CASAN_CP_JWT_ROLE_CLAIM=groups
CASAN_CP_JWT_TENANT_CLAIM=casan_tenant
CASAN_CP_JWT_PROJECT_CLAIM=casan_project
CASAN_CP_JWT_CLOCK_SKEW_SECONDS=60
CP_BIND=0.0.0.0
CP_PORT=3010
CASAN_APP_ROOT=/app
CASAN_DASHBOARD_STALE_S=300
CASAN_PREFLIGHT=1
CASAN_CHAT_MODEL_MODE=deterministic
CASAN_SIGNING_PROVIDER=vault_kms
CASAN_IMMUTABLE_ANCHOR_PROVIDER=s3_object_lock
+12 -4
View File
@@ -43,6 +43,7 @@ server {
auth_request /oauth2/auth;
auth_request_set $auth_user $upstream_http_x_auth_request_user;
auth_request_set $auth_groups $upstream_http_x_auth_request_groups;
auth_request_set $auth_access_token $upstream_http_x_auth_request_access_token;
proxy_pass http://control-panel-api:3010/api/v1/;
proxy_http_version 1.1;
@@ -55,16 +56,23 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# Overwrite browser-supplied identity headers. The API maps these IdP
# group claims through rbac-check.py map-claim.
proxy_set_header X-CASAN-Actor $auth_user;
proxy_set_header X-CASAN-Groups $auth_groups;
# Remove every caller-supplied identity assertion. The API derives
# actor/role/scope only after cryptographic JWT verification.
proxy_set_header X-CASAN-Actor "";
proxy_set_header X-CASAN-Groups "";
proxy_set_header X-CASAN-Role "";
proxy_set_header X-CASAN-Project "";
proxy_set_header X-CASAN-Tenant "";
proxy_set_header X-Forwarded-User "";
proxy_set_header X-Forwarded-Groups "";
proxy_set_header Authorization "Bearer $auth_access_token";
}
location = /healthz {
auth_request /oauth2/auth;
auth_request_set $auth_access_token $upstream_http_x_auth_request_access_token;
proxy_pass http://control-panel-api:3010/healthz;
proxy_set_header Authorization "Bearer $auth_access_token";
}
location / {
+19 -10
View File
@@ -101,8 +101,10 @@ Goal workspace context:
a different actor. The executor verifies the artifact hash, applies it, runs fixed
project build/test commands, and reverses the patch if verification fails.
Local management headers: `x-casan-actor`, `x-casan-role`, `x-casan-project`,
`x-casan-tenant`. Missing role defaults to `viewer`, so writes fail closed.
In explicit loopback development mode only, local management headers are
`x-casan-actor`, `x-casan-role`, `x-casan-project`, and `x-casan-tenant`.
Missing role defaults to `viewer`, so writes fail closed. JWT mode discards
these caller assertions and derives them only from verified token claims.
Kill-switch management:
@@ -169,12 +171,19 @@ App root + telemetry paths resolve via the same marker walk-up as `casan-paths.s
compatibility aliases. Freshness is calculated independently from each file's mtime using
`CASAN_DASHBOARD_STALE_S` (default `3600`).
## Security posture (MVP)
Binds `127.0.0.1` by default. Refuses a non-loopback bind under `CASAN_PROFILE=prod` /
`CASAN_CP_STRICT=1` unless `CASAN_CP_TRUST_AUTH_PROXY=1` is set for an authenticated reverse
proxy that overwrites identity headers. Management endpoints are RBAC-gated via the harness
`rbac-check.py`; IdP group claims such as `casan-approver` are mapped to RBAC roles through
the same harness engine.
## Security posture
Binds `127.0.0.1` in explicit local development mode. Every non-loopback bind,
and every production profile, requires `CASAN_CP_AUTH_MODE=jwt`. Production
requires an RS256 public key plus configured issuer and audience; missing or
invalid configuration refuses startup. The API verifies signature, expiry,
issuer, audience, `nbf`/`iat`, and bounded clock skew in-process, then maps
verified group claims through `rbac-check.py`. Arbitrary `X-CASAN-*` and
forwarded-user headers are not an authentication mechanism.
Production variables are documented in `infra/production/runtime.env.example`.
The packaged boundary is oauth2-proxy → Nginx header stripping/bearer forwarding
→ API cryptographic verification. The current provider uses a mounted RS256
public key; automated JWKS discovery/rotation remains future work.
## Test
```bash
@@ -189,8 +198,8 @@ bash packages/casan-control-panel/scripts/local-prod-smoke.sh
The scaffold includes `Dockerfile.control-panel-api`, `Dockerfile.control-panel-ui`, and
`nginx/control-panel.conf`. Nginx protects UI/API through oauth2-proxy `auth_request`,
overwrites browser-supplied `X-CASAN-*` headers, and passes IdP group claims to the API for
RBAC mapping. The local smoke starts a self-signed HTTPS stack with a mock OIDC IdP and
removes browser-supplied identity headers, and forwards the signed bearer token for API
verification. The local smoke starts a self-signed HTTPS stack with a mock OIDC IdP and
expects `CP_LOCAL_SMOKE_PASS https_oidc=true actor=oidc-ops role=org-admin`; it also
asserts the Command Center returns all nine widgets with provenance envelopes and invokes
`managed-prod-smoke.sh` with the authenticated mock-IdP cookie jar. A passing local run
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { APP_ROOT } from './app-root.js';
import type { SettingsActor } from '../settings/settings.service.js';
import { UnauthorizedException } from '@nestjs/common';
const RBAC_CLI = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash', 'rbac-check.py');
const LOCAL_ROLES = new Set(['org-admin', 'project-admin', 'approver', 'operator', 'viewer', 'auditor']);
@@ -23,7 +24,7 @@ function mapClaim(claim: string): string | null {
}
}
function roleFromClaim(raw: string | undefined): string {
export function roleFromClaim(raw: string | undefined): string {
if (!raw) return 'viewer';
const claims = raw.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
for (const claim of claims) {
@@ -33,6 +34,23 @@ function roleFromClaim(raw: string | undefined): string {
return 'viewer';
}
export interface VerifiedClaimInput {
subject: string;
issuer: string;
roles: string[];
project: string;
tenant: string;
}
export function actorFromVerifiedClaims(claims: VerifiedClaimInput): SettingsActor {
return {
actor: claims.subject,
role: roleFromClaim(claims.roles.join(',')),
project: /^[A-Za-z0-9._-]+$/.test(claims.project) ? claims.project : 'default',
tenant: /^[A-Za-z0-9._-]+$/.test(claims.tenant) ? claims.tenant : 'default',
};
}
function projectFromClaims(raw: string | undefined): string | undefined {
if (!raw) return undefined;
const prefix = 'casan-project:';
@@ -42,6 +60,9 @@ function projectFromClaims(raw: string | undefined): string | undefined {
}
export function actorFromHeaders(headers: Record<string, string | string[] | undefined>): SettingsActor {
if (process.env.CASAN_CP_AUTH_MODE === 'jwt' && firstHeader(headers['x-casan-identity-verified']) !== '1') {
throw new UnauthorizedException('AUTH_VERIFIED_IDENTITY_REQUIRED');
}
const actor = firstHeader(headers['x-casan-actor'])
|| firstHeader(headers['x-auth-request-user'])
|| firstHeader(headers['x-forwarded-user'])
@@ -0,0 +1,270 @@
import { createHash, createHmac, createPublicKey, createVerify, timingSafeEqual } from 'node:crypto';
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, writeSync } from 'node:fs';
import { dirname, join } from 'node:path';
import type { NextFunction, Request, Response } from 'express';
import { APP_ROOT } from './app-root.js';
import { actorFromVerifiedClaims, type VerifiedClaimInput } from './auth-context.js';
export type AuthMode = 'local' | 'jwt';
export type JwtAlgorithm = 'RS256' | 'HS256';
export interface ControlPlaneAuthConfig {
mode: AuthMode;
profile: string;
bind: string;
issuer?: string;
audience?: string;
algorithm?: JwtAlgorithm;
publicKey?: string;
hmacSecret?: string;
clockSkewSeconds: number;
roleClaim: string;
tenantClaim: string;
projectClaim: string;
}
export interface VerifiedClaims {
subject: string;
issuer: string;
audience: string[];
expiresAt: number;
issuedAt?: number;
roles: string[];
tenant: string;
project: string;
authenticationMethod: 'jwt';
}
export interface AuthenticationDecision {
allowed: boolean;
reasonCode: string;
claims?: VerifiedClaims;
}
export interface AuthProvider {
authenticate(headers: Record<string, string | string[] | undefined>, nowSeconds?: number): AuthenticationDecision;
}
interface JwtHeader {
alg?: string;
typ?: string;
}
type JwtPayload = Record<string, unknown>;
const IDENTITY_HEADERS = [
'x-casan-actor', 'x-casan-role', 'x-casan-groups', 'x-casan-project', 'x-casan-tenant',
'x-auth-request-user', 'x-auth-request-groups', 'x-forwarded-user', 'x-forwarded-groups',
'x-casan-identity-verified', 'x-casan-identity-issuer',
];
function firstHeader(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function decodeSegment<T>(segment: string): T {
const decoded = Buffer.from(segment, 'base64url').toString('utf8');
const payload: unknown = JSON.parse(decoded);
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('jwt_segment_not_object');
return payload as T;
}
function stringClaim(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
function stringListClaim(value: unknown): string[] {
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string' && item.length > 0);
if (typeof value === 'string') return value.split(/[\s,]+/).filter(Boolean);
return [];
}
function audienceClaim(value: unknown): string[] {
return typeof value === 'string' ? [value] : stringListClaim(value);
}
function numericClaim(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function safeScope(value: string | undefined, fallback: string): string {
return value && /^[A-Za-z0-9._-]+$/.test(value) ? value : fallback;
}
function verifySignature(input: string, signature: Buffer, config: ControlPlaneAuthConfig): boolean {
if (config.algorithm === 'RS256' && config.publicKey) {
const verifier = createVerify('RSA-SHA256');
verifier.update(input);
verifier.end();
return verifier.verify(config.publicKey, signature);
}
if (config.algorithm === 'HS256' && config.hmacSecret) {
const expected = createHmac('sha256', config.hmacSecret).update(input).digest();
return expected.length === signature.length && timingSafeEqual(expected, signature);
}
return false;
}
export class JwtAuthProvider implements AuthProvider {
constructor(private readonly config: ControlPlaneAuthConfig) {}
authenticate(headers: Record<string, string | string[] | undefined>, nowSeconds = Math.floor(Date.now() / 1000)): AuthenticationDecision {
const authorization = firstHeader(headers.authorization);
if (!authorization?.startsWith('Bearer ')) return { allowed: false, reasonCode: 'auth_bearer_token_required' };
const token = authorization.slice('Bearer '.length).trim();
const parts = token.split('.');
if (parts.length !== 3 || parts.some((part) => !part)) return { allowed: false, reasonCode: 'auth_token_malformed' };
try {
const header = decodeSegment<JwtHeader>(parts[0]);
const payload = decodeSegment<JwtPayload>(parts[1]);
if (header.alg !== this.config.algorithm) return { allowed: false, reasonCode: 'auth_algorithm_mismatch' };
if (!verifySignature(`${parts[0]}.${parts[1]}`, Buffer.from(parts[2], 'base64url'), this.config)) {
return { allowed: false, reasonCode: 'auth_signature_invalid' };
}
const issuer = stringClaim(payload.iss);
const audience = audienceClaim(payload.aud);
const subject = stringClaim(payload.sub);
const expiresAt = numericClaim(payload.exp);
const notBefore = numericClaim(payload.nbf);
const issuedAt = numericClaim(payload.iat);
const skew = this.config.clockSkewSeconds;
if (!issuer || issuer !== this.config.issuer) return { allowed: false, reasonCode: 'auth_issuer_invalid' };
if (!this.config.audience || !audience.includes(this.config.audience)) return { allowed: false, reasonCode: 'auth_audience_invalid' };
if (!subject) return { allowed: false, reasonCode: 'auth_subject_required' };
if (!expiresAt || nowSeconds - skew >= expiresAt) return { allowed: false, reasonCode: 'auth_token_expired' };
if (notBefore !== undefined && nowSeconds + skew < notBefore) return { allowed: false, reasonCode: 'auth_token_not_yet_valid' };
if (issuedAt !== undefined && issuedAt > nowSeconds + skew) return { allowed: false, reasonCode: 'auth_issued_at_invalid' };
const roles = stringListClaim(payload[this.config.roleClaim]);
const rawTenant = stringClaim(payload[this.config.tenantClaim]);
const rawProject = stringClaim(payload[this.config.projectClaim]);
if ((rawTenant && safeScope(rawTenant, '') === '') || (rawProject && safeScope(rawProject, '') === '')) {
return { allowed: false, reasonCode: 'auth_scope_invalid' };
}
const tenant = safeScope(rawTenant, 'default');
const project = safeScope(rawProject, 'default');
return {
allowed: true,
reasonCode: 'auth_verified',
claims: { subject, issuer, audience, expiresAt, issuedAt, roles, tenant, project, authenticationMethod: 'jwt' },
};
} catch {
return { allowed: false, reasonCode: 'auth_token_malformed' };
}
}
}
export function loadAuthConfig(env: NodeJS.ProcessEnv = process.env): ControlPlaneAuthConfig {
const profile = env.CASAN_CP_STRICT === '1' ? 'strict' : (env.CASAN_PROFILE || 'development').toLowerCase();
const mode = (env.CASAN_CP_AUTH_MODE || 'local').toLowerCase() as AuthMode;
const bind = env.CP_BIND || '127.0.0.1';
const publicKeyPath = env.CASAN_CP_JWT_PUBLIC_KEY_FILE;
const publicKey = publicKeyPath && existsSync(publicKeyPath) ? readFileSync(publicKeyPath, 'utf8') : undefined;
const skew = Number(env.CASAN_CP_JWT_CLOCK_SKEW_SECONDS ?? 60);
return {
mode,
profile,
bind,
issuer: env.CASAN_CP_JWT_ISSUER,
audience: env.CASAN_CP_JWT_AUDIENCE,
algorithm: publicKey ? 'RS256' : env.CASAN_CP_JWT_HS256_SECRET ? 'HS256' : undefined,
publicKey,
hmacSecret: env.CASAN_CP_JWT_HS256_SECRET,
clockSkewSeconds: Number.isFinite(skew) ? skew : -1,
roleClaim: env.CASAN_CP_JWT_ROLE_CLAIM || 'groups',
tenantClaim: env.CASAN_CP_JWT_TENANT_CLAIM || 'casan_tenant',
projectClaim: env.CASAN_CP_JWT_PROJECT_CLAIM || 'casan_project',
};
}
export function validateAuthConfig(config: ControlPlaneAuthConfig): string[] {
const errors: string[] = [];
const production = config.profile === 'prod' || config.profile === 'production' || config.profile === 'strict';
const nonLoopback = !['127.0.0.1', 'localhost', '::1'].includes(config.bind);
if (!['local', 'jwt'].includes(config.mode)) errors.push('auth_mode_invalid');
if ((production || nonLoopback) && config.mode !== 'jwt') errors.push('verified_identity_required');
if (config.clockSkewSeconds < 0 || config.clockSkewSeconds > 300) errors.push('auth_clock_skew_invalid');
if (config.mode === 'jwt') {
if (!config.issuer) errors.push('auth_issuer_required');
if (!config.audience) errors.push('auth_audience_required');
if (!config.algorithm) errors.push('auth_verification_key_required');
if (production && config.algorithm !== 'RS256') errors.push('auth_asymmetric_key_required_in_production');
if (config.algorithm === 'RS256') {
try {
if (!config.publicKey || createPublicKey(config.publicKey).asymmetricKeyType !== 'rsa') {
errors.push('auth_rsa_public_key_invalid');
}
} catch {
errors.push('auth_rsa_public_key_invalid');
}
}
if (config.algorithm === 'HS256' && (!config.hmacSecret || Buffer.byteLength(config.hmacSecret) < 32)) {
errors.push('auth_hmac_secret_too_short');
}
}
return errors;
}
function auditAuthentication(decision: AuthenticationDecision, request: Request): void {
const stateRoot = process.env.CASAN_STATE_ROOT || join(APP_ROOT, '.specify');
const path = join(stateRoot, 'logs', 'auth', 'decisions.jsonl');
mkdirSync(dirname(path), { recursive: true });
const record = {
schema_version: '1.0.0', category: 'runtime_control', policy_id: 'casan.control-plane.authentication',
timestamp: new Date().toISOString(), decision: decision.allowed ? 'allow' : 'deny', reason_code: decision.reasonCode,
actor: decision.claims?.subject, issuer: decision.claims?.issuer, tenant: decision.claims?.tenant,
project: decision.claims?.project, method: decision.claims?.authenticationMethod,
request: { method: request.method, path: request.path },
};
const fd = openSync(path, 'a', 0o600);
try {
writeSync(fd, `${JSON.stringify(record)}\n`);
fsyncSync(fd);
} finally {
closeSync(fd);
}
}
function overwriteVerifiedHeaders(request: Request, claims: VerifiedClaims): void {
for (const header of IDENTITY_HEADERS) delete request.headers[header];
const input: VerifiedClaimInput = {
subject: claims.subject,
issuer: claims.issuer,
roles: claims.roles,
tenant: claims.tenant,
project: claims.project,
};
const actor = actorFromVerifiedClaims(input);
request.headers['x-casan-actor'] = actor.actor;
request.headers['x-casan-role'] = actor.role;
request.headers['x-casan-project'] = actor.project;
request.headers['x-casan-tenant'] = actor.tenant;
request.headers['x-casan-identity-verified'] = '1';
request.headers['x-casan-identity-issuer'] = claims.issuer;
}
export function createAuthMiddleware(config: ControlPlaneAuthConfig) {
const provider = config.mode === 'jwt' ? new JwtAuthProvider(config) : undefined;
return (request: Request, response: Response, next: NextFunction): void => {
if (!provider) {
request.headers['x-casan-identity-verified'] = 'local-development-only';
next();
return;
}
const headers = request.headers as Record<string, string | string[] | undefined>;
const decision = provider.authenticate(headers);
auditAuthentication(decision, request);
if (!decision.allowed || !decision.claims) {
response.status(401).json({ success: false, error: { code: decision.reasonCode, message: 'Authentication failed' } });
return;
}
overwriteVerifiedHeaders(request, decision.claims);
next();
};
}
export function tokenFingerprint(token: string): string {
return createHash('sha256').update(token).digest('hex').slice(0, 12);
}
@@ -3,23 +3,28 @@ import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module.js';
import { APP_ROOT } from './common/app-root.js';
import { createAuthMiddleware, loadAuthConfig, validateAuthConfig } from './common/auth-provider.js';
// Ops Console API (Plan-13). Binds loopback by default and refuses a non-loopback
// bind under CASAN_PROFILE=prod / CASAN_CP_STRICT=1 unless an authenticated reverse
// proxy is explicitly configured to overwrite identity headers.
// Ops Console API (Plan-13). Binds loopback by default. Networked and production
// deployments require in-process cryptographic identity verification.
async function bootstrap() {
const authConfig = loadAuthConfig();
const authErrors = validateAuthConfig(authConfig);
if (authErrors.length > 0) {
console.error(`CP_AUTH_CONFIGURATION_INVALID reasons=${authErrors.join(',')}`);
process.exit(2);
}
const app = await NestFactory.create(AppModule, { cors: true });
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.use(createAuthMiddleware(authConfig));
const port = Number(process.env.CP_PORT ?? 3010);
let host = process.env.CP_BIND ?? '127.0.0.1';
const strict = process.env.CASAN_PROFILE === 'prod' || process.env.CASAN_CP_STRICT === '1';
const authProxy = process.env.CASAN_CP_TRUST_AUTH_PROXY === '1';
if (strict && host !== '127.0.0.1' && host !== 'localhost' && !authProxy) {
// The console must not expose telemetry/management off-loopback without TLS/OIDC
// at the reverse proxy, which must overwrite X-CASAN-* identity headers.
if (strict && host !== '127.0.0.1' && host !== 'localhost' && authConfig.mode !== 'jwt') {
// Networked production requires in-process cryptographic verification.
// eslint-disable-next-line no-console
console.error(`CP_REFUSE_NONLOOPBACK host=${host} (set up TLS/OIDC per Plan-13 Track 4 first)`);
console.error(`CP_REFUSE_NONLOOPBACK host=${host} reason=verified_identity_required`);
process.exit(2);
}
@@ -298,6 +298,8 @@ export function buildH6Report(input: H6ReportInput, query: H6ReportQuery): H6Rep
return {
schema_version: 1,
category: 'report_dimension',
dimension_id: 'ReportDimension.H6',
report_id: `H6-${input.now.toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`,
harness: 'H6',
title: 'H6 · AgentOps Report',
@@ -36,6 +36,8 @@ export interface HarnessReportEvidenceSource extends SourceFreshness {
export interface HarnessReport<TSummary, TDetails> {
schema_version: 1;
category: 'report_dimension';
dimension_id: `ReportDimension.${HarnessReportId}`;
report_id: string;
harness: HarnessReportId;
title: string;
@@ -71,6 +73,8 @@ export interface HarnessReportCatalogEntry {
title: string;
description: string;
contract_version: 1;
category: 'report_dimension';
dimension_id: `ReportDimension.${HarnessReportId}`;
endpoint: string;
availability: 'implemented' | 'contract_ready';
}
@@ -78,6 +82,8 @@ export interface HarnessReportCatalogEntry {
export const HARNESS_REPORT_CATALOG: HarnessReportCatalogEntry[] = HARNESS_REPORT_DEFINITIONS.map((definition) => ({
...definition,
contract_version: 1,
category: 'report_dimension',
dimension_id: `ReportDimension.${definition.id}`,
endpoint: `/api/v1/reports/${definition.id.toLowerCase()}`,
availability: definition.id === 'H6' ? 'implemented' : 'contract_ready',
}));
@@ -29,3 +29,17 @@ test('auth context fails closed to viewer for unknown role claim', () => {
});
assert.equal(actor.role, 'viewer');
});
test('JWT mode rejects direct spoofed identity headers without middleware verification', () => {
const prior = process.env.CASAN_CP_AUTH_MODE;
process.env.CASAN_CP_AUTH_MODE = 'jwt';
try {
assert.throws(() => actorFromHeaders({
'x-casan-actor': 'attacker',
'x-casan-role': 'org-admin',
}), /AUTH_VERIFIED_IDENTITY_REQUIRED/);
} finally {
if (prior === undefined) delete process.env.CASAN_CP_AUTH_MODE;
else process.env.CASAN_CP_AUTH_MODE = prior;
}
});
@@ -0,0 +1,110 @@
import { createHmac, createSign, generateKeyPairSync } from 'node:crypto';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
JwtAuthProvider,
validateAuthConfig,
type ControlPlaneAuthConfig,
} from '../src/common/auth-provider.js';
const secret = 'test-only-secret-with-sufficient-length';
const baseConfig: ControlPlaneAuthConfig = {
mode: 'jwt',
profile: 'test',
bind: '0.0.0.0',
issuer: 'https://issuer.test',
audience: 'casan-control-plane',
algorithm: 'HS256',
hmacSecret: secret,
clockSkewSeconds: 30,
roleClaim: 'groups',
tenantClaim: 'casan_tenant',
projectClaim: 'casan_project',
};
function token(payload: Record<string, unknown>, signingSecret = secret): string {
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signature = createHmac('sha256', signingSecret).update(`${header}.${body}`).digest('base64url');
return `${header}.${body}.${signature}`;
}
function rsToken(payload: Record<string, unknown>, privateKey: string): string {
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signer = createSign('RSA-SHA256');
signer.update(`${header}.${body}`);
signer.end();
return `${header}.${body}.${signer.sign(privateKey).toString('base64url')}`;
}
function claims(now: number, overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
iss: baseConfig.issuer,
aud: baseConfig.audience,
sub: 'verified-user',
exp: now + 300,
iat: now,
groups: ['project-admin'],
casan_tenant: 'tenant-a',
casan_project: 'project-a',
...overrides,
};
}
test('JWT provider verifies claims and ignores spoofed forwarded identity headers', () => {
const now = 1_800_000_000;
const provider = new JwtAuthProvider(baseConfig);
const decision = provider.authenticate({
authorization: `Bearer ${token(claims(now))}`,
'x-forwarded-user': 'attacker',
'x-casan-role': 'org-admin',
'x-casan-tenant': 'victim',
}, now);
assert.equal(decision.allowed, true);
assert.equal(decision.claims?.subject, 'verified-user');
assert.equal(decision.claims?.tenant, 'tenant-a');
assert.deepEqual(decision.claims?.roles, ['project-admin']);
});
test('JWT provider rejects missing, invalid, expired, wrong-audience and wrong-issuer tokens', () => {
const now = 1_800_000_000;
const provider = new JwtAuthProvider(baseConfig);
assert.equal(provider.authenticate({}, now).reasonCode, 'auth_bearer_token_required');
assert.equal(provider.authenticate({ authorization: `Bearer ${token(claims(now), 'wrong-secret')}` }, now).reasonCode, 'auth_signature_invalid');
assert.equal(provider.authenticate({ authorization: `Bearer ${token(claims(now, { exp: now - 31 }))}` }, now).reasonCode, 'auth_token_expired');
assert.equal(provider.authenticate({ authorization: `Bearer ${token(claims(now, { aud: 'wrong' }))}` }, now).reasonCode, 'auth_audience_invalid');
assert.equal(provider.authenticate({ authorization: `Bearer ${token(claims(now, { iss: 'https://wrong.test' }))}` }, now).reasonCode, 'auth_issuer_invalid');
});
test('production-compatible RS256 verification accepts a valid asymmetric token', () => {
const now = 1_800_000_000;
const keys = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const provider = new JwtAuthProvider({
...baseConfig,
profile: 'production',
algorithm: 'RS256',
publicKey: keys.publicKey,
hmacSecret: undefined,
});
const decision = provider.authenticate({ authorization: `Bearer ${rsToken(claims(now), keys.privateKey)}` }, now);
assert.equal(decision.allowed, true);
assert.equal(decision.claims?.subject, 'verified-user');
});
test('production and non-loopback startup refuse local or symmetric identity modes', () => {
assert.deepEqual(
validateAuthConfig({ ...baseConfig, mode: 'local', profile: 'production' }),
['verified_identity_required'],
);
assert.ok(validateAuthConfig({ ...baseConfig, profile: 'production' }).includes('auth_asymmetric_key_required_in_production'));
assert.ok(validateAuthConfig({ ...baseConfig, mode: 'local', profile: 'development' }).includes('verified_identity_required'));
assert.ok(validateAuthConfig({ ...baseConfig, mode: 'local', profile: 'strict', bind: '127.0.0.1' }).includes('verified_identity_required'));
assert.ok(validateAuthConfig({
...baseConfig, profile: 'production', algorithm: 'RS256', publicKey: 'not-a-public-key', hmacSecret: undefined,
}).includes('auth_rsa_public_key_invalid'));
});
@@ -17,6 +17,14 @@ if [[ ! -f "$TLS_DIR/tls.crt" || ! -f "$TLS_DIR/tls.key" ]]; then
-subj "/CN=localhost" \
-days 1 >/dev/null 2>&1
fi
IDP_PRIVATE="$ROOT/tmp/control-panel-local/idp-private.pem"
IDP_PUBLIC="$ROOT/tmp/control-panel-local/idp-public.pem"
if [[ ! -f "$IDP_PRIVATE" || ! -f "$IDP_PUBLIC" ]]; then
openssl genrsa -out "$IDP_PRIVATE" 2048 >/dev/null 2>&1
openssl rsa -in "$IDP_PRIVATE" -pubout -out "$IDP_PUBLIC" >/dev/null 2>&1
chmod 0600 "$IDP_PRIVATE"
chmod 0644 "$IDP_PUBLIC"
fi
cleanup() {
docker compose -f "$COMPOSE" down --remove-orphans >/dev/null 2>&1 || true
@@ -0,0 +1,78 @@
{
"schema_version": "1.0.0",
"description": "Deterministic action classes and minimum risk floors shared by CASAN runtimes.",
"risk_order": ["low", "medium", "high", "critical"],
"classes": {
"read_only": {"risk_floor": "low", "side_effect_level": "none", "requires_approval": false, "actor_required": false, "evidence_required": false},
"write": {"risk_floor": "medium", "side_effect_level": "write", "requires_approval": false, "actor_required": false, "evidence_required": true},
"delete": {"risk_floor": "high", "side_effect_level": "destructive", "requires_approval": true, "actor_required": true, "evidence_required": true},
"database_mutation": {"risk_floor": "high", "side_effect_level": "write", "requires_approval": true, "actor_required": true, "evidence_required": true},
"migration": {"risk_floor": "high", "side_effect_level": "write", "requires_approval": true, "actor_required": true, "evidence_required": true},
"deployment": {"risk_floor": "high", "side_effect_level": "external", "requires_approval": true, "actor_required": true, "evidence_required": true},
"release": {"risk_floor": "high", "side_effect_level": "external", "requires_approval": true, "actor_required": true, "evidence_required": true},
"credential_access": {"risk_floor": "high", "side_effect_level": "sensitive_read", "requires_approval": true, "actor_required": true, "evidence_required": true},
"identity_permission_modification": {"risk_floor": "high", "side_effect_level": "write", "requires_approval": true, "actor_required": true, "evidence_required": true},
"external_network_side_effect": {"risk_floor": "medium", "side_effect_level": "external", "requires_approval": true, "actor_required": true, "evidence_required": true},
"infrastructure_modification": {"risk_floor": "high", "side_effect_level": "external", "requires_approval": true, "actor_required": true, "evidence_required": true},
"unknown": {"risk_floor": "high", "side_effect_level": "unknown", "requires_approval": true, "actor_required": true, "evidence_required": true}
},
"action_aliases": {
"agent_step": "read_only",
"read": "read_only",
"search": "read_only",
"write": "write",
"write_code": "write",
"write_file": "write",
"delete": "delete",
"delete_file": "delete",
"db_write": "database_mutation",
"database_mutation": "database_mutation",
"migration": "migration",
"migrate": "migration",
"deploy": "deployment",
"deployment": "deployment",
"launch": "deployment",
"release": "release",
"credential_access": "credential_access",
"secret_access": "credential_access",
"permission_change": "identity_permission_modification",
"identity_change": "identity_permission_modification",
"external_api": "external_network_side_effect",
"infrastructure_change": "infrastructure_modification"
},
"tool_aliases": {
"read": "read_only",
"grep": "read_only",
"glob": "read_only",
"search": "read_only",
"view": "read_only",
"codegraph_search": "read_only",
"codegraph_node": "read_only",
"codegraph_context": "read_only",
"edit": "write",
"write": "write",
"multiedit": "write",
"notebookedit": "write",
"apply_patch": "write",
"str_replace_editor": "write",
"create_file": "write",
"update_file": "write",
"delete_file": "delete",
"webfetch": "external_network_side_effect",
"web_fetch": "external_network_side_effect",
"browser": "external_network_side_effect",
"sendmessage": "external_network_side_effect"
},
"command_patterns": [
{"pattern": "(^|\\s)(kubectl|helm)(\\s|$).*(apply|delete|upgrade|install)|(^|\\s)terraform\\s+(apply|destroy)", "class": "infrastructure_modification"},
{"pattern": "(^|\\s)(deploy|deployment|release)(\\s|$)|git\\s+push", "class": "deployment"},
{"pattern": "(^|\\s)(prisma|alembic|flyway|liquibase|rails)\\s+.*(migrat|upgrade)|\\bmigration\\b", "class": "migration"},
{"pattern": "\\b(delete|insert|update|alter|drop|truncate)\\s+(from|into|table|database)|\\b(db_write|database_mutation)\\b", "class": "database_mutation"},
{"pattern": "(^|\\s)(rm|rmdir|unlink)\\s|delete_file", "class": "delete"},
{"pattern": "(\\.ssh/|id_rsa|id_ed25519|\\.aws/credentials|\\.env($|\\s)|secret|credential|api[_-]?key)", "class": "credential_access"},
{"pattern": "\\b(iam|chmod|chown|setfacl|role|permission)\\b.*\\b(add|create|delete|grant|modify|remove|set|update)\\b", "class": "identity_permission_modification"},
{"pattern": "(^|\\s)(curl|wget|scp|sftp|ssh|nc|ncat)\\s", "class": "external_network_side_effect"},
{"pattern": "(^|\\s)(cp|mv|mkdir|touch|tee|sed\\s+-i)\\s|(^|\\s)(npm|pnpm|yarn|pip|cargo|go)\\s+(install|add|get)\\b|(^|\\s)(cat|printf|echo).*(>|>>)", "class": "write"},
{"pattern": "^\\s*(ls|pwd|cat|head|tail|wc|rg|grep|find|stat|git\\s+(status|diff|log|show))\\b", "class": "read_only"}
]
}
+25
View File
@@ -0,0 +1,25 @@
"""Framework-independent CASAN Assurance Kernel contracts and policy primitives."""
from .adapters import AgenticBridgeAdapter, NativeHarnessAdapter
from .contracts import CONTRACT_VERSION, validate_bundle
from .policy import (
classify_action,
evaluate_failure_policy,
evaluate_h2_gate,
evaluate_registry_configuration,
evaluate_risk,
evaluate_trust_capabilities,
)
__all__ = [
"AgenticBridgeAdapter",
"CONTRACT_VERSION",
"NativeHarnessAdapter",
"classify_action",
"evaluate_failure_policy",
"evaluate_h2_gate",
"evaluate_registry_configuration",
"evaluate_risk",
"evaluate_trust_capabilities",
"validate_bundle",
]
+194
View File
@@ -0,0 +1,194 @@
"""Compatibility adapters from existing runtimes into the canonical contract."""
from __future__ import annotations
from typing import Any, Mapping
from .contracts import CONTRACT_VERSION, actor, evidence_digest, outcome, utc_now
from .policy import evaluate_risk
class NativeHarnessAdapter:
runtime_type = "casan-native-harness"
def map_execution(self, event: Mapping[str, Any]) -> dict[str, Any]:
run_id = str(event.get("run_id") or event.get("trace_id") or "")
actor_id = str(event.get("actor") or "unidentified")
action_name = str(event.get("action") or "agent_step")
mode = str(event.get("mode") or "enforce")
risk = evaluate_risk(
action=action_name,
tool=str(event.get("tool") or action_name),
resource=str(event.get("resource") or ""),
command=str(event.get("command") or ""),
actor=actor_id if actor_id != "unidentified" else "",
environment=str(event.get("environment") or "development"),
)
execution_status = str(event.get("execution_status") or "unknown")
assurance_status = str(event.get("assurance_status") or "unknown")
certification = str(event.get("certification_status") or "non_certified")
return _bundle(
run_id=run_id,
runtime_type=self.runtime_type,
runtime_version=str(event.get("runtime_version") or "legacy-compatible"),
mode=mode,
actor_id=actor_id,
action_name=action_name,
risk=risk,
execution_status=execution_status,
assurance_status=assurance_status,
certification=certification,
event=event,
)
class AgenticBridgeAdapter:
runtime_type = "agentic-bridge"
def map_execution(self, event: Mapping[str, Any]) -> dict[str, Any]:
run_id = str(event.get("trace_id") or event.get("run_id") or "")
actor_id = str(event.get("actor") or "unidentified")
action_name = str(event.get("action") or event.get("last_action") or "agent_step")
mode = str(event.get("mode") or event.get("hook_trust_mode") or "observe")
risk = evaluate_risk(
action=action_name,
tool=str(event.get("tool") or event.get("last_tool") or action_name),
resource=str(event.get("resource") or ""),
command=str(event.get("command") or ""),
actor=actor_id if actor_id != "unidentified" else "",
environment=str(event.get("environment") or "development"),
)
execution_status = str(event.get("execution_status") or "unknown")
assurance_status = str(event.get("assurance_status") or "unknown")
certification = "certified" if event.get("certified") else "non_certified"
return _bundle(
run_id=run_id,
runtime_type=self.runtime_type,
runtime_version=str(event.get("adapter_version") or "legacy-compatible"),
mode=mode,
actor_id=actor_id,
action_name=action_name,
risk=risk,
execution_status=execution_status,
assurance_status=assurance_status,
certification=certification,
event=event,
)
def _bundle(
*,
run_id: str,
runtime_type: str,
runtime_version: str,
mode: str,
actor_id: str,
action_name: str,
risk: Mapping[str, Any],
execution_status: str,
assurance_status: str,
certification: str,
event: Mapping[str, Any],
) -> dict[str, Any]:
step_id = str(event.get("step_id") or f"{run_id}:1")
correlation_id = str(event.get("correlation_id") or run_id)
raw_evidence = list(event.get("evidence") or [])
claim_id = f"{run_id}:assurance-claim"
evidence = []
for index, item in enumerate(raw_evidence, 1):
raw = item if isinstance(item, Mapping) else {"detail": str(item)}
canonical_evidence = {
"evidence_id": str(raw.get("evidence_id") or f"{run_id}:evidence:{index}"),
"claim_refs": [claim_id],
"producer_identity": runtime_type,
"timestamp": raw.get("at") or raw.get("timestamp") or utc_now(),
"artifact_ref": str(raw.get("artifact_ref") or f"inline:{run_id}:{index}"),
"validation_status": "valid" if raw.get("decision") in {"allow", "pass", "passed", "certified"} else "unverified",
"retention_class": str(raw.get("retention_class") or "runtime_assurance"),
"category": str(raw.get("category") or "runtime_control"),
}
canonical_evidence["integrity"] = {
"algorithm": "sha256",
"digest": evidence_digest(canonical_evidence),
"source_content_hash": raw.get("content_hash"),
}
evidence.append(canonical_evidence)
policy_decisions = []
for item in list(event.get("policy_decisions") or []):
if not isinstance(item, Mapping):
continue
decision = str(item.get("decision") or "observe_only")
if decision not in {"allow", "deny", "quarantine", "require_approval", "transform", "observe_only"}:
decision = "observe_only"
policy_decisions.append({
"policy_id": str(item.get("policy_id") or "casan.compatibility.policy"),
"policy_version": str(item.get("policy_version") or item.get("schema_version") or CONTRACT_VERSION),
"decision": decision,
"reason_codes": list(item.get("reason_codes") or [item.get("reason_code") or "compatibility_mapping"]),
"effective_risk": str(item.get("effective_risk") or "high"),
"input_facts": dict(item),
"enforcement_point": str(item.get("enforcement_path") or runtime_type),
"timestamp": str(item.get("timestamp") or utc_now()),
"decision_engine_identity": "casan-assurance-kernel",
"evidence_ref": evidence[0]["evidence_id"] if evidence else None,
})
result = outcome(execution_status, assurance_status, certification)
return {
"schema_version": CONTRACT_VERSION,
"extension_namespace": f"casan.runtime.{runtime_type}",
"run": {
"run_id": run_id,
"parent_run_id": event.get("parent_run_id"),
"tenant": str(event.get("tenant") or "default"),
"project": str(event.get("project") or event.get("project_id") or "default"),
"environment": str(event.get("environment") or "development"),
"runtime": {"type": runtime_type, "version": runtime_version},
"mode": mode,
"requested_goal": str(event.get("requested_goal") or ""),
"started_at": event.get("started_at") or event.get("timestamp") or utc_now(),
"completed_at": event.get("completed_at") or utc_now(),
"status": execution_status,
"risk_summary": dict(risk),
"evidence_manifest_ref": event.get("evidence_manifest_ref"),
"correlation_id": correlation_id,
"outcome": result,
},
"actors": [actor(actor_id, "agent" if runtime_type == "casan-native-harness" else "runtime", runtime_type, "verified" if actor_id != "unidentified" else "unverified", event.get("authentication_evidence_ref"))],
"steps": [{
"step_id": step_id,
"run_id": run_id,
"parent_step_id": event.get("parent_step_id"),
"sequence": int(event.get("sequence") or 1),
"actor_ref": actor_id,
"action": {"class": risk["action_class"], "name": action_name, "tool": event.get("tool"), "side_effect_level": risk["side_effect_level"]},
"resource": event.get("resource"),
"input_context_refs": list(event.get("input_context_refs") or []),
"policy_decisions": policy_decisions,
"tool_invocation": event.get("tool_invocation"),
"verification_results": list(event.get("verification_results") or []),
"evidence_refs": [item["evidence_id"] for item in evidence],
"outcome": result,
"started_at": event.get("started_at") or event.get("timestamp") or utc_now(),
"completed_at": event.get("completed_at") or utc_now(),
}],
"runtime_capabilities": {
"canCancel": False, "canPause": False, "canResume": False,
"canRollback": False, "canReduceAuthority": False,
"canQuarantine": runtime_type == "agentic-bridge",
},
"claims": [{
"claim_id": claim_id,
"statement": "CASAN evaluated the runtime assurance outcome",
"evidence_refs": [item["evidence_id"] for item in evidence],
"validation_status": "validated" if assurance_status == "passed" else "insufficient",
}],
"evidence": evidence,
"trace_links": [
{"type": "parent_to_child_execution", "from": run_id, "to": step_id},
*[
{"type": "evidence_to_outcome", "from": item["evidence_id"], "to": claim_id}
for item in evidence
],
],
"extensions": {f"casan.runtime.{runtime_type}": dict(event.get("extensions") or {})},
}
+172
View File
@@ -0,0 +1,172 @@
"""Canonical CASAN wire-contract builders and lightweight validation."""
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from typing import Any, Mapping
CONTRACT_VERSION = "1.0.0"
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def actor(actor_id: str, actor_type: str, issuer: str, trust_level: str, auth_evidence_ref: str | None = None) -> dict[str, Any]:
return {
"actor_id": actor_id,
"actor_type": actor_type,
"issuer": issuer,
"trust_level": trust_level,
"authentication_evidence_ref": auth_evidence_ref,
}
def outcome(execution: str, assurance: str, certification: str, business: str = "not_evaluated") -> dict[str, str]:
return {
"execution_result": execution,
"assurance_result": assurance,
"certification_result": certification,
"business_result": business,
}
def evidence_digest(evidence: Mapping[str, Any]) -> str:
"""Digest the claim-bearing evidence metadata, excluding its digest field."""
material = {
key: evidence.get(key)
for key in (
"evidence_id", "claim_refs", "producer_identity", "timestamp",
"artifact_ref", "validation_status", "retention_class", "category",
)
}
payload = json.dumps(material, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def verify_evidence_integrity(evidence: Mapping[str, Any]) -> bool:
integrity = evidence.get("integrity")
if not isinstance(integrity, Mapping) or integrity.get("algorithm") != "sha256":
return False
digest = integrity.get("digest")
return isinstance(digest, str) and digest == evidence_digest(evidence)
def validate_bundle(bundle: Mapping[str, Any]) -> list[str]:
"""Return deterministic contract errors; an empty list means valid.
JSON Schema is canonical. This small validator keeps runtime adapters stdlib-
only and catches the invariants needed at enforcement boundaries.
"""
errors: list[str] = []
if bundle.get("schema_version") != CONTRACT_VERSION:
errors.append("unsupported_schema_version")
run = bundle.get("run")
if not isinstance(run, Mapping):
errors.append("missing_execution_run")
return errors
for key in ("run_id", "runtime", "mode", "status", "correlation_id", "outcome"):
if not run.get(key):
errors.append(f"run.{key}_required")
actors = bundle.get("actors")
actor_ids: set[object] = set()
if not isinstance(actors, list) or not actors:
errors.append("actors_required")
else:
actor_ids = {item.get("actor_id") for item in actors if isinstance(item, Mapping)}
if "" in actor_ids or None in actor_ids:
errors.append("actor_identity_required")
steps = bundle.get("steps")
if not isinstance(steps, list):
errors.append("steps_required")
else:
for index, step in enumerate(steps):
if not isinstance(step, Mapping):
errors.append(f"steps.{index}_invalid")
continue
for key in ("step_id", "run_id", "sequence", "actor_ref", "action", "outcome"):
if step.get(key) is None:
errors.append(f"steps.{index}.{key}_required")
if step.get("actor_ref") not in actor_ids:
errors.append(f"steps.{index}.actor_ref_unknown")
evidence = bundle.get("evidence")
evidence_ids: set[object] = set()
if not isinstance(evidence, list):
errors.append("evidence_required")
else:
for index, item in enumerate(evidence):
if not isinstance(item, Mapping):
errors.append(f"evidence.{index}_invalid")
continue
evidence_ids.add(item.get("evidence_id"))
if not verify_evidence_integrity(item):
errors.append(f"evidence.{index}.integrity_invalid")
claims = bundle.get("claims")
claim_ids = {
item.get("claim_id") for item in claims or [] if isinstance(item, Mapping)
} if isinstance(claims, list) else set()
if isinstance(claims, list):
for index, claim in enumerate(claims):
if not isinstance(claim, Mapping):
errors.append(f"claims.{index}_invalid")
continue
for evidence_ref in claim.get("evidence_refs") or []:
if evidence_ref not in evidence_ids:
errors.append(f"claims.{index}.evidence_ref_unknown")
if isinstance(evidence, list):
for index, item in enumerate(evidence):
if isinstance(item, Mapping):
for claim_ref in item.get("claim_refs") or []:
if claim_ref not in claim_ids:
errors.append(f"evidence.{index}.claim_ref_unknown")
delegations = bundle.get("delegations")
if delegations is not None:
errors.extend(_validate_delegations(delegations, actor_ids))
if run.get("mode") == "observe" and isinstance(run.get("outcome"), Mapping):
if run["outcome"].get("certification_result") == "certified":
errors.append("observed_only_cannot_be_certified")
return errors
def _validate_delegations(delegations: object, actor_ids: set[object]) -> list[str]:
if not isinstance(delegations, list):
return ["delegations_invalid"]
errors: list[str] = []
records = {
item.get("delegation_id"): item
for item in delegations if isinstance(item, Mapping) and item.get("delegation_id")
}
for index, item in enumerate(delegations):
if not isinstance(item, Mapping):
errors.append(f"delegations.{index}_invalid")
continue
for actor_key in ("delegator_ref", "delegate_ref"):
if item.get(actor_key) not in actor_ids:
errors.append(f"delegations.{index}.{actor_key}_unknown")
current: Mapping[str, Any] = item
visited: set[object] = set()
depth = 0
while current.get("parent_delegation_ref"):
parent_ref = current.get("parent_delegation_ref")
if parent_ref in visited:
errors.append(f"delegations.{index}.cycle")
break
visited.add(parent_ref)
parent = records.get(parent_ref)
if not isinstance(parent, Mapping):
errors.append(f"delegations.{index}.parent_unknown")
break
depth += 1
current = parent
maximum_depth = item.get("maximum_depth")
if isinstance(maximum_depth, int) and depth > maximum_depth:
errors.append(f"delegations.{index}.maximum_depth_exceeded")
return errors
+400
View File
@@ -0,0 +1,400 @@
"""Deterministic security policy primitives shared by every CASAN adapter.
This module is deliberately stdlib-only. It never calls a model and never treats
model text as an enforcement decision. The JSON action registry is the canonical
machine-readable source; callers receive structured decisions rather than booleans.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import stat
import subprocess
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Mapping, MutableMapping, Optional
POLICY_VERSION = "1.0.0"
RISK_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3}
PRODUCTION_PROFILES = {"prod", "production", "strict"}
DEVELOPMENT_PROFILES = {"", "dev", "development", "local", "test", "testing"}
FAILURE_POLICIES = {"halt", "quarantine", "require_approval", "record_only"}
def _harness_root() -> Path:
override = os.environ.get("CASAN_HARNESS_ROOT")
return Path(override).resolve() if override else Path(__file__).resolve().parents[1]
def _action_registry_path() -> Path:
override = os.environ.get("CASAN_ACTION_CLASS_REGISTRY")
return Path(override).resolve() if override else _harness_root() / "config" / "action-classes.json"
def _load_action_registry() -> dict[str, Any]:
with _action_registry_path().open(encoding="utf-8") as handle:
payload = json.load(handle)
if not isinstance(payload, dict) or not isinstance(payload.get("classes"), dict):
raise ValueError("action class registry is malformed")
return payload
def _normal(value: object) -> str:
return re.sub(r"[^a-z0-9]+", "_", str(value or "").strip().lower()).strip("_")
def _higher(left: str, right: str) -> str:
return right if RISK_ORDER.get(right, 3) > RISK_ORDER.get(left, 3) else left
def _content_risk(text: str) -> str:
value = (text or "").lower()
if re.search(r"\b(drop\s+table|truncate|password|api[_-]?key|secret|credential|shutdown|rm\s+-rf)\b", value):
return "high"
if re.search(r"\b(deploy|release|migration|permission|policy|external\s+api|database)\b", value):
return "medium"
return "low"
def _resource_risk(resource: str) -> str:
value = (resource or "").lower()
if re.search(r"(^|/)(\.env|\.ssh|\.aws)(/|$)|\.(pem|key|p12|pfx)$|credential|secret", value):
return "high"
if re.search(r"\b(prod|production|customer|tenant)\b", value):
return "medium"
return "low"
def _environment_risk(environment: str) -> str:
return "medium" if _normal(environment) in PRODUCTION_PROFILES else "low"
def classify_action(
action: str = "",
tool: str = "",
resource: str = "",
command: str = "",
) -> dict[str, Any]:
"""Classify an action using deterministic aliases and command patterns.
When several signals match, the class with the highest risk floor wins. An
unknown side-effect-shaped tool is therefore never downgraded by benign text.
"""
registry = _load_action_registry()
classes = registry["classes"]
candidates: list[tuple[str, str]] = []
action_key = _normal(action)
tool_key = _normal(tool)
action_class = registry.get("action_aliases", {}).get(action_key)
if action_class:
candidates.append(("action", action_class))
tool_class = registry.get("tool_aliases", {}).get(tool_key)
if tool_class:
candidates.append(("tool", tool_class))
combined = " ".join(part for part in (command, resource) if part)
for rule in registry.get("command_patterns", []):
try:
if re.search(str(rule["pattern"]), combined, re.IGNORECASE):
candidates.append(("command_or_resource", str(rule["class"])))
except (KeyError, re.error, TypeError) as exc:
raise ValueError("invalid action class pattern") from exc
shell_like = tool_key in {"bash", "shell", "exec", "run", "run_command", "terminal"}
if not candidates and shell_like:
candidates.append(("unknown_shell_command", "unknown"))
if not candidates:
candidates.append(("unclassified", "unknown"))
selected_source, selected_class = candidates[0]
selected_risk = str(classes[selected_class]["risk_floor"])
for source, candidate in candidates[1:]:
risk = str(classes[candidate]["risk_floor"])
if RISK_ORDER.get(risk, 3) > RISK_ORDER.get(selected_risk, 3):
selected_source, selected_class, selected_risk = source, candidate, risk
metadata = classes[selected_class]
return {
"schema_version": registry.get("schema_version", POLICY_VERSION),
"action_class": selected_class,
"classification_source": selected_source,
"risk_floor": selected_risk,
"side_effect_level": metadata["side_effect_level"],
"side_effecting": metadata["side_effect_level"] != "none",
"requires_approval": bool(metadata["requires_approval"]),
"actor_required": bool(metadata["actor_required"]),
"evidence_required": bool(metadata["evidence_required"]),
"matched_classes": [candidate for _source, candidate in candidates],
}
def evaluate_risk(
action: str = "",
tool: str = "",
resource: str = "",
command: str = "",
content: str = "",
actor: str = "",
environment: str = "",
) -> dict[str, Any]:
classification = classify_action(action, tool, resource, command)
actor_present = bool(str(actor or "").strip())
identity_risk = "high" if classification["actor_required"] and not actor_present else "low"
factors = {
"content_risk": _content_risk(content),
"action_risk": classification["risk_floor"],
"resource_risk": _resource_risk(resource),
"identity_risk": identity_risk,
"environment_risk": _environment_risk(environment),
}
effective = "low"
for factor in factors.values():
effective = _higher(effective, factor)
reason_codes: list[str] = ["action_risk_floor_applied"]
if classification["actor_required"] and not actor_present:
decision = "deny"
reason_codes.append("actor_identity_required")
elif classification["requires_approval"] or effective in {"high", "critical"}:
decision = "require_approval"
reason_codes.append("explicit_approval_required")
else:
decision = "allow"
reason_codes.append("risk_within_auto_approval_policy")
return {
"schema_version": POLICY_VERSION,
"policy_id": "casan.action-risk-floor",
"policy_version": POLICY_VERSION,
"decision": decision,
"reason_codes": reason_codes,
"effective_risk": effective,
"risk_factors": factors,
"actor_identity_present": actor_present,
"evidence_requirement": "required" if classification["evidence_required"] else "standard",
**classification,
}
def evaluate_registry_configuration(
mode: str,
profile: str,
explicit_value: Optional[str],
) -> dict[str, Any]:
normalized_mode = _normal(mode)
normalized_profile = _normal(profile)
explicit = None if explicit_value is None else _normal(explicit_value) in {"1", "true", "yes", "on", "enabled"}
production = normalized_profile in PRODUCTION_PROFILES
if explicit is False and production:
return {
"enabled": True,
"valid": False,
"unsafe_bypass": False,
"severity": "critical",
"reason_code": "h2_registry_bypass_forbidden",
}
if explicit is False:
return {
"enabled": False,
"valid": normalized_profile in DEVELOPMENT_PROFILES,
"unsafe_bypass": True,
"severity": "high",
"reason_code": "h2_registry_unsafe_development_bypass",
}
return {
"enabled": True if explicit is None else explicit,
"valid": True,
"unsafe_bypass": False,
"severity": "info",
"reason_code": "h2_registry_default_enabled" if explicit is None and normalized_mode == "enforce" else "h2_registry_enabled",
}
def evaluate_failure_policy(
*,
side_effecting: bool,
mode: str,
profile: str,
configured: Optional[str] = None,
) -> dict[str, Any]:
"""Select single-step failure handling without conflating telemetry success.
Invalid configuration fails closed. Production side effects may be made
stricter, but they may never be configured as record-only.
"""
normalized = _normal(configured or "")
production = _normal(profile) in PRODUCTION_PROFILES
enforce = _normal(mode) == "enforce"
if normalized and normalized not in FAILURE_POLICIES:
policy, source, valid = "halt", "invalid_policy_fail_closed", False
elif normalized == "record_only" and side_effecting and production:
policy, source, valid = "halt", "production_record_only_forbidden", False
elif normalized:
policy, source, valid = normalized, "configured", True
elif side_effecting and (enforce or production):
policy, source, valid = "halt", "safe_enforce_default", True
else:
policy, source, valid = "record_only", "observe_or_read_only_default", True
return {
"schema_version": POLICY_VERSION,
"policy_id": "casan.single-step-failure",
"policy_version": POLICY_VERSION,
"failure_policy": policy,
"source": source,
"valid": valid,
"side_effecting": side_effecting,
"mode": _normal(mode) or "observe",
"profile": _normal(profile) or "development",
"execution_result": "failed",
"telemetry_result": "recorded",
"assurance_may_continue": policy == "record_only",
"reason_codes": [source],
}
@dataclass(frozen=True)
class H2GateContext:
mode: str
actor: str
action: str
tool: str
execution_id: str
enforcement_path: str
idempotency_key: str
timeout_seconds: float = 8.0
def _h2_result(context: H2GateContext, underlying: str, reason_code: str, detail: str) -> dict[str, Any]:
enforce = _normal(context.mode) == "enforce"
actual_deny = underlying == "deny"
dependency_failure = underlying == "error"
execution_allowed = not enforce or (not actual_deny and not dependency_failure)
if enforce:
decision = "allow" if execution_allowed else "deny"
else:
decision = "observe_only"
return {
"schema_version": POLICY_VERSION,
"policy_id": "casan.h2.tool-registry",
"policy_version": POLICY_VERSION,
"decision": decision,
"underlying_decision": underlying,
"reason_codes": [reason_code],
"reason_code": reason_code,
"mode": _normal(context.mode) or "observe",
"actor": context.actor or "unidentified",
"action": context.action,
"tool": context.tool,
"execution_id": context.execution_id,
"enforcement_path": context.enforcement_path,
"execution_allowed": execution_allowed,
"certifiable": enforce and execution_allowed,
"assurance_status": "enforced" if enforce and execution_allowed else "denied" if enforce else "degraded",
"severity": "high" if reason_code != "h2_ok" else "info",
"detail": detail[:400],
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
}
def evaluate_h2_gate(
gate_path: str,
context: H2GateContext,
env: Optional[Mapping[str, str]] = None,
bash_path: str = "bash",
) -> dict[str, Any]:
"""Execute the H2 dependency and convert every outcome into one policy decision."""
path = Path(gate_path)
if not path.is_file():
return _h2_result(context, "error", "h2_gate_unavailable", "gate file is missing")
try:
mode_bits = stat.S_IMODE(path.stat().st_mode)
except OSError as exc:
return _h2_result(context, "error", "h2_gate_unavailable", f"gate stat failed: {type(exc).__name__}")
if mode_bits & 0o444 == 0:
return _h2_result(context, "error", "h2_gate_permission_denied", "gate file is not readable")
resolved_bash = shutil.which(bash_path) if not os.path.isabs(bash_path) else bash_path
if not resolved_bash or not Path(resolved_bash).is_file():
return _h2_result(context, "error", "h2_gate_unavailable", "bash runtime is unavailable")
child_env: MutableMapping[str, str] = dict(os.environ)
if env:
child_env.update({str(key): str(value) for key, value in env.items()})
child_env["CASAN_IDEMPOTENCY_KEY"] = context.idempotency_key
if context.actor and not child_env.get("CASAN_AGENT"):
child_env["CASAN_AGENT"] = context.actor
try:
proc = subprocess.run(
[resolved_bash, str(path), context.action],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=max(0.001, context.timeout_seconds),
env=child_env,
check=False,
text=True,
)
except subprocess.TimeoutExpired:
return _h2_result(context, "error", "h2_gate_timeout", "gate exceeded internal timeout")
except (OSError, ValueError, subprocess.SubprocessError) as exc:
return _h2_result(context, "error", "h2_gate_internal_error", type(exc).__name__)
stdout = proc.stdout.strip()[:4096]
stderr = proc.stderr.strip()[:4096]
if proc.returncode == 0 and re.search(r"\bTOOL_APPROVED\b", stdout):
return _h2_result(context, "allow", "h2_ok", stdout)
if re.search(r"\bTOOL_DENIED\b", stdout + "\n" + stderr):
reason_match = re.search(r"reason=([^\s]+)", stdout + "\n" + stderr)
reason = "h2_denied:%s" % (reason_match.group(1) if reason_match else "policy")
return _h2_result(context, "deny", reason, stderr or stdout)
if proc.returncode in {126, 127}:
return _h2_result(context, "error", "h2_gate_unavailable", stderr or "gate runtime unavailable")
if proc.returncode != 0:
return _h2_result(context, "error", "h2_gate_internal_error", stderr or stdout or f"exit={proc.returncode}")
return _h2_result(context, "error", "h2_gate_malformed_response", stdout or "empty response")
def evaluate_trust_capabilities(env: Optional[Mapping[str, str]] = None) -> dict[str, Any]:
values = dict(os.environ if env is None else env)
profile = _normal(values.get("CASAN_PROFILE", ""))
production = profile in PRODUCTION_PROFILES
signing_provider = _normal(values.get("CASAN_SIGNING_PROVIDER", "local_openssl"))
anchor_provider = _normal(values.get("CASAN_IMMUTABLE_ANCHOR_PROVIDER", "local_hash_chain"))
emergency = _normal(values.get("CASAN_TRUST_EMERGENCY_OVERRIDE", "")) in {"1", "true", "yes", "on"}
signing_external = signing_provider in {"vault", "vault_kms", "kms", "hsm"}
anchor_external = anchor_provider in {"s3_object_lock", "qldb", "external_worm"}
vault_addr = str(values.get("VAULT_ADDR") or "")
signing_configured = signing_external and vault_addr.startswith("https://") and bool(values.get("VAULT_TOKEN"))
anchor_configured = anchor_external and bool(values.get("CASAN_S3_BUCKET")) and bool(values.get("CASAN_S3_KMS_KEY_ID"))
ready = (not production) or (signing_configured and anchor_configured)
certifiable = ready and not emergency
reasons: list[str] = []
if production and not signing_configured:
reasons.append("external_signing_trust_root_required")
if production and not anchor_configured:
reasons.append("external_immutable_anchor_required")
if emergency:
reasons.append("emergency_local_trust_override_active")
return {
"schema_version": POLICY_VERSION,
"profile": profile or "development",
"production": production,
"signing_provider": signing_provider or "local_openssl",
"signing_capability": "external" if signing_configured else "local_or_unavailable",
"immutable_anchor_provider": anchor_provider or "local_hash_chain",
"immutable_anchor_capability": "external" if anchor_configured else "local_or_unavailable",
"ready": ready,
"certifiable": certifiable,
"emergency_override": emergency,
"severity": "critical" if emergency or not ready else "info",
"reason_codes": reasons or ["trust_capabilities_satisfied"],
}
@@ -0,0 +1,42 @@
"""Runtime supervision capability negotiation without pretending support."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from typing import Any
@dataclass(frozen=True)
class RuntimeCapabilities:
canCancel: bool = False
canPause: bool = False
canResume: bool = False
canRollback: bool = False
canReduceAuthority: bool = False
canQuarantine: bool = False
CAPABILITY_FOR_INTERVENTION = {
"cancel": "canCancel",
"pause": "canPause",
"resume": "canResume",
"roll_back": "canRollback",
"reduce_authority": "canReduceAuthority",
"quarantine": "canQuarantine",
}
def negotiate_intervention(intervention_id: str, intervention: str, requested_by: str, capabilities: RuntimeCapabilities) -> dict[str, Any]:
capability = CAPABILITY_FOR_INTERVENTION.get(intervention)
supported = bool(capability and getattr(capabilities, capability))
return {
"intervention_id": intervention_id,
"type": intervention,
"requested_by": requested_by,
"status": "pending" if supported else "unsupported",
"reason_code": "runtime_intervention_supported_pending_adapter" if supported else "runtime_intervention_unsupported",
"required_capability": capability,
"runtime_capabilities": asdict(capabilities),
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
}
+43
View File
@@ -0,0 +1,43 @@
"""Typed H1-H7 namespaces. Legacy `Hn` labels remain display aliases only."""
from __future__ import annotations
from enum import Enum
class AssuranceCategory(str, Enum):
RUNTIME_CONTROL = "runtime_control"
READINESS_CHECK = "readiness_check"
REPORT_DIMENSION = "report_dimension"
CERTIFICATION_CLAIM = "certification_claim"
class _HNamespace(str, Enum):
@property
def legacy_id(self) -> str:
return self.value.rsplit(".", 1)[-1]
class RuntimeControl(_HNamespace):
H1 = "RuntimeControl.H1"; H2 = "RuntimeControl.H2"; H3 = "RuntimeControl.H3"
H4 = "RuntimeControl.H4"; H5 = "RuntimeControl.H5"; H6 = "RuntimeControl.H6"; H7 = "RuntimeControl.H7"
class ReadinessCheck(_HNamespace):
H1 = "ReadinessCheck.H1"; H2 = "ReadinessCheck.H2"; H3 = "ReadinessCheck.H3"
H4 = "ReadinessCheck.H4"; H5 = "ReadinessCheck.H5"; H6 = "ReadinessCheck.H6"; H7 = "ReadinessCheck.H7"
class ReportDimension(_HNamespace):
H1 = "ReportDimension.H1"; H2 = "ReportDimension.H2"; H3 = "ReportDimension.H3"
H4 = "ReportDimension.H4"; H5 = "ReportDimension.H5"; H6 = "ReportDimension.H6"; H7 = "ReportDimension.H7"
class CertificationClaim(_HNamespace):
H1 = "CertificationClaim.H1"; H2 = "CertificationClaim.H2"; H3 = "CertificationClaim.H3"
H4 = "CertificationClaim.H4"; H5 = "CertificationClaim.H5"; H6 = "CertificationClaim.H6"; H7 = "CertificationClaim.H7"
def same_legacy_label(left: _HNamespace, right: _HNamespace) -> bool:
"""Compatibility display helper; never authorizes cross-category interpretation."""
return left.legacy_id == right.legacy_id
@@ -58,6 +58,10 @@
"type": ["string", "null"],
"description": "Client-native session id. Hashed by the bridge, never stored raw."
},
"actor": {
"type": ["string", "null"],
"description": "Stable actor identity asserted by the authenticated runtime boundary."
},
"integration_mode": {
"type": ["string", "null"],
"enum": ["casan_owned", "managed_hook", "project_hook", "observed_only", null],
@@ -167,7 +171,7 @@
"schema_version": { "type": "string" },
"decision": {
"type": "string",
"enum": ["allow", "block", "deny", "recorded", "certified", "non_certified", "error"]
"enum": ["allow", "block", "deny", "require_approval", "halt", "quarantine", "recorded", "certified", "non_certified", "error"]
},
"admission_id": { "type": ["string", "null"] },
"trace_id": { "type": ["string", "null"] },
@@ -0,0 +1,153 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://casan.dev/schemas/assurance-kernel/1.0.0",
"title": "CASAN Assurance Kernel Execution Envelope",
"description": "Framework-independent canonical wire contract. Runtime-specific fields belong under a namespaced extensions object.",
"type": "object",
"required": ["schema_version", "extension_namespace", "run", "actors", "steps", "evidence", "trace_links", "extensions"],
"properties": {
"schema_version": {"const": "1.0.0"},
"extension_namespace": {"type": "string", "pattern": "^[a-z][a-z0-9.-]+$"},
"run": {"$ref": "#/$defs/ExecutionRun"},
"actors": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/Actor"}},
"steps": {"type": "array", "items": {"$ref": "#/$defs/ExecutionStep"}},
"delegations": {"type": "array", "items": {"$ref": "#/$defs/Delegation"}},
"context_items": {"type": "array", "items": {"$ref": "#/$defs/ContextItem"}},
"runtime_constraints": {"type": "array", "items": {"$ref": "#/$defs/RuntimeConstraint"}},
"runtime_capabilities": {"$ref": "#/$defs/RuntimeCapabilities"},
"approvals": {"type": "array", "items": {"$ref": "#/$defs/Approval"}},
"interventions": {"type": "array", "items": {"$ref": "#/$defs/Intervention"}},
"claims": {"type": "array", "items": {"$ref": "#/$defs/Claim"}},
"evidence": {"type": "array", "items": {"$ref": "#/$defs/EvidenceItem"}},
"trace_links": {"type": "array", "items": {"$ref": "#/$defs/TraceLink"}},
"extensions": {"type": "object", "additionalProperties": {"type": "object"}}
},
"additionalProperties": false,
"$defs": {
"Timestamp": {"type": "string", "format": "date-time"},
"Outcome": {
"type": "object",
"required": ["execution_result", "assurance_result", "certification_result", "business_result"],
"properties": {
"execution_result": {"enum": ["pending", "success", "failed", "cancelled", "quarantined", "unknown"]},
"assurance_result": {"enum": ["pending", "passed", "failed", "degraded", "not_evaluated", "unknown"]},
"certification_result": {"enum": ["pending", "certified", "non_certified", "ineligible"]},
"business_result": {"enum": ["achieved", "not_achieved", "partial", "not_evaluated", "unknown"]}
},
"additionalProperties": false
},
"ExecutionRun": {
"type": "object",
"required": ["run_id", "tenant", "project", "environment", "runtime", "mode", "requested_goal", "started_at", "status", "risk_summary", "correlation_id", "outcome"],
"properties": {
"run_id": {"type": "string", "minLength": 1},
"parent_run_id": {"type": ["string", "null"]},
"tenant": {"type": "string"},
"project": {"type": "string"},
"environment": {"type": "string"},
"runtime": {"type": "object", "required": ["type", "version"], "properties": {"type": {"type": "string"}, "version": {"type": "string"}}, "additionalProperties": false},
"mode": {"enum": ["observe", "enforce"]},
"requested_goal": {"type": "string"},
"started_at": {"$ref": "#/$defs/Timestamp"},
"completed_at": {"anyOf": [{"$ref": "#/$defs/Timestamp"}, {"type": "null"}]},
"status": {"enum": ["pending", "running", "success", "failed", "cancelled", "quarantined", "unknown"]},
"risk_summary": {"type": "object"},
"evidence_manifest_ref": {"type": ["string", "null"]},
"correlation_id": {"type": "string", "minLength": 1},
"outcome": {"$ref": "#/$defs/Outcome"}
},
"additionalProperties": false
},
"ExecutionStep": {
"type": "object",
"required": ["step_id", "run_id", "sequence", "actor_ref", "action", "input_context_refs", "policy_decisions", "verification_results", "evidence_refs", "outcome", "started_at", "completed_at"],
"properties": {
"step_id": {"type": "string"}, "run_id": {"type": "string"}, "parent_step_id": {"type": ["string", "null"]}, "sequence": {"type": "integer", "minimum": 1},
"actor_ref": {"type": "string"}, "action": {"$ref": "#/$defs/Action"}, "resource": {"type": ["object", "string", "null"]},
"input_context_refs": {"type": "array", "items": {"type": "string"}}, "policy_decisions": {"type": "array", "items": {"$ref": "#/$defs/PolicyDecision"}},
"tool_invocation": {"type": ["object", "null"]}, "verification_results": {"type": "array", "items": {"$ref": "#/$defs/VerificationResult"}},
"evidence_refs": {"type": "array", "items": {"type": "string"}}, "outcome": {"$ref": "#/$defs/Outcome"},
"started_at": {"$ref": "#/$defs/Timestamp"}, "completed_at": {"anyOf": [{"$ref": "#/$defs/Timestamp"}, {"type": "null"}]}
},
"additionalProperties": false
},
"Actor": {
"type": "object",
"required": ["actor_id", "actor_type", "issuer", "trust_level"],
"properties": {
"actor_id": {"type": "string"}, "actor_type": {"enum": ["human", "agent", "child_agent", "model", "tool", "service_account", "runtime", "approver", "policy_engine"]},
"issuer": {"type": "string"}, "trust_level": {"enum": ["unverified", "asserted", "verified", "hardware_backed"]}, "authentication_evidence_ref": {"type": ["string", "null"]}
},
"additionalProperties": false
},
"Delegation": {
"type": "object",
"required": ["delegation_id", "delegator_ref", "delegate_ref", "allowed_authority", "resource_scope", "expiry", "maximum_depth", "revoked"],
"properties": {
"delegation_id": {"type": "string"}, "delegator_ref": {"type": "string"}, "delegate_ref": {"type": "string"}, "allowed_authority": {"type": "array", "items": {"type": "string"}},
"resource_scope": {"type": "array", "items": {"type": "string"}}, "expiry": {"$ref": "#/$defs/Timestamp"}, "maximum_depth": {"type": "integer", "minimum": 0},
"parent_delegation_ref": {"type": ["string", "null"]}, "revoked": {"type": "boolean"}, "approval_ref": {"type": ["string", "null"]}
},
"additionalProperties": false
},
"ContextItem": {
"type": "object", "required": ["context_id", "source", "trust_classification", "content_hash", "transform_history", "compression_lineage", "classification", "injection_scan_result"],
"properties": {
"context_id": {"type": "string"}, "source": {"type": "string"}, "trust_classification": {"enum": ["trusted", "untrusted", "mixed", "unknown"]}, "content_hash": {"type": "string"},
"transform_history": {"type": "array", "items": {"type": "object"}}, "compression_lineage": {"type": "array", "items": {"type": "string"}},
"origin_ref": {"type": ["string", "null"]}, "classification": {"enum": ["instruction", "data", "mixed", "unknown"]}, "injection_scan_result": {"type": "object"}
}, "additionalProperties": false
},
"Action": {
"type": "object", "required": ["class", "name", "side_effect_level"],
"properties": {"class": {"enum": ["read_only", "write", "delete", "database_mutation", "migration", "deployment", "release", "credential_access", "identity_permission_modification", "external_network_side_effect", "infrastructure_modification", "unknown"]}, "name": {"type": "string"}, "tool": {"type": ["string", "null"]}, "side_effect_level": {"enum": ["none", "sensitive_read", "write", "destructive", "external", "unknown"]}, "required_authority": {"type": ["string", "null"]}, "requested_operation": {"type": ["string", "null"]}, "environment": {"type": ["string", "null"]}},
"additionalProperties": false
},
"ResourceAccess": {
"type": "object", "required": ["resource", "operation", "side_effect_level", "environment"],
"properties": {"resource": {"type": "string"}, "operation": {"type": "string"}, "side_effect_level": {"type": "string"}, "required_authority": {"type": ["string", "null"]}, "environment": {"type": "string"}}, "additionalProperties": false
},
"PolicyDecision": {
"type": "object", "required": ["policy_id", "policy_version", "decision", "reason_codes", "effective_risk", "input_facts", "enforcement_point", "timestamp", "decision_engine_identity"],
"properties": {"policy_id": {"type": "string"}, "policy_version": {"type": "string"}, "decision": {"enum": ["allow", "deny", "quarantine", "require_approval", "transform", "observe_only"]}, "reason_codes": {"type": "array", "items": {"type": "string"}}, "effective_risk": {"enum": ["low", "medium", "high", "critical"]}, "input_facts": {"type": "object"}, "enforcement_point": {"type": "string"}, "timestamp": {"$ref": "#/$defs/Timestamp"}, "decision_engine_identity": {"type": "string"}, "evidence_ref": {"type": ["string", "null"]}}, "additionalProperties": false
},
"RuntimeConstraint": {
"type": "object", "required": ["constraint_id", "kind", "limit", "enforcement"],
"properties": {"constraint_id": {"type": "string"}, "kind": {"enum": ["iteration", "time", "cost", "token", "child_agent_count", "graph_depth", "retry", "repetition", "network", "filesystem", "tool", "environment"]}, "limit": {}, "enforcement": {"enum": ["hard", "soft", "observe"]}}, "additionalProperties": false
},
"RuntimeCapabilities": {
"type": "object",
"required": ["canCancel", "canPause", "canResume", "canRollback", "canReduceAuthority", "canQuarantine"],
"properties": {"canCancel": {"type": "boolean"}, "canPause": {"type": "boolean"}, "canResume": {"type": "boolean"}, "canRollback": {"type": "boolean"}, "canReduceAuthority": {"type": "boolean"}, "canQuarantine": {"type": "boolean"}},
"additionalProperties": false
},
"VerificationRequirement": {
"type": "object", "required": ["requirement_id", "validator", "expected_result", "independence_level", "failure_severity"],
"properties": {"requirement_id": {"type": "string"}, "validator": {"type": "string"}, "expected_result": {}, "independence_level": {"enum": ["same_runtime", "independent_process", "independent_service", "external_authority"]}, "failure_severity": {"enum": ["info", "warning", "error", "critical"]}}, "additionalProperties": false
},
"VerificationResult": {
"type": "object", "required": ["requirement_id", "validator", "expected_result", "actual_result", "status", "evidence_refs", "independence_level", "failure_severity"],
"properties": {"requirement_id": {"type": "string"}, "validator": {"type": "string"}, "expected_result": {}, "actual_result": {}, "status": {"enum": ["passed", "failed", "degraded", "not_run"]}, "evidence_refs": {"type": "array", "items": {"type": "string"}}, "independence_level": {"type": "string"}, "failure_severity": {"type": "string"}}, "additionalProperties": false
},
"Claim": {
"type": "object", "required": ["claim_id", "statement", "evidence_refs", "validation_status"],
"properties": {"claim_id": {"type": "string"}, "statement": {"type": "string"}, "evidence_refs": {"type": "array", "items": {"type": "string"}}, "validation_status": {"enum": ["validated", "rejected", "insufficient", "not_validated"]}}, "additionalProperties": false
},
"EvidenceItem": {
"type": "object", "required": ["evidence_id", "claim_refs", "integrity", "producer_identity", "timestamp", "artifact_ref", "validation_status", "retention_class"],
"properties": {"evidence_id": {"type": "string"}, "claim_refs": {"type": "array", "items": {"type": "string"}}, "integrity": {"type": "object", "required": ["algorithm", "digest"], "properties": {"algorithm": {"const": "sha256"}, "digest": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, "source_content_hash": {"type": ["string", "null"]}}, "additionalProperties": false}, "producer_identity": {"type": "string"}, "timestamp": {"$ref": "#/$defs/Timestamp"}, "artifact_ref": {"type": "string"}, "validation_status": {"enum": ["valid", "invalid", "unverified", "unavailable"]}, "retention_class": {"type": "string"}, "category": {"enum": ["runtime_control", "readiness_check", "report_dimension", "certification_claim"]}}, "additionalProperties": false
},
"TraceLink": {
"type": "object", "required": ["type", "from", "to"],
"properties": {"type": {"enum": ["requirement_to_policy", "policy_to_decision", "decision_to_action", "action_to_artifact", "artifact_to_verification", "verification_to_evidence", "evidence_to_outcome", "parent_to_child_execution"]}, "from": {"type": "string"}, "to": {"type": "string"}}, "additionalProperties": false
},
"Approval": {
"type": "object", "required": ["approval_id", "decision", "approver_ref", "timestamp"],
"properties": {"approval_id": {"type": "string"}, "decision": {"enum": ["approve", "reject"]}, "approver_ref": {"type": "string"}, "reason": {"type": ["string", "null"]}, "timestamp": {"$ref": "#/$defs/Timestamp"}}, "additionalProperties": false
},
"Intervention": {
"type": "object", "required": ["intervention_id", "type", "requested_by", "status", "timestamp"],
"properties": {"intervention_id": {"type": "string"}, "type": {"enum": ["pause", "resume", "cancel", "quarantine", "reduce_authority", "redirect", "roll_back"]}, "requested_by": {"type": "string"}, "status": {"enum": ["applied", "rejected", "unsupported", "pending"]}, "timestamp": {"$ref": "#/$defs/Timestamp"}}, "additionalProperties": false
}
}
}
@@ -110,6 +110,10 @@ LATENCY_MS=$((END_MS - START_MS))
if [[ ! -f "$OUTPUT_FILE" ]]; then
STATUS="failed"
ERROR_MSG="${ERROR_MSG:-output file not produced}"
# A zero command exit does not make the step successful when the runtime
# contract requires an output artifact and none was produced. Telemetry can
# record this failure successfully, but must propagate a failed step outcome.
[[ "$EXIT_CODE" -eq 0 ]] && EXIT_CODE=1
: > "$OUTPUT_FILE"
fi
@@ -24,6 +24,7 @@ fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/casan-paths.sh"
PROJECT_ROOT="$CASAN_APP_ROOT"
KERNEL_CLI="$CASAN_HARNESS_ROOT/scripts/python/kernel_cli.py"
# SEC-23 (MT-01): make state (control-plane settings, telemetry, audit) tenant-scoped
# when CASAN_TENANT_ID is set, so a run for tenant A never touches tenant B's state.
# No-op when no tenant is set (baseline unchanged); invalid tenant fails closed.
@@ -56,12 +57,52 @@ write_phase_report() {
"$ACTION_NAME" "$CACHE_STATUS" "$PHASE_LOG" > "$PHASE_REPORT" 2>/dev/null || true
}
emit_failed_kernel() { # phase rc — best effort, never masks the original failure
local failed_phase="$1" failed_rc="$2"
[[ -f "$KERNEL_CLI" ]] || return 0
local run_id="${EXECUTION_ID:-native-failed-${TRACE_SUFFIX:-$$}}"
local mode="${NATIVE_MODE:-observe}"
local event bundle path
event="$(CASAN_NATIVE_RISK="${ACTION_RISK_JSON:-}" python3 - "$run_id" "$ACTION_NAME" "$mode" "$failed_phase" "$failed_rc" "${CASAN_ACTOR:-${CASAN_AGENT:-}}" <<'PY'
import json, os, sys
run_id, action, mode, phase, rc, actor = sys.argv[1:]
try: risk = json.loads(os.environ.get("CASAN_NATIVE_RISK") or "{}")
except ValueError: risk = {}
print(json.dumps({
"run_id": run_id, "correlation_id": run_id, "action": action,
"tool": action, "actor": actor, "mode": mode,
"environment": os.environ.get("CASAN_PROFILE", "development"),
"execution_status": "failed", "assurance_status": "failed",
"certification_status": "non_certified", "policy_decisions": [risk] if risk else [],
"extensions": {"failed_phase": phase, "exit_code": int(rc)},
}, separators=(",", ":")))
PY
)" || return 0
bundle="$(printf '%s' "$event" | python3 "$KERNEL_CLI" adapt-native - 2>/dev/null)" || return 0
path="$CASAN_STATE_ROOT/logs/kernel/$run_id.json"
CASAN_KERNEL_BUNDLE="$bundle" python3 - "$path" <<'PY' || return 0
import json, os, sys, tempfile
path = sys.argv[1]; payload = json.loads(os.environ["CASAN_KERNEL_BUNDLE"])
os.makedirs(os.path.dirname(path), exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".kernel-", dir=os.path.dirname(path))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, sort_keys=True, separators=(",", ":")); handle.write("\n")
handle.flush(); os.fsync(handle.fileno())
os.replace(tmp, path)
finally:
try: os.unlink(tmp)
except OSError: pass
PY
}
run_phase() { # <phase-name> <command...> — preserves the failing rc exactly
local phase="$1"; shift
local rc=0
"$@" || rc=$?
record_phase "$phase" "$rc"
if [[ "$rc" -ne 0 ]]; then
emit_failed_kernel "$phase" "$rc"
write_phase_report
exit "$rc"
fi
@@ -76,9 +117,16 @@ hash_text() {
}
CMD_STR="${*:-no_cmd}"
NATIVE_MODE="${CASAN_ENFORCEMENT_MODE:-}"
if [[ -z "$NATIVE_MODE" ]]; then
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ]]; then NATIVE_MODE="enforce"; else NATIVE_MODE="observe"; fi
fi
case "$NATIVE_MODE" in observe|enforce) : ;; *) NATIVE_MODE="enforce"; casan_log error harness "INVALID_ENFORCEMENT_MODE fail_closed=enforce" ;; esac
INPUT_HASH="$(cat "$INPUT_FILE" | hash_text)"
CMD_HASH="$(printf '%s' "$CMD_STR" | hash_text)"
IDEMPOTENCY_KEY="$(printf '%s|%s|%s' "$INPUT_HASH" "$CMD_HASH" "$ACTION_NAME" | hash_text)"
EXECUTION_ID="${CASAN_EXECUTION_ID:-native-${IDEMPOTENCY_KEY:0:24}}"
export CASAN_EXECUTION_ID="$EXECUTION_ID"
CACHE_META="$CACHE_DIR/$IDEMPOTENCY_KEY.json"
CACHE_OUT="$CACHE_DIR/$IDEMPOTENCY_KEY.output"
@@ -92,7 +140,7 @@ casan_log debug harness "action=$ACTION_NAME input=$INPUT_FILE output=$FINAL_OUT
# C7: honor an engaged kill-switch before doing any work (incident containment).
# Opt-in (default off) so the baseline is unchanged. SEC-17 (ARCH-03): under
# CASAN_PROFILE=prod it defaults ON (secure-by-default); an explicit =0 still wins.
if [[ "${CASAN_KILLSWITCH_ENFORCE:-0}" == "1" || ( -z "${CASAN_KILLSWITCH_ENFORCE+x}" && "${CASAN_PROFILE:-}" == "prod" ) ]]; then
if [[ "${CASAN_KILLSWITCH_ENFORCE:-0}" == "1" || ( -z "${CASAN_KILLSWITCH_ENFORCE+x}" && ( "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ) ) ]]; then
KS_SCOPE="${CASAN_KILLSWITCH_SCOPE:-project}"
KS_ID="${CASAN_KILLSWITCH_ID:-${CASAN_PROJECT:-current}}"
if ! bash "$SCRIPT_DIR/kill-switch.sh" check "$KS_SCOPE" "$KS_ID" >/dev/null 2>&1; then
@@ -116,7 +164,7 @@ fi
# signed manifest and REFUSE to run on any drift — editing a gate/policy is a bypass
# that leaves no input trace. Only active when a manifest is provisioned (so dev and
# prod-without-a-manifest are unaffected); a present-but-drifted bundle fails closed.
if [[ ( "${CASAN_PROFILE:-}" == "prod" || "${CASAN_VERIFY_STRICT:-}" == "1" ) \
if [[ ( "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" || "${CASAN_VERIFY_STRICT:-}" == "1" ) \
&& -f "$SCRIPT_DIR/bundle-integrity.py" ]]; then
BUNDLE_MANIFEST="${CASAN_BUNDLE_MANIFEST:-$CASAN_GOVERNANCE_ROOT/harness-bundle-manifest.json}"
if [[ -f "$BUNDLE_MANIFEST" ]]; then
@@ -132,15 +180,62 @@ fi
run_phase "H4-in" "$SCRIPT_DIR/security-check.sh" "$INPUT_FILE" "$SAFE_INPUT" input
run_phase "H5" "$SCRIPT_DIR/governance-check.sh" "$SAFE_INPUT" "$APPROVED_INPUT" "$ACTION_NAME"
# H2 tool registry gate is in the line of fire for side-effecting actions:
# it enforces idempotency key, per-agent permission, and rollback strategy
# before the command is allowed to execute. The wrapper already derived a
# content-addressed idempotency key above.
case "$ACTION_NAME" in
write_code|migration|deploy|db_write|external_api|write_file)
run_phase "H2-gate" env CASAN_IDEMPOTENCY_KEY="$IDEMPOTENCY_KEY" "$SCRIPT_DIR/tool-registry-gate.sh" "$ACTION_NAME"
;;
esac
# Canonical action classification decides whether the H2 registry and isolated
# executor are required. A classifier failure is treated as unknown/high-risk.
ACTION_RISK_JSON=""
ACTION_RISK_RC=0
ACTION_RISK_JSON="$(python3 "$KERNEL_CLI" risk --action "$ACTION_NAME" --tool "$ACTION_NAME" \
--command "$CMD_STR" --content-file "$SAFE_INPUT" --actor "${CASAN_ACTOR:-${CASAN_AGENT:-}}" \
--environment "${CASAN_PROFILE:-development}" 2>/dev/null)" || ACTION_RISK_RC=$?
if [[ "$ACTION_RISK_RC" -eq 0 && -n "$ACTION_RISK_JSON" ]]; then
ACTION_FIELDS="$(python3 - "$ACTION_RISK_JSON" <<'PY'
import json, sys
p=json.loads(sys.argv[1])
print("%s\t%s" % ("1" if p["side_effecting"] else "0", p["action_class"]))
PY
)" || ACTION_RISK_RC=$?
fi
if [[ "$ACTION_RISK_RC" -eq 0 && -n "${ACTION_FIELDS:-}" ]]; then
IFS=$'\t' read -r SIDE_EFFECTING ACTION_CLASS <<< "$ACTION_FIELDS"
else
SIDE_EFFECTING=1
ACTION_CLASS="unknown"
casan_log error harness "ACTION_CLASSIFIER_FAILED_CLOSED action=$ACTION_NAME"
fi
if [[ "$SIDE_EFFECTING" == "1" ]]; then
REGISTRY_ARGS=(registry-config --mode "$NATIVE_MODE" --profile "${CASAN_PROFILE:-development}" \
--evidence-log "$CASAN_STATE_ROOT/logs/policy/h2-registry-config.jsonl")
[[ -n "${CASAN_H2_REGISTRY+x}" ]] && REGISTRY_ARGS+=(--explicit "$CASAN_H2_REGISTRY")
REGISTRY_RC=0
REGISTRY_JSON="$(python3 "$KERNEL_CLI" "${REGISTRY_ARGS[@]}")" || REGISTRY_RC=$?
if [[ "$REGISTRY_RC" -ne 0 ]]; then
record_phase "H2-config" "$REGISTRY_RC"
echo "H2_REGISTRY_CONFIGURATION_DENIED $REGISTRY_JSON" >&2
write_phase_report
exit "$REGISTRY_RC"
fi
REGISTRY_ENABLED="$(python3 -c 'import json,sys; print("1" if json.load(sys.stdin)["enabled"] else "0")' <<< "$REGISTRY_JSON")"
if [[ "$REGISTRY_ENABLED" == "1" ]]; then
H2_ACTION="$ACTION_NAME"
case "$ACTION_CLASS" in
write) H2_ACTION="write_file" ;;
delete) H2_ACTION="delete_file" ;;
database_mutation) H2_ACTION="db_write" ;;
deployment|release|infrastructure_modification) H2_ACTION="deploy" ;;
external_network_side_effect) H2_ACTION="external_api" ;;
unknown) H2_ACTION="unknown_tool" ;;
esac
run_phase "H2-gate" python3 "$KERNEL_CLI" h2-gate \
--gate "${CASAN_H2_GATE_PATH:-$SCRIPT_DIR/tool-registry-gate.sh}" --mode "$NATIVE_MODE" \
--actor "${CASAN_ACTOR:-${CASAN_AGENT:-}}" --action "$H2_ACTION" --tool "$ACTION_NAME" \
--execution-id "$EXECUTION_ID" --enforcement-path "native_harness.pre_execution.h2_registry" \
--idempotency-key "$IDEMPOTENCY_KEY" --timeout "${CASAN_H2_GATE_TIMEOUT_SECONDS:-8}" \
--evidence-log "$CASAN_STATE_ROOT/logs/policy/h2-decisions.jsonl"
else
casan_log warn harness "HIGH H2 registry unsafe development/test bypass active; run is non-certifiable"
fi
fi
# T4: propagate step name so any nested model calls (model-call.py) log against the same step
# name, enabling provider-cost-lookup.py to match real Ollama token counts in agent-metrics.sh.
@@ -155,13 +250,34 @@ if [[ -f "$CACHE_META" && -f "$CACHE_OUT" ]]; then
run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" -- bash -c 'cp "$1" "$CASAN_OUTPUT"' _ "$CACHE_OUT"
elif [[ "$#" -gt 0 ]]; then
CACHE_STATUS="stored"
if [[ "$SIDE_EFFECTING" == "1" && "$NATIVE_MODE" == "enforce" ]]; then
run_phase "H6-exec" env CASAN_ENFORCEMENT_MODE="$NATIVE_MODE" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" -- \
"$SCRIPT_DIR/sandbox-run.sh" --workspace "$PROJECT_ROOT" --timeout "$TOOL_TIMEOUT" -- "$@"
else
run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" -- \
"$SCRIPT_DIR/tool-exec.sh" "$TOOL_TIMEOUT" -- "$@"
fi
else
CACHE_STATUS="stored"
run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT"
fi
TOOL_OUTPUT_MAX_BYTES="${CASAN_TOOL_OUTPUT_MAX_BYTES:-1048576}"
if [[ ! "$TOOL_OUTPUT_MAX_BYTES" =~ ^[1-9][0-9]*$ ]]; then
casan_log error harness "TOOL_OUTPUT_LIMIT_INVALID value=$TOOL_OUTPUT_MAX_BYTES"
: > "$FINAL_OUTPUT"
exit 2
fi
RAW_OUTPUT_BYTES="$(wc -c < "$RAW_OUTPUT" | tr -d ' ')"
if [[ "$RAW_OUTPUT_BYTES" -gt "$TOOL_OUTPUT_MAX_BYTES" ]]; then
record_phase "H4-output-size" 2
casan_log error harness "TOOL_OUTPUT_QUARANTINED bytes=$RAW_OUTPUT_BYTES limit=$TOOL_OUTPUT_MAX_BYTES"
: > "$FINAL_OUTPUT"
write_phase_report
echo "TOOL_OUTPUT_QUARANTINED reason=output_size_limit bytes=$RAW_OUTPUT_BYTES limit=$TOOL_OUTPUT_MAX_BYTES" >&2
exit 2
fi
# V7: tool output can carry indirect injection that would re-enter a downstream
# model's context. Scan RAW_OUTPUT for injection/secret patterns before it is
# reused. Mode: off | warn (default) | block. Strict mode upgrades to block.
@@ -170,7 +286,7 @@ fi
TOOL_OUTPUT_SCAN_MODE="${CASAN_TOOL_OUTPUT_SCAN:-}"
if [[ -z "$TOOL_OUTPUT_SCAN_MODE" ]]; then
# SEC-17/M-02: prod profile defaults tool-output scanning to block (fail-closed).
if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" || ( -z "${CASAN_SECURITY_STRICT+x}" && "${CASAN_PROFILE:-}" == "prod" ) ]]; then TOOL_OUTPUT_SCAN_MODE="block"; else TOOL_OUTPUT_SCAN_MODE="warn"; fi
if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" || ( -z "${CASAN_SECURITY_STRICT+x}" && ( "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ) ) ]]; then TOOL_OUTPUT_SCAN_MODE="block"; else TOOL_OUTPUT_SCAN_MODE="warn"; fi
fi
if [[ "$TOOL_OUTPUT_SCAN_MODE" != "off" ]]; then
TOS_RC=0
@@ -204,6 +320,65 @@ EOF
cp "$FINAL_OUTPUT" "$CACHE_OUT"
fi
# Dual-emit the framework-independent kernel envelope. Legacy phase reports and
# outputs remain unchanged; the canonical contract is an additive artifact.
TRUST_JSON="$(python3 "$KERNEL_CLI" trust-capabilities 2>/dev/null || true)"
RUN_CERTIFICATION="non_certified"
RUN_ASSURANCE="passed"
[[ "$NATIVE_MODE" == "observe" ]] && RUN_ASSURANCE="degraded"
if [[ "$NATIVE_MODE" == "enforce" && -n "$TRUST_JSON" ]]; then
TRUST_CERTIFIABLE="$(python3 -c 'import json,sys; print("1" if json.load(sys.stdin).get("certifiable") else "0")' <<< "$TRUST_JSON" 2>/dev/null || echo 0)"
REGISTRY_BYPASS="$(printf '%s' "${REGISTRY_JSON:-{}}" | python3 -c 'import json,sys; print("1" if json.load(sys.stdin).get("unsafe_bypass") else "0")' 2>/dev/null || echo 0)"
[[ "$REGISTRY_BYPASS" == "1" ]] && RUN_ASSURANCE="degraded"
[[ "$TRUST_CERTIFIABLE" == "1" && "$REGISTRY_BYPASS" == "0" ]] && RUN_CERTIFICATION="certified"
fi
NATIVE_EVENT="$(CASAN_NATIVE_RISK="$ACTION_RISK_JSON" python3 - "$EXECUTION_ID" "$ACTION_NAME" "$ACTION_CLASS" "$NATIVE_MODE" "$RUN_ASSURANCE" "$RUN_CERTIFICATION" "${CASAN_ACTOR:-${CASAN_AGENT:-}}" "$INPUT_HASH" "$CMD_HASH" <<'PY'
import json, os, sys
run_id, action, action_class, mode, assurance, certification, actor, input_hash, command_hash = sys.argv[1:]
try:
risk = json.loads(os.environ.get("CASAN_NATIVE_RISK") or "{}")
except ValueError:
risk = {}
print(json.dumps({
"run_id": run_id, "correlation_id": run_id, "action": action,
"tool": action, "actor": actor, "mode": mode,
"environment": os.environ.get("CASAN_PROFILE", "development"),
"execution_status": "success", "assurance_status": assurance,
"certification_status": certification,
"policy_decisions": [risk] if risk else [],
"extensions": {"input_hash": input_hash, "command_hash": command_hash, "action_class": action_class},
}, separators=(",", ":")))
PY
)"
KERNEL_RC=0
KERNEL_BUNDLE="$(printf '%s' "$NATIVE_EVENT" | python3 "$KERNEL_CLI" adapt-native -)" || KERNEL_RC=$?
if [[ "$KERNEL_RC" -ne 0 ]]; then
casan_log error harness "KERNEL_CONTRACT_EMISSION_FAILED rc=$KERNEL_RC"
if [[ "$NATIVE_MODE" == "enforce" ]]; then
: > "$FINAL_OUTPUT"
exit 2
fi
RUN_CERTIFICATION="non_certified"
else
KERNEL_PATH="$CASAN_STATE_ROOT/logs/kernel/$EXECUTION_ID.json"
python3 - "$KERNEL_PATH" "$KERNEL_BUNDLE" <<'PY'
import json, os, sys, tempfile
path, raw = sys.argv[1:]
os.makedirs(os.path.dirname(path), exist_ok=True)
payload = json.loads(raw)
fd, tmp = tempfile.mkstemp(prefix=".kernel-", dir=os.path.dirname(path))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, sort_keys=True, separators=(",", ":"))
handle.write("\n")
handle.flush(); os.fsync(handle.fileno())
os.replace(tmp, path)
finally:
try: os.unlink(tmp)
except OSError: pass
PY
fi
write_phase_report
casan_log debug harness "action=$ACTION_NAME complete cache=$CACHE_STATUS"
echo "CASAN_HARNESS_COMPLETE cache=$CACHE_STATUS key=$IDEMPOTENCY_KEY output=$FINAL_OUTPUT"
echo "CASAN_HARNESS_COMPLETE cache=$CACHE_STATUS key=$IDEMPOTENCY_KEY execution=success assurance=$RUN_ASSURANCE certification=$RUN_CERTIFICATION output=$FINAL_OUTPUT"
@@ -58,27 +58,48 @@ TRACE_ID="$(new_trace_id)"
TIMESTAMP="$(timestamp)"
INPUT="$(cat "$INPUT_FILE")"
LOWER_INPUT="$(printf '%s' "$INPUT" | tr '[:upper:]' '[:lower:]')"
ACTOR="${CASAN_ACTOR:-developer}"
ACTOR="${CASAN_ACTOR:-${CASAN_AGENT:-}}"
APPROVER="${CASAN_APPROVER:-}"
APPROVAL_DECISION="${CASAN_APPROVAL_DECISION:-auto}"
AUDIT_LOG="$AUDIT_DIR/audit.jsonl"
RISK_LEVEL="low"
REASONS=()
case "$ACTION_NAME" in
deploy|launch|write_code|write_file|migration|db_write|external_api|tool_call)
RISK_LEVEL="medium"
REASONS+=("sensitive-action:$ACTION_NAME")
;;
esac
if printf '%s' "$LOWER_INPUT" | grep -Eq "(delete|drop table|password|api[_-]?key|secret|token|credential|migration|deploy|external api|shutdown|dump database)"; then
ACTION_CLASS="unknown"
RISK_FACTORS_JSON='{"action_risk":"high","content_risk":"high","environment_risk":"low","identity_risk":"low","resource_risk":"low"}'
EVIDENCE_REQUIREMENT="required"
RISK_POLICY_DECISION="require_approval"
KERNEL_CLI="$CASAN_HARNESS_ROOT/scripts/python/kernel_cli.py"
RISK_JSON=""
RISK_RC=0
if [[ -f "$KERNEL_CLI" ]]; then
RISK_JSON="$(python3 "$KERNEL_CLI" risk --action "$ACTION_NAME" --tool "$ACTION_NAME" \
--content-file "$INPUT_FILE" --actor "$ACTOR" --environment "${CASAN_PROFILE:-development}" 2>/dev/null)" || RISK_RC=$?
else
RISK_RC=127
fi
if [[ "$RISK_RC" -eq 0 && -n "$RISK_JSON" ]]; then
RISK_FIELDS="$(python3 - "$RISK_JSON" <<'PY'
import json, sys
payload = json.loads(sys.argv[1])
print("\t".join([
str(payload["action_class"]),
str(payload["effective_risk"]),
json.dumps(payload["risk_factors"], sort_keys=True, separators=(",", ":")),
str(payload["evidence_requirement"]),
str(payload["decision"]),
]))
PY
)" || RISK_RC=$?
fi
if [[ "$RISK_RC" -eq 0 && -n "${RISK_FIELDS:-}" ]]; then
IFS=$'\t' read -r ACTION_CLASS RISK_LEVEL RISK_FACTORS_JSON EVIDENCE_REQUIREMENT RISK_POLICY_DECISION <<< "$RISK_FIELDS"
REASONS+=("action-risk-floor:$ACTION_CLASS")
else
RISK_LEVEL="high"
REASONS+=("high-risk-content")
elif printf '%s' "$LOWER_INPUT" | grep -Eq "(internal|config|system|policy|permission)"; then
[[ "$RISK_LEVEL" == "low" ]] && RISK_LEVEL="medium"
REASONS+=("medium-risk-content")
ACTION_CLASS="unknown"
RISK_POLICY_DECISION="require_approval"
REASONS+=("action-risk-classifier-failed-closed")
fi
APPROVAL_STATUS="auto_approved"
@@ -88,8 +109,14 @@ if [[ "$RISK_LEVEL" == "medium" ]]; then
APPROVAL_STATUS="policy_auto_approved_with_audit"
fi
if [[ "$RISK_LEVEL" == "high" ]]; then
if [[ "${CASAN_APPROVAL_STRICT:-0}" == "1" ]]; then
if [[ "$RISK_POLICY_DECISION" == "deny" ]]; then
APPROVAL_STATUS="actor_identity_required"
DECISION="denied"
REASONS+=("actor-identity-required")
elif [[ "$RISK_LEVEL" == "high" || "$RISK_LEVEL" == "critical" || "$RISK_POLICY_DECISION" == "require_approval" ]]; then
APPROVAL_STRICT_EFFECTIVE="${CASAN_APPROVAL_STRICT:-0}"
[[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ]] && APPROVAL_STRICT_EFFECTIVE="1"
if [[ "$APPROVAL_STRICT_EFFECTIVE" == "1" ]]; then
# Approval-identity mode (V20): an env-var approver is NOT enough — the
# reviewer must cryptographically SIGN this exact request and their role must
# be authorized for the action. SoD (actor != approver) still enforced.
@@ -147,7 +174,7 @@ fi
REASONS_JSON="$(printf '%s\n' "${REASONS[@]:-}" | python -c 'import json,sys; print(json.dumps([x for x in sys.stdin.read().splitlines() if x]))')"
# approver and output_hash are part of the hashed core so they cannot be
# silently mutated after the fact.
RECORD_CORE="$(printf '%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s' "$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTOR" "$RISK_LEVEL" "$DECISION" "$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH")"
RECORD_CORE="$(printf '%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s' "$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTION_CLASS" "$ACTOR" "$RISK_LEVEL" "$RISK_FACTORS_JSON" "$EVIDENCE_REQUIREMENT" "$DECISION" "$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH")"
RECORD_HASH="$(printf '%s' "$RECORD_CORE" | hash_text)"
TRACE_FILE="$TRACE_DIR/governance-$TRACE_ID.json"
@@ -160,18 +187,24 @@ TRACE_FILE="$TRACE_DIR/governance-$TRACE_ID.json"
# written (disk full, read-only, quota), there must be NO governed action without
# its accountability record — deny and empty the output rather than proceed.
if ! CASAN_GC_REASONS="$REASONS_JSON" python - "$TRACE_FILE" "$AUDIT_LOG" \
"$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTOR" "$RISK_LEVEL" "$DECISION" \
"$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH" "$RECORD_HASH" <<'PY'
"$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTION_CLASS" "$ACTOR" "$RISK_LEVEL" \
"$RISK_FACTORS_JSON" "$EVIDENCE_REQUIREMENT" "$DECISION" "$APPROVAL_STATUS" \
"$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH" "$RECORD_HASH" <<'PY'
import json, os, sys
(trace_file, audit_log, ts, trace_id, action, actor, risk, decision,
approval_status, approver, input_hash, output_hash, prev_hash, record_hash) = sys.argv[1:]
(trace_file, audit_log, ts, trace_id, action, action_class, actor, risk,
risk_factors_json, evidence_requirement, decision, approval_status, approver,
input_hash, output_hash, prev_hash, record_hash) = sys.argv[1:]
try:
reasons = json.loads(os.environ.get("CASAN_GC_REASONS") or "[]")
except ValueError:
reasons = []
rec = {
"schema_version": 2, "category": "runtime_control",
"timestamp": ts, "trace_id": trace_id, "harness": "H5-governance",
"action": action, "actor": actor, "risk_level": risk, "decision": decision,
"action": action, "action_class": action_class, "actor": actor,
"risk_level": risk, "effective_risk": risk,
"risk_factors": json.loads(risk_factors_json),
"evidence_requirement": evidence_requirement, "decision": decision,
"approval_status": approval_status, "approver": approver,
"input_hash": input_hash, "output_hash": output_hash,
"previous_record_hash": prev_hash, "record_hash": record_hash,
@@ -196,8 +229,43 @@ fi
# --- External anchor: cryptographically sign the new chain head ---
# A re-forged chain (recomputed hashes) changes the head; without the private
# key the attacker cannot produce a matching signature, so verification fails.
# Production note: the private key must live off-repo (KMS/HSM). It is local
# here only for self-contained demonstration.
# Development may use a local key for self-contained demonstration. Production
# refuses that path unless an explicit emergency override is visible in evidence.
PRODUCTION_PROFILE=0
[[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ]] && PRODUCTION_PROFILE=1
EMERGENCY_TRUST_OVERRIDE="${CASAN_TRUST_EMERGENCY_OVERRIDE:-0}"
TRUST_LOG="$CASAN_STATE_ROOT/logs/readiness/trust-capabilities.jsonl"
mkdir -p "$(dirname "$TRUST_LOG")"
if [[ "$PRODUCTION_PROFILE" == "1" && "$EMERGENCY_TRUST_OVERRIDE" != "1" ]]; then
TRUST_RC=0
TRUST_JSON="$(python3 "$KERNEL_CLI" trust-capabilities 2>/dev/null)" || TRUST_RC=$?
if [[ "$TRUST_RC" -ne 0 ]]; then
if [[ -n "$TRUST_JSON" ]]; then
printf '%s\n' "$TRUST_JSON" >> "$TRUST_LOG"
else
printf '{"ready":false,"severity":"critical","reason_codes":["production_trust_configuration_invalid"]}\n' >> "$TRUST_LOG"
fi
: > "$OUTPUT_FILE"
echo "GOVERNANCE_DENIED trace_id=$TRACE_ID reason=production_trust_root_unavailable" >&2
exit 2
fi
printf '%s\n' "$TRUST_JSON" >> "$TRUST_LOG"
if ! bash "$SCRIPT_DIR/sign-audit-head.sh" "$AUDIT_LOG" >/dev/null 2>&1; then
: > "$OUTPUT_FILE"
echo "GOVERNANCE_DENIED trace_id=$TRACE_ID reason=external_signing_failed" >&2
exit 2
fi
if ! bash "$SCRIPT_DIR/audit-ship-s3.sh" "$AUDIT_DIR/audit-head.txt" >/dev/null 2>&1; then
: > "$OUTPUT_FILE"
echo "GOVERNANCE_DENIED trace_id=$TRACE_ID reason=external_immutable_anchor_failed" >&2
exit 2
fi
else
if [[ "$PRODUCTION_PROFILE" == "1" ]]; then
printf '{"schema_version":"1.0.0","profile":"production","ready":false,"certifiable":false,"emergency_override":true,"severity":"critical","reason_codes":["emergency_local_trust_override_active"]}\n' >> "$TRUST_LOG"
echo "CRITICAL: emergency local trust override active; execution cannot be production-certified" >&2
fi
if command -v openssl >/dev/null 2>&1; then
# Private signing key lives OFF-REPO (default ~/.casan/audit-keys); only the
# public key is committed. Production: replace with KMS/HSM.
@@ -207,7 +275,7 @@ if command -v openssl >/dev/null 2>&1; then
AUDIT_PUB="$PUB_DIR/audit-public.pem"
mkdir -p "$PUB_DIR" "$PRIV_DIR"
if [[ ! -f "$AUDIT_PRIV" ]]; then
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_VERIFY_STRICT:-}" == "1" ]]; then
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" || "${CASAN_VERIFY_STRICT:-}" == "1" ]]; then
# SEC-02 (H-02): in enforced mode NEVER auto-generate a local signing key.
# A freshly-minted key next to the data lets any file-writer re-sign a forged
# head. Prod must provision the key out-of-band (KMS/HSM — see sign-audit-head.sh
@@ -229,6 +297,7 @@ if command -v openssl >/dev/null 2>&1; then
openssl dgst -sha256 -sign "$AUDIT_PRIV" -out "$AUDIT_DIR/audit-head.sig" "$AUDIT_DIR/audit-head.txt" 2>/dev/null || true
fi
fi
fi
if [[ "$DECISION" != "approved" ]]; then
: > "$OUTPUT_FILE"
@@ -21,22 +21,22 @@ while IFS= read -r raw || [[ -n "$raw" ]]; do
[[ "$line" =~ ^([A-Z0-9_]+)=(.*)$ ]] || fail "invalid_env_syntax"
key="${BASH_REMATCH[1]}"; value="${BASH_REMATCH[2]}"
case "$key" in
CASAN_PUBLIC_FQDN|CASAN_CP_HTTPS_PORT|CASAN_CP_TLS_DIR|CASAN_CP_OAUTH_ENV|CASAN_CP_RUNTIME_ENV|CASAN_CP_VAULT_ENV|CASAN_CP_STATE_DIR|CASAN_CP_OUTPUT_DIR|CASAN_CP_API_IMAGE|CASAN_CP_UI_IMAGE|CASAN_S3_BUCKET|CASAN_S3_PREFIX|CASAN_S3_REGION|CASAN_S3_RETENTION_DAYS|CASAN_S3_KMS_KEY_ID) export "$key=$value" ;;
CASAN_PUBLIC_FQDN|CASAN_CP_HTTPS_PORT|CASAN_CP_TLS_DIR|CASAN_CP_OAUTH_ENV|CASAN_CP_RUNTIME_ENV|CASAN_CP_VAULT_ENV|CASAN_CP_STATE_DIR|CASAN_CP_OUTPUT_DIR|CASAN_CP_IDP_PUBLIC_KEY|CASAN_CP_API_IMAGE|CASAN_CP_UI_IMAGE|CASAN_CP_OAUTH2_PROXY_IMAGE|CASAN_S3_BUCKET|CASAN_S3_PREFIX|CASAN_S3_REGION|CASAN_S3_RETENTION_DAYS|CASAN_S3_KMS_KEY_ID) export "$key=$value" ;;
*) fail "unexpected_env_key key=$key" ;;
esac
done < "$ENV_FILE"
required=(CASAN_PUBLIC_FQDN CASAN_CP_TLS_DIR CASAN_CP_OAUTH_ENV CASAN_CP_RUNTIME_ENV CASAN_CP_VAULT_ENV CASAN_CP_STATE_DIR CASAN_CP_OUTPUT_DIR CASAN_CP_API_IMAGE CASAN_CP_UI_IMAGE CASAN_S3_BUCKET CASAN_S3_REGION CASAN_S3_KMS_KEY_ID)
required=(CASAN_PUBLIC_FQDN CASAN_CP_TLS_DIR CASAN_CP_OAUTH_ENV CASAN_CP_RUNTIME_ENV CASAN_CP_VAULT_ENV CASAN_CP_STATE_DIR CASAN_CP_OUTPUT_DIR CASAN_CP_IDP_PUBLIC_KEY CASAN_CP_API_IMAGE CASAN_CP_UI_IMAGE CASAN_CP_OAUTH2_PROXY_IMAGE CASAN_S3_BUCKET CASAN_S3_REGION CASAN_S3_KMS_KEY_ID)
for key in "${required[@]}"; do [[ -n "${!key:-}" ]] || fail "missing_env key=$key"; done
case "$CASAN_PUBLIC_FQDN" in *localhost*|*127.0.0.1*|*example.com*|*replace-with*|*/*|[0-9]* ) fail "invalid_fqdn";; esac
[[ "$CASAN_PUBLIC_FQDN" == *.* ]] || fail "fqdn_required"
for image in "$CASAN_CP_API_IMAGE" "$CASAN_CP_UI_IMAGE"; do
for image in "$CASAN_CP_API_IMAGE" "$CASAN_CP_UI_IMAGE" "$CASAN_CP_OAUTH2_PROXY_IMAGE"; do
[[ "$image" =~ @sha256:[a-f0-9]{64}$ ]] || fail "image_must_be_digest_pinned image=$image"
done
pass "public FQDN and images are production-safe"
for file in "$CASAN_CP_TLS_DIR/tls.crt" "$CASAN_CP_TLS_DIR/tls.key" "$CASAN_CP_OAUTH_ENV" "$CASAN_CP_RUNTIME_ENV" "$CASAN_CP_VAULT_ENV"; do
for file in "$CASAN_CP_TLS_DIR/tls.crt" "$CASAN_CP_TLS_DIR/tls.key" "$CASAN_CP_IDP_PUBLIC_KEY" "$CASAN_CP_OAUTH_ENV" "$CASAN_CP_RUNTIME_ENV" "$CASAN_CP_VAULT_ENV"; do
[[ -s "$file" ]] || fail "missing_or_empty path=$file"
done
openssl x509 -in "$CASAN_CP_TLS_DIR/tls.crt" -noout >/dev/null || fail "invalid_tls_certificate"
@@ -45,6 +45,7 @@ openssl x509 -in "$CASAN_CP_TLS_DIR/tls.crt" -noout -checkhost "$CASAN_PUBLIC_FQ
cert_pub="$(openssl x509 -in "$CASAN_CP_TLS_DIR/tls.crt" -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha256 | awk '{print $NF}')"
key_pub="$(openssl pkey -in "$CASAN_CP_TLS_DIR/tls.key" -pubout -outform DER | openssl dgst -sha256 | awk '{print $NF}')"
[[ "$cert_pub" == "$key_pub" ]] || fail "tls_key_does_not_match_certificate"
openssl rsa -pubin -in "$CASAN_CP_IDP_PUBLIC_KEY" -noout -modulus >/dev/null 2>&1 || fail "invalid_idp_rsa_public_key"
pass "TLS certificate is valid for at least 30 days"
value_of() { sed -n -E "s/^${1}=//p" "$CASAN_CP_OAUTH_ENV" | tail -1; }
@@ -56,8 +57,24 @@ done
[[ "$(value_of OAUTH2_PROXY_OIDC_ISSUER_URL)" == https://* ]] || fail "oidc_issuer_https_required"
[[ "$(value_of OAUTH2_PROXY_REDIRECT_URL)" == "https://$CASAN_PUBLIC_FQDN/oauth2/callback" ]] || fail "oidc_redirect_mismatch"
[[ "$(value_of OAUTH2_PROXY_COOKIE_SECURE)" == true ]] || fail "oidc_secure_cookie_required"
[[ "$(value_of OAUTH2_PROXY_SET_XAUTHREQUEST)" == true ]] || fail "oidc_xauthrequest_required"
[[ "$(value_of OAUTH2_PROXY_PASS_ACCESS_TOKEN)" == true ]] || fail "oidc_access_token_forwarding_required"
[[ "$(value_of OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER)" == true ]] || fail "oidc_authorization_header_forwarding_required"
pass "enterprise OIDC configuration"
runtime_value_of() { sed -n -E "s/^${1}=//p" "$CASAN_CP_RUNTIME_ENV" | tail -1; }
[[ "$(runtime_value_of CASAN_PROFILE)" == prod ]] || fail "runtime_profile_must_be_prod"
[[ "$(runtime_value_of CASAN_CP_AUTH_MODE)" == jwt ]] || fail "runtime_jwt_auth_required"
[[ "$(runtime_value_of CASAN_CP_JWT_ISSUER)" == "$(value_of OAUTH2_PROXY_OIDC_ISSUER_URL)" ]] || fail "runtime_oidc_issuer_mismatch"
[[ "$(runtime_value_of CASAN_CP_JWT_AUDIENCE)" == "$(value_of OAUTH2_PROXY_CLIENT_ID)" ]] || fail "runtime_oidc_audience_mismatch"
[[ "$(runtime_value_of CASAN_CP_JWT_PUBLIC_KEY_FILE)" == /run/casan-idp/idp-public.pem ]] || fail "runtime_idp_public_key_path_invalid"
[[ "$(runtime_value_of CASAN_SIGNING_PROVIDER)" == vault_kms ]] || fail "runtime_external_signing_required"
[[ "$(runtime_value_of CASAN_IMMUTABLE_ANCHOR_PROVIDER)" == s3_object_lock ]] || fail "runtime_immutable_anchor_required"
[[ -z "$(runtime_value_of CASAN_CP_TRUST_AUTH_PROXY)" ]] || fail "legacy_trusted_header_auth_forbidden"
clock_skew="$(runtime_value_of CASAN_CP_JWT_CLOCK_SKEW_SECONDS)"
[[ "$clock_skew" =~ ^[0-9]+$ && "$clock_skew" -le 300 ]] || fail "runtime_jwt_clock_skew_invalid"
pass "Control Plane verifies OIDC token identity cryptographically"
vault_addr="$(sed -n -E 's/^VAULT_ADDR=//p' "$CASAN_CP_VAULT_ENV" | tail -1)"
vault_token="$(sed -n -E 's/^VAULT_TOKEN=//p' "$CASAN_CP_VAULT_ENV" | tail -1)"
vault_cacert="$(sed -n -E 's/^VAULT_CACERT=//p' "$CASAN_CP_VAULT_ENV" | tail -1)"
@@ -88,7 +105,8 @@ COMPOSE="$ROOT/docker-compose.control-panel.yml"
CASAN_CP_TLS_DIR="$CASAN_CP_TLS_DIR" CASAN_CP_OAUTH_ENV="$CASAN_CP_OAUTH_ENV" \
CASAN_CP_RUNTIME_ENV="$CASAN_CP_RUNTIME_ENV" CASAN_CP_VAULT_ENV="$CASAN_CP_VAULT_ENV" \
CASAN_CP_STATE_DIR="$CASAN_CP_STATE_DIR" CASAN_CP_OUTPUT_DIR="$CASAN_CP_OUTPUT_DIR" \
CASAN_CP_API_IMAGE="$CASAN_CP_API_IMAGE" CASAN_CP_UI_IMAGE="$CASAN_CP_UI_IMAGE" \
CASAN_CP_IDP_PUBLIC_KEY="$CASAN_CP_IDP_PUBLIC_KEY" CASAN_CP_API_IMAGE="$CASAN_CP_API_IMAGE" \
CASAN_CP_UI_IMAGE="$CASAN_CP_UI_IMAGE" CASAN_CP_OAUTH2_PROXY_IMAGE="$CASAN_CP_OAUTH2_PROXY_IMAGE" \
docker compose -f "$COMPOSE" config >/dev/null || fail "compose_config_invalid"
pass "production compose config"
@@ -47,10 +47,15 @@ docker info >/dev/null 2>&1 || { echo "SANDBOX_CONTAINER_DOCKER_DOWN" >&2; exit
# rootful Docker daemon because a compromised daemon socket defeats container
# isolation. Local developer/test profiles may use a rootful daemon, but cannot
# claim that configuration as a hardened production runner.
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_SANDBOX_REQUIRE_ROOTLESS:-0}" == "1" ]]; then
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" \
|| "${CASAN_SANDBOX_REQUIRE_ROOTLESS:-0}" == "1" ]]; then
docker info --format '{{json .SecurityOptions}}' 2>/dev/null | grep -q 'rootless' \
|| { echo "SANDBOX_CONTAINER_ROOTLESS_REQUIRED" >&2; exit 2; }
fi
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ]]; then
[[ "$IMAGE" =~ @sha256:[a-f0-9]{64}$ ]] \
|| { echo "SANDBOX_CONTAINER_IMAGE_DIGEST_REQUIRED image=$IMAGE" >&2; exit 2; }
fi
WS_ABS="$(cd "$WORKSPACE" 2>/dev/null && pwd)" || { echo "SANDBOX_CONTAINER_BAD_WORKSPACE" >&2; exit 2; }
@@ -36,6 +36,37 @@ CPU_SECONDS="${CASAN_SANDBOX_CPU_SECONDS:-30}"
# check is the real gate; a container --pids-limit is the production backstop.
MAX_PROCS="${CASAN_SANDBOX_MAX_PROCS:-}"
TIMEOUT="${CASAN_SANDBOX_TIMEOUT:-30}"
SANDBOX_MODE="${CASAN_SANDBOX_MODE:-}"
STRICT_SANDBOX=0
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" \
|| "${CASAN_ENFORCEMENT_MODE:-}" == "enforce" || "${CASAN_SANDBOX_STRICT:-0}" == "1" ]]; then
STRICT_SANDBOX=1
fi
[[ -n "$SANDBOX_MODE" ]] || { if [[ "$STRICT_SANDBOX" == "1" ]]; then SANDBOX_MODE="container"; else SANDBOX_MODE="static"; fi; }
record_sandbox() { # decision reason backend capability-json
local decision="$1" reason="$2" backend="$3" capabilities="$4"
local log="$CASAN_STATE_ROOT/logs/sandbox/decisions.jsonl"
mkdir -p "$(dirname "$log")"
CASAN_SANDBOX_CAPABILITIES="$capabilities" python3 - "$log" "$decision" "$reason" "$backend" "${CASAN_EXECUTION_ID:-sandbox-$$}" <<'PY'
import json, os, sys
path, decision, reason, backend, execution_id = sys.argv[1:]
try:
capabilities = json.loads(os.environ.get("CASAN_SANDBOX_CAPABILITIES", "{}"))
except ValueError:
capabilities = {}
record = {
"schema_version": "1.0.0", "category": "runtime_control",
"policy_id": "casan.sandbox.backend", "decision": decision,
"reason_code": reason, "backend": backend, "execution_id": execution_id,
"capabilities": capabilities,
}
with open(path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n")
handle.flush()
os.fsync(handle.fileno())
PY
}
while [[ "$#" -gt 0 ]]; do
case "$1" in
@@ -54,12 +85,35 @@ if [[ "$#" -eq 0 ]]; then
exit 64
fi
# C6 production form: CASAN_SANDBOX_MODE=container runs under TRUE kernel
# C6 production form: CASAN_SANDBOX_MODE=container runs under kernel-backed
# isolation (sandbox-container.sh: --network=none --read-only --pids-limit …).
# Default stays the static-policy + ulimit scaffold so existing behaviour is
# unchanged. Falls back to the scaffold if Docker is unavailable.
if [[ "${CASAN_SANDBOX_MODE:-static}" == "container" ]] && command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
# A requested/required container backend never silently falls back.
if [[ "$SANDBOX_MODE" == "container" ]]; then
if [[ "${CASAN_SANDBOX_TEST_FORCE_UNAVAILABLE:-0}" != "1" ]] \
&& command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
record_sandbox "allow" "sandbox_container_selected" "docker" '{"network_disabled":true,"read_only_root":true,"workspace_write_restricted":true,"environment_filtered":true,"non_root":true,"resource_limits":true}'
exec "$SCRIPT_DIR/sandbox-container.sh" --workspace "$WORKSPACE" --timeout "$TIMEOUT" -- "$@"
fi
if [[ "$STRICT_SANDBOX" == "1" ]]; then
record_sandbox "deny" "sandbox_isolation_backend_unavailable" "none" '{"timeout_only":false}'
echo "SANDBOX_ISOLATION_REQUIRED backend=container reason=unavailable" >&2
exit 2
fi
if [[ "${CASAN_SANDBOX_ALLOW_STATIC_FALLBACK:-0}" != "1" ]]; then
record_sandbox "deny" "sandbox_fallback_not_approved" "none" '{}'
echo "SANDBOX_FALLBACK_REQUIRES_EXPLICIT_DEVELOPMENT_APPROVAL" >&2
exit 2
fi
record_sandbox "observe_only" "sandbox_static_fallback_development_only" "static_rlimit" '{"network_disabled":false,"read_only_root":false,"workspace_write_restricted":false,"environment_filtered":false,"non_root":false,"resource_limits":true}'
echo "HIGH: container sandbox unavailable; explicit development static fallback is not production isolation" >&2
elif [[ "$SANDBOX_MODE" != "static" ]]; then
record_sandbox "deny" "sandbox_backend_unknown" "$SANDBOX_MODE" '{}'
echo "SANDBOX_BACKEND_UNKNOWN mode=$SANDBOX_MODE" >&2
exit 2
elif [[ "$STRICT_SANDBOX" == "1" ]]; then
record_sandbox "deny" "sandbox_static_forbidden_in_enforce_mode" "static_rlimit" '{"network_disabled":false,"read_only_root":false}'
echo "SANDBOX_STATIC_FORBIDDEN_IN_ENFORCE_MODE" >&2
exit 2
fi
CMD_STR="$*"
@@ -96,6 +150,7 @@ done < <(printf '%s\n' "$CMD_STR" | grep -oE '>>?[[:space:]]*[^[:space:];|&]+' |
# ── 2. Runtime rlimits + wall-clock timeout ─────────────────────────────────
casan_log debug sandbox "SANDBOX_RUN workspace=$WS_ABS file_kb=$MAX_FILE_KB cpu=$CPU_SECONDS procs=$MAX_PROCS timeout=$TIMEOUT"
record_sandbox "allow" "sandbox_static_policy_selected" "static_rlimit" '{"network_disabled":false,"read_only_root":false,"workspace_write_restricted":false,"environment_filtered":false,"non_root":false,"resource_limits":true}'
(
ulimit -f "$((MAX_FILE_KB * 2))" 2>/dev/null || true # ulimit -f is in 512-byte blocks
ulimit -t "$CPU_SECONDS" 2>/dev/null || true
@@ -45,13 +45,23 @@ with open(path, encoding="utf-8") as f:
if not line.strip():
continue
record = json.loads(line)
if int(record.get("schema_version", 1)) >= 2:
core = "|".join([
record.get("timestamp",""), record.get("trace_id",""),
record.get("action",""), record.get("action_class",""),
record.get("actor",""), record.get("risk_level",""),
json.dumps(record.get("risk_factors", {}), sort_keys=True, separators=(",", ":")),
record.get("evidence_requirement",""), record.get("decision",""),
record.get("approval_status",""), record.get("approver",""),
record.get("input_hash",""), record.get("output_hash",""), previous,
])
else:
core = "|".join([
record.get("timestamp",""), record.get("trace_id",""),
record.get("action",""), record.get("actor",""),
record.get("risk_level",""), record.get("decision",""),
record.get("approval_status",""), record.get("approver",""),
record.get("input_hash",""), record.get("output_hash",""),
previous,
record.get("input_hash",""), record.get("output_hash",""), previous,
])
previous = hashlib.sha256(core.encode()).hexdigest()
print(previous)
@@ -68,6 +78,10 @@ printf '%s' "$HEAD_HASH" > "$HEAD_FILE"
# ── Sign the head file ────────────────────────────────────────────────────
VAULT_KMS="$SCRIPT_DIR/vault-kms.sh"
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ]]; then
[[ "${VAULT_ADDR:-}" == https://* ]] || { echo "SIGN_AUDIT_HEAD_FAIL reason=vault_https_required_in_prod" >&2; exit 1; }
fi
if [[ -n "${VAULT_ADDR:-}" && -n "${VAULT_TOKEN:-}" ]] && \
curl -sf "$VAULT_ADDR/v1/sys/health" >/dev/null 2>&1; then
# KMS path — sign via Vault Transit, export public key
@@ -103,7 +117,7 @@ PY
fi
else
# Fallback — local key (dev environment without Vault)
if [[ "${CASAN_PROFILE:-}" == "prod" ]]; then
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ]]; then
echo "SIGN_AUDIT_HEAD_FAIL reason=vault_kms_required_in_prod" >&2
exit 1
fi
@@ -35,18 +35,25 @@ with open(path, encoding="utf-8") as f:
f"AUDIT_CHAIN_BROKEN line={line_no} expected_previous={previous} actual_previous={expected_previous}"
)
if int(record.get("schema_version", 1)) >= 2:
core = "|".join([
record.get("timestamp", ""), record.get("trace_id", ""),
record.get("action", ""), record.get("action_class", ""),
record.get("actor", ""), record.get("risk_level", ""),
json.dumps(record.get("risk_factors", {}), sort_keys=True, separators=(",", ":")),
record.get("evidence_requirement", ""), record.get("decision", ""),
record.get("approval_status", ""), record.get("approver", ""),
record.get("input_hash", ""), record.get("output_hash", ""),
expected_previous,
])
else:
core = "|".join(
[
record.get("timestamp", ""),
record.get("trace_id", ""),
record.get("action", ""),
record.get("actor", ""),
record.get("risk_level", ""),
record.get("decision", ""),
record.get("approval_status", ""),
record.get("approver", ""),
record.get("input_hash", ""),
record.get("output_hash", ""),
record.get("timestamp", ""), record.get("trace_id", ""),
record.get("action", ""), record.get("actor", ""),
record.get("risk_level", ""), record.get("decision", ""),
record.get("approval_status", ""), record.get("approver", ""),
record.get("input_hash", ""), record.get("output_hash", ""),
expected_previous,
]
)
@@ -40,8 +40,23 @@ import sys
import time
import uuid
SCHEMA_VERSION = "20.1"
ADAPTER_DEFAULT_VERSION = "20.1.0"
HARNESS_PACKAGE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
if HARNESS_PACKAGE_ROOT not in sys.path:
sys.path.insert(0, HARNESS_PACKAGE_ROOT)
from kernel.adapters import AgenticBridgeAdapter
from kernel.contracts import validate_bundle
from kernel.policy import (
H2GateContext,
evaluate_failure_policy,
evaluate_h2_gate,
evaluate_registry_configuration,
evaluate_risk,
evaluate_trust_capabilities,
)
SCHEMA_VERSION = "20.2"
ADAPTER_DEFAULT_VERSION = "20.2.0"
# ── Certification strength ranking (higher == stronger) ──────────────────────
STRENGTH_RANK = {
@@ -77,6 +92,20 @@ TOOL_REGISTRY_ACTION = {
"str_replace_editor": "write_file",
}
REGISTRY_ACTION_BY_CLASS = {
"write": "write_file",
"delete": "delete_file",
"database_mutation": "db_write",
"migration": "migration",
"deployment": "deploy",
"release": "deploy",
"credential_access": "credential_access",
"identity_permission_modification": "identity_change",
"external_network_side_effect": "external_api",
"infrastructure_modification": "deploy",
"unknown": "unknown_tool",
}
# ─────────────────────────────────────────────────────────────────────────────
# Path resolution (mirrors scripts/bash/casan-paths.sh so state lands in the
@@ -145,6 +174,12 @@ def trace_event_dir():
return d
def kernel_trace_dir():
d = os.path.join(state_root(), "logs", "kernel")
os.makedirs(d, exist_ok=True)
return d
def metrics_log():
override = os.environ.get("CASAN_TELEMETRY_METRICS_LOG") or os.environ.get(
"CASAN_METRICS_LOG"
@@ -612,28 +647,52 @@ def h4_scan(text, mode="input"):
pass
def h2_registry_gate(action, idempotency_key):
"""H2 tool-registry gate for a mapped side-effect action."""
script = os.path.join(gates_dir(), "tool-registry-gate.sh")
if not os.path.exists(script) or not bash_available():
return True, "h2_gate_missing"
env = dict(os.environ)
env["CASAN_IDEMPOTENCY_KEY"] = idempotency_key
try:
proc = subprocess.run(
[bash_bin(), script, action],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=internal_timeout(),
env=env,
def h2_registry_gate(action, tool, actor, execution_id, idempotency_key):
"""Return a structured H2 policy decision for every dependency outcome."""
script = os.environ.get("CASAN_H2_GATE_PATH") or os.path.join(
gates_dir(), "tool-registry-gate.sh")
context = H2GateContext(
mode=enforcement_mode(),
actor=actor or "",
action=action,
tool=tool or action,
execution_id=execution_id,
enforcement_path="agentic_bridge.pre_tool.h2_registry",
idempotency_key=idempotency_key,
timeout_seconds=internal_timeout(),
)
if proc.returncode == 0:
return True, "h2_ok"
return False, "h2_denied"
except subprocess.TimeoutExpired:
return False, "h2_internal_timeout"
except (OSError, ValueError) as exc:
return False, "h2_error:%s" % exc
return evaluate_h2_gate(
script,
context,
env=os.environ,
bash_path=bash_bin(),
)
def h5_governance_gate(action, content):
"""Invoke the existing H5 gate for action-floor approval decisions."""
script = os.path.join(gates_dir(), "governance-check.sh")
if not os.path.isfile(script) or not bash_available():
return {"approved": False, "reason_code": "h5_gate_unavailable", "detail": "governance gate unavailable"}
tmpin = os.path.join(sessions_dir(), ".h5-in-%s" % uuid.uuid4().hex[:8])
tmpout = os.path.join(sessions_dir(), ".h5-out-%s" % uuid.uuid4().hex[:8])
try:
with open(tmpin, "w", encoding="utf-8") as handle:
handle.write(content or "")
rc, stdout, stderr = _run_gate([bash_bin(), script, tmpin, tmpout, action])
if rc == 0 and "GOVERNANCE_APPROVED" in stdout:
return {"approved": True, "reason_code": "h5_approval_verified", "detail": stdout.strip()}
if rc == 124:
return {"approved": False, "reason_code": "h5_gate_timeout", "detail": "governance gate timed out"}
if rc == 125:
return {"approved": False, "reason_code": "h5_gate_internal_error", "detail": stderr}
return {"approved": False, "reason_code": "h5_approval_required", "detail": stderr or stdout}
finally:
for path in (tmpin, tmpout):
try:
os.unlink(path)
except OSError:
pass
# ─────────────────────────────────────────────────────────────────────────────
@@ -692,6 +751,8 @@ def write_trace_events(rec, evidence):
"kind": evidence.get("kind"),
"decision": evidence.get("decision"),
"detail": evidence.get("detail"),
"category": evidence.get("category"),
"facts": evidence.get("facts", {}),
"certification_strength": rec.get(
"certification_strength"),
},
@@ -700,13 +761,16 @@ def write_trace_events(rec, evidence):
event, ensure_ascii=False, separators=(",", ":")) + "\n")
def add_evidence(rec, h, kind, decision, detail):
def add_evidence(rec, h, kind, decision, detail, facts=None, category="runtime_control"):
evidence = {
"evidence_id": "%s:%s" % (rec.get("trace_id", "trace"), len(rec.get("evidence", [])) + 1),
"h": h,
"category": category,
"kind": kind,
"decision": decision,
"at": now_iso(),
"detail": redact(detail, 160),
"facts": facts if isinstance(facts, dict) else {},
}
rec.setdefault("evidence", []).append(evidence)
write_trace_events(rec, evidence)
@@ -828,6 +892,27 @@ def write_h6_record(rec, status, quality, warnings, missing):
return record
def write_kernel_bundle(rec):
"""Dual-emit the canonical contract without changing legacy trace readers."""
event = dict(rec)
event.update({
"mode": enforcement_mode(),
"execution_status": rec.get("execution_status", "unknown"),
"assurance_status": rec.get("assurance_status", "unknown"),
"environment": os.environ.get("CASAN_PROFILE", "development"),
"completed_at": rec.get("finalized_at") or now_iso(),
"policy_decisions": rec.get("policy_decisions", []),
})
bundle = AgenticBridgeAdapter().map_execution(event)
errors = validate_bundle(bundle)
if errors:
raise ValueError("kernel_contract_invalid:%s" % ",".join(errors))
path = os.path.join(kernel_trace_dir(), "%s.json" % rec.get("trace_id"))
atomic_write_json(path, bundle)
rec["kernel_contract_ref"] = path
return path
def write_trace_file(rec, certified, reasons):
path = os.path.join(trace_dir(), "agentic-%s.json" % rec.get("trace_id"))
payload = {
@@ -851,6 +936,9 @@ def write_trace_file(rec, certified, reasons):
"failures": rec.get("failures", 0),
"certified": certified,
"certification_reasons": reasons,
"execution_outcome": rec.get("execution_status"),
"assurance_outcome": rec.get("assurance_status"),
"kernel_contract_ref": rec.get("kernel_contract_ref"),
"evidence": rec.get("evidence", []),
}
atomic_write_json(path, payload)
@@ -886,6 +974,8 @@ def op_begin(req):
"client": client,
"client_version": req.get("client_version"),
"adapter_version": req.get("adapter_version") or ADAPTER_DEFAULT_VERSION,
"actor": req.get("actor") or os.environ.get("CASAN_ACTOR") or os.environ.get("CASAN_AGENT") or "",
"mode": enforcement_mode(),
"project_root": project,
"project_id": project_id(project),
"session_id_hash": salted_hash(req.get("session")),
@@ -900,8 +990,10 @@ def op_begin(req):
"state": "Admitted",
"tool_calls": 0,
"failures": 0,
"side_effect_failures": 0,
"retries": 0,
"evidence": [],
"policy_decisions": [],
"telemetry": {},
"bypass_signal": False,
"finalized": False,
@@ -1012,30 +1104,109 @@ def op_pre_tool(req):
# record the degradation but do not block the developer.
add_evidence(rec, "H4", "pre-tool", "degraded", "%s:gate_unavailable_no_bash" % tool)
# H2 registry gate for mapped side-effect actions. Opt-in via
# CASAN_AGENTIC_H2_REGISTRY=1: the tool-registry is keyed on NAMED CASAN
# agent identities, which the transparent developer flow does not carry, so
# enabling it unconditionally would deny every write. The always-on H2
# equivalent for this flow is the admission gate above (a side effect
# without a valid admission is denied). Managed deployments that define
# agent identities can turn the registry gate on for defence in depth.
registry_on = os.environ.get("CASAN_AGENTIC_H2_REGISTRY", "0") in ("1", "true", "yes")
if side_effect and registry_on and enforcement_mode() == "enforce":
action = TOOL_REGISTRY_ACTION.get((tool or "").strip().lower())
if action:
key = plain_hash("%s|%s|%s" % (rec["trace_id"], tool, redact(tool_input_text)))[:24]
ok, reason = h2_registry_gate(action, key)
if not ok:
add_evidence(rec, "H2", "pre-tool", "deny", "%s:%s" % (tool, reason))
tool_input_string = tool_input_text if isinstance(tool_input_text, str) else json.dumps(
tool_input_text, ensure_ascii=False, sort_keys=True) if tool_input_text is not None else ""
# Native hook payloads commonly wrap a shell command in {"command": ...}.
# Classify the actual command while retaining the complete serialized input
# for scanning, governance evidence and hashing.
risk_command = tool_input_string
if isinstance(tool_input_text, dict) and isinstance(tool_input_text.get("command"), str):
risk_command = tool_input_text["command"]
risk = evaluate_risk(
action=str(req.get("action") or ""),
tool=tool,
resource=str(req.get("resource") or ""),
command=risk_command,
content=tool_input_string,
actor=rec.get("actor", ""),
environment=os.environ.get("CASAN_PROFILE", "development"),
)
# The canonical classifier can prove a shell command read-only; unknown
# shell commands remain side-effecting/high-risk by construction.
side_effect = bool(risk["side_effecting"])
action = REGISTRY_ACTION_BY_CLASS.get(risk["action_class"], TOOL_REGISTRY_ACTION.get((tool or "").strip().lower(), "unknown_tool"))
rec["last_tool"] = tool
rec["last_action"] = action
rec["last_risk"] = risk
rec.setdefault("policy_decisions", []).append(risk)
add_evidence(
rec, "H5", "action-risk", risk["decision"],
"class=%s effective=%s" % (risk["action_class"], risk["effective_risk"]),
facts=risk,
)
if side_effect and risk["decision"] == "deny":
reason = risk["reason_codes"][-1]
add_evidence(rec, "H5", "pre-tool", "deny", reason, facts=risk)
save_admission(rec)
return _base_response("pre-tool", "deny", admission_id=admission_id,
trace_id=rec.get("trace_id"), reason=reason)
trace_id=rec.get("trace_id"), reason=reason,
policy_decision=risk)
if side_effect and risk["decision"] == "require_approval":
approval = h5_governance_gate(action, tool_input_string)
add_evidence(
rec, "H5", "approval", "allow" if approval["approved"] else "require_approval",
approval["reason_code"], facts={"risk": risk, "approval": approval},
)
if not approval["approved"]:
save_admission(rec)
return _base_response("pre-tool", "require_approval", admission_id=admission_id,
trace_id=rec.get("trace_id"), reason=approval["reason_code"],
policy_decision=risk)
registry = evaluate_registry_configuration(
enforcement_mode(),
os.environ.get("CASAN_PROFILE", "development"),
os.environ.get("CASAN_AGENTIC_H2_REGISTRY") if "CASAN_AGENTIC_H2_REGISTRY" in os.environ else None,
)
if side_effect and not registry["valid"]:
add_evidence(rec, "H2", "registry-configuration", "deny", registry["reason_code"], facts=registry)
save_admission(rec)
return _base_response("pre-tool", "deny", admission_id=admission_id,
trace_id=rec.get("trace_id"), reason=registry["reason_code"])
if side_effect and registry["unsafe_bypass"]:
rec["integration_mode"] = "observed_only"
rec["certification_strength"] = "observed_only"
add_evidence(rec, "H2", "registry-configuration", "degraded", registry["reason_code"], facts=registry)
if side_effect and registry["enabled"]:
key = plain_hash("%s|%s|%s" % (rec["trace_id"], tool, redact(tool_input_text)))[:24]
h2_decision = h2_registry_gate(action, tool, rec.get("actor", ""), rec["trace_id"], key)
rec.setdefault("policy_decisions", []).append(h2_decision)
add_evidence(
rec, "H2", "tool-registry", h2_decision["decision"],
h2_decision["reason_code"], facts=h2_decision,
)
if not h2_decision["execution_allowed"]:
save_admission(rec)
return _base_response("pre-tool", "deny", admission_id=admission_id,
trace_id=rec.get("trace_id"), reason=h2_decision["reason_code"],
policy_decision=h2_decision)
if not h2_decision["certifiable"]:
rec["integration_mode"] = "observed_only"
rec["certification_strength"] = "observed_only"
add_evidence(rec, "H2/H4", "pre-tool", "allow",
"tool=%s side_effect=%s" % (tool, side_effect))
"tool=%s side_effect=%s action=%s" % (tool, side_effect, action),
facts={"action_risk": risk, "registry": registry})
save_admission(rec)
return _base_response("pre-tool", "allow", admission_id=admission_id,
trace_id=rec.get("trace_id"), reason="allowed")
response = _base_response("pre-tool", "allow", admission_id=admission_id,
trace_id=rec.get("trace_id"), reason="allowed",
policy_decision=risk)
if registry["unsafe_bypass"]:
response["warnings"].append("HIGH: H2 registry bypass active in development/test; execution is not certifiable")
return response
def single_step_failure_policy(side_effect):
decision = evaluate_failure_policy(
side_effecting=side_effect,
mode=enforcement_mode(),
profile=os.environ.get("CASAN_PROFILE", "development"),
configured=os.environ.get("CASAN_SINGLE_STEP_FAILURE_POLICY"),
)
return decision["failure_policy"], decision["source"]
def op_post_tool(req):
@@ -1045,11 +1216,52 @@ def op_post_tool(req):
reason="no_admission")
rec["tool_calls"] = rec.get("tool_calls", 0) + 1
status = req.get("status") or "success"
if status in ("error", "timeout"):
risk = evaluate_risk(
action=str(rec.get("last_action") or ""),
tool=str(req.get("tool") or rec.get("last_tool") or ""),
command=str(req.get("command") or ""),
actor=rec.get("actor", ""),
environment=os.environ.get("CASAN_PROFILE", "development"),
)
side_effect = bool(risk["side_effecting"])
if status in ("error", "timeout", "denied", "failed"):
rec["failures"] = rec.get("failures", 0) + 1
if side_effect:
rec["side_effect_failures"] = rec.get("side_effect_failures", 0) + 1
policy, source = single_step_failure_policy(side_effect)
rec["failure_policy"] = policy
rec["execution_status"] = "failed"
rec["assurance_status"] = "failed"
add_evidence(
rec, "H6", "failure-policy", policy,
"tool=%s status=%s policy=%s" % (req.get("tool"), status, policy),
facts={
"command_executed": status not in ("denied",),
"command_outcome": status,
"telemetry_recorded": True,
"assurance_may_continue": policy == "record_only",
"failure_policy": policy,
"policy_source": source,
"side_effecting": side_effect,
},
)
add_evidence(rec, "H5", "post-tool", status,
"tool=%s dur_ms=%s result=%s" % (req.get("tool"), req.get("duration_ms"),
redact(req.get("result"), 80)))
redact(req.get("result"), 80)),
facts={"risk": risk, "failure_policy": policy})
save_admission(rec)
decision = "recorded" if policy == "record_only" else policy
return _base_response(
"post-tool", decision, admission_id=rec["admission_id"],
trace_id=rec.get("trace_id"), reason="tool_failed:%s" % policy,
execution_outcome="failed", assurance_outcome="failed",
assurance_may_continue=policy == "record_only",
)
rec["execution_status"] = rec.get("execution_status") or "success"
add_evidence(rec, "H5", "post-tool", status,
"tool=%s dur_ms=%s result=%s" % (req.get("tool"), req.get("duration_ms"),
redact(req.get("result"), 80)),
facts={"risk": risk, "command_outcome": status, "telemetry_recorded": True})
save_admission(rec)
return _base_response("post-tool", "recorded", admission_id=rec["admission_id"],
trace_id=rec.get("trace_id"), reason="evidence_appended")
@@ -1092,6 +1304,10 @@ def op_finalize(req):
rec["summary_hash"] = salted_hash(req.get("assistant_summary"))
stop_reason = req.get("stop_reason") or "completed"
status = "success" if stop_reason in ("completed", "max_turns") else "failed"
if rec.get("failures", 0) > 0:
# Telemetry success is not execution success. A failed tool remains a
# failed execution even if the client later emits Stop(completed).
status = "failed"
observed_harnesses = {
harness
@@ -1110,6 +1326,8 @@ def op_finalize(req):
# H3/H5/H7 finalize controls: run the H4 output filter over the assistant
# summary as the closing verification control.
reasons = []
if rec.get("failures", 0) > 0:
reasons.append("tool_execution_failed")
if req.get("assistant_summary"):
h4 = h4_scan(req.get("assistant_summary"), "output")
if h4 == "ok":
@@ -1158,7 +1376,17 @@ def op_finalize(req):
reasons.append("coverage_bypass")
if status != "success":
certified = False
reasons.append("stop_%s" % stop_reason)
failure_reason = "failed_tool_outcome" if rec.get("failures", 0) > 0 else "stop_%s" % stop_reason
if failure_reason not in reasons:
reasons.append(failure_reason)
trust = evaluate_trust_capabilities()
add_evidence(
rec, "H7", "trust-capability", "pass" if trust["certifiable"] else "degraded",
",".join(trust["reason_codes"]), facts=trust, category="readiness_check",
)
if trust["production"] and not trust["certifiable"]:
certified = False
reasons.extend(reason for reason in trust["reason_codes"] if reason not in reasons)
if not certified and not reasons:
reasons.append("unknown")
if certified:
@@ -1168,12 +1396,15 @@ def op_finalize(req):
rec["certified"] = certified
rec["finalized"] = True
rec["finalized_at"] = now_iso()
rec["execution_status"] = status
rec["assurance_status"] = "passed" if certified else "failed" if rec.get("failures", 0) else "degraded"
add_evidence(
rec,
"H7",
"certification",
"certified" if certified else "non_certified",
",".join(reasons),
category="certification_claim",
)
quality, warnings, missing = classify_telemetry(rec)
@@ -1185,13 +1416,26 @@ def op_finalize(req):
",".join(warnings) if warnings else "provider_usage_complete",
)
h6_record = write_h6_record(rec, status, quality, warnings, missing)
try:
write_kernel_bundle(rec)
except (OSError, ValueError, TypeError) as exc:
certified = False
rec["certified"] = False
rec["state"] = "NonCertified"
rec["assurance_status"] = "failed"
if "kernel_contract_emission_failed" not in reasons:
reasons.append("kernel_contract_emission_failed")
add_evidence(rec, "H7", "kernel-contract", "failed",
"kernel_contract_emission_failed:%s" % type(exc).__name__)
trace_path = write_trace_file(rec, certified, reasons)
save_admission(rec)
resp = _base_response("finalize", "certified" if certified else "non_certified",
admission_id=rec["admission_id"], trace_id=rec.get("trace_id"),
certification_strength=strength, telemetry_quality=quality,
reason=",".join(reasons))
reason=",".join(reasons), execution_outcome=status,
assurance_outcome=rec.get("assurance_status"),
certification_outcome="certified" if certified else "non_certified")
resp["warnings"].extend(warnings)
resp["context"] = "trace=%s certified=%s" % (os.path.basename(trace_path), certified)
report_url = dashboard_url(rec.get("trace_id"))
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""CLI boundary for shell/native runtimes to consume Assurance Kernel policy."""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
HARNESS_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(HARNESS_ROOT))
from kernel.adapters import NativeHarnessAdapter # noqa: E402
from kernel.contracts import validate_bundle # noqa: E402
from kernel.policy import ( # noqa: E402
H2GateContext,
evaluate_h2_gate,
evaluate_registry_configuration,
evaluate_risk,
evaluate_trust_capabilities,
)
def _print(payload: object) -> None:
print(json.dumps(payload, sort_keys=True, separators=(",", ":")))
def _read_content(path: str | None) -> str:
if not path:
return ""
with open(path, encoding="utf-8") as handle:
return handle.read()
def _append_jsonl(path: str, payload: object) -> None:
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
data = (json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
fd = os.open(str(target), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
try:
try:
import fcntl
fcntl.flock(fd, fcntl.LOCK_EX)
except (ImportError, OSError):
pass
os.write(fd, data)
os.fsync(fd)
finally:
os.close(fd)
def cmd_risk(args: argparse.Namespace) -> int:
payload = evaluate_risk(
action=args.action,
tool=args.tool,
resource=args.resource,
command=args.command,
content=_read_content(args.content_file),
actor=args.actor,
environment=args.environment,
)
_print(payload)
return 0
def cmd_registry(args: argparse.Namespace) -> int:
payload = evaluate_registry_configuration(args.mode, args.profile, args.explicit)
if args.evidence_log:
_append_jsonl(args.evidence_log, payload)
_print(payload)
return 0 if payload["valid"] else 2
def cmd_h2(args: argparse.Namespace) -> int:
context = H2GateContext(
mode=args.mode,
actor=args.actor,
action=args.action,
tool=args.tool,
execution_id=args.execution_id,
enforcement_path=args.enforcement_path,
idempotency_key=args.idempotency_key,
timeout_seconds=args.timeout,
)
payload = evaluate_h2_gate(args.gate, context, bash_path=args.bash)
if args.evidence_log:
_append_jsonl(args.evidence_log, payload)
_print(payload)
return 0 if payload["execution_allowed"] else 2
def cmd_trust(_args: argparse.Namespace) -> int:
payload = evaluate_trust_capabilities()
_print(payload)
return 0 if payload["ready"] else 2
def cmd_native(args: argparse.Namespace) -> int:
event = json.load(sys.stdin) if args.event_file == "-" else json.load(open(args.event_file, encoding="utf-8"))
payload = NativeHarnessAdapter().map_execution(event)
errors = validate_bundle(payload)
if errors:
_print({"valid": False, "errors": errors, "bundle": payload})
return 2
_print(payload)
return 0
def parser() -> argparse.ArgumentParser:
root = argparse.ArgumentParser()
sub = root.add_subparsers(dest="command", required=True)
risk = sub.add_parser("risk")
for name in ("action", "tool", "resource", "command", "actor", "environment"):
risk.add_argument(f"--{name}", default="")
risk.add_argument("--content-file")
risk.set_defaults(func=cmd_risk)
registry = sub.add_parser("registry-config")
registry.add_argument("--mode", default="observe")
registry.add_argument("--profile", default="development")
registry.add_argument("--explicit")
registry.add_argument("--evidence-log")
registry.set_defaults(func=cmd_registry)
h2 = sub.add_parser("h2-gate")
h2.add_argument("--gate", required=True)
h2.add_argument("--mode", choices=("observe", "enforce"), required=True)
h2.add_argument("--actor", default="")
h2.add_argument("--action", required=True)
h2.add_argument("--tool", required=True)
h2.add_argument("--execution-id", required=True)
h2.add_argument("--enforcement-path", required=True)
h2.add_argument("--idempotency-key", required=True)
h2.add_argument("--timeout", type=float, default=8.0)
h2.add_argument("--bash", default="bash")
h2.add_argument("--evidence-log")
h2.set_defaults(func=cmd_h2)
trust = sub.add_parser("trust-capabilities")
trust.set_defaults(func=cmd_trust)
native = sub.add_parser("adapt-native")
native.add_argument("event_file")
native.set_defaults(func=cmd_native)
return root
def main() -> int:
args = parser().parse_args()
return int(args.func(args))
if __name__ == "__main__":
raise SystemExit(main())
@@ -199,6 +199,9 @@ def _check(
) -> dict[str, Any]:
return {
"gate": gate,
"legacy_gate": gate,
"category": "readiness_check",
"check_id": "ReadinessCheck.%s" % gate,
"title": title,
"status": status,
"summary": summary,
@@ -0,0 +1,320 @@
#!/usr/bin/env python3
"""Deterministic unit and cross-runtime conformance tests for the kernel."""
from __future__ import annotations
import os
import stat
import sys
import tempfile
import unittest
from copy import deepcopy
from pathlib import Path
from unittest.mock import patch
HARNESS_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(HARNESS_ROOT))
from kernel.adapters import AgenticBridgeAdapter, NativeHarnessAdapter
from kernel.contracts import validate_bundle
from kernel.policy import (
H2GateContext,
classify_action,
evaluate_failure_policy,
evaluate_h2_gate,
evaluate_registry_configuration,
evaluate_risk,
evaluate_trust_capabilities,
)
from kernel.taxonomy import CertificationClaim, ReadinessCheck, ReportDimension, RuntimeControl, same_legacy_label
from kernel.supervision import RuntimeCapabilities, negotiate_intervention
class RiskFloorTests(unittest.TestCase):
def test_benign_deploy_has_high_floor_and_requires_approval(self) -> None:
decision = evaluate_risk(action="deploy", content="publish a harmless documentation update", actor="alice")
self.assertEqual(decision["action_class"], "deployment")
self.assertEqual(decision["risk_factors"]["content_risk"], "low")
self.assertEqual(decision["effective_risk"], "high")
self.assertEqual(decision["decision"], "require_approval")
def test_effective_risk_is_maximum_of_all_factors(self) -> None:
decision = evaluate_risk(action="read", resource="/.ssh/id_rsa", actor="alice")
self.assertEqual(decision["risk_factors"]["action_risk"], "high")
self.assertEqual(decision["risk_factors"]["resource_risk"], "high")
self.assertEqual(decision["effective_risk"], "high")
def test_missing_actor_denies_high_impact_action(self) -> None:
decision = evaluate_risk(action="migration", content="apply schema", actor="")
self.assertEqual(decision["decision"], "deny")
self.assertIn("actor_identity_required", decision["reason_codes"])
def test_read_only_shell_command_is_not_side_effecting(self) -> None:
decision = classify_action(tool="Bash", command="git status --short")
self.assertEqual(decision["action_class"], "read_only")
self.assertFalse(decision["side_effecting"])
class RegistryConfigurationTests(unittest.TestCase):
def test_enforce_defaults_registry_on(self) -> None:
result = evaluate_registry_configuration("enforce", "development", None)
self.assertTrue(result["enabled"])
self.assertEqual(result["reason_code"], "h2_registry_default_enabled")
def test_development_bypass_is_visible_and_non_silent(self) -> None:
result = evaluate_registry_configuration("enforce", "test", "0")
self.assertFalse(result["enabled"])
self.assertTrue(result["unsafe_bypass"])
self.assertEqual(result["severity"], "high")
def test_production_bypass_is_rejected(self) -> None:
result = evaluate_registry_configuration("enforce", "production", "0")
self.assertFalse(result["valid"])
self.assertEqual(result["reason_code"], "h2_registry_bypass_forbidden")
class FailurePolicyTests(unittest.TestCase):
def test_all_supported_policies_are_explicit(self) -> None:
for configured in ("halt", "quarantine", "require_approval", "record_only"):
result = evaluate_failure_policy(
side_effecting=False, mode="observe", profile="test", configured=configured,
)
self.assertEqual(result["failure_policy"], configured)
self.assertEqual(result["execution_result"], "failed")
self.assertEqual(result["telemetry_result"], "recorded")
def test_production_side_effect_cannot_be_record_only(self) -> None:
result = evaluate_failure_policy(
side_effecting=True, mode="enforce", profile="production", configured="record_only",
)
self.assertEqual(result["failure_policy"], "halt")
self.assertFalse(result["valid"])
self.assertEqual(result["source"], "production_record_only_forbidden")
def test_invalid_policy_fails_closed(self) -> None:
result = evaluate_failure_policy(
side_effecting=True, mode="enforce", profile="test", configured="continue_anyway",
)
self.assertEqual(result["failure_policy"], "halt")
self.assertFalse(result["valid"])
class TaxonomyTests(unittest.TestCase):
def test_same_h_label_cannot_be_interpreted_as_same_category(self) -> None:
self.assertTrue(same_legacy_label(RuntimeControl.H4, ReadinessCheck.H4))
self.assertNotEqual(RuntimeControl.H4.value, ReadinessCheck.H4.value)
self.assertNotEqual(ReportDimension.H4.value, CertificationClaim.H4.value)
def test_unsupported_runtime_intervention_is_explicit(self) -> None:
result = negotiate_intervention("i-1", "roll_back", "operator", RuntimeCapabilities())
self.assertEqual(result["status"], "unsupported")
self.assertEqual(result["reason_code"], "runtime_intervention_unsupported")
class H2DependencyTests(unittest.TestCase):
def context(self, mode: str) -> H2GateContext:
return H2GateContext(mode, "alice", "write_file", "Edit", "run-1", "test.h2", "idem", 0.5)
def script(self, directory: str, body: str) -> str:
path = Path(directory) / "gate.sh"
path.write_text("#!/usr/bin/env bash\n" + body + "\n", encoding="utf-8")
path.chmod(0o700)
return str(path)
def test_missing_gate_denies_enforce_and_degrades_observe(self) -> None:
with tempfile.TemporaryDirectory() as temp:
missing = str(Path(temp) / "missing.sh")
enforced = evaluate_h2_gate(missing, self.context("enforce"))
observed = evaluate_h2_gate(missing, self.context("observe"))
self.assertFalse(enforced["execution_allowed"])
self.assertEqual(enforced["reason_code"], "h2_gate_unavailable")
self.assertTrue(observed["execution_allowed"])
self.assertEqual(observed["decision"], "observe_only")
self.assertFalse(observed["certifiable"])
def test_permission_denied_fails_closed(self) -> None:
with tempfile.TemporaryDirectory() as temp:
path = self.script(temp, "echo 'TOOL_APPROVED tool=x reason=test'")
os.chmod(path, 0)
result = evaluate_h2_gate(path, self.context("enforce"))
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
self.assertFalse(result["execution_allowed"])
self.assertEqual(result["reason_code"], "h2_gate_permission_denied")
def test_timeout_malformed_and_internal_exception_fail_closed(self) -> None:
with tempfile.TemporaryDirectory() as temp:
timeout = self.script(temp, "sleep 1")
timed = evaluate_h2_gate(timeout, self.context("enforce"))
malformed = self.script(temp, "echo nonsense")
malformed_result = evaluate_h2_gate(malformed, self.context("enforce"))
with patch("kernel.policy.subprocess.run", side_effect=OSError("boom")):
internal = evaluate_h2_gate(malformed, self.context("enforce"))
self.assertEqual(timed["reason_code"], "h2_gate_timeout")
self.assertEqual(malformed_result["reason_code"], "h2_gate_malformed_response")
self.assertEqual(internal["reason_code"], "h2_gate_internal_error")
self.assertTrue(all(not item["execution_allowed"] for item in (timed, malformed_result, internal)))
def test_explicit_approval_and_denial_protocols(self) -> None:
with tempfile.TemporaryDirectory() as temp:
allowed = self.script(temp, "echo 'TOOL_APPROVED tool=write_file reason=registered'")
allow_result = evaluate_h2_gate(allowed, self.context("enforce"))
denied = self.script(temp, "echo 'TOOL_DENIED tool=write_file reason=missing_agent_identity' >&2; exit 2")
deny_result = evaluate_h2_gate(denied, self.context("enforce"))
self.assertTrue(allow_result["execution_allowed"])
self.assertFalse(deny_result["execution_allowed"])
self.assertEqual(deny_result["reason_code"], "h2_denied:missing_agent_identity")
class TrustRootTests(unittest.TestCase):
def test_production_refuses_local_fallback(self) -> None:
result = evaluate_trust_capabilities({"CASAN_PROFILE": "production"})
self.assertFalse(result["ready"])
self.assertFalse(result["certifiable"])
self.assertIn("external_signing_trust_root_required", result["reason_codes"])
def test_external_configuration_is_capable_but_not_claimed_provisioned(self) -> None:
result = evaluate_trust_capabilities({
"CASAN_PROFILE": "production",
"CASAN_SIGNING_PROVIDER": "vault_kms",
"VAULT_ADDR": "https://vault.example",
"VAULT_TOKEN": "redacted-runtime-token",
"CASAN_IMMUTABLE_ANCHOR_PROVIDER": "s3_object_lock",
"CASAN_S3_BUCKET": "audit-lock",
"CASAN_S3_KMS_KEY_ID": "kms-key",
})
self.assertTrue(result["ready"])
self.assertTrue(result["certifiable"])
def test_emergency_override_never_certifies(self) -> None:
result = evaluate_trust_capabilities({
"CASAN_PROFILE": "production",
"CASAN_TRUST_EMERGENCY_OVERRIDE": "1",
})
self.assertFalse(result["certifiable"])
self.assertEqual(result["severity"], "critical")
class CrossRuntimeConformanceTests(unittest.TestCase):
def events(self, **overrides: object) -> tuple[dict[str, object], dict[str, object]]:
common: dict[str, object] = {
"run_id": "run-1", "trace_id": "run-1", "action": "deploy", "tool": "Bash",
"command": "deploy harmless docs", "actor": "alice", "mode": "enforce",
"execution_status": "failed", "assurance_status": "failed", "certification_status": "non_certified",
"certified": False, "correlation_id": "corr-1", "parent_run_id": "parent-1",
"parent_step_id": "parent-step-1", "evidence": [
{"evidence_id": "e-1", "decision": "deny", "category": "runtime_control"},
],
}
common.update(overrides)
return dict(common), dict(common, adapter_version="20.2.0")
def map_both(self, **overrides: object) -> tuple[dict[str, object], dict[str, object]]:
native_event, agentic_event = self.events(**overrides)
native = NativeHarnessAdapter().map_execution(native_event)
agentic = AgenticBridgeAdapter().map_execution(agentic_event)
return native, agentic
def test_01_same_dangerous_action_classification(self) -> None:
native, agentic = self.map_both()
self.assertEqual(validate_bundle(native), [])
self.assertEqual(validate_bundle(agentic), [])
self.assertEqual(native["run"]["risk_summary"]["action_class"], "deployment")
self.assertEqual(agentic["run"]["risk_summary"]["action_class"], "deployment")
self.assertEqual(native["run"]["risk_summary"]["effective_risk"], "high")
def test_02_same_missing_actor_denial(self) -> None:
native, agentic = self.map_both(actor="")
for bundle in (native, agentic):
self.assertEqual(bundle["run"]["risk_summary"]["decision"], "deny")
self.assertIn("actor_identity_required", bundle["run"]["risk_summary"]["reason_codes"])
def test_03_same_missing_h2_gate_denial(self) -> None:
with tempfile.TemporaryDirectory() as temp:
missing = str(Path(temp) / "missing.sh")
decisions = [
evaluate_h2_gate(missing, H2GateContext("enforce", "alice", "write_file", "Edit", "run-1", point, "idem"))
for point in ("native-harness.h2", "agentic-bridge.h2")
]
native, agentic = self.map_both(
action="write_file", tool="Edit", command="safe edit",
policy_decisions=[decisions[0]], evidence=[{"evidence_id": "e-1", "decision": "deny"}],
)
# The second adapter receives the same kernel decision with only its
# enforcement-point extension changed.
agentic_event = self.events(
action="write_file", tool="Edit", command="safe edit",
policy_decisions=[decisions[1]], evidence=[{"evidence_id": "e-1", "decision": "deny"}],
)[1]
agentic = AgenticBridgeAdapter().map_execution(agentic_event)
for bundle in (native, agentic):
decision = bundle["steps"][0]["policy_decisions"][0]
self.assertEqual(decision["decision"], "deny")
self.assertIn("h2_gate_unavailable", decision["reason_codes"])
def test_04_same_approval_requirement(self) -> None:
native, agentic = self.map_both(actor="alice")
for bundle in (native, agentic):
self.assertEqual(bundle["run"]["risk_summary"]["decision"], "require_approval")
self.assertEqual(bundle["run"]["risk_summary"]["evidence_requirement"], "required")
def test_05_same_claim_evidence_relationship(self) -> None:
native, agentic = self.map_both()
for bundle in (native, agentic):
self.assertEqual(bundle["claims"][0]["evidence_refs"], ["e-1"])
self.assertEqual(bundle["evidence"][0]["claim_refs"], [bundle["claims"][0]["claim_id"]])
def test_06_observed_only_execution_is_never_certified(self) -> None:
native, agentic = self.map_both(
mode="observe", certification_status="certified", certified=True,
)
for bundle in (native, agentic):
self.assertIn("observed_only_cannot_be_certified", validate_bundle(bundle))
def test_07_execution_and_assurance_outcomes_are_distinct(self) -> None:
native, agentic = self.map_both(
execution_status="failed", assurance_status="passed", certification_status="non_certified",
)
for bundle in (native, agentic):
outcome = bundle["run"]["outcome"]
self.assertEqual(outcome["execution_result"], "failed")
self.assertEqual(outcome["assurance_result"], "passed")
self.assertEqual(outcome["certification_result"], "non_certified")
def test_08_correlation_and_causation_are_preserved(self) -> None:
native, agentic = self.map_both()
for bundle in (native, agentic):
self.assertEqual(bundle["run"]["correlation_id"], "corr-1")
self.assertEqual(bundle["run"]["parent_run_id"], "parent-1")
self.assertEqual(bundle["steps"][0]["parent_step_id"], "parent-step-1")
self.assertEqual(bundle["trace_links"][0]["from"], "run-1")
def test_09_same_verifier_detects_evidence_tampering(self) -> None:
native, agentic = self.map_both()
for original in (native, agentic):
self.assertEqual(validate_bundle(original), [])
tampered = deepcopy(original)
tampered["evidence"][0]["artifact_ref"] = "inline:tampered"
self.assertIn("evidence.0.integrity_invalid", validate_bundle(tampered))
def test_10_runtime_details_are_namespaced_extensions(self) -> None:
native, agentic = self.map_both()
self.assertNotEqual(native["run"]["runtime"]["type"], agentic["run"]["runtime"]["type"])
self.assertIn("casan.runtime.casan-native-harness", native["extensions"])
self.assertIn("casan.runtime.agentic-bridge", agentic["extensions"])
def test_delegation_depth_is_enforced(self) -> None:
native, _ = self.map_both()
native["actors"].append({
"actor_id": "child", "actor_type": "child_agent", "issuer": "alice",
"trust_level": "verified", "authentication_evidence_ref": None,
})
native["delegations"] = [
{"delegation_id": "d1", "delegator_ref": "alice", "delegate_ref": "child", "allowed_authority": ["read"], "resource_scope": ["project"], "expiry": "2030-01-01T00:00:00Z", "maximum_depth": 3, "parent_delegation_ref": None, "revoked": False, "approval_ref": None},
{"delegation_id": "d2", "delegator_ref": "alice", "delegate_ref": "child", "allowed_authority": ["read"], "resource_scope": ["project"], "expiry": "2030-01-01T00:00:00Z", "maximum_depth": 3, "parent_delegation_ref": "d1", "revoked": False, "approval_ref": None},
{"delegation_id": "d3", "delegator_ref": "alice", "delegate_ref": "child", "allowed_authority": ["read"], "resource_scope": ["project"], "expiry": "2030-01-01T00:00:00Z", "maximum_depth": 1, "parent_delegation_ref": "d2", "revoked": False, "approval_ref": None},
]
self.assertIn("delegations.2.maximum_depth_exceeded", validate_bundle(native))
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""Focused bridge integration regressions for the Assurance Kernel upgrade."""
from __future__ import annotations
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
HARNESS_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(HARNESS_ROOT / "scripts" / "python"))
import agentic_bridge as bridge
class BridgeUpgradeIntegrationTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.state = str(Path(self.temp.name) / "state")
self.project = str(HARNESS_ROOT.parent.parent)
self.base_env = {
"CASAN_STATE_ROOT": self.state,
"CASAN_AGENTIC_ENFORCEMENT_MODE": "enforce",
"CASAN_AGENT": "boss",
"CASAN_ACTOR": "boss",
"CASAN_PROFILE": "test",
}
def tearDown(self) -> None:
self.temp.cleanup()
def begin(self, actor: str = "boss") -> dict[str, object]:
return bridge.op_begin({
"op": "begin", "client": "codex", "project": self.project,
"session": "integration", "prompt": "edit a source file safely",
"integration_mode": "project_hook", "actor": actor,
})
def fixture_gate(self, body: str) -> str:
path = Path(self.temp.name) / "h2-gate.sh"
path.write_text("#!/usr/bin/env bash\n" + body + "\n", encoding="utf-8")
path.chmod(0o700)
return str(path)
def pre_edit(self, admission_id: str) -> dict[str, object]:
return bridge.op_pre_tool({
"op": "pre-tool", "admission_id": admission_id, "tool": "Edit",
"tool_input": "update a source file", "project": self.project,
})
def test_missing_h2_gate_denies_enforce_with_structured_evidence(self) -> None:
missing = str(Path(self.temp.name) / "missing.sh")
with patch.dict(os.environ, {**self.base_env, "CASAN_H2_GATE_PATH": missing}, clear=False):
begin = self.begin()
result = self.pre_edit(str(begin["admission_id"]))
record = bridge.load_admission(str(begin["admission_id"]))
self.assertEqual(result["decision"], "deny")
self.assertEqual(result["reason"], "h2_gate_unavailable")
evidence = next(item for item in record["evidence"] if item["kind"] == "tool-registry")
self.assertEqual(evidence["facts"]["mode"], "enforce")
self.assertEqual(evidence["facts"]["actor"], "boss")
self.assertEqual(evidence["facts"]["tool"], "Edit")
self.assertEqual(evidence["facts"]["execution_id"], begin["trace_id"])
self.assertEqual(evidence["facts"]["enforcement_path"], "agentic_bridge.pre_tool.h2_registry")
def test_missing_h2_gate_observe_allows_only_degraded_non_certifiable_execution(self) -> None:
missing = str(Path(self.temp.name) / "missing.sh")
env = {**self.base_env, "CASAN_AGENTIC_ENFORCEMENT_MODE": "observe", "CASAN_H2_GATE_PATH": missing}
with patch.dict(os.environ, env, clear=False):
begin = self.begin()
result = self.pre_edit(str(begin["admission_id"]))
finalized = bridge.op_finalize({"op": "finalize", "admission_id": begin["admission_id"], "stop_reason": "completed"})
self.assertEqual(result["decision"], "allow")
self.assertEqual(finalized["decision"], "non_certified")
self.assertEqual(finalized["certification_strength"], "observed_only")
def test_registry_defaults_on_and_development_bypass_is_visible(self) -> None:
missing = str(Path(self.temp.name) / "missing.sh")
with patch.dict(os.environ, {**self.base_env, "CASAN_H2_GATE_PATH": missing}, clear=False):
begin = self.begin()
default_result = self.pre_edit(str(begin["admission_id"]))
self.assertEqual(default_result["reason"], "h2_gate_unavailable")
with patch.dict(os.environ, {**self.base_env, "CASAN_AGENTIC_H2_REGISTRY": "0", "CASAN_H2_GATE_PATH": missing}, clear=False):
begin = self.begin()
bypass = self.pre_edit(str(begin["admission_id"]))
record = bridge.load_admission(str(begin["admission_id"]))
self.assertEqual(bypass["decision"], "allow")
self.assertTrue(any("HIGH" in warning for warning in bypass["warnings"]))
self.assertEqual(record["certification_strength"], "observed_only")
def test_benign_deploy_requires_approval_from_action_floor(self) -> None:
gate = self.fixture_gate("echo 'TOOL_APPROVED tool=deploy reason=registered'")
with patch.dict(os.environ, {**self.base_env, "CASAN_H2_GATE_PATH": gate}, clear=False):
begin = self.begin()
result = bridge.op_pre_tool({
"op": "pre-tool", "admission_id": begin["admission_id"], "tool": "Bash",
"tool_input": "deploy harmless documentation", "project": self.project,
})
self.assertEqual(result["decision"], "require_approval")
risk = result["policy_decision"]
self.assertEqual(risk["action_class"], "deployment")
self.assertEqual(risk["risk_factors"]["content_risk"], "medium")
self.assertEqual(risk["effective_risk"], "high")
def test_structured_shell_payload_classifies_the_inner_read_only_command(self) -> None:
with patch.dict(os.environ, self.base_env, clear=False):
begin = self.begin()
result = bridge.op_pre_tool({
"op": "pre-tool", "admission_id": begin["admission_id"], "tool": "Bash",
"tool_input": {"command": "ls"}, "project": self.project,
})
self.assertEqual(result["decision"], "allow")
self.assertEqual(result["policy_decision"]["action_class"], "read_only")
def test_failed_side_effect_halts_and_cannot_finalize_successfully(self) -> None:
gate = self.fixture_gate("echo 'TOOL_APPROVED tool=write_file reason=registered'")
with patch.dict(os.environ, {**self.base_env, "CASAN_H2_GATE_PATH": gate}, clear=False):
begin = self.begin()
admitted = self.pre_edit(str(begin["admission_id"]))
post = bridge.op_post_tool({
"op": "post-tool", "admission_id": begin["admission_id"], "tool": "Edit",
"status": "error", "duration_ms": 2, "result": "write failed",
})
finalized = bridge.op_finalize({
"op": "finalize", "admission_id": begin["admission_id"],
"stop_reason": "completed", "assistant_summary": "completed",
})
self.assertEqual(admitted["decision"], "allow")
self.assertEqual(post["decision"], "halt")
self.assertFalse(post["assurance_may_continue"])
self.assertEqual(finalized["decision"], "non_certified")
self.assertEqual(finalized["execution_outcome"], "failed")
self.assertEqual(finalized["assurance_outcome"], "failed")
self.assertIn("failed_tool_outcome", finalized["reason"])
kernel_files = list((Path(self.state) / "logs" / "kernel").glob("*.json"))
self.assertEqual(len(kernel_files), 1)
kernel = json.loads(kernel_files[0].read_text(encoding="utf-8"))
self.assertEqual(kernel["run"]["outcome"]["execution_result"], "failed")
self.assertEqual(kernel["run"]["outcome"]["certification_result"], "non_certified")
def test_production_without_external_trust_root_never_certifies(self) -> None:
env = {**self.base_env, "CASAN_PROFILE": "production", "CASAN_AGENTIC_ENFORCEMENT_MODE": "enforce"}
with patch.dict(os.environ, env, clear=False):
begin = self.begin()
finalized = bridge.op_finalize({"op": "finalize", "admission_id": begin["admission_id"], "stop_reason": "completed"})
self.assertEqual(finalized["decision"], "non_certified")
self.assertIn("external_signing_trust_root_required", finalized["reason"])
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -40,6 +40,8 @@ print(d.get(sys.argv[1],""))'
echo "===== C1: normal turn = one admission + one trace + one metric (single model) ====="
newstate
export CASAN_AGENTIC_ENFORCEMENT_MODE=enforce
export CASAN_AGENT=boss
export CASAN_ACTOR=boss
B=$(bridge '{"op":"begin","client":"claude-code","project":"'"$PROJ"'","session":"c1","prompt":"add a helper","integration_mode":"project_hook"}')
DEC=$(printf '%s' "$B" | field decision)
AID=$(printf '%s' "$B" | field admission_id)
@@ -76,7 +78,8 @@ B=$(bridge '{"op":"begin","client":"claude-code","project":"'"$PROJ"'","session"
AID=$(printf '%s' "$B" | field admission_id); TID=$(printf '%s' "$B" | field trace_id)
OK3=1
for tool in Bash Edit Write; do
R=$(bridge '{"op":"pre-tool","admission_id":"'"$AID"'","tool":"'"$tool"'","tool_input":"x","project":"'"$PROJ"'"}')
INPUT_VALUE="x"; [[ "$tool" == "Bash" ]] && INPUT_VALUE="ls"
R=$(bridge '{"op":"pre-tool","admission_id":"'"$AID"'","tool":"'"$tool"'","tool_input":"'"$INPUT_VALUE"'","project":"'"$PROJ"'"}')
[[ "$(printf '%s' "$R" | field decision)" == "allow" ]] || OK3=0
bridge '{"op":"post-tool","admission_id":"'"$AID"'","tool":"'"$tool"'","status":"success"}' >/dev/null
done
@@ -245,7 +248,7 @@ AID=$(printf '%s' "$B" | field admission_id)
|| fail "no-bash begin did not degrade gracefully ($B)"
R=$(bridge '{"op":"pre-tool","admission_id":"'"$AID"'","tool":"Bash","tool_input":"ls","project":"'"$PROJ"'"}')
[[ "$(printf '%s' "$R" | field decision)" == "allow" ]] \
&& pass "no-bash: side-effect tool allowed (admission gate still governs)" \
&& pass "no-bash: classified read-only shell command remains available" \
|| fail "no-bash pre-tool blocked the developer ($R)"
F=$(bridge '{"op":"finalize","admission_id":"'"$AID"'","stop_reason":"completed"}')
[[ "$(printf '%s' "$F" | field decision)" == "non_certified" ]] \
@@ -0,0 +1,148 @@
#!/usr/bin/env bash
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../scripts/bash/casan-paths.sh"
S="$CASAN_HARNESS_ROOT/scripts/bash"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
PASS=0; FAIL=0
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
echo "===== Assurance Kernel unit + cross-runtime conformance ====="
if CASAN_STATE_ROOT="$WORK/unit-state" python3 "$SCRIPT_DIR/assurance-kernel-tests.py" >/dev/null; then
pass "kernel policy and two-adapter conformance tests"
else
fail "kernel policy/conformance tests"
fi
echo "===== Agentic bridge integration ====="
if CASAN_STATE_ROOT="$WORK/bridge-state" python3 "$SCRIPT_DIR/assurance-upgrade-integration-tests.py" >/dev/null; then
pass "agentic H2, risk floor, failure outcome, trust-root integration"
else
fail "agentic upgrade integration tests"
fi
echo "===== Sandbox fail-closed selection ====="
SBX_STATE="$WORK/sandbox-state"
SBX_OUT="$(CASAN_STATE_ROOT="$SBX_STATE" CASAN_ENFORCEMENT_MODE=enforce \
CASAN_SANDBOX_MODE=container CASAN_SANDBOX_TEST_FORCE_UNAVAILABLE=1 \
bash "$S/sandbox-run.sh" --workspace "$WORK" -- sh -c 'printf should-not-run' 2>&1)"
SBX_RC=$?
if [[ "$SBX_RC" -eq 2 && "$SBX_OUT" == *"SANDBOX_ISOLATION_REQUIRED"* ]] \
&& grep -q '"reason_code":"sandbox_isolation_backend_unavailable"' "$SBX_STATE/logs/sandbox/decisions.jsonl"; then
pass "enforce mode denies unavailable isolation backend with structured evidence"
else
fail "strict sandbox unavailable behavior rc=$SBX_RC out=$SBX_OUT"
fi
DEV_OUT="$(CASAN_STATE_ROOT="$SBX_STATE" CASAN_PROFILE=test CASAN_SANDBOX_MODE=container \
CASAN_SANDBOX_TEST_FORCE_UNAVAILABLE=1 CASAN_SANDBOX_ALLOW_STATIC_FALLBACK=1 \
bash "$S/sandbox-run.sh" --workspace "$WORK" -- sh -c 'printf dev-ok' 2>/dev/null)"
[[ "$DEV_OUT" == "dev-ok" ]] && pass "explicit test-only static fallback remains available" || fail "explicit test fallback failed"
echo "===== H5 action-class risk floor ====="
GOV_STATE="$WORK/governance-state"
mkdir -p "$GOV_STATE" "$WORK/keys"
printf 'deploy a harmless documentation-only change\n' > "$WORK/deploy.txt"
GOV_OUT="$(CASAN_STATE_ROOT="$GOV_STATE" CASAN_AUDIT_KEY_DIR="$WORK/keys" CASAN_ACTOR=alice \
bash "$S/governance-check.sh" "$WORK/deploy.txt" "$WORK/deploy.out" deploy 2>&1)"
GOV_RC=$?
TRACE_FILE="$(find "$GOV_STATE/logs/trace" -name 'governance-*.json' -print -quit 2>/dev/null)"
if [[ "$GOV_RC" -eq 2 && -n "$TRACE_FILE" ]] \
&& python3 - "$TRACE_FILE" <<'PY'
import json, sys
r=json.load(open(sys.argv[1], encoding="utf-8"))
raise SystemExit(0 if r.get("action_class") == "deployment" and r.get("effective_risk") == "high" and r.get("decision") == "denied" else 1)
PY
then
pass "benign deploy text cannot lower deployment risk or bypass approval"
else
fail "deploy risk-floor regression rc=$GOV_RC out=$GOV_OUT"
fi
MISSING_ACTOR_OUT="$(CASAN_STATE_ROOT="$WORK/missing-actor-state" CASAN_AUDIT_KEY_DIR="$WORK/missing-actor-keys" \
CASAN_APPROVAL_DECISION=approve CASAN_APPROVER=bob \
bash "$S/governance-check.sh" "$WORK/deploy.txt" "$WORK/missing-actor.out" deploy 2>&1)"
MISSING_ACTOR_RC=$?
if [[ "$MISSING_ACTOR_RC" -eq 2 && "$MISSING_ACTOR_OUT" == *"actor_identity_required"* ]]; then
pass "approval cannot replace required actor identity for a high-impact action"
else
fail "missing actor was not denied rc=$MISSING_ACTOR_RC out=$MISSING_ACTOR_OUT"
fi
echo "===== Native failed side-effect completion semantics ====="
NATIVE_STATE="$WORK/native-failure-state"
printf 'safe write request\n' > "$WORK/native-input.txt"
OBSERVE_STATE="$WORK/native-observe-state"
OBSERVE_OUT="$(CASAN_STATE_ROOT="$OBSERVE_STATE" CASAN_GOVERNANCE_ROOT="$WORK/observe-governance" \
CASAN_AUDIT_KEY_DIR="$WORK/observe-keys" CASAN_ENFORCEMENT_MODE=observe CASAN_ACTOR=alice \
bash "$S/casan-harness.sh" "$WORK/native-input.txt" "$WORK/native-observe-output.txt" agent_step 2>&1)"
OBSERVE_RC=$?
if [[ "$OBSERVE_RC" -eq 0 && "$OBSERVE_OUT" == *"assurance=degraded certification=non_certified"* ]] \
&& grep -q '"assurance_result":"degraded"' "$OBSERVE_STATE/logs/kernel/"*.json; then
pass "native observe-only success is explicitly degraded and non-certified"
else
fail "native observe-only outcome was overstated rc=$OBSERVE_RC out=$OBSERVE_OUT"
fi
NATIVE_OUT="$(CASAN_STATE_ROOT="$NATIVE_STATE" CASAN_AUDIT_KEY_DIR="$WORK/native-keys" \
CASAN_ENFORCEMENT_MODE=observe CASAN_AGENT=boss CASAN_ACTOR=boss \
bash "$S/casan-harness.sh" "$WORK/native-input.txt" "$WORK/native-output.txt" write_file -- \
bash -c 'exit 7' 2>&1)"
NATIVE_RC=$?
if [[ "$NATIVE_RC" -eq 7 && "$NATIVE_OUT" != *"CASAN_HARNESS_COMPLETE"* ]] \
&& grep -q '"status":"failed"' "$NATIVE_STATE/logs/cost/metrics.jsonl" \
&& grep -q '"execution_result":"failed"' "$NATIVE_STATE/logs/kernel/"*.json; then
pass "failed native side effect records telemetry but cannot emit successful completion"
else
fail "native failed-outcome semantics rc=$NATIVE_RC out=$NATIVE_OUT"
fi
NO_OUTPUT_STATE="$WORK/native-no-output-state"
NO_OUTPUT_OUT="$(CASAN_STATE_ROOT="$NO_OUTPUT_STATE" CASAN_GOVERNANCE_ROOT="$WORK/no-output-governance" \
CASAN_AUDIT_KEY_DIR="$WORK/no-output-keys" CASAN_ENFORCEMENT_MODE=observe CASAN_ACTOR=alice \
bash "$S/casan-harness.sh" "$WORK/native-input.txt" "$WORK/native-no-output.txt" agent_step -- \
bash -c 'true' 2>&1)"
NO_OUTPUT_RC=$?
if [[ "$NO_OUTPUT_RC" -eq 1 && "$NO_OUTPUT_OUT" != *"CASAN_HARNESS_COMPLETE"* ]] \
&& grep -q '"status":"failed"' "$NO_OUTPUT_STATE/logs/cost/metrics.jsonl" \
&& grep -q '"execution_result":"failed"' "$NO_OUTPUT_STATE/logs/kernel/"*.json; then
pass "zero-exit command missing its required output remains a failed execution"
else
fail "missing required output was presented as success rc=$NO_OUTPUT_RC out=$NO_OUTPUT_OUT"
fi
echo "===== Native H2 missing dependency ====="
NATIVE_H2_STATE="$WORK/native-h2-state"
NATIVE_H2_OUT="$(CASAN_STATE_ROOT="$NATIVE_H2_STATE" CASAN_GOVERNANCE_ROOT="$WORK/native-h2-governance" \
CASAN_AUDIT_KEY_DIR="$WORK/native-h2-keys" CASAN_ENFORCEMENT_MODE=enforce CASAN_PROFILE=test \
CASAN_ACTOR=alice CASAN_APPROVAL_DECISION=approve CASAN_APPROVER=bob \
CASAN_H2_GATE_PATH="$WORK/missing-native-h2.sh" \
bash "$S/casan-harness.sh" "$WORK/native-input.txt" "$WORK/native-h2-output.txt" write_file -- \
bash -c 'printf should-not-run' 2>&1)"
NATIVE_H2_RC=$?
if [[ "$NATIVE_H2_RC" -eq 2 ]] \
&& grep -q '"reason_code":"h2_gate_unavailable"' "$NATIVE_H2_STATE/logs/policy/h2-decisions.jsonl" \
&& grep -q '"execution_result":"failed"' "$NATIVE_H2_STATE/logs/kernel/"*.json; then
pass "native enforce mode denies a missing H2 gate and emits failed canonical evidence"
else
fail "native missing H2 gate did not fail closed rc=$NATIVE_H2_RC out=$NATIVE_H2_OUT"
fi
echo "===== Production trust-root refusal ====="
printf 'read status\n' > "$WORK/read.txt"
TRUST_OUT="$(CASAN_STATE_ROOT="$WORK/trust-state" CASAN_AUDIT_KEY_DIR="$WORK/trust-keys" \
CASAN_PROFILE=production bash "$S/governance-check.sh" "$WORK/read.txt" "$WORK/read.out" agent_step 2>&1)"
TRUST_RC=$?
if [[ "$TRUST_RC" -eq 2 && "$TRUST_OUT" == *"production_trust_root_unavailable"* ]]; then
pass "production refuses local signing and local hash-chain fallback"
else
fail "production trust-root refusal rc=$TRUST_RC out=$TRUST_OUT"
fi
echo
echo "===== ASSURANCE UPGRADE SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]]
@@ -6,6 +6,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../scripts/bash/casan-paths.sh"
SHIP="$CASAN_HARNESS_ROOT/scripts/bash/audit-ship-s3.sh"
PREFLIGHT="$CASAN_HARNESS_ROOT/scripts/bash/production-preflight.sh"
WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
PASS=0; FAIL=0
pass() { echo "PASS: $1"; PASS=$((PASS+1)); }
@@ -52,5 +53,109 @@ printf 'not-a-digest\n' > "$WORK/bad-head.txt"
[[ "$(rc env PATH="$WORK/bin:$PATH" CASAN_PROFILE=prod CASAN_S3_BUCKET=casan-production-audit CASAN_S3_REGION=ap-northeast-1 CASAN_S3_KMS_KEY_ID=alias/casan-audit bash "$SHIP" "$WORK/bad-head.txt")" -ne 0 ]] \
&& pass "malformed audit head is refused" || fail "malformed audit head accepted"
echo "===== Production configuration boundary ====="
mkdir -p "$WORK/tls" "$WORK/state/logs/audit" "$WORK/output"
openssl req -x509 -newkey rsa:2048 -nodes -days 60 \
-subj '/CN=control.casan.internal' -addext 'subjectAltName=DNS:control.casan.internal' \
-keyout "$WORK/tls/tls.key" -out "$WORK/tls/tls.crt" >/dev/null 2>&1
openssl genrsa -out "$WORK/idp-private.pem" 2048 >/dev/null 2>&1
openssl rsa -in "$WORK/idp-private.pem" -pubout -out "$WORK/idp-public.pem" >/dev/null 2>&1
printf 'test-ca\n' > "$WORK/vault-ca.pem"
printf '%064d\n' 0 > "$WORK/state/logs/audit/audit-head.txt"
cat > "$WORK/oauth.env" <<'EOF'
OAUTH2_PROXY_PROVIDER=oidc
OAUTH2_PROXY_OIDC_ISSUER_URL=https://id.casan.internal/realms/casan
OAUTH2_PROXY_CLIENT_ID=casan-control-plane
OAUTH2_PROXY_CLIENT_SECRET=secret-manager-injected
OAUTH2_PROXY_COOKIE_SECRET=base64-cookie-secret-value
OAUTH2_PROXY_COOKIE_SECURE=true
OAUTH2_PROXY_REDIRECT_URL=https://control.casan.internal/oauth2/callback
OAUTH2_PROXY_OIDC_GROUPS_CLAIM=groups
OAUTH2_PROXY_SET_XAUTHREQUEST=true
OAUTH2_PROXY_PASS_ACCESS_TOKEN=true
OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER=true
EOF
cat > "$WORK/vault.env" <<EOF
VAULT_ADDR=https://vault.casan.internal
VAULT_TOKEN=short-lived-workload-token
VAULT_CACERT=$WORK/vault-ca.pem
EOF
write_runtime() {
local audience="$1" signing="$2" anchor="$3"
cat > "$WORK/runtime.env" <<EOF
CASAN_PROFILE=prod
CASAN_CP_AUTH_MODE=jwt
CASAN_CP_JWT_ISSUER=https://id.casan.internal/realms/casan
CASAN_CP_JWT_AUDIENCE=$audience
CASAN_CP_JWT_PUBLIC_KEY_FILE=/run/casan-idp/idp-public.pem
CASAN_CP_JWT_CLOCK_SKEW_SECONDS=60
CASAN_SIGNING_PROVIDER=$signing
CASAN_IMMUTABLE_ANCHOR_PROVIDER=$anchor
EOF
}
write_prod_env() {
local public_key="$1"
cat > "$WORK/casan-prod.env" <<EOF
CASAN_PUBLIC_FQDN=control.casan.internal
CASAN_CP_HTTPS_PORT=443
CASAN_CP_TLS_DIR=$WORK/tls
CASAN_CP_OAUTH_ENV=$WORK/oauth.env
CASAN_CP_RUNTIME_ENV=$WORK/runtime.env
CASAN_CP_VAULT_ENV=$WORK/vault.env
CASAN_CP_STATE_DIR=$WORK/state
CASAN_CP_OUTPUT_DIR=$WORK/output
CASAN_CP_IDP_PUBLIC_KEY=$public_key
CASAN_CP_API_IMAGE=registry.casan.internal/api@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
CASAN_CP_UI_IMAGE=registry.casan.internal/ui@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
CASAN_CP_OAUTH2_PROXY_IMAGE=registry.casan.internal/oauth@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc
CASAN_S3_BUCKET=casan-production-audit
CASAN_S3_PREFIX=audit-anchors
CASAN_S3_REGION=ap-northeast-1
CASAN_S3_RETENTION_DAYS=365
CASAN_S3_KMS_KEY_ID=alias/casan-audit
EOF
}
cat > "$WORK/bin/curl" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' '{"data":{"ttl":300,"policies":["casan-audit-sign"]}}'
EOF
cat > "$WORK/bin/docker" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
cat > "$WORK/bin/aws" <<'EOF'
#!/usr/bin/env bash
if [[ "$1 $2" == "s3api get-object-lock-configuration" ]]; then
printf '%s\n' '{"ObjectLockConfiguration":{"ObjectLockEnabled":"Enabled","Rule":{"DefaultRetention":{"Mode":"COMPLIANCE","Days":365}}}}'
elif [[ "$1 $2" == "s3api head-object" ]]; then
exit 1
fi
exit 0
EOF
chmod +x "$WORK/bin/curl" "$WORK/bin/docker" "$WORK/bin/aws"
write_runtime casan-control-plane vault_kms s3_object_lock
write_prod_env "$WORK/idp-public.pem"
[[ "$(rc env PATH="$WORK/bin:$PATH" bash "$PREFLIGHT" "$WORK/casan-prod.env")" -eq 0 ]] \
&& pass "complete JWT/Vault/Object-Lock production contract passes with local service stubs" \
|| fail "valid production configuration rejected"
write_runtime wrong-audience vault_kms s3_object_lock
[[ "$(rc env PATH="$WORK/bin:$PATH" bash "$PREFLIGHT" "$WORK/casan-prod.env")" -ne 0 ]] \
&& pass "OIDC audience mismatch is refused" || fail "OIDC audience mismatch accepted"
write_runtime casan-control-plane local_openssl local_hash_chain
[[ "$(rc env PATH="$WORK/bin:$PATH" bash "$PREFLIGHT" "$WORK/casan-prod.env")" -ne 0 ]] \
&& pass "production local signing and local ledger configuration is refused" \
|| fail "production local trust fallback accepted"
write_runtime casan-control-plane vault_kms s3_object_lock
write_prod_env "$WORK/missing-idp-public.pem"
[[ "$(rc env PATH="$WORK/bin:$PATH" bash "$PREFLIGHT" "$WORK/casan-prod.env")" -ne 0 ]] \
&& pass "missing IdP verification key is refused" || fail "missing IdP key accepted"
echo "===== PRODUCTION HANDOFF SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]]
@@ -117,7 +117,7 @@ set -e
# H5: high-risk action approved with explicit approver
APPROVED_OUT="$EVIDENCE_DIR/04-high-risk-approved-output.txt"
CASAN_APPROVAL_DECISION=approve CASAN_APPROVER=architect@example.local \
CASAN_ACTOR=developer CASAN_APPROVAL_DECISION=approve CASAN_APPROVER=architect@example.local \
"$SCRIPTS/governance-check.sh" "$RISK_IN" "$APPROVED_OUT" deploy > "$EVIDENCE_DIR/04-governance-approve.stdout"
assert_contains "$APPROVED_OUT" "Deploy"