feat(casan): establish assurance kernel and harden control plane
This commit is contained in:
@@ -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/node_modules ./node_modules
|
||||||
COPY --from=builder /app/packages/casan-control-panel/backend/dist ./packages/casan-control-panel/backend/dist
|
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/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/config ./packages/casan-harness/config
|
||||||
COPY packages/casan-harness/security ./packages/casan-harness/security
|
COPY packages/casan-harness/security ./packages/casan-harness/security
|
||||||
COPY packages/casan-harness/config/project-registry.json ./packages/casan-harness/config/project-registry.json
|
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 NODE_ENV=production
|
||||||
ENV CASAN_PROFILE=prod
|
ENV CASAN_PROFILE=prod
|
||||||
ENV CASAN_CP_STRICT=1
|
ENV CASAN_CP_STRICT=1
|
||||||
ENV CASAN_CP_TRUST_AUTH_PROXY=1
|
|
||||||
ENV CP_BIND=0.0.0.0
|
ENV CP_BIND=0.0.0.0
|
||||||
ENV CP_PORT=3010
|
ENV CP_PORT=3010
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
CASAN_PROFILE: prod
|
CASAN_PROFILE: prod
|
||||||
CASAN_CP_STRICT: "1"
|
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_BIND: 0.0.0.0
|
||||||
CP_PORT: "3010"
|
CP_PORT: "3010"
|
||||||
CASAN_APP_ROOT: /app
|
CASAN_APP_ROOT: /app
|
||||||
@@ -63,6 +68,7 @@ services:
|
|||||||
- ./apps/service-desk:/app/apps/service-desk
|
- ./apps/service-desk:/app/apps/service-desk
|
||||||
- ./apps/projects:/app/apps/projects
|
- ./apps/projects:/app/apps/projects
|
||||||
- ./packages/casan-harness/config/project-registry.json:/app/packages/casan-harness/config/project-registry.json
|
- ./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:
|
expose:
|
||||||
- "3010"
|
- "3010"
|
||||||
networks:
|
networks:
|
||||||
@@ -111,8 +117,8 @@ services:
|
|||||||
- --oidc-groups-claim=groups
|
- --oidc-groups-claim=groups
|
||||||
- --reverse-proxy=true
|
- --reverse-proxy=true
|
||||||
- --set-xauthrequest=true
|
- --set-xauthrequest=true
|
||||||
- --pass-access-token=false
|
- --pass-access-token=true
|
||||||
- --pass-authorization-header=false
|
- --pass-authorization-header=true
|
||||||
- --skip-provider-button=true
|
- --skip-provider-button=true
|
||||||
- --ssl-insecure-skip-verify=true
|
- --ssl-insecure-skip-verify=true
|
||||||
expose:
|
expose:
|
||||||
@@ -129,7 +135,10 @@ services:
|
|||||||
CASAN_IDP_SUB: oidc-ops
|
CASAN_IDP_SUB: oidc-ops
|
||||||
CASAN_IDP_EMAIL: oidc-ops@example.com
|
CASAN_IDP_EMAIL: oidc-ops@example.com
|
||||||
CASAN_IDP_GROUPS: casan-org-admin,casan-approver,casan-project:AINative_OKR_CASAN4
|
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}
|
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:
|
ports:
|
||||||
- "18082:8080"
|
- "18082:8080"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
# `production-preflight.sh` validates all files, endpoints, Object Lock and
|
# `production-preflight.sh` validates all files, endpoints, Object Lock and
|
||||||
# images before `docker compose up` is allowed.
|
# images before `docker compose up` is allowed.
|
||||||
#
|
#
|
||||||
# oauth2-proxy must emit X-Auth-Request-User and X-Auth-Request-Groups.
|
# oauth2-proxy must emit a signed OIDC access token. Nginx removes caller-supplied
|
||||||
# Nginx overwrites X-CASAN-* headers before proxying to the API; the API maps
|
# identity headers and forwards only that bearer token; the API verifies issuer,
|
||||||
# groups such as casan-approver -> approver via rbac-check.py map-claim.
|
# audience, expiry and signature in-process before deriving CASAN identity.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
control-panel-api:
|
control-panel-api:
|
||||||
@@ -19,7 +19,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
CASAN_PROFILE: prod
|
CASAN_PROFILE: prod
|
||||||
CASAN_CP_STRICT: "1"
|
CASAN_CP_STRICT: "1"
|
||||||
CASAN_CP_TRUST_AUTH_PROXY: "1"
|
|
||||||
CP_BIND: 0.0.0.0
|
CP_BIND: 0.0.0.0
|
||||||
CP_PORT: "3010"
|
CP_PORT: "3010"
|
||||||
CASAN_APP_ROOT: /app
|
CASAN_APP_ROOT: /app
|
||||||
@@ -29,6 +28,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ${CASAN_CP_STATE_DIR:?Set CASAN_CP_STATE_DIR}:/app/.specify
|
- ${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_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:
|
expose:
|
||||||
- "3010"
|
- "3010"
|
||||||
networks:
|
networks:
|
||||||
@@ -57,8 +57,8 @@ services:
|
|||||||
- --http-address=0.0.0.0:4180
|
- --http-address=0.0.0.0:4180
|
||||||
- --reverse-proxy=true
|
- --reverse-proxy=true
|
||||||
- --set-xauthrequest=true
|
- --set-xauthrequest=true
|
||||||
- --pass-access-token=false
|
- --pass-access-token=true
|
||||||
- --pass-authorization-header=false
|
- --pass-authorization-header=true
|
||||||
- --skip-provider-button=true
|
- --skip-provider-button=true
|
||||||
- --cookie-secure=true
|
- --cookie-secure=true
|
||||||
- --cookie-httponly=true
|
- --cookie-httponly=true
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -10,20 +10,26 @@ This document defines the boundary between the local workstation experience and
|
|||||||
flowchart LR
|
flowchart LR
|
||||||
B["Browser"] -->|TLS + OIDC cookie| N["Nginx"]
|
B["Browser"] -->|TLS + OIDC cookie| N["Nginx"]
|
||||||
N -->|auth_request| O["oauth2-proxy"]
|
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 -->|tenant-scoped encrypted store| S["CASAN state"]
|
||||||
A -->|random bridge token, local only| H["Mac host bridge"]
|
A -->|random bridge token, local only| H["Mac host bridge"]
|
||||||
H -->|official CLI auth| P["Codex / Claude"]
|
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
|
## Implemented controls
|
||||||
|
|
||||||
- TLS at Nginx; production accepts only TLS 1.2/1.3.
|
- TLS at Nginx; production accepts only TLS 1.2/1.3.
|
||||||
- OIDC authentication through oauth2-proxy.
|
- OIDC authentication through oauth2-proxy.
|
||||||
- Secure, HttpOnly, SameSite=Lax session cookies with bounded expiry/refresh.
|
- 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.
|
- CSP, frame denial, MIME sniffing protection, referrer and browser permission restrictions.
|
||||||
- API request body capped at 1 MiB.
|
- API request body capped at 1 MiB.
|
||||||
- SSE buffering disabled for trace streams; no intermediate proxy cache.
|
- 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`.
|
2. Do not deploy `provider-auth-bridge.py`.
|
||||||
3. Use managed OpenAI/Anthropic credentials from Vault/KMS or workload identity.
|
3. Use managed OpenAI/Anthropic credentials from Vault/KMS or workload identity.
|
||||||
4. Use an enterprise IdP and explicit group-to-role mapping.
|
4. Use an enterprise IdP and explicit group-to-role mapping.
|
||||||
5. Use a CA-issued certificate and a fixed production hostname.
|
5. Mount the IdP RS256 public key and configure exact issuer/audience; coordinate
|
||||||
6. Set a digest-pinned `CASAN_CP_API_IMAGE`, `CASAN_CP_UI_IMAGE`, and `CASAN_CP_OAUTH2_PROXY_IMAGE`.
|
key rotation with an API restart until JWKS rollover is implemented.
|
||||||
7. Keep the API on a private container/network segment with Nginx as its only caller.
|
6. Use a CA-issued certificate and a fixed production hostname.
|
||||||
8. Restrict egress from the API to allowlisted model providers, Vault/KMS, object storage and required observability endpoints.
|
7. Set a digest-pinned `CASAN_CP_API_IMAGE`, `CASAN_CP_UI_IMAGE`, and `CASAN_CP_OAUTH2_PROXY_IMAGE`.
|
||||||
9. Store state on encrypted storage; send audit heads to KMS/WORM/Object Lock.
|
8. Keep the API on a private container/network segment with Nginx as its only caller.
|
||||||
10. Export rate-limit, auth failure, bridge-disabled and audit-chain metrics to alerting.
|
9. Restrict egress from the API to allowlisted model providers, Vault/KMS, object storage and required observability endpoints.
|
||||||
11. Back up and restore-test tenant state before enabling write actions.
|
10. Store state on encrypted storage; send audit heads to KMS/WORM/Object Lock.
|
||||||
12. Run the production preflight and security suites against the exact deployment images.
|
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
|
## Known residual risks
|
||||||
|
|
||||||
- Local bridge authorization is bearer-token based; a process with access to the token file can call it.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|||||||
from urllib.parse import parse_qs, urlencode, urlparse
|
from urllib.parse import parse_qs, urlencode, urlparse
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
|
||||||
|
|
||||||
@@ -33,7 +34,12 @@ USERS = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
KID = "casan-local-prod-idp"
|
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 = {}
|
CODES = {}
|
||||||
APPROVAL_SIGNER_TOKEN = os.environ.get("CASAN_APPROVAL_SIGNER_TOKEN", "")
|
APPROVAL_SIGNER_TOKEN = os.environ.get("CASAN_APPROVAL_SIGNER_TOKEN", "")
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ not be invented or committed.
|
|||||||
it at `/opt/casan-control-panel/tls/tls.crt` and `tls.key` (mode `0600`).
|
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
|
2. Register `https://<fqdn>/oauth2/callback` with the enterprise IdP. Map the
|
||||||
`groups` claim to CASAN groups such as `casan-org-admin` and
|
`groups` claim to CASAN groups such as `casan-org-admin` and
|
||||||
`casan-approver`. Copy `oauth2-proxy.env.example` outside the repo and fill
|
`casan-approver`. Export the IdP's RS256 verification public key to
|
||||||
it using the secret manager.
|
`/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
|
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.
|
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
|
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/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/runtime.env
|
||||||
sudo install -m 0600 /dev/null /opt/casan-control-panel/vault.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
|
bash packages/casan-harness/scripts/bash/production-preflight.sh infra/production/casan-prod.env
|
||||||
set -a; source infra/production/casan-prod.env; set +a
|
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
|
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
|
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.
|
`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
|
## Operational anchors
|
||||||
|
|
||||||
Run `audit-ship-s3.sh` after every audit seal from the same workload identity.
|
Run `audit-ship-s3.sh` after every audit seal from the same workload identity.
|
||||||
|
|||||||
@@ -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_VAULT_ENV=/opt/casan-control-panel/vault.env
|
||||||
CASAN_CP_STATE_DIR=/srv/casan/state
|
CASAN_CP_STATE_DIR=/srv/casan/state
|
||||||
CASAN_CP_OUTPUT_DIR=/srv/casan/output
|
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.
|
# 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_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_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
|
# WORM anchor destination. The deploy identity needs only PutObject and
|
||||||
# GetObjectLockConfiguration for this bucket/prefix; use workload identity,
|
# GetObjectLockConfiguration for this bucket/prefix; use workload identity,
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ OAUTH2_PROXY_COOKIE_SECURE=true
|
|||||||
OAUTH2_PROXY_REDIRECT_URL=https://control.casan.company.internal/oauth2/callback
|
OAUTH2_PROXY_REDIRECT_URL=https://control.casan.company.internal/oauth2/callback
|
||||||
OAUTH2_PROXY_OIDC_GROUPS_CLAIM=groups
|
OAUTH2_PROXY_OIDC_GROUPS_CLAIM=groups
|
||||||
OAUTH2_PROXY_SET_XAUTHREQUEST=true
|
OAUTH2_PROXY_SET_XAUTHREQUEST=true
|
||||||
OAUTH2_PROXY_PASS_ACCESS_TOKEN=false
|
OAUTH2_PROXY_PASS_ACCESS_TOKEN=true
|
||||||
OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER=false
|
OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER=true
|
||||||
OAUTH2_PROXY_SCOPE="openid profile email groups"
|
OAUTH2_PROXY_SCOPE="openid profile email groups"
|
||||||
|
|||||||
@@ -2,10 +2,19 @@
|
|||||||
# companion Vault file and the production preflight validation.
|
# companion Vault file and the production preflight validation.
|
||||||
CASAN_PROFILE=prod
|
CASAN_PROFILE=prod
|
||||||
CASAN_CP_STRICT=1
|
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_BIND=0.0.0.0
|
||||||
CP_PORT=3010
|
CP_PORT=3010
|
||||||
CASAN_APP_ROOT=/app
|
CASAN_APP_ROOT=/app
|
||||||
CASAN_DASHBOARD_STALE_S=300
|
CASAN_DASHBOARD_STALE_S=300
|
||||||
CASAN_PREFLIGHT=1
|
CASAN_PREFLIGHT=1
|
||||||
CASAN_CHAT_MODEL_MODE=deterministic
|
CASAN_CHAT_MODEL_MODE=deterministic
|
||||||
|
CASAN_SIGNING_PROVIDER=vault_kms
|
||||||
|
CASAN_IMMUTABLE_ANCHOR_PROVIDER=s3_object_lock
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ server {
|
|||||||
auth_request /oauth2/auth;
|
auth_request /oauth2/auth;
|
||||||
auth_request_set $auth_user $upstream_http_x_auth_request_user;
|
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_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_pass http://control-panel-api:3010/api/v1/;
|
||||||
proxy_http_version 1.1;
|
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-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
|
||||||
# Overwrite browser-supplied identity headers. The API maps these IdP
|
# Remove every caller-supplied identity assertion. The API derives
|
||||||
# group claims through rbac-check.py map-claim.
|
# actor/role/scope only after cryptographic JWT verification.
|
||||||
proxy_set_header X-CASAN-Actor $auth_user;
|
proxy_set_header X-CASAN-Actor "";
|
||||||
proxy_set_header X-CASAN-Groups $auth_groups;
|
proxy_set_header X-CASAN-Groups "";
|
||||||
proxy_set_header X-CASAN-Role "";
|
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 {
|
location = /healthz {
|
||||||
auth_request /oauth2/auth;
|
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_pass http://control-panel-api:3010/healthz;
|
||||||
|
proxy_set_header Authorization "Bearer $auth_access_token";
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
@@ -101,8 +101,10 @@ Goal workspace context:
|
|||||||
a different actor. The executor verifies the artifact hash, applies it, runs fixed
|
a different actor. The executor verifies the artifact hash, applies it, runs fixed
|
||||||
project build/test commands, and reverses the patch if verification fails.
|
project build/test commands, and reverses the patch if verification fails.
|
||||||
|
|
||||||
Local management headers: `x-casan-actor`, `x-casan-role`, `x-casan-project`,
|
In explicit loopback development mode only, local management headers are
|
||||||
`x-casan-tenant`. Missing role defaults to `viewer`, so writes fail closed.
|
`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:
|
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
|
compatibility aliases. Freshness is calculated independently from each file's mtime using
|
||||||
`CASAN_DASHBOARD_STALE_S` (default `3600`).
|
`CASAN_DASHBOARD_STALE_S` (default `3600`).
|
||||||
|
|
||||||
## Security posture (MVP)
|
## Security posture
|
||||||
Binds `127.0.0.1` by default. Refuses a non-loopback bind under `CASAN_PROFILE=prod` /
|
Binds `127.0.0.1` in explicit local development mode. Every non-loopback bind,
|
||||||
`CASAN_CP_STRICT=1` unless `CASAN_CP_TRUST_AUTH_PROXY=1` is set for an authenticated reverse
|
and every production profile, requires `CASAN_CP_AUTH_MODE=jwt`. Production
|
||||||
proxy that overwrites identity headers. Management endpoints are RBAC-gated via the harness
|
requires an RS256 public key plus configured issuer and audience; missing or
|
||||||
`rbac-check.py`; IdP group claims such as `casan-approver` are mapped to RBAC roles through
|
invalid configuration refuses startup. The API verifies signature, expiry,
|
||||||
the same harness engine.
|
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
|
## Test
|
||||||
```bash
|
```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
|
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`,
|
`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
|
removes browser-supplied identity headers, and forwards the signed bearer token for API
|
||||||
RBAC mapping. The local smoke starts a self-signed HTTPS stack with a mock OIDC IdP and
|
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
|
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
|
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
|
`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 { join } from 'node:path';
|
||||||
import { APP_ROOT } from './app-root.js';
|
import { APP_ROOT } from './app-root.js';
|
||||||
import type { SettingsActor } from '../settings/settings.service.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 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']);
|
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';
|
if (!raw) return 'viewer';
|
||||||
const claims = raw.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
|
const claims = raw.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
|
||||||
for (const claim of claims) {
|
for (const claim of claims) {
|
||||||
@@ -33,6 +34,23 @@ function roleFromClaim(raw: string | undefined): string {
|
|||||||
return 'viewer';
|
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 {
|
function projectFromClaims(raw: string | undefined): string | undefined {
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
const prefix = 'casan-project:';
|
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 {
|
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'])
|
const actor = firstHeader(headers['x-casan-actor'])
|
||||||
|| firstHeader(headers['x-auth-request-user'])
|
|| firstHeader(headers['x-auth-request-user'])
|
||||||
|| firstHeader(headers['x-forwarded-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 { ValidationPipe } from '@nestjs/common';
|
||||||
import { AppModule } from './app.module.js';
|
import { AppModule } from './app.module.js';
|
||||||
import { APP_ROOT } from './common/app-root.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
|
// Ops Console API (Plan-13). Binds loopback by default. Networked and production
|
||||||
// bind under CASAN_PROFILE=prod / CASAN_CP_STRICT=1 unless an authenticated reverse
|
// deployments require in-process cryptographic identity verification.
|
||||||
// proxy is explicitly configured to overwrite identity headers.
|
|
||||||
async function bootstrap() {
|
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 });
|
const app = await NestFactory.create(AppModule, { cors: true });
|
||||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||||
|
app.use(createAuthMiddleware(authConfig));
|
||||||
|
|
||||||
const port = Number(process.env.CP_PORT ?? 3010);
|
const port = Number(process.env.CP_PORT ?? 3010);
|
||||||
let host = process.env.CP_BIND ?? '127.0.0.1';
|
let host = process.env.CP_BIND ?? '127.0.0.1';
|
||||||
const strict = process.env.CASAN_PROFILE === 'prod' || process.env.CASAN_CP_STRICT === '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' && authConfig.mode !== 'jwt') {
|
||||||
if (strict && host !== '127.0.0.1' && host !== 'localhost' && !authProxy) {
|
// Networked production requires in-process cryptographic verification.
|
||||||
// The console must not expose telemetry/management off-loopback without TLS/OIDC
|
|
||||||
// at the reverse proxy, which must overwrite X-CASAN-* identity headers.
|
|
||||||
// eslint-disable-next-line no-console
|
// 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);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -298,6 +298,8 @@ export function buildH6Report(input: H6ReportInput, query: H6ReportQuery): H6Rep
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
schema_version: 1,
|
schema_version: 1,
|
||||||
|
category: 'report_dimension',
|
||||||
|
dimension_id: 'ReportDimension.H6',
|
||||||
report_id: `H6-${input.now.toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`,
|
report_id: `H6-${input.now.toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`,
|
||||||
harness: 'H6',
|
harness: 'H6',
|
||||||
title: 'H6 · AgentOps Report',
|
title: 'H6 · AgentOps Report',
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export interface HarnessReportEvidenceSource extends SourceFreshness {
|
|||||||
|
|
||||||
export interface HarnessReport<TSummary, TDetails> {
|
export interface HarnessReport<TSummary, TDetails> {
|
||||||
schema_version: 1;
|
schema_version: 1;
|
||||||
|
category: 'report_dimension';
|
||||||
|
dimension_id: `ReportDimension.${HarnessReportId}`;
|
||||||
report_id: string;
|
report_id: string;
|
||||||
harness: HarnessReportId;
|
harness: HarnessReportId;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -71,6 +73,8 @@ export interface HarnessReportCatalogEntry {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
contract_version: 1;
|
contract_version: 1;
|
||||||
|
category: 'report_dimension';
|
||||||
|
dimension_id: `ReportDimension.${HarnessReportId}`;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
availability: 'implemented' | 'contract_ready';
|
availability: 'implemented' | 'contract_ready';
|
||||||
}
|
}
|
||||||
@@ -78,6 +82,8 @@ export interface HarnessReportCatalogEntry {
|
|||||||
export const HARNESS_REPORT_CATALOG: HarnessReportCatalogEntry[] = HARNESS_REPORT_DEFINITIONS.map((definition) => ({
|
export const HARNESS_REPORT_CATALOG: HarnessReportCatalogEntry[] = HARNESS_REPORT_DEFINITIONS.map((definition) => ({
|
||||||
...definition,
|
...definition,
|
||||||
contract_version: 1,
|
contract_version: 1,
|
||||||
|
category: 'report_dimension',
|
||||||
|
dimension_id: `ReportDimension.${definition.id}`,
|
||||||
endpoint: `/api/v1/reports/${definition.id.toLowerCase()}`,
|
endpoint: `/api/v1/reports/${definition.id.toLowerCase()}`,
|
||||||
availability: definition.id === 'H6' ? 'implemented' : 'contract_ready',
|
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');
|
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" \
|
-subj "/CN=localhost" \
|
||||||
-days 1 >/dev/null 2>&1
|
-days 1 >/dev/null 2>&1
|
||||||
fi
|
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() {
|
cleanup() {
|
||||||
docker compose -f "$COMPOSE" down --remove-orphans >/dev/null 2>&1 || true
|
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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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 {})},
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"),
|
||||||
|
}
|
||||||
@@ -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"],
|
"type": ["string", "null"],
|
||||||
"description": "Client-native session id. Hashed by the bridge, never stored raw."
|
"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": {
|
"integration_mode": {
|
||||||
"type": ["string", "null"],
|
"type": ["string", "null"],
|
||||||
"enum": ["casan_owned", "managed_hook", "project_hook", "observed_only", null],
|
"enum": ["casan_owned", "managed_hook", "project_hook", "observed_only", null],
|
||||||
@@ -167,7 +171,7 @@
|
|||||||
"schema_version": { "type": "string" },
|
"schema_version": { "type": "string" },
|
||||||
"decision": {
|
"decision": {
|
||||||
"type": "string",
|
"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"] },
|
"admission_id": { "type": ["string", "null"] },
|
||||||
"trace_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
|
if [[ ! -f "$OUTPUT_FILE" ]]; then
|
||||||
STATUS="failed"
|
STATUS="failed"
|
||||||
ERROR_MSG="${ERROR_MSG:-output file not produced}"
|
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"
|
: > "$OUTPUT_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ fi
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
source "$SCRIPT_DIR/casan-paths.sh"
|
source "$SCRIPT_DIR/casan-paths.sh"
|
||||||
PROJECT_ROOT="$CASAN_APP_ROOT"
|
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
|
# 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.
|
# 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.
|
# 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
|
"$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
|
run_phase() { # <phase-name> <command...> — preserves the failing rc exactly
|
||||||
local phase="$1"; shift
|
local phase="$1"; shift
|
||||||
local rc=0
|
local rc=0
|
||||||
"$@" || rc=$?
|
"$@" || rc=$?
|
||||||
record_phase "$phase" "$rc"
|
record_phase "$phase" "$rc"
|
||||||
if [[ "$rc" -ne 0 ]]; then
|
if [[ "$rc" -ne 0 ]]; then
|
||||||
|
emit_failed_kernel "$phase" "$rc"
|
||||||
write_phase_report
|
write_phase_report
|
||||||
exit "$rc"
|
exit "$rc"
|
||||||
fi
|
fi
|
||||||
@@ -76,9 +117,16 @@ hash_text() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
CMD_STR="${*:-no_cmd}"
|
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)"
|
INPUT_HASH="$(cat "$INPUT_FILE" | hash_text)"
|
||||||
CMD_HASH="$(printf '%s' "$CMD_STR" | hash_text)"
|
CMD_HASH="$(printf '%s' "$CMD_STR" | hash_text)"
|
||||||
IDEMPOTENCY_KEY="$(printf '%s|%s|%s' "$INPUT_HASH" "$CMD_HASH" "$ACTION_NAME" | 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_META="$CACHE_DIR/$IDEMPOTENCY_KEY.json"
|
||||||
CACHE_OUT="$CACHE_DIR/$IDEMPOTENCY_KEY.output"
|
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).
|
# 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
|
# 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.
|
# 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_SCOPE="${CASAN_KILLSWITCH_SCOPE:-project}"
|
||||||
KS_ID="${CASAN_KILLSWITCH_ID:-${CASAN_PROJECT:-current}}"
|
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
|
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
|
# 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
|
# 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.
|
# 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
|
&& -f "$SCRIPT_DIR/bundle-integrity.py" ]]; then
|
||||||
BUNDLE_MANIFEST="${CASAN_BUNDLE_MANIFEST:-$CASAN_GOVERNANCE_ROOT/harness-bundle-manifest.json}"
|
BUNDLE_MANIFEST="${CASAN_BUNDLE_MANIFEST:-$CASAN_GOVERNANCE_ROOT/harness-bundle-manifest.json}"
|
||||||
if [[ -f "$BUNDLE_MANIFEST" ]]; then
|
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 "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"
|
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:
|
# Canonical action classification decides whether the H2 registry and isolated
|
||||||
# it enforces idempotency key, per-agent permission, and rollback strategy
|
# executor are required. A classifier failure is treated as unknown/high-risk.
|
||||||
# before the command is allowed to execute. The wrapper already derived a
|
ACTION_RISK_JSON=""
|
||||||
# content-addressed idempotency key above.
|
ACTION_RISK_RC=0
|
||||||
case "$ACTION_NAME" in
|
ACTION_RISK_JSON="$(python3 "$KERNEL_CLI" risk --action "$ACTION_NAME" --tool "$ACTION_NAME" \
|
||||||
write_code|migration|deploy|db_write|external_api|write_file)
|
--command "$CMD_STR" --content-file "$SAFE_INPUT" --actor "${CASAN_ACTOR:-${CASAN_AGENT:-}}" \
|
||||||
run_phase "H2-gate" env CASAN_IDEMPOTENCY_KEY="$IDEMPOTENCY_KEY" "$SCRIPT_DIR/tool-registry-gate.sh" "$ACTION_NAME"
|
--environment "${CASAN_PROFILE:-development}" 2>/dev/null)" || ACTION_RISK_RC=$?
|
||||||
;;
|
if [[ "$ACTION_RISK_RC" -eq 0 && -n "$ACTION_RISK_JSON" ]]; then
|
||||||
esac
|
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
|
# 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.
|
# 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"
|
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
|
elif [[ "$#" -gt 0 ]]; then
|
||||||
CACHE_STATUS="stored"
|
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" -- \
|
run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" -- \
|
||||||
"$SCRIPT_DIR/tool-exec.sh" "$TOOL_TIMEOUT" -- "$@"
|
"$SCRIPT_DIR/tool-exec.sh" "$TOOL_TIMEOUT" -- "$@"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
CACHE_STATUS="stored"
|
CACHE_STATUS="stored"
|
||||||
run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT"
|
run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT"
|
||||||
fi
|
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
|
# 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
|
# model's context. Scan RAW_OUTPUT for injection/secret patterns before it is
|
||||||
# reused. Mode: off | warn (default) | block. Strict mode upgrades to block.
|
# reused. Mode: off | warn (default) | block. Strict mode upgrades to block.
|
||||||
@@ -170,7 +286,7 @@ fi
|
|||||||
TOOL_OUTPUT_SCAN_MODE="${CASAN_TOOL_OUTPUT_SCAN:-}"
|
TOOL_OUTPUT_SCAN_MODE="${CASAN_TOOL_OUTPUT_SCAN:-}"
|
||||||
if [[ -z "$TOOL_OUTPUT_SCAN_MODE" ]]; then
|
if [[ -z "$TOOL_OUTPUT_SCAN_MODE" ]]; then
|
||||||
# SEC-17/M-02: prod profile defaults tool-output scanning to block (fail-closed).
|
# 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
|
fi
|
||||||
if [[ "$TOOL_OUTPUT_SCAN_MODE" != "off" ]]; then
|
if [[ "$TOOL_OUTPUT_SCAN_MODE" != "off" ]]; then
|
||||||
TOS_RC=0
|
TOS_RC=0
|
||||||
@@ -204,6 +320,65 @@ EOF
|
|||||||
cp "$FINAL_OUTPUT" "$CACHE_OUT"
|
cp "$FINAL_OUTPUT" "$CACHE_OUT"
|
||||||
fi
|
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
|
write_phase_report
|
||||||
casan_log debug harness "action=$ACTION_NAME complete cache=$CACHE_STATUS"
|
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)"
|
TIMESTAMP="$(timestamp)"
|
||||||
INPUT="$(cat "$INPUT_FILE")"
|
INPUT="$(cat "$INPUT_FILE")"
|
||||||
LOWER_INPUT="$(printf '%s' "$INPUT" | tr '[:upper:]' '[:lower:]')"
|
LOWER_INPUT="$(printf '%s' "$INPUT" | tr '[:upper:]' '[:lower:]')"
|
||||||
ACTOR="${CASAN_ACTOR:-developer}"
|
ACTOR="${CASAN_ACTOR:-${CASAN_AGENT:-}}"
|
||||||
APPROVER="${CASAN_APPROVER:-}"
|
APPROVER="${CASAN_APPROVER:-}"
|
||||||
APPROVAL_DECISION="${CASAN_APPROVAL_DECISION:-auto}"
|
APPROVAL_DECISION="${CASAN_APPROVAL_DECISION:-auto}"
|
||||||
AUDIT_LOG="$AUDIT_DIR/audit.jsonl"
|
AUDIT_LOG="$AUDIT_DIR/audit.jsonl"
|
||||||
|
|
||||||
RISK_LEVEL="low"
|
RISK_LEVEL="low"
|
||||||
REASONS=()
|
REASONS=()
|
||||||
|
ACTION_CLASS="unknown"
|
||||||
case "$ACTION_NAME" in
|
RISK_FACTORS_JSON='{"action_risk":"high","content_risk":"high","environment_risk":"low","identity_risk":"low","resource_risk":"low"}'
|
||||||
deploy|launch|write_code|write_file|migration|db_write|external_api|tool_call)
|
EVIDENCE_REQUIREMENT="required"
|
||||||
RISK_LEVEL="medium"
|
RISK_POLICY_DECISION="require_approval"
|
||||||
REASONS+=("sensitive-action:$ACTION_NAME")
|
KERNEL_CLI="$CASAN_HARNESS_ROOT/scripts/python/kernel_cli.py"
|
||||||
;;
|
RISK_JSON=""
|
||||||
esac
|
RISK_RC=0
|
||||||
|
if [[ -f "$KERNEL_CLI" ]]; then
|
||||||
if printf '%s' "$LOWER_INPUT" | grep -Eq "(delete|drop table|password|api[_-]?key|secret|token|credential|migration|deploy|external api|shutdown|dump database)"; 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"
|
RISK_LEVEL="high"
|
||||||
REASONS+=("high-risk-content")
|
ACTION_CLASS="unknown"
|
||||||
elif printf '%s' "$LOWER_INPUT" | grep -Eq "(internal|config|system|policy|permission)"; then
|
RISK_POLICY_DECISION="require_approval"
|
||||||
[[ "$RISK_LEVEL" == "low" ]] && RISK_LEVEL="medium"
|
REASONS+=("action-risk-classifier-failed-closed")
|
||||||
REASONS+=("medium-risk-content")
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
APPROVAL_STATUS="auto_approved"
|
APPROVAL_STATUS="auto_approved"
|
||||||
@@ -88,8 +109,14 @@ if [[ "$RISK_LEVEL" == "medium" ]]; then
|
|||||||
APPROVAL_STATUS="policy_auto_approved_with_audit"
|
APPROVAL_STATUS="policy_auto_approved_with_audit"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$RISK_LEVEL" == "high" ]]; then
|
if [[ "$RISK_POLICY_DECISION" == "deny" ]]; then
|
||||||
if [[ "${CASAN_APPROVAL_STRICT:-0}" == "1" ]]; 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
|
# Approval-identity mode (V20): an env-var approver is NOT enough — the
|
||||||
# reviewer must cryptographically SIGN this exact request and their role must
|
# reviewer must cryptographically SIGN this exact request and their role must
|
||||||
# be authorized for the action. SoD (actor != approver) still enforced.
|
# 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]))')"
|
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
|
# approver and output_hash are part of the hashed core so they cannot be
|
||||||
# silently mutated after the fact.
|
# 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)"
|
RECORD_HASH="$(printf '%s' "$RECORD_CORE" | hash_text)"
|
||||||
|
|
||||||
TRACE_FILE="$TRACE_DIR/governance-$TRACE_ID.json"
|
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
|
# written (disk full, read-only, quota), there must be NO governed action without
|
||||||
# its accountability record — deny and empty the output rather than proceed.
|
# its accountability record — deny and empty the output rather than proceed.
|
||||||
if ! CASAN_GC_REASONS="$REASONS_JSON" python - "$TRACE_FILE" "$AUDIT_LOG" \
|
if ! CASAN_GC_REASONS="$REASONS_JSON" python - "$TRACE_FILE" "$AUDIT_LOG" \
|
||||||
"$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTOR" "$RISK_LEVEL" "$DECISION" \
|
"$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTION_CLASS" "$ACTOR" "$RISK_LEVEL" \
|
||||||
"$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH" "$RECORD_HASH" <<'PY'
|
"$RISK_FACTORS_JSON" "$EVIDENCE_REQUIREMENT" "$DECISION" "$APPROVAL_STATUS" \
|
||||||
|
"$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH" "$RECORD_HASH" <<'PY'
|
||||||
import json, os, sys
|
import json, os, sys
|
||||||
(trace_file, audit_log, ts, trace_id, action, actor, risk, decision,
|
(trace_file, audit_log, ts, trace_id, action, action_class, actor, risk,
|
||||||
approval_status, approver, input_hash, output_hash, prev_hash, record_hash) = sys.argv[1:]
|
risk_factors_json, evidence_requirement, decision, approval_status, approver,
|
||||||
|
input_hash, output_hash, prev_hash, record_hash) = sys.argv[1:]
|
||||||
try:
|
try:
|
||||||
reasons = json.loads(os.environ.get("CASAN_GC_REASONS") or "[]")
|
reasons = json.loads(os.environ.get("CASAN_GC_REASONS") or "[]")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
reasons = []
|
reasons = []
|
||||||
rec = {
|
rec = {
|
||||||
|
"schema_version": 2, "category": "runtime_control",
|
||||||
"timestamp": ts, "trace_id": trace_id, "harness": "H5-governance",
|
"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,
|
"approval_status": approval_status, "approver": approver,
|
||||||
"input_hash": input_hash, "output_hash": output_hash,
|
"input_hash": input_hash, "output_hash": output_hash,
|
||||||
"previous_record_hash": prev_hash, "record_hash": record_hash,
|
"previous_record_hash": prev_hash, "record_hash": record_hash,
|
||||||
@@ -196,8 +229,43 @@ fi
|
|||||||
# --- External anchor: cryptographically sign the new chain head ---
|
# --- External anchor: cryptographically sign the new chain head ---
|
||||||
# A re-forged chain (recomputed hashes) changes the head; without the private
|
# A re-forged chain (recomputed hashes) changes the head; without the private
|
||||||
# key the attacker cannot produce a matching signature, so verification fails.
|
# 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
|
# Development may use a local key for self-contained demonstration. Production
|
||||||
# here only for self-contained demonstration.
|
# 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
|
if command -v openssl >/dev/null 2>&1; then
|
||||||
# Private signing key lives OFF-REPO (default ~/.casan/audit-keys); only the
|
# Private signing key lives OFF-REPO (default ~/.casan/audit-keys); only the
|
||||||
# public key is committed. Production: replace with KMS/HSM.
|
# 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"
|
AUDIT_PUB="$PUB_DIR/audit-public.pem"
|
||||||
mkdir -p "$PUB_DIR" "$PRIV_DIR"
|
mkdir -p "$PUB_DIR" "$PRIV_DIR"
|
||||||
if [[ ! -f "$AUDIT_PRIV" ]]; then
|
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.
|
# 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
|
# 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
|
# 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
|
openssl dgst -sha256 -sign "$AUDIT_PRIV" -out "$AUDIT_DIR/audit-head.sig" "$AUDIT_DIR/audit-head.txt" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$DECISION" != "approved" ]]; then
|
if [[ "$DECISION" != "approved" ]]; then
|
||||||
: > "$OUTPUT_FILE"
|
: > "$OUTPUT_FILE"
|
||||||
|
|||||||
@@ -21,22 +21,22 @@ while IFS= read -r raw || [[ -n "$raw" ]]; do
|
|||||||
[[ "$line" =~ ^([A-Z0-9_]+)=(.*)$ ]] || fail "invalid_env_syntax"
|
[[ "$line" =~ ^([A-Z0-9_]+)=(.*)$ ]] || fail "invalid_env_syntax"
|
||||||
key="${BASH_REMATCH[1]}"; value="${BASH_REMATCH[2]}"
|
key="${BASH_REMATCH[1]}"; value="${BASH_REMATCH[2]}"
|
||||||
case "$key" in
|
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" ;;
|
*) fail "unexpected_env_key key=$key" ;;
|
||||||
esac
|
esac
|
||||||
done < "$ENV_FILE"
|
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
|
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
|
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"
|
[[ "$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"
|
[[ "$image" =~ @sha256:[a-f0-9]{64}$ ]] || fail "image_must_be_digest_pinned image=$image"
|
||||||
done
|
done
|
||||||
pass "public FQDN and images are production-safe"
|
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"
|
[[ -s "$file" ]] || fail "missing_or_empty path=$file"
|
||||||
done
|
done
|
||||||
openssl x509 -in "$CASAN_CP_TLS_DIR/tls.crt" -noout >/dev/null || fail "invalid_tls_certificate"
|
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}')"
|
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}')"
|
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"
|
[[ "$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"
|
pass "TLS certificate is valid for at least 30 days"
|
||||||
|
|
||||||
value_of() { sed -n -E "s/^${1}=//p" "$CASAN_CP_OAUTH_ENV" | tail -1; }
|
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_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_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_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"
|
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_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_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)"
|
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_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_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_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"
|
docker compose -f "$COMPOSE" config >/dev/null || fail "compose_config_invalid"
|
||||||
pass "production compose config"
|
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
|
# rootful Docker daemon because a compromised daemon socket defeats container
|
||||||
# isolation. Local developer/test profiles may use a rootful daemon, but cannot
|
# isolation. Local developer/test profiles may use a rootful daemon, but cannot
|
||||||
# claim that configuration as a hardened production runner.
|
# 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' \
|
docker info --format '{{json .SecurityOptions}}' 2>/dev/null | grep -q 'rootless' \
|
||||||
|| { echo "SANDBOX_CONTAINER_ROOTLESS_REQUIRED" >&2; exit 2; }
|
|| { echo "SANDBOX_CONTAINER_ROOTLESS_REQUIRED" >&2; exit 2; }
|
||||||
fi
|
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; }
|
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.
|
# check is the real gate; a container --pids-limit is the production backstop.
|
||||||
MAX_PROCS="${CASAN_SANDBOX_MAX_PROCS:-}"
|
MAX_PROCS="${CASAN_SANDBOX_MAX_PROCS:-}"
|
||||||
TIMEOUT="${CASAN_SANDBOX_TIMEOUT:-30}"
|
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
|
while [[ "$#" -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -54,12 +85,35 @@ if [[ "$#" -eq 0 ]]; then
|
|||||||
exit 64
|
exit 64
|
||||||
fi
|
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 …).
|
# isolation (sandbox-container.sh: --network=none --read-only --pids-limit …).
|
||||||
# Default stays the static-policy + ulimit scaffold so existing behaviour is
|
# A requested/required container backend never silently falls back.
|
||||||
# unchanged. Falls back to the scaffold if Docker is unavailable.
|
if [[ "$SANDBOX_MODE" == "container" ]]; then
|
||||||
if [[ "${CASAN_SANDBOX_MODE:-static}" == "container" ]] && command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; 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" -- "$@"
|
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
|
fi
|
||||||
|
|
||||||
CMD_STR="$*"
|
CMD_STR="$*"
|
||||||
@@ -96,6 +150,7 @@ done < <(printf '%s\n' "$CMD_STR" | grep -oE '>>?[[:space:]]*[^[:space:];|&]+' |
|
|||||||
|
|
||||||
# ── 2. Runtime rlimits + wall-clock timeout ─────────────────────────────────
|
# ── 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"
|
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 -f "$((MAX_FILE_KB * 2))" 2>/dev/null || true # ulimit -f is in 512-byte blocks
|
||||||
ulimit -t "$CPU_SECONDS" 2>/dev/null || true
|
ulimit -t "$CPU_SECONDS" 2>/dev/null || true
|
||||||
|
|||||||
@@ -45,13 +45,23 @@ with open(path, encoding="utf-8") as f:
|
|||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
record = json.loads(line)
|
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([
|
core = "|".join([
|
||||||
record.get("timestamp",""), record.get("trace_id",""),
|
record.get("timestamp",""), record.get("trace_id",""),
|
||||||
record.get("action",""), record.get("actor",""),
|
record.get("action",""), record.get("actor",""),
|
||||||
record.get("risk_level",""), record.get("decision",""),
|
record.get("risk_level",""), record.get("decision",""),
|
||||||
record.get("approval_status",""), record.get("approver",""),
|
record.get("approval_status",""), record.get("approver",""),
|
||||||
record.get("input_hash",""), record.get("output_hash",""),
|
record.get("input_hash",""), record.get("output_hash",""), previous,
|
||||||
previous,
|
|
||||||
])
|
])
|
||||||
previous = hashlib.sha256(core.encode()).hexdigest()
|
previous = hashlib.sha256(core.encode()).hexdigest()
|
||||||
print(previous)
|
print(previous)
|
||||||
@@ -68,6 +78,10 @@ printf '%s' "$HEAD_HASH" > "$HEAD_FILE"
|
|||||||
# ── Sign the head file ────────────────────────────────────────────────────
|
# ── Sign the head file ────────────────────────────────────────────────────
|
||||||
VAULT_KMS="$SCRIPT_DIR/vault-kms.sh"
|
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:-}" ]] && \
|
if [[ -n "${VAULT_ADDR:-}" && -n "${VAULT_TOKEN:-}" ]] && \
|
||||||
curl -sf "$VAULT_ADDR/v1/sys/health" >/dev/null 2>&1; then
|
curl -sf "$VAULT_ADDR/v1/sys/health" >/dev/null 2>&1; then
|
||||||
# KMS path — sign via Vault Transit, export public key
|
# KMS path — sign via Vault Transit, export public key
|
||||||
@@ -103,7 +117,7 @@ PY
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# Fallback — local key (dev environment without Vault)
|
# 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
|
echo "SIGN_AUDIT_HEAD_FAIL reason=vault_kms_required_in_prod" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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}"
|
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(
|
core = "|".join(
|
||||||
[
|
[
|
||||||
record.get("timestamp", ""),
|
record.get("timestamp", ""), record.get("trace_id", ""),
|
||||||
record.get("trace_id", ""),
|
record.get("action", ""), record.get("actor", ""),
|
||||||
record.get("action", ""),
|
record.get("risk_level", ""), record.get("decision", ""),
|
||||||
record.get("actor", ""),
|
record.get("approval_status", ""), record.get("approver", ""),
|
||||||
record.get("risk_level", ""),
|
record.get("input_hash", ""), record.get("output_hash", ""),
|
||||||
record.get("decision", ""),
|
|
||||||
record.get("approval_status", ""),
|
|
||||||
record.get("approver", ""),
|
|
||||||
record.get("input_hash", ""),
|
|
||||||
record.get("output_hash", ""),
|
|
||||||
expected_previous,
|
expected_previous,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,8 +40,23 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
SCHEMA_VERSION = "20.1"
|
HARNESS_PACKAGE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
ADAPTER_DEFAULT_VERSION = "20.1.0"
|
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) ──────────────────────
|
# ── Certification strength ranking (higher == stronger) ──────────────────────
|
||||||
STRENGTH_RANK = {
|
STRENGTH_RANK = {
|
||||||
@@ -77,6 +92,20 @@ TOOL_REGISTRY_ACTION = {
|
|||||||
"str_replace_editor": "write_file",
|
"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
|
# Path resolution (mirrors scripts/bash/casan-paths.sh so state lands in the
|
||||||
@@ -145,6 +174,12 @@ def trace_event_dir():
|
|||||||
return d
|
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():
|
def metrics_log():
|
||||||
override = os.environ.get("CASAN_TELEMETRY_METRICS_LOG") or os.environ.get(
|
override = os.environ.get("CASAN_TELEMETRY_METRICS_LOG") or os.environ.get(
|
||||||
"CASAN_METRICS_LOG"
|
"CASAN_METRICS_LOG"
|
||||||
@@ -612,28 +647,52 @@ def h4_scan(text, mode="input"):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def h2_registry_gate(action, idempotency_key):
|
def h2_registry_gate(action, tool, actor, execution_id, idempotency_key):
|
||||||
"""H2 tool-registry gate for a mapped side-effect action."""
|
"""Return a structured H2 policy decision for every dependency outcome."""
|
||||||
script = os.path.join(gates_dir(), "tool-registry-gate.sh")
|
script = os.environ.get("CASAN_H2_GATE_PATH") or os.path.join(
|
||||||
if not os.path.exists(script) or not bash_available():
|
gates_dir(), "tool-registry-gate.sh")
|
||||||
return True, "h2_gate_missing"
|
context = H2GateContext(
|
||||||
env = dict(os.environ)
|
mode=enforcement_mode(),
|
||||||
env["CASAN_IDEMPOTENCY_KEY"] = idempotency_key
|
actor=actor or "",
|
||||||
try:
|
action=action,
|
||||||
proc = subprocess.run(
|
tool=tool or action,
|
||||||
[bash_bin(), script, action],
|
execution_id=execution_id,
|
||||||
stdout=subprocess.PIPE,
|
enforcement_path="agentic_bridge.pre_tool.h2_registry",
|
||||||
stderr=subprocess.PIPE,
|
idempotency_key=idempotency_key,
|
||||||
timeout=internal_timeout(),
|
timeout_seconds=internal_timeout(),
|
||||||
env=env,
|
|
||||||
)
|
)
|
||||||
if proc.returncode == 0:
|
return evaluate_h2_gate(
|
||||||
return True, "h2_ok"
|
script,
|
||||||
return False, "h2_denied"
|
context,
|
||||||
except subprocess.TimeoutExpired:
|
env=os.environ,
|
||||||
return False, "h2_internal_timeout"
|
bash_path=bash_bin(),
|
||||||
except (OSError, ValueError) as exc:
|
)
|
||||||
return False, "h2_error:%s" % exc
|
|
||||||
|
|
||||||
|
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"),
|
"kind": evidence.get("kind"),
|
||||||
"decision": evidence.get("decision"),
|
"decision": evidence.get("decision"),
|
||||||
"detail": evidence.get("detail"),
|
"detail": evidence.get("detail"),
|
||||||
|
"category": evidence.get("category"),
|
||||||
|
"facts": evidence.get("facts", {}),
|
||||||
"certification_strength": rec.get(
|
"certification_strength": rec.get(
|
||||||
"certification_strength"),
|
"certification_strength"),
|
||||||
},
|
},
|
||||||
@@ -700,13 +761,16 @@ def write_trace_events(rec, evidence):
|
|||||||
event, ensure_ascii=False, separators=(",", ":")) + "\n")
|
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 = {
|
||||||
|
"evidence_id": "%s:%s" % (rec.get("trace_id", "trace"), len(rec.get("evidence", [])) + 1),
|
||||||
"h": h,
|
"h": h,
|
||||||
|
"category": category,
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
"decision": decision,
|
"decision": decision,
|
||||||
"at": now_iso(),
|
"at": now_iso(),
|
||||||
"detail": redact(detail, 160),
|
"detail": redact(detail, 160),
|
||||||
|
"facts": facts if isinstance(facts, dict) else {},
|
||||||
}
|
}
|
||||||
rec.setdefault("evidence", []).append(evidence)
|
rec.setdefault("evidence", []).append(evidence)
|
||||||
write_trace_events(rec, evidence)
|
write_trace_events(rec, evidence)
|
||||||
@@ -828,6 +892,27 @@ def write_h6_record(rec, status, quality, warnings, missing):
|
|||||||
return record
|
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):
|
def write_trace_file(rec, certified, reasons):
|
||||||
path = os.path.join(trace_dir(), "agentic-%s.json" % rec.get("trace_id"))
|
path = os.path.join(trace_dir(), "agentic-%s.json" % rec.get("trace_id"))
|
||||||
payload = {
|
payload = {
|
||||||
@@ -851,6 +936,9 @@ def write_trace_file(rec, certified, reasons):
|
|||||||
"failures": rec.get("failures", 0),
|
"failures": rec.get("failures", 0),
|
||||||
"certified": certified,
|
"certified": certified,
|
||||||
"certification_reasons": reasons,
|
"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", []),
|
"evidence": rec.get("evidence", []),
|
||||||
}
|
}
|
||||||
atomic_write_json(path, payload)
|
atomic_write_json(path, payload)
|
||||||
@@ -886,6 +974,8 @@ def op_begin(req):
|
|||||||
"client": client,
|
"client": client,
|
||||||
"client_version": req.get("client_version"),
|
"client_version": req.get("client_version"),
|
||||||
"adapter_version": req.get("adapter_version") or ADAPTER_DEFAULT_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_root": project,
|
||||||
"project_id": project_id(project),
|
"project_id": project_id(project),
|
||||||
"session_id_hash": salted_hash(req.get("session")),
|
"session_id_hash": salted_hash(req.get("session")),
|
||||||
@@ -900,8 +990,10 @@ def op_begin(req):
|
|||||||
"state": "Admitted",
|
"state": "Admitted",
|
||||||
"tool_calls": 0,
|
"tool_calls": 0,
|
||||||
"failures": 0,
|
"failures": 0,
|
||||||
|
"side_effect_failures": 0,
|
||||||
"retries": 0,
|
"retries": 0,
|
||||||
"evidence": [],
|
"evidence": [],
|
||||||
|
"policy_decisions": [],
|
||||||
"telemetry": {},
|
"telemetry": {},
|
||||||
"bypass_signal": False,
|
"bypass_signal": False,
|
||||||
"finalized": False,
|
"finalized": False,
|
||||||
@@ -1012,30 +1104,109 @@ def op_pre_tool(req):
|
|||||||
# record the degradation but do not block the developer.
|
# record the degradation but do not block the developer.
|
||||||
add_evidence(rec, "H4", "pre-tool", "degraded", "%s:gate_unavailable_no_bash" % tool)
|
add_evidence(rec, "H4", "pre-tool", "degraded", "%s:gate_unavailable_no_bash" % tool)
|
||||||
|
|
||||||
# H2 registry gate for mapped side-effect actions. Opt-in via
|
tool_input_string = tool_input_text if isinstance(tool_input_text, str) else json.dumps(
|
||||||
# CASAN_AGENTIC_H2_REGISTRY=1: the tool-registry is keyed on NAMED CASAN
|
tool_input_text, ensure_ascii=False, sort_keys=True) if tool_input_text is not None else ""
|
||||||
# agent identities, which the transparent developer flow does not carry, so
|
# Native hook payloads commonly wrap a shell command in {"command": ...}.
|
||||||
# enabling it unconditionally would deny every write. The always-on H2
|
# Classify the actual command while retaining the complete serialized input
|
||||||
# equivalent for this flow is the admission gate above (a side effect
|
# for scanning, governance evidence and hashing.
|
||||||
# without a valid admission is denied). Managed deployments that define
|
risk_command = tool_input_string
|
||||||
# agent identities can turn the registry gate on for defence in depth.
|
if isinstance(tool_input_text, dict) and isinstance(tool_input_text.get("command"), str):
|
||||||
registry_on = os.environ.get("CASAN_AGENTIC_H2_REGISTRY", "0") in ("1", "true", "yes")
|
risk_command = tool_input_text["command"]
|
||||||
if side_effect and registry_on and enforcement_mode() == "enforce":
|
risk = evaluate_risk(
|
||||||
action = TOOL_REGISTRY_ACTION.get((tool or "").strip().lower())
|
action=str(req.get("action") or ""),
|
||||||
if action:
|
tool=tool,
|
||||||
key = plain_hash("%s|%s|%s" % (rec["trace_id"], tool, redact(tool_input_text)))[:24]
|
resource=str(req.get("resource") or ""),
|
||||||
ok, reason = h2_registry_gate(action, key)
|
command=risk_command,
|
||||||
if not ok:
|
content=tool_input_string,
|
||||||
add_evidence(rec, "H2", "pre-tool", "deny", "%s:%s" % (tool, reason))
|
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)
|
save_admission(rec)
|
||||||
return _base_response("pre-tool", "deny", admission_id=admission_id,
|
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",
|
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)
|
save_admission(rec)
|
||||||
return _base_response("pre-tool", "allow", admission_id=admission_id,
|
response = _base_response("pre-tool", "allow", admission_id=admission_id,
|
||||||
trace_id=rec.get("trace_id"), reason="allowed")
|
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):
|
def op_post_tool(req):
|
||||||
@@ -1045,11 +1216,52 @@ def op_post_tool(req):
|
|||||||
reason="no_admission")
|
reason="no_admission")
|
||||||
rec["tool_calls"] = rec.get("tool_calls", 0) + 1
|
rec["tool_calls"] = rec.get("tool_calls", 0) + 1
|
||||||
status = req.get("status") or "success"
|
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
|
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,
|
add_evidence(rec, "H5", "post-tool", status,
|
||||||
"tool=%s dur_ms=%s result=%s" % (req.get("tool"), req.get("duration_ms"),
|
"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)
|
save_admission(rec)
|
||||||
return _base_response("post-tool", "recorded", admission_id=rec["admission_id"],
|
return _base_response("post-tool", "recorded", admission_id=rec["admission_id"],
|
||||||
trace_id=rec.get("trace_id"), reason="evidence_appended")
|
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"))
|
rec["summary_hash"] = salted_hash(req.get("assistant_summary"))
|
||||||
stop_reason = req.get("stop_reason") or "completed"
|
stop_reason = req.get("stop_reason") or "completed"
|
||||||
status = "success" if stop_reason in ("completed", "max_turns") else "failed"
|
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 = {
|
observed_harnesses = {
|
||||||
harness
|
harness
|
||||||
@@ -1110,6 +1326,8 @@ def op_finalize(req):
|
|||||||
# H3/H5/H7 finalize controls: run the H4 output filter over the assistant
|
# H3/H5/H7 finalize controls: run the H4 output filter over the assistant
|
||||||
# summary as the closing verification control.
|
# summary as the closing verification control.
|
||||||
reasons = []
|
reasons = []
|
||||||
|
if rec.get("failures", 0) > 0:
|
||||||
|
reasons.append("tool_execution_failed")
|
||||||
if req.get("assistant_summary"):
|
if req.get("assistant_summary"):
|
||||||
h4 = h4_scan(req.get("assistant_summary"), "output")
|
h4 = h4_scan(req.get("assistant_summary"), "output")
|
||||||
if h4 == "ok":
|
if h4 == "ok":
|
||||||
@@ -1158,7 +1376,17 @@ def op_finalize(req):
|
|||||||
reasons.append("coverage_bypass")
|
reasons.append("coverage_bypass")
|
||||||
if status != "success":
|
if status != "success":
|
||||||
certified = False
|
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:
|
if not certified and not reasons:
|
||||||
reasons.append("unknown")
|
reasons.append("unknown")
|
||||||
if certified:
|
if certified:
|
||||||
@@ -1168,12 +1396,15 @@ def op_finalize(req):
|
|||||||
rec["certified"] = certified
|
rec["certified"] = certified
|
||||||
rec["finalized"] = True
|
rec["finalized"] = True
|
||||||
rec["finalized_at"] = now_iso()
|
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(
|
add_evidence(
|
||||||
rec,
|
rec,
|
||||||
"H7",
|
"H7",
|
||||||
"certification",
|
"certification",
|
||||||
"certified" if certified else "non_certified",
|
"certified" if certified else "non_certified",
|
||||||
",".join(reasons),
|
",".join(reasons),
|
||||||
|
category="certification_claim",
|
||||||
)
|
)
|
||||||
|
|
||||||
quality, warnings, missing = classify_telemetry(rec)
|
quality, warnings, missing = classify_telemetry(rec)
|
||||||
@@ -1185,13 +1416,26 @@ def op_finalize(req):
|
|||||||
",".join(warnings) if warnings else "provider_usage_complete",
|
",".join(warnings) if warnings else "provider_usage_complete",
|
||||||
)
|
)
|
||||||
h6_record = write_h6_record(rec, status, quality, warnings, missing)
|
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)
|
trace_path = write_trace_file(rec, certified, reasons)
|
||||||
save_admission(rec)
|
save_admission(rec)
|
||||||
|
|
||||||
resp = _base_response("finalize", "certified" if certified else "non_certified",
|
resp = _base_response("finalize", "certified" if certified else "non_certified",
|
||||||
admission_id=rec["admission_id"], trace_id=rec.get("trace_id"),
|
admission_id=rec["admission_id"], trace_id=rec.get("trace_id"),
|
||||||
certification_strength=strength, telemetry_quality=quality,
|
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["warnings"].extend(warnings)
|
||||||
resp["context"] = "trace=%s certified=%s" % (os.path.basename(trace_path), certified)
|
resp["context"] = "trace=%s certified=%s" % (os.path.basename(trace_path), certified)
|
||||||
report_url = dashboard_url(rec.get("trace_id"))
|
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]:
|
) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"gate": gate,
|
"gate": gate,
|
||||||
|
"legacy_gate": gate,
|
||||||
|
"category": "readiness_check",
|
||||||
|
"check_id": "ReadinessCheck.%s" % gate,
|
||||||
"title": title,
|
"title": title,
|
||||||
"status": status,
|
"status": status,
|
||||||
"summary": summary,
|
"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) ====="
|
echo "===== C1: normal turn = one admission + one trace + one metric (single model) ====="
|
||||||
newstate
|
newstate
|
||||||
export CASAN_AGENTIC_ENFORCEMENT_MODE=enforce
|
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"}')
|
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)
|
DEC=$(printf '%s' "$B" | field decision)
|
||||||
AID=$(printf '%s' "$B" | field admission_id)
|
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)
|
AID=$(printf '%s' "$B" | field admission_id); TID=$(printf '%s' "$B" | field trace_id)
|
||||||
OK3=1
|
OK3=1
|
||||||
for tool in Bash Edit Write; do
|
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
|
[[ "$(printf '%s' "$R" | field decision)" == "allow" ]] || OK3=0
|
||||||
bridge '{"op":"post-tool","admission_id":"'"$AID"'","tool":"'"$tool"'","status":"success"}' >/dev/null
|
bridge '{"op":"post-tool","admission_id":"'"$AID"'","tool":"'"$tool"'","status":"success"}' >/dev/null
|
||||||
done
|
done
|
||||||
@@ -245,7 +248,7 @@ AID=$(printf '%s' "$B" | field admission_id)
|
|||||||
|| fail "no-bash begin did not degrade gracefully ($B)"
|
|| fail "no-bash begin did not degrade gracefully ($B)"
|
||||||
R=$(bridge '{"op":"pre-tool","admission_id":"'"$AID"'","tool":"Bash","tool_input":"ls","project":"'"$PROJ"'"}')
|
R=$(bridge '{"op":"pre-tool","admission_id":"'"$AID"'","tool":"Bash","tool_input":"ls","project":"'"$PROJ"'"}')
|
||||||
[[ "$(printf '%s' "$R" | field decision)" == "allow" ]] \
|
[[ "$(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)"
|
|| fail "no-bash pre-tool blocked the developer ($R)"
|
||||||
F=$(bridge '{"op":"finalize","admission_id":"'"$AID"'","stop_reason":"completed"}')
|
F=$(bridge '{"op":"finalize","admission_id":"'"$AID"'","stop_reason":"completed"}')
|
||||||
[[ "$(printf '%s' "$F" | field decision)" == "non_certified" ]] \
|
[[ "$(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)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
source "$SCRIPT_DIR/../scripts/bash/casan-paths.sh"
|
source "$SCRIPT_DIR/../scripts/bash/casan-paths.sh"
|
||||||
SHIP="$CASAN_HARNESS_ROOT/scripts/bash/audit-ship-s3.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
|
WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
|
||||||
PASS=0; FAIL=0
|
PASS=0; FAIL=0
|
||||||
pass() { echo "PASS: $1"; PASS=$((PASS+1)); }
|
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 ]] \
|
[[ "$(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"
|
&& 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 ====="
|
echo "===== PRODUCTION HANDOFF SUMMARY: PASS=$PASS FAIL=$FAIL ====="
|
||||||
[[ "$FAIL" -eq 0 ]]
|
[[ "$FAIL" -eq 0 ]]
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ set -e
|
|||||||
|
|
||||||
# H5: high-risk action approved with explicit approver
|
# H5: high-risk action approved with explicit approver
|
||||||
APPROVED_OUT="$EVIDENCE_DIR/04-high-risk-approved-output.txt"
|
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"
|
"$SCRIPTS/governance-check.sh" "$RISK_IN" "$APPROVED_OUT" deploy > "$EVIDENCE_DIR/04-governance-approve.stdout"
|
||||||
assert_contains "$APPROVED_OUT" "Deploy"
|
assert_contains "$APPROVED_OUT" "Deploy"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user