From 574551912659d62378d9a1928e702f0e9f986f20 Mon Sep 17 00:00:00 2001 From: thanhnv Date: Sun, 2 Aug 2026 23:24:51 +0700 Subject: [PATCH] feat(casan): establish assurance kernel and harden control plane --- Dockerfile.control-panel-api | 2 +- docker-compose.control-panel.local.yml | 15 +- docker-compose.control-panel.yml | 12 +- docs/architecture/CASAN_ASSURANCE_KERNEL.md | 141 ++++++ docs/architecture/CASAN_CONTROL_PLANE.md | 113 +++++ docs/architecture/CASAN_EXECUTION_ADAPTERS.md | 95 +++++ docs/architecture/CASAN_TRUST_BOUNDARIES.md | 109 +++++ ..._ASSURANCE_KERNEL_CONTROL_PLANE_UPGRADE.md | 221 ++++++++++ .../CASAN_ASSURANCE_KERNEL_MIGRATION.md | 126 ++++++ docs/security/CONTROL_PANEL_AUTH_HARDENING.md | 36 +- infra/local-prod/idp/mock_idp.py | 8 +- infra/production/README.md | 15 +- infra/production/casan-prod.env.example | 2 + infra/production/oauth2-proxy.env.example | 4 +- infra/production/runtime.env.example | 11 +- nginx/control-panel.conf | 16 +- packages/casan-control-panel/README.md | 29 +- .../backend/src/common/auth-context.ts | 23 +- .../backend/src/common/auth-provider.ts | 270 ++++++++++++ .../casan-control-panel/backend/src/main.ts | 21 +- .../backend/src/reports/h6-report.ts | 2 + .../backend/src/reports/report.contract.ts | 6 + .../backend/test/auth-context.test.ts | 14 + .../backend/test/auth-provider.test.ts | 110 +++++ .../scripts/local-prod-smoke.sh | 8 + .../casan-harness/config/action-classes.json | 78 ++++ packages/casan-harness/kernel/__init__.py | 25 ++ packages/casan-harness/kernel/adapters.py | 194 +++++++++ packages/casan-harness/kernel/contracts.py | 172 ++++++++ packages/casan-harness/kernel/policy.py | 400 ++++++++++++++++++ packages/casan-harness/kernel/supervision.py | 42 ++ packages/casan-harness/kernel/taxonomy.py | 43 ++ .../schemas/agentic-lifecycle.schema.json | 6 +- .../schemas/assurance-kernel.schema.json | 153 +++++++ .../scripts/bash/agent-metrics.sh | 4 + .../scripts/bash/casan-harness.sh | 205 ++++++++- .../scripts/bash/governance-check.sh | 119 ++++-- .../scripts/bash/production-preflight.sh | 28 +- .../scripts/bash/sandbox-container.sh | 7 +- .../casan-harness/scripts/bash/sandbox-run.sh | 65 ++- .../scripts/bash/sign-audit-head.sh | 32 +- .../scripts/bash/verify-audit-chain.sh | 35 +- .../scripts/python/agentic_bridge.py | 342 ++++++++++++--- .../scripts/python/kernel_cli.py | 156 +++++++ .../casan-harness/scripts/python/readiness.py | 3 + .../tests/assurance-kernel-tests.py | 320 ++++++++++++++ .../assurance-upgrade-integration-tests.py | 156 +++++++ .../tests/phase-agentic-bridge-tests.sh | 7 +- .../tests/phase-assurance-upgrade-tests.sh | 148 +++++++ .../tests/phase-production-preflight-tests.sh | 105 +++++ .../tests/run-casan4-harness-tests.sh | 2 +- 51 files changed, 4076 insertions(+), 180 deletions(-) create mode 100644 docs/architecture/CASAN_ASSURANCE_KERNEL.md create mode 100644 docs/architecture/CASAN_CONTROL_PLANE.md create mode 100644 docs/architecture/CASAN_EXECUTION_ADAPTERS.md create mode 100644 docs/architecture/CASAN_TRUST_BOUNDARIES.md create mode 100644 docs/implementation/CASAN_ASSURANCE_KERNEL_CONTROL_PLANE_UPGRADE.md create mode 100644 docs/migration/CASAN_ASSURANCE_KERNEL_MIGRATION.md create mode 100644 packages/casan-control-panel/backend/src/common/auth-provider.ts create mode 100644 packages/casan-control-panel/backend/test/auth-provider.test.ts create mode 100644 packages/casan-harness/config/action-classes.json create mode 100644 packages/casan-harness/kernel/__init__.py create mode 100644 packages/casan-harness/kernel/adapters.py create mode 100644 packages/casan-harness/kernel/contracts.py create mode 100644 packages/casan-harness/kernel/policy.py create mode 100644 packages/casan-harness/kernel/supervision.py create mode 100644 packages/casan-harness/kernel/taxonomy.py create mode 100644 packages/casan-harness/schemas/assurance-kernel.schema.json create mode 100644 packages/casan-harness/scripts/python/kernel_cli.py create mode 100644 packages/casan-harness/tests/assurance-kernel-tests.py create mode 100644 packages/casan-harness/tests/assurance-upgrade-integration-tests.py create mode 100644 packages/casan-harness/tests/phase-assurance-upgrade-tests.sh diff --git a/Dockerfile.control-panel-api b/Dockerfile.control-panel-api index 6574734..bc3fd96 100644 --- a/Dockerfile.control-panel-api +++ b/Dockerfile.control-panel-api @@ -28,6 +28,7 @@ COPY packages/casan-control-panel/frontend/package.json ./packages/casan-control COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/packages/casan-control-panel/backend/dist ./packages/casan-control-panel/backend/dist COPY packages/casan-harness/scripts ./packages/casan-harness/scripts +COPY packages/casan-harness/kernel ./packages/casan-harness/kernel COPY packages/casan-harness/config ./packages/casan-harness/config COPY packages/casan-harness/security ./packages/casan-harness/security COPY packages/casan-harness/config/project-registry.json ./packages/casan-harness/config/project-registry.json @@ -46,7 +47,6 @@ WORKDIR /app/packages/casan-control-panel/backend ENV NODE_ENV=production ENV CASAN_PROFILE=prod ENV CASAN_CP_STRICT=1 -ENV CASAN_CP_TRUST_AUTH_PROXY=1 ENV CP_BIND=0.0.0.0 ENV CP_PORT=3010 diff --git a/docker-compose.control-panel.local.yml b/docker-compose.control-panel.local.yml index 867fa90..bb3ba97 100644 --- a/docker-compose.control-panel.local.yml +++ b/docker-compose.control-panel.local.yml @@ -11,7 +11,12 @@ services: environment: CASAN_PROFILE: prod CASAN_CP_STRICT: "1" - CASAN_CP_TRUST_AUTH_PROXY: "1" + CASAN_CP_AUTH_MODE: jwt + CASAN_CP_JWT_ISSUER: http://localhost:18082 + CASAN_CP_JWT_AUDIENCE: casan-control-panel + CASAN_CP_JWT_PUBLIC_KEY_FILE: /run/casan-idp/idp-public.pem + CASAN_CP_JWT_ROLE_CLAIM: groups + CASAN_CP_JWT_CLOCK_SKEW_SECONDS: "30" CP_BIND: 0.0.0.0 CP_PORT: "3010" CASAN_APP_ROOT: /app @@ -63,6 +68,7 @@ services: - ./apps/service-desk:/app/apps/service-desk - ./apps/projects:/app/apps/projects - ./packages/casan-harness/config/project-registry.json:/app/packages/casan-harness/config/project-registry.json + - ./tmp/control-panel-local/idp-public.pem:/run/casan-idp/idp-public.pem:ro expose: - "3010" networks: @@ -111,8 +117,8 @@ services: - --oidc-groups-claim=groups - --reverse-proxy=true - --set-xauthrequest=true - - --pass-access-token=false - - --pass-authorization-header=false + - --pass-access-token=true + - --pass-authorization-header=true - --skip-provider-button=true - --ssl-insecure-skip-verify=true expose: @@ -129,7 +135,10 @@ services: CASAN_IDP_SUB: oidc-ops CASAN_IDP_EMAIL: oidc-ops@example.com CASAN_IDP_GROUPS: casan-org-admin,casan-approver,casan-project:AINative_OKR_CASAN4 + CASAN_IDP_PRIVATE_KEY_FILE: /run/casan-idp/idp-private.pem CASAN_APPROVAL_SIGNER_TOKEN: ${CASAN_APPROVAL_SIGNER_TOKEN:-local-approval-signer-secret} + volumes: + - ./tmp/control-panel-local/idp-private.pem:/run/casan-idp/idp-private.pem:ro ports: - "18082:8080" networks: diff --git a/docker-compose.control-panel.yml b/docker-compose.control-panel.yml index ddb8a80..ec4a0fd 100644 --- a/docker-compose.control-panel.yml +++ b/docker-compose.control-panel.yml @@ -3,9 +3,9 @@ # `production-preflight.sh` validates all files, endpoints, Object Lock and # images before `docker compose up` is allowed. # -# oauth2-proxy must emit X-Auth-Request-User and X-Auth-Request-Groups. -# Nginx overwrites X-CASAN-* headers before proxying to the API; the API maps -# groups such as casan-approver -> approver via rbac-check.py map-claim. +# oauth2-proxy must emit a signed OIDC access token. Nginx removes caller-supplied +# identity headers and forwards only that bearer token; the API verifies issuer, +# audience, expiry and signature in-process before deriving CASAN identity. services: control-panel-api: @@ -19,7 +19,6 @@ services: environment: CASAN_PROFILE: prod CASAN_CP_STRICT: "1" - CASAN_CP_TRUST_AUTH_PROXY: "1" CP_BIND: 0.0.0.0 CP_PORT: "3010" CASAN_APP_ROOT: /app @@ -29,6 +28,7 @@ services: volumes: - ${CASAN_CP_STATE_DIR:?Set CASAN_CP_STATE_DIR}:/app/.specify - ${CASAN_CP_OUTPUT_DIR:?Set CASAN_CP_OUTPUT_DIR}:/app/docs/output:ro + - ${CASAN_CP_IDP_PUBLIC_KEY:?Set CASAN_CP_IDP_PUBLIC_KEY}:/run/casan-idp/idp-public.pem:ro expose: - "3010" networks: @@ -57,8 +57,8 @@ services: - --http-address=0.0.0.0:4180 - --reverse-proxy=true - --set-xauthrequest=true - - --pass-access-token=false - - --pass-authorization-header=false + - --pass-access-token=true + - --pass-authorization-header=true - --skip-provider-button=true - --cookie-secure=true - --cookie-httponly=true diff --git a/docs/architecture/CASAN_ASSURANCE_KERNEL.md b/docs/architecture/CASAN_ASSURANCE_KERNEL.md new file mode 100644 index 0000000..146999c --- /dev/null +++ b/docs/architecture/CASAN_ASSURANCE_KERNEL.md @@ -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. diff --git a/docs/architecture/CASAN_CONTROL_PLANE.md b/docs/architecture/CASAN_CONTROL_PLANE.md new file mode 100644 index 0000000..e6c88f8 --- /dev/null +++ b/docs/architecture/CASAN_CONTROL_PLANE.md @@ -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. diff --git a/docs/architecture/CASAN_EXECUTION_ADAPTERS.md b/docs/architecture/CASAN_EXECUTION_ADAPTERS.md new file mode 100644 index 0000000..313a961 --- /dev/null +++ b/docs/architecture/CASAN_EXECUTION_ADAPTERS.md @@ -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.` 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. diff --git a/docs/architecture/CASAN_TRUST_BOUNDARIES.md b/docs/architecture/CASAN_TRUST_BOUNDARIES.md new file mode 100644 index 0000000..02bb2a3 --- /dev/null +++ b/docs/architecture/CASAN_TRUST_BOUNDARIES.md @@ -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. diff --git a/docs/implementation/CASAN_ASSURANCE_KERNEL_CONTROL_PLANE_UPGRADE.md b/docs/implementation/CASAN_ASSURANCE_KERNEL_CONTROL_PLANE_UPGRADE.md new file mode 100644 index 0000000..2334a87 --- /dev/null +++ b/docs/implementation/CASAN_ASSURANCE_KERNEL_CONTROL_PLANE_UPGRADE.md @@ -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. diff --git a/docs/migration/CASAN_ASSURANCE_KERNEL_MIGRATION.md b/docs/migration/CASAN_ASSURANCE_KERNEL_MIGRATION.md new file mode 100644 index 0000000..aae1081 --- /dev/null +++ b/docs/migration/CASAN_ASSURANCE_KERNEL_MIGRATION.md @@ -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. diff --git a/docs/security/CONTROL_PANEL_AUTH_HARDENING.md b/docs/security/CONTROL_PANEL_AUTH_HARDENING.md index 1cd5c50..89d60e3 100644 --- a/docs/security/CONTROL_PANEL_AUTH_HARDENING.md +++ b/docs/security/CONTROL_PANEL_AUTH_HARDENING.md @@ -10,20 +10,26 @@ This document defines the boundary between the local workstation experience and flowchart LR B["Browser"] -->|TLS + OIDC cookie| N["Nginx"] N -->|auth_request| O["oauth2-proxy"] - N -->|overwritten identity headers| A["Control Panel API"] + N -->|signed bearer token; identity headers removed| A["Control Panel API"] + A -->|verify RS256 + issuer + audience + time claims| I["VerifiedClaims"] A -->|tenant-scoped encrypted store| S["CASAN state"] A -->|random bridge token, local only| H["Mac host bridge"] H -->|official CLI auth| P["Codex / Claude"] ``` -The API must not be published directly. Nginx is the only ingress and overwrites `X-CASAN-Actor`, `X-CASAN-Groups`, and `X-CASAN-Role`. The API refuses a non-loopback strict bind unless `CASAN_CP_TRUST_AUTH_PROXY=1` is explicit. +The API must not be published directly. Nginx is the only ingress and removes +`X-CASAN-*` and forwarded identity headers. The API refuses every production or +non-loopback bind unless `CASAN_CP_AUTH_MODE=jwt` has a valid cryptographic +verifier. Proxy headers alone are never authenticated identity. ## Implemented controls - TLS at Nginx; production accepts only TLS 1.2/1.3. - OIDC authentication through oauth2-proxy. - Secure, HttpOnly, SameSite=Lax session cookies with bounded expiry/refresh. -- Browser-supplied identity headers are overwritten at Nginx. +- Browser-supplied identity headers are removed at Nginx. +- The API verifies RS256 signature, issuer, audience, subject, expiry, + not-before/issued-at bounds and clock skew before deriving request identity. - CSP, frame denial, MIME sniffing protection, referrer and browser permission restrictions. - API request body capped at 1 MiB. - SSE buffering disabled for trace streams; no intermediate proxy cache. @@ -53,21 +59,26 @@ Before production deployment: 2. Do not deploy `provider-auth-bridge.py`. 3. Use managed OpenAI/Anthropic credentials from Vault/KMS or workload identity. 4. Use an enterprise IdP and explicit group-to-role mapping. -5. Use a CA-issued certificate and a fixed production hostname. -6. Set a digest-pinned `CASAN_CP_API_IMAGE`, `CASAN_CP_UI_IMAGE`, and `CASAN_CP_OAUTH2_PROXY_IMAGE`. -7. Keep the API on a private container/network segment with Nginx as its only caller. -8. Restrict egress from the API to allowlisted model providers, Vault/KMS, object storage and required observability endpoints. -9. Store state on encrypted storage; send audit heads to KMS/WORM/Object Lock. -10. Export rate-limit, auth failure, bridge-disabled and audit-chain metrics to alerting. -11. Back up and restore-test tenant state before enabling write actions. -12. Run the production preflight and security suites against the exact deployment images. +5. Mount the IdP RS256 public key and configure exact issuer/audience; coordinate + key rotation with an API restart until JWKS rollover is implemented. +6. Use a CA-issued certificate and a fixed production hostname. +7. Set a digest-pinned `CASAN_CP_API_IMAGE`, `CASAN_CP_UI_IMAGE`, and `CASAN_CP_OAUTH2_PROXY_IMAGE`. +8. Keep the API on a private container/network segment with Nginx as its only caller. +9. Restrict egress from the API to allowlisted model providers, Vault/KMS, object storage and required observability endpoints. +10. Store state on encrypted storage; send audit heads to KMS/WORM/Object Lock. +11. Export rate-limit, auth failure, bridge-disabled and audit-chain metrics to alerting. +12. Back up and restore-test tenant state before enabling write actions. +13. Run the production preflight and security suites against the exact deployment images. ## Known residual risks - Local bridge authorization is bearer-token based; a process with access to the token file can call it. - In-memory rate windows reset when the API or bridge restarts. Production should also rate-limit at ingress. - Goal output is stored in tenant-scoped local state but is not currently envelope-encrypted as a whole. -- A compromised container on the private Control Panel network may attempt to forge proxy headers. Production network policy must keep unrelated workloads off that network. +- Static public-key rotation requires a coordinated file replacement and API + restart; automated JWKS rollover is not implemented. +- A compromised ingress container can interfere with availability or bearer + forwarding, but cannot mint a valid IdP signature without the IdP key. - Developer account subscriptions have provider-specific quotas and are not an availability SLA. - The local mock IdP and self-signed TLS do not prove enterprise SSO readiness. @@ -83,4 +94,3 @@ packages/casan-harness/scripts/bash/local-full.sh start This terminates the bridge, deletes the old token and creates a new one. Then inspect `tmp/control-panel-local/auth-bridge/model-audit.jsonl` for unexpected provider/status/hash activity. The log contains no raw prompts. If a goal audit chain is suspected of tampering, stop new goal submissions, preserve `.specify/logs/audit/goal-orchestrator.jsonl` and its head, and compare each `prev_hash`/`record_hash` before restoring service. - diff --git a/infra/local-prod/idp/mock_idp.py b/infra/local-prod/idp/mock_idp.py index 3df4e2a..bdc46bf 100644 --- a/infra/local-prod/idp/mock_idp.py +++ b/infra/local-prod/idp/mock_idp.py @@ -10,6 +10,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlencode, urlparse import jwt +from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -33,7 +34,12 @@ USERS = { }, } KID = "casan-local-prod-idp" -KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) +KEY_FILE = os.environ.get("CASAN_IDP_PRIVATE_KEY_FILE", "") +if KEY_FILE: + with open(KEY_FILE, "rb") as key_handle: + KEY = serialization.load_pem_private_key(key_handle.read(), password=None) +else: + KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) CODES = {} APPROVAL_SIGNER_TOKEN = os.environ.get("CASAN_APPROVAL_SIGNER_TOKEN", "") diff --git a/infra/production/README.md b/infra/production/README.md index 85d450c..ccc99a3 100644 --- a/infra/production/README.md +++ b/infra/production/README.md @@ -12,8 +12,10 @@ not be invented or committed. it at `/opt/casan-control-panel/tls/tls.crt` and `tls.key` (mode `0600`). 2. Register `https:///oauth2/callback` with the enterprise IdP. Map the `groups` claim to CASAN groups such as `casan-org-admin` and - `casan-approver`. Copy `oauth2-proxy.env.example` outside the repo and fill - it using the secret manager. + `casan-approver`. Export the IdP's RS256 verification public key to + `/opt/casan-control-panel/idp-public.pem`; rotation requires replacing this + file and restarting the API. Copy `oauth2-proxy.env.example` outside the + repo and fill it using the secret manager. 3. Create a Vault Transit key `casan-audit-key` with `exportable=false`; give a workload identity only `transit/sign/casan-audit-key` and read-key metadata. Render a short-lived token into `vault.env` outside Git. Never use Vault dev @@ -35,6 +37,7 @@ sudo install -d -m 0700 /opt/casan-control-panel/tls /srv/casan/state /srv/casan sudo install -m 0600 /dev/null /opt/casan-control-panel/oauth2-proxy.env sudo install -m 0600 /dev/null /opt/casan-control-panel/runtime.env sudo install -m 0600 /dev/null /opt/casan-control-panel/vault.env +sudo install -m 0644 /secure/export/idp-public.pem /opt/casan-control-panel/idp-public.pem bash packages/casan-harness/scripts/bash/production-preflight.sh infra/production/casan-prod.env set -a; source infra/production/casan-prod.env; set +a @@ -43,9 +46,15 @@ docker compose -f docker-compose.control-panel.yml up -d The preflight performs a real Object Lock anchor write. It will reject local URLs, image tags, expired/near-expiry TLS, dev/root Vault tokens, incomplete -OIDC, missing paths, and buckets without Object Lock. After login, run +OIDC, issuer/audience mismatches, missing IdP keys, local trust providers, +missing paths, and buckets without Object Lock. After login, run `managed-prod-smoke.sh` with an authenticated cookie jar. +The API does not trust `X-CASAN-*` or forwarded-user headers from Nginx. +oauth2-proxy returns the signed access token, Nginx removes caller-supplied +identity headers, and the API verifies RS256 signature, issuer, audience, +expiry and clock bounds before deriving actor, role, tenant and project. + ## Operational anchors Run `audit-ship-s3.sh` after every audit seal from the same workload identity. diff --git a/infra/production/casan-prod.env.example b/infra/production/casan-prod.env.example index bf37441..c823320 100644 --- a/infra/production/casan-prod.env.example +++ b/infra/production/casan-prod.env.example @@ -9,10 +9,12 @@ CASAN_CP_RUNTIME_ENV=/opt/casan-control-panel/runtime.env CASAN_CP_VAULT_ENV=/opt/casan-control-panel/vault.env CASAN_CP_STATE_DIR=/srv/casan/state CASAN_CP_OUTPUT_DIR=/srv/casan/output +CASAN_CP_IDP_PUBLIC_KEY=/opt/casan-control-panel/idp-public.pem # CI must publish immutable image references, not tags such as :latest. CASAN_CP_API_IMAGE=registry.example.internal/casan/control-panel-api@sha256:replace-with-64-hex-digest CASAN_CP_UI_IMAGE=registry.example.internal/casan/control-panel-ui@sha256:replace-with-64-hex-digest +CASAN_CP_OAUTH2_PROXY_IMAGE=quay.io/oauth2-proxy/oauth2-proxy@sha256:replace-with-64-hex-digest # WORM anchor destination. The deploy identity needs only PutObject and # GetObjectLockConfiguration for this bucket/prefix; use workload identity, diff --git a/infra/production/oauth2-proxy.env.example b/infra/production/oauth2-proxy.env.example index bdf5df5..e54f7e1 100644 --- a/infra/production/oauth2-proxy.env.example +++ b/infra/production/oauth2-proxy.env.example @@ -8,6 +8,6 @@ OAUTH2_PROXY_COOKIE_SECURE=true OAUTH2_PROXY_REDIRECT_URL=https://control.casan.company.internal/oauth2/callback OAUTH2_PROXY_OIDC_GROUPS_CLAIM=groups OAUTH2_PROXY_SET_XAUTHREQUEST=true -OAUTH2_PROXY_PASS_ACCESS_TOKEN=false -OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER=false +OAUTH2_PROXY_PASS_ACCESS_TOKEN=true +OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER=true OAUTH2_PROXY_SCOPE="openid profile email groups" diff --git a/infra/production/runtime.env.example b/infra/production/runtime.env.example index a083a1c..4b4ef74 100644 --- a/infra/production/runtime.env.example +++ b/infra/production/runtime.env.example @@ -2,10 +2,19 @@ # companion Vault file and the production preflight validation. CASAN_PROFILE=prod CASAN_CP_STRICT=1 -CASAN_CP_TRUST_AUTH_PROXY=1 +CASAN_CP_AUTH_MODE=jwt +CASAN_CP_JWT_ISSUER=https://id.example.internal/realms/casan +CASAN_CP_JWT_AUDIENCE=casan-control-plane +CASAN_CP_JWT_PUBLIC_KEY_FILE=/run/casan-idp/idp-public.pem +CASAN_CP_JWT_ROLE_CLAIM=groups +CASAN_CP_JWT_TENANT_CLAIM=casan_tenant +CASAN_CP_JWT_PROJECT_CLAIM=casan_project +CASAN_CP_JWT_CLOCK_SKEW_SECONDS=60 CP_BIND=0.0.0.0 CP_PORT=3010 CASAN_APP_ROOT=/app CASAN_DASHBOARD_STALE_S=300 CASAN_PREFLIGHT=1 CASAN_CHAT_MODEL_MODE=deterministic +CASAN_SIGNING_PROVIDER=vault_kms +CASAN_IMMUTABLE_ANCHOR_PROVIDER=s3_object_lock diff --git a/nginx/control-panel.conf b/nginx/control-panel.conf index bb2cc33..f75b8b8 100644 --- a/nginx/control-panel.conf +++ b/nginx/control-panel.conf @@ -43,6 +43,7 @@ server { auth_request /oauth2/auth; auth_request_set $auth_user $upstream_http_x_auth_request_user; auth_request_set $auth_groups $upstream_http_x_auth_request_groups; + auth_request_set $auth_access_token $upstream_http_x_auth_request_access_token; proxy_pass http://control-panel-api:3010/api/v1/; proxy_http_version 1.1; @@ -55,16 +56,23 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; - # Overwrite browser-supplied identity headers. The API maps these IdP - # group claims through rbac-check.py map-claim. - proxy_set_header X-CASAN-Actor $auth_user; - proxy_set_header X-CASAN-Groups $auth_groups; + # Remove every caller-supplied identity assertion. The API derives + # actor/role/scope only after cryptographic JWT verification. + proxy_set_header X-CASAN-Actor ""; + proxy_set_header X-CASAN-Groups ""; proxy_set_header X-CASAN-Role ""; + proxy_set_header X-CASAN-Project ""; + proxy_set_header X-CASAN-Tenant ""; + proxy_set_header X-Forwarded-User ""; + proxy_set_header X-Forwarded-Groups ""; + proxy_set_header Authorization "Bearer $auth_access_token"; } location = /healthz { auth_request /oauth2/auth; + auth_request_set $auth_access_token $upstream_http_x_auth_request_access_token; proxy_pass http://control-panel-api:3010/healthz; + proxy_set_header Authorization "Bearer $auth_access_token"; } location / { diff --git a/packages/casan-control-panel/README.md b/packages/casan-control-panel/README.md index 9a1f679..10fb756 100644 --- a/packages/casan-control-panel/README.md +++ b/packages/casan-control-panel/README.md @@ -101,8 +101,10 @@ Goal workspace context: a different actor. The executor verifies the artifact hash, applies it, runs fixed project build/test commands, and reverses the patch if verification fails. -Local management headers: `x-casan-actor`, `x-casan-role`, `x-casan-project`, -`x-casan-tenant`. Missing role defaults to `viewer`, so writes fail closed. +In explicit loopback development mode only, local management headers are +`x-casan-actor`, `x-casan-role`, `x-casan-project`, and `x-casan-tenant`. +Missing role defaults to `viewer`, so writes fail closed. JWT mode discards +these caller assertions and derives them only from verified token claims. Kill-switch management: @@ -169,12 +171,19 @@ App root + telemetry paths resolve via the same marker walk-up as `casan-paths.s compatibility aliases. Freshness is calculated independently from each file's mtime using `CASAN_DASHBOARD_STALE_S` (default `3600`). -## Security posture (MVP) -Binds `127.0.0.1` by default. Refuses a non-loopback bind under `CASAN_PROFILE=prod` / -`CASAN_CP_STRICT=1` unless `CASAN_CP_TRUST_AUTH_PROXY=1` is set for an authenticated reverse -proxy that overwrites identity headers. Management endpoints are RBAC-gated via the harness -`rbac-check.py`; IdP group claims such as `casan-approver` are mapped to RBAC roles through -the same harness engine. +## Security posture +Binds `127.0.0.1` in explicit local development mode. Every non-loopback bind, +and every production profile, requires `CASAN_CP_AUTH_MODE=jwt`. Production +requires an RS256 public key plus configured issuer and audience; missing or +invalid configuration refuses startup. The API verifies signature, expiry, +issuer, audience, `nbf`/`iat`, and bounded clock skew in-process, then maps +verified group claims through `rbac-check.py`. Arbitrary `X-CASAN-*` and +forwarded-user headers are not an authentication mechanism. + +Production variables are documented in `infra/production/runtime.env.example`. +The packaged boundary is oauth2-proxy → Nginx header stripping/bearer forwarding +→ API cryptographic verification. The current provider uses a mounted RS256 +public key; automated JWKS discovery/rotation remains future work. ## Test ```bash @@ -189,8 +198,8 @@ bash packages/casan-control-panel/scripts/local-prod-smoke.sh The scaffold includes `Dockerfile.control-panel-api`, `Dockerfile.control-panel-ui`, and `nginx/control-panel.conf`. Nginx protects UI/API through oauth2-proxy `auth_request`, -overwrites browser-supplied `X-CASAN-*` headers, and passes IdP group claims to the API for -RBAC mapping. The local smoke starts a self-signed HTTPS stack with a mock OIDC IdP and +removes browser-supplied identity headers, and forwards the signed bearer token for API +verification. The local smoke starts a self-signed HTTPS stack with a mock OIDC IdP and expects `CP_LOCAL_SMOKE_PASS https_oidc=true actor=oidc-ops role=org-admin`; it also asserts the Command Center returns all nine widgets with provenance envelopes and invokes `managed-prod-smoke.sh` with the authenticated mock-IdP cookie jar. A passing local run diff --git a/packages/casan-control-panel/backend/src/common/auth-context.ts b/packages/casan-control-panel/backend/src/common/auth-context.ts index 3960620..8108847 100644 --- a/packages/casan-control-panel/backend/src/common/auth-context.ts +++ b/packages/casan-control-panel/backend/src/common/auth-context.ts @@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process'; import { join } from 'node:path'; import { APP_ROOT } from './app-root.js'; import type { SettingsActor } from '../settings/settings.service.js'; +import { UnauthorizedException } from '@nestjs/common'; const RBAC_CLI = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash', 'rbac-check.py'); const LOCAL_ROLES = new Set(['org-admin', 'project-admin', 'approver', 'operator', 'viewer', 'auditor']); @@ -23,7 +24,7 @@ function mapClaim(claim: string): string | null { } } -function roleFromClaim(raw: string | undefined): string { +export function roleFromClaim(raw: string | undefined): string { if (!raw) return 'viewer'; const claims = raw.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean); for (const claim of claims) { @@ -33,6 +34,23 @@ function roleFromClaim(raw: string | undefined): string { return 'viewer'; } +export interface VerifiedClaimInput { + subject: string; + issuer: string; + roles: string[]; + project: string; + tenant: string; +} + +export function actorFromVerifiedClaims(claims: VerifiedClaimInput): SettingsActor { + return { + actor: claims.subject, + role: roleFromClaim(claims.roles.join(',')), + project: /^[A-Za-z0-9._-]+$/.test(claims.project) ? claims.project : 'default', + tenant: /^[A-Za-z0-9._-]+$/.test(claims.tenant) ? claims.tenant : 'default', + }; +} + function projectFromClaims(raw: string | undefined): string | undefined { if (!raw) return undefined; const prefix = 'casan-project:'; @@ -42,6 +60,9 @@ function projectFromClaims(raw: string | undefined): string | undefined { } export function actorFromHeaders(headers: Record): SettingsActor { + if (process.env.CASAN_CP_AUTH_MODE === 'jwt' && firstHeader(headers['x-casan-identity-verified']) !== '1') { + throw new UnauthorizedException('AUTH_VERIFIED_IDENTITY_REQUIRED'); + } const actor = firstHeader(headers['x-casan-actor']) || firstHeader(headers['x-auth-request-user']) || firstHeader(headers['x-forwarded-user']) diff --git a/packages/casan-control-panel/backend/src/common/auth-provider.ts b/packages/casan-control-panel/backend/src/common/auth-provider.ts new file mode 100644 index 0000000..bef00d8 --- /dev/null +++ b/packages/casan-control-panel/backend/src/common/auth-provider.ts @@ -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, nowSeconds?: number): AuthenticationDecision; +} + +interface JwtHeader { + alg?: string; + typ?: string; +} + +type JwtPayload = Record; + +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(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, 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(parts[0]); + const payload = decodeSegment(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; + 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); +} diff --git a/packages/casan-control-panel/backend/src/main.ts b/packages/casan-control-panel/backend/src/main.ts index 0eb5e41..65bddec 100644 --- a/packages/casan-control-panel/backend/src/main.ts +++ b/packages/casan-control-panel/backend/src/main.ts @@ -3,23 +3,28 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { AppModule } from './app.module.js'; import { APP_ROOT } from './common/app-root.js'; +import { createAuthMiddleware, loadAuthConfig, validateAuthConfig } from './common/auth-provider.js'; -// Ops Console API (Plan-13). Binds loopback by default and refuses a non-loopback -// bind under CASAN_PROFILE=prod / CASAN_CP_STRICT=1 unless an authenticated reverse -// proxy is explicitly configured to overwrite identity headers. +// Ops Console API (Plan-13). Binds loopback by default. Networked and production +// deployments require in-process cryptographic identity verification. async function bootstrap() { + const authConfig = loadAuthConfig(); + const authErrors = validateAuthConfig(authConfig); + if (authErrors.length > 0) { + console.error(`CP_AUTH_CONFIGURATION_INVALID reasons=${authErrors.join(',')}`); + process.exit(2); + } const app = await NestFactory.create(AppModule, { cors: true }); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + app.use(createAuthMiddleware(authConfig)); const port = Number(process.env.CP_PORT ?? 3010); let host = process.env.CP_BIND ?? '127.0.0.1'; const strict = process.env.CASAN_PROFILE === 'prod' || process.env.CASAN_CP_STRICT === '1'; - const authProxy = process.env.CASAN_CP_TRUST_AUTH_PROXY === '1'; - if (strict && host !== '127.0.0.1' && host !== 'localhost' && !authProxy) { - // The console must not expose telemetry/management off-loopback without TLS/OIDC - // at the reverse proxy, which must overwrite X-CASAN-* identity headers. + if (strict && host !== '127.0.0.1' && host !== 'localhost' && authConfig.mode !== 'jwt') { + // Networked production requires in-process cryptographic verification. // eslint-disable-next-line no-console - console.error(`CP_REFUSE_NONLOOPBACK host=${host} (set up TLS/OIDC per Plan-13 Track 4 first)`); + console.error(`CP_REFUSE_NONLOOPBACK host=${host} reason=verified_identity_required`); process.exit(2); } diff --git a/packages/casan-control-panel/backend/src/reports/h6-report.ts b/packages/casan-control-panel/backend/src/reports/h6-report.ts index f0f99bf..9908d08 100644 --- a/packages/casan-control-panel/backend/src/reports/h6-report.ts +++ b/packages/casan-control-panel/backend/src/reports/h6-report.ts @@ -298,6 +298,8 @@ export function buildH6Report(input: H6ReportInput, query: H6ReportQuery): H6Rep return { schema_version: 1, + category: 'report_dimension', + dimension_id: 'ReportDimension.H6', report_id: `H6-${input.now.toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`, harness: 'H6', title: 'H6 · AgentOps Report', diff --git a/packages/casan-control-panel/backend/src/reports/report.contract.ts b/packages/casan-control-panel/backend/src/reports/report.contract.ts index b926b9a..7c4d451 100644 --- a/packages/casan-control-panel/backend/src/reports/report.contract.ts +++ b/packages/casan-control-panel/backend/src/reports/report.contract.ts @@ -36,6 +36,8 @@ export interface HarnessReportEvidenceSource extends SourceFreshness { export interface HarnessReport { schema_version: 1; + category: 'report_dimension'; + dimension_id: `ReportDimension.${HarnessReportId}`; report_id: string; harness: HarnessReportId; title: string; @@ -71,6 +73,8 @@ export interface HarnessReportCatalogEntry { title: string; description: string; contract_version: 1; + category: 'report_dimension'; + dimension_id: `ReportDimension.${HarnessReportId}`; endpoint: string; availability: 'implemented' | 'contract_ready'; } @@ -78,6 +82,8 @@ export interface HarnessReportCatalogEntry { export const HARNESS_REPORT_CATALOG: HarnessReportCatalogEntry[] = HARNESS_REPORT_DEFINITIONS.map((definition) => ({ ...definition, contract_version: 1, + category: 'report_dimension', + dimension_id: `ReportDimension.${definition.id}`, endpoint: `/api/v1/reports/${definition.id.toLowerCase()}`, availability: definition.id === 'H6' ? 'implemented' : 'contract_ready', })); diff --git a/packages/casan-control-panel/backend/test/auth-context.test.ts b/packages/casan-control-panel/backend/test/auth-context.test.ts index 4609fb3..7646885 100644 --- a/packages/casan-control-panel/backend/test/auth-context.test.ts +++ b/packages/casan-control-panel/backend/test/auth-context.test.ts @@ -29,3 +29,17 @@ test('auth context fails closed to viewer for unknown role claim', () => { }); assert.equal(actor.role, 'viewer'); }); + +test('JWT mode rejects direct spoofed identity headers without middleware verification', () => { + const prior = process.env.CASAN_CP_AUTH_MODE; + process.env.CASAN_CP_AUTH_MODE = 'jwt'; + try { + assert.throws(() => actorFromHeaders({ + 'x-casan-actor': 'attacker', + 'x-casan-role': 'org-admin', + }), /AUTH_VERIFIED_IDENTITY_REQUIRED/); + } finally { + if (prior === undefined) delete process.env.CASAN_CP_AUTH_MODE; + else process.env.CASAN_CP_AUTH_MODE = prior; + } +}); diff --git a/packages/casan-control-panel/backend/test/auth-provider.test.ts b/packages/casan-control-panel/backend/test/auth-provider.test.ts new file mode 100644 index 0000000..aa7ddf3 --- /dev/null +++ b/packages/casan-control-panel/backend/test/auth-provider.test.ts @@ -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, 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, 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 = {}): Record { + 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')); +}); diff --git a/packages/casan-control-panel/scripts/local-prod-smoke.sh b/packages/casan-control-panel/scripts/local-prod-smoke.sh index 2512a30..e2970ae 100755 --- a/packages/casan-control-panel/scripts/local-prod-smoke.sh +++ b/packages/casan-control-panel/scripts/local-prod-smoke.sh @@ -17,6 +17,14 @@ if [[ ! -f "$TLS_DIR/tls.crt" || ! -f "$TLS_DIR/tls.key" ]]; then -subj "/CN=localhost" \ -days 1 >/dev/null 2>&1 fi +IDP_PRIVATE="$ROOT/tmp/control-panel-local/idp-private.pem" +IDP_PUBLIC="$ROOT/tmp/control-panel-local/idp-public.pem" +if [[ ! -f "$IDP_PRIVATE" || ! -f "$IDP_PUBLIC" ]]; then + openssl genrsa -out "$IDP_PRIVATE" 2048 >/dev/null 2>&1 + openssl rsa -in "$IDP_PRIVATE" -pubout -out "$IDP_PUBLIC" >/dev/null 2>&1 + chmod 0600 "$IDP_PRIVATE" + chmod 0644 "$IDP_PUBLIC" +fi cleanup() { docker compose -f "$COMPOSE" down --remove-orphans >/dev/null 2>&1 || true diff --git a/packages/casan-harness/config/action-classes.json b/packages/casan-harness/config/action-classes.json new file mode 100644 index 0000000..f183909 --- /dev/null +++ b/packages/casan-harness/config/action-classes.json @@ -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"} + ] +} diff --git a/packages/casan-harness/kernel/__init__.py b/packages/casan-harness/kernel/__init__.py new file mode 100644 index 0000000..2ecc67d --- /dev/null +++ b/packages/casan-harness/kernel/__init__.py @@ -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", +] diff --git a/packages/casan-harness/kernel/adapters.py b/packages/casan-harness/kernel/adapters.py new file mode 100644 index 0000000..e5d0c18 --- /dev/null +++ b/packages/casan-harness/kernel/adapters.py @@ -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 {})}, + } diff --git a/packages/casan-harness/kernel/contracts.py b/packages/casan-harness/kernel/contracts.py new file mode 100644 index 0000000..ab262d0 --- /dev/null +++ b/packages/casan-harness/kernel/contracts.py @@ -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 diff --git a/packages/casan-harness/kernel/policy.py b/packages/casan-harness/kernel/policy.py new file mode 100644 index 0000000..437dde4 --- /dev/null +++ b/packages/casan-harness/kernel/policy.py @@ -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"], + } diff --git a/packages/casan-harness/kernel/supervision.py b/packages/casan-harness/kernel/supervision.py new file mode 100644 index 0000000..e74e2c6 --- /dev/null +++ b/packages/casan-harness/kernel/supervision.py @@ -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"), + } diff --git a/packages/casan-harness/kernel/taxonomy.py b/packages/casan-harness/kernel/taxonomy.py new file mode 100644 index 0000000..fc2c183 --- /dev/null +++ b/packages/casan-harness/kernel/taxonomy.py @@ -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 diff --git a/packages/casan-harness/schemas/agentic-lifecycle.schema.json b/packages/casan-harness/schemas/agentic-lifecycle.schema.json index d662493..0fc5a30 100644 --- a/packages/casan-harness/schemas/agentic-lifecycle.schema.json +++ b/packages/casan-harness/schemas/agentic-lifecycle.schema.json @@ -58,6 +58,10 @@ "type": ["string", "null"], "description": "Client-native session id. Hashed by the bridge, never stored raw." }, + "actor": { + "type": ["string", "null"], + "description": "Stable actor identity asserted by the authenticated runtime boundary." + }, "integration_mode": { "type": ["string", "null"], "enum": ["casan_owned", "managed_hook", "project_hook", "observed_only", null], @@ -167,7 +171,7 @@ "schema_version": { "type": "string" }, "decision": { "type": "string", - "enum": ["allow", "block", "deny", "recorded", "certified", "non_certified", "error"] + "enum": ["allow", "block", "deny", "require_approval", "halt", "quarantine", "recorded", "certified", "non_certified", "error"] }, "admission_id": { "type": ["string", "null"] }, "trace_id": { "type": ["string", "null"] }, diff --git a/packages/casan-harness/schemas/assurance-kernel.schema.json b/packages/casan-harness/schemas/assurance-kernel.schema.json new file mode 100644 index 0000000..9629980 --- /dev/null +++ b/packages/casan-harness/schemas/assurance-kernel.schema.json @@ -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 + } + } +} diff --git a/packages/casan-harness/scripts/bash/agent-metrics.sh b/packages/casan-harness/scripts/bash/agent-metrics.sh index 455f298..6c18755 100755 --- a/packages/casan-harness/scripts/bash/agent-metrics.sh +++ b/packages/casan-harness/scripts/bash/agent-metrics.sh @@ -110,6 +110,10 @@ LATENCY_MS=$((END_MS - START_MS)) if [[ ! -f "$OUTPUT_FILE" ]]; then STATUS="failed" ERROR_MSG="${ERROR_MSG:-output file not produced}" + # A zero command exit does not make the step successful when the runtime + # contract requires an output artifact and none was produced. Telemetry can + # record this failure successfully, but must propagate a failed step outcome. + [[ "$EXIT_CODE" -eq 0 ]] && EXIT_CODE=1 : > "$OUTPUT_FILE" fi diff --git a/packages/casan-harness/scripts/bash/casan-harness.sh b/packages/casan-harness/scripts/bash/casan-harness.sh index 65da677..934393f 100755 --- a/packages/casan-harness/scripts/bash/casan-harness.sh +++ b/packages/casan-harness/scripts/bash/casan-harness.sh @@ -24,6 +24,7 @@ fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/casan-paths.sh" PROJECT_ROOT="$CASAN_APP_ROOT" +KERNEL_CLI="$CASAN_HARNESS_ROOT/scripts/python/kernel_cli.py" # SEC-23 (MT-01): make state (control-plane settings, telemetry, audit) tenant-scoped # when CASAN_TENANT_ID is set, so a run for tenant A never touches tenant B's state. # No-op when no tenant is set (baseline unchanged); invalid tenant fails closed. @@ -56,12 +57,52 @@ write_phase_report() { "$ACTION_NAME" "$CACHE_STATUS" "$PHASE_LOG" > "$PHASE_REPORT" 2>/dev/null || true } +emit_failed_kernel() { # phase rc — best effort, never masks the original failure + local failed_phase="$1" failed_rc="$2" + [[ -f "$KERNEL_CLI" ]] || return 0 + local run_id="${EXECUTION_ID:-native-failed-${TRACE_SUFFIX:-$$}}" + local mode="${NATIVE_MODE:-observe}" + local event bundle path + event="$(CASAN_NATIVE_RISK="${ACTION_RISK_JSON:-}" python3 - "$run_id" "$ACTION_NAME" "$mode" "$failed_phase" "$failed_rc" "${CASAN_ACTOR:-${CASAN_AGENT:-}}" <<'PY' +import json, os, sys +run_id, action, mode, phase, rc, actor = sys.argv[1:] +try: risk = json.loads(os.environ.get("CASAN_NATIVE_RISK") or "{}") +except ValueError: risk = {} +print(json.dumps({ + "run_id": run_id, "correlation_id": run_id, "action": action, + "tool": action, "actor": actor, "mode": mode, + "environment": os.environ.get("CASAN_PROFILE", "development"), + "execution_status": "failed", "assurance_status": "failed", + "certification_status": "non_certified", "policy_decisions": [risk] if risk else [], + "extensions": {"failed_phase": phase, "exit_code": int(rc)}, +}, separators=(",", ":"))) +PY +)" || return 0 + bundle="$(printf '%s' "$event" | python3 "$KERNEL_CLI" adapt-native - 2>/dev/null)" || return 0 + path="$CASAN_STATE_ROOT/logs/kernel/$run_id.json" + CASAN_KERNEL_BUNDLE="$bundle" python3 - "$path" <<'PY' || return 0 +import json, os, sys, tempfile +path = sys.argv[1]; payload = json.loads(os.environ["CASAN_KERNEL_BUNDLE"]) +os.makedirs(os.path.dirname(path), exist_ok=True) +fd, tmp = tempfile.mkstemp(prefix=".kernel-", dir=os.path.dirname(path)) +try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle, sort_keys=True, separators=(",", ":")); handle.write("\n") + handle.flush(); os.fsync(handle.fileno()) + os.replace(tmp, path) +finally: + try: os.unlink(tmp) + except OSError: pass +PY +} + run_phase() { # — preserves the failing rc exactly local phase="$1"; shift local rc=0 "$@" || rc=$? record_phase "$phase" "$rc" if [[ "$rc" -ne 0 ]]; then + emit_failed_kernel "$phase" "$rc" write_phase_report exit "$rc" fi @@ -76,9 +117,16 @@ hash_text() { } CMD_STR="${*:-no_cmd}" +NATIVE_MODE="${CASAN_ENFORCEMENT_MODE:-}" +if [[ -z "$NATIVE_MODE" ]]; then + if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ]]; then NATIVE_MODE="enforce"; else NATIVE_MODE="observe"; fi +fi +case "$NATIVE_MODE" in observe|enforce) : ;; *) NATIVE_MODE="enforce"; casan_log error harness "INVALID_ENFORCEMENT_MODE fail_closed=enforce" ;; esac INPUT_HASH="$(cat "$INPUT_FILE" | hash_text)" CMD_HASH="$(printf '%s' "$CMD_STR" | hash_text)" IDEMPOTENCY_KEY="$(printf '%s|%s|%s' "$INPUT_HASH" "$CMD_HASH" "$ACTION_NAME" | hash_text)" +EXECUTION_ID="${CASAN_EXECUTION_ID:-native-${IDEMPOTENCY_KEY:0:24}}" +export CASAN_EXECUTION_ID="$EXECUTION_ID" CACHE_META="$CACHE_DIR/$IDEMPOTENCY_KEY.json" CACHE_OUT="$CACHE_DIR/$IDEMPOTENCY_KEY.output" @@ -92,7 +140,7 @@ casan_log debug harness "action=$ACTION_NAME input=$INPUT_FILE output=$FINAL_OUT # C7: honor an engaged kill-switch before doing any work (incident containment). # Opt-in (default off) so the baseline is unchanged. SEC-17 (ARCH-03): under # CASAN_PROFILE=prod it defaults ON (secure-by-default); an explicit =0 still wins. -if [[ "${CASAN_KILLSWITCH_ENFORCE:-0}" == "1" || ( -z "${CASAN_KILLSWITCH_ENFORCE+x}" && "${CASAN_PROFILE:-}" == "prod" ) ]]; then +if [[ "${CASAN_KILLSWITCH_ENFORCE:-0}" == "1" || ( -z "${CASAN_KILLSWITCH_ENFORCE+x}" && ( "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ) ) ]]; then KS_SCOPE="${CASAN_KILLSWITCH_SCOPE:-project}" KS_ID="${CASAN_KILLSWITCH_ID:-${CASAN_PROJECT:-current}}" if ! bash "$SCRIPT_DIR/kill-switch.sh" check "$KS_SCOPE" "$KS_ID" >/dev/null 2>&1; then @@ -116,7 +164,7 @@ fi # signed manifest and REFUSE to run on any drift — editing a gate/policy is a bypass # that leaves no input trace. Only active when a manifest is provisioned (so dev and # prod-without-a-manifest are unaffected); a present-but-drifted bundle fails closed. -if [[ ( "${CASAN_PROFILE:-}" == "prod" || "${CASAN_VERIFY_STRICT:-}" == "1" ) \ +if [[ ( "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" || "${CASAN_VERIFY_STRICT:-}" == "1" ) \ && -f "$SCRIPT_DIR/bundle-integrity.py" ]]; then BUNDLE_MANIFEST="${CASAN_BUNDLE_MANIFEST:-$CASAN_GOVERNANCE_ROOT/harness-bundle-manifest.json}" if [[ -f "$BUNDLE_MANIFEST" ]]; then @@ -132,15 +180,62 @@ fi run_phase "H4-in" "$SCRIPT_DIR/security-check.sh" "$INPUT_FILE" "$SAFE_INPUT" input run_phase "H5" "$SCRIPT_DIR/governance-check.sh" "$SAFE_INPUT" "$APPROVED_INPUT" "$ACTION_NAME" -# H2 tool registry gate is in the line of fire for side-effecting actions: -# it enforces idempotency key, per-agent permission, and rollback strategy -# before the command is allowed to execute. The wrapper already derived a -# content-addressed idempotency key above. -case "$ACTION_NAME" in - write_code|migration|deploy|db_write|external_api|write_file) - run_phase "H2-gate" env CASAN_IDEMPOTENCY_KEY="$IDEMPOTENCY_KEY" "$SCRIPT_DIR/tool-registry-gate.sh" "$ACTION_NAME" - ;; -esac +# Canonical action classification decides whether the H2 registry and isolated +# executor are required. A classifier failure is treated as unknown/high-risk. +ACTION_RISK_JSON="" +ACTION_RISK_RC=0 +ACTION_RISK_JSON="$(python3 "$KERNEL_CLI" risk --action "$ACTION_NAME" --tool "$ACTION_NAME" \ + --command "$CMD_STR" --content-file "$SAFE_INPUT" --actor "${CASAN_ACTOR:-${CASAN_AGENT:-}}" \ + --environment "${CASAN_PROFILE:-development}" 2>/dev/null)" || ACTION_RISK_RC=$? +if [[ "$ACTION_RISK_RC" -eq 0 && -n "$ACTION_RISK_JSON" ]]; then + ACTION_FIELDS="$(python3 - "$ACTION_RISK_JSON" <<'PY' +import json, sys +p=json.loads(sys.argv[1]) +print("%s\t%s" % ("1" if p["side_effecting"] else "0", p["action_class"])) +PY +)" || ACTION_RISK_RC=$? +fi +if [[ "$ACTION_RISK_RC" -eq 0 && -n "${ACTION_FIELDS:-}" ]]; then + IFS=$'\t' read -r SIDE_EFFECTING ACTION_CLASS <<< "$ACTION_FIELDS" +else + SIDE_EFFECTING=1 + ACTION_CLASS="unknown" + casan_log error harness "ACTION_CLASSIFIER_FAILED_CLOSED action=$ACTION_NAME" +fi + +if [[ "$SIDE_EFFECTING" == "1" ]]; then + REGISTRY_ARGS=(registry-config --mode "$NATIVE_MODE" --profile "${CASAN_PROFILE:-development}" \ + --evidence-log "$CASAN_STATE_ROOT/logs/policy/h2-registry-config.jsonl") + [[ -n "${CASAN_H2_REGISTRY+x}" ]] && REGISTRY_ARGS+=(--explicit "$CASAN_H2_REGISTRY") + REGISTRY_RC=0 + REGISTRY_JSON="$(python3 "$KERNEL_CLI" "${REGISTRY_ARGS[@]}")" || REGISTRY_RC=$? + if [[ "$REGISTRY_RC" -ne 0 ]]; then + record_phase "H2-config" "$REGISTRY_RC" + echo "H2_REGISTRY_CONFIGURATION_DENIED $REGISTRY_JSON" >&2 + write_phase_report + exit "$REGISTRY_RC" + fi + REGISTRY_ENABLED="$(python3 -c 'import json,sys; print("1" if json.load(sys.stdin)["enabled"] else "0")' <<< "$REGISTRY_JSON")" + if [[ "$REGISTRY_ENABLED" == "1" ]]; then + H2_ACTION="$ACTION_NAME" + case "$ACTION_CLASS" in + write) H2_ACTION="write_file" ;; + delete) H2_ACTION="delete_file" ;; + database_mutation) H2_ACTION="db_write" ;; + deployment|release|infrastructure_modification) H2_ACTION="deploy" ;; + external_network_side_effect) H2_ACTION="external_api" ;; + unknown) H2_ACTION="unknown_tool" ;; + esac + run_phase "H2-gate" python3 "$KERNEL_CLI" h2-gate \ + --gate "${CASAN_H2_GATE_PATH:-$SCRIPT_DIR/tool-registry-gate.sh}" --mode "$NATIVE_MODE" \ + --actor "${CASAN_ACTOR:-${CASAN_AGENT:-}}" --action "$H2_ACTION" --tool "$ACTION_NAME" \ + --execution-id "$EXECUTION_ID" --enforcement-path "native_harness.pre_execution.h2_registry" \ + --idempotency-key "$IDEMPOTENCY_KEY" --timeout "${CASAN_H2_GATE_TIMEOUT_SECONDS:-8}" \ + --evidence-log "$CASAN_STATE_ROOT/logs/policy/h2-decisions.jsonl" + else + casan_log warn harness "HIGH H2 registry unsafe development/test bypass active; run is non-certifiable" + fi +fi # T4: propagate step name so any nested model calls (model-call.py) log against the same step # name, enabling provider-cost-lookup.py to match real Ollama token counts in agent-metrics.sh. @@ -155,13 +250,34 @@ if [[ -f "$CACHE_META" && -f "$CACHE_OUT" ]]; then run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" -- bash -c 'cp "$1" "$CASAN_OUTPUT"' _ "$CACHE_OUT" elif [[ "$#" -gt 0 ]]; then CACHE_STATUS="stored" - run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" -- \ - "$SCRIPT_DIR/tool-exec.sh" "$TOOL_TIMEOUT" -- "$@" + if [[ "$SIDE_EFFECTING" == "1" && "$NATIVE_MODE" == "enforce" ]]; then + run_phase "H6-exec" env CASAN_ENFORCEMENT_MODE="$NATIVE_MODE" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" -- \ + "$SCRIPT_DIR/sandbox-run.sh" --workspace "$PROJECT_ROOT" --timeout "$TOOL_TIMEOUT" -- "$@" + else + run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" -- \ + "$SCRIPT_DIR/tool-exec.sh" "$TOOL_TIMEOUT" -- "$@" + fi else CACHE_STATUS="stored" run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" fi +TOOL_OUTPUT_MAX_BYTES="${CASAN_TOOL_OUTPUT_MAX_BYTES:-1048576}" +if [[ ! "$TOOL_OUTPUT_MAX_BYTES" =~ ^[1-9][0-9]*$ ]]; then + casan_log error harness "TOOL_OUTPUT_LIMIT_INVALID value=$TOOL_OUTPUT_MAX_BYTES" + : > "$FINAL_OUTPUT" + exit 2 +fi +RAW_OUTPUT_BYTES="$(wc -c < "$RAW_OUTPUT" | tr -d ' ')" +if [[ "$RAW_OUTPUT_BYTES" -gt "$TOOL_OUTPUT_MAX_BYTES" ]]; then + record_phase "H4-output-size" 2 + casan_log error harness "TOOL_OUTPUT_QUARANTINED bytes=$RAW_OUTPUT_BYTES limit=$TOOL_OUTPUT_MAX_BYTES" + : > "$FINAL_OUTPUT" + write_phase_report + echo "TOOL_OUTPUT_QUARANTINED reason=output_size_limit bytes=$RAW_OUTPUT_BYTES limit=$TOOL_OUTPUT_MAX_BYTES" >&2 + exit 2 +fi + # V7: tool output can carry indirect injection that would re-enter a downstream # model's context. Scan RAW_OUTPUT for injection/secret patterns before it is # reused. Mode: off | warn (default) | block. Strict mode upgrades to block. @@ -170,7 +286,7 @@ fi TOOL_OUTPUT_SCAN_MODE="${CASAN_TOOL_OUTPUT_SCAN:-}" if [[ -z "$TOOL_OUTPUT_SCAN_MODE" ]]; then # SEC-17/M-02: prod profile defaults tool-output scanning to block (fail-closed). - if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" || ( -z "${CASAN_SECURITY_STRICT+x}" && "${CASAN_PROFILE:-}" == "prod" ) ]]; then TOOL_OUTPUT_SCAN_MODE="block"; else TOOL_OUTPUT_SCAN_MODE="warn"; fi + if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" || ( -z "${CASAN_SECURITY_STRICT+x}" && ( "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ) ) ]]; then TOOL_OUTPUT_SCAN_MODE="block"; else TOOL_OUTPUT_SCAN_MODE="warn"; fi fi if [[ "$TOOL_OUTPUT_SCAN_MODE" != "off" ]]; then TOS_RC=0 @@ -204,6 +320,65 @@ EOF cp "$FINAL_OUTPUT" "$CACHE_OUT" fi +# Dual-emit the framework-independent kernel envelope. Legacy phase reports and +# outputs remain unchanged; the canonical contract is an additive artifact. +TRUST_JSON="$(python3 "$KERNEL_CLI" trust-capabilities 2>/dev/null || true)" +RUN_CERTIFICATION="non_certified" +RUN_ASSURANCE="passed" +[[ "$NATIVE_MODE" == "observe" ]] && RUN_ASSURANCE="degraded" +if [[ "$NATIVE_MODE" == "enforce" && -n "$TRUST_JSON" ]]; then + TRUST_CERTIFIABLE="$(python3 -c 'import json,sys; print("1" if json.load(sys.stdin).get("certifiable") else "0")' <<< "$TRUST_JSON" 2>/dev/null || echo 0)" + REGISTRY_BYPASS="$(printf '%s' "${REGISTRY_JSON:-{}}" | python3 -c 'import json,sys; print("1" if json.load(sys.stdin).get("unsafe_bypass") else "0")' 2>/dev/null || echo 0)" + [[ "$REGISTRY_BYPASS" == "1" ]] && RUN_ASSURANCE="degraded" + [[ "$TRUST_CERTIFIABLE" == "1" && "$REGISTRY_BYPASS" == "0" ]] && RUN_CERTIFICATION="certified" +fi +NATIVE_EVENT="$(CASAN_NATIVE_RISK="$ACTION_RISK_JSON" python3 - "$EXECUTION_ID" "$ACTION_NAME" "$ACTION_CLASS" "$NATIVE_MODE" "$RUN_ASSURANCE" "$RUN_CERTIFICATION" "${CASAN_ACTOR:-${CASAN_AGENT:-}}" "$INPUT_HASH" "$CMD_HASH" <<'PY' +import json, os, sys +run_id, action, action_class, mode, assurance, certification, actor, input_hash, command_hash = sys.argv[1:] +try: + risk = json.loads(os.environ.get("CASAN_NATIVE_RISK") or "{}") +except ValueError: + risk = {} +print(json.dumps({ + "run_id": run_id, "correlation_id": run_id, "action": action, + "tool": action, "actor": actor, "mode": mode, + "environment": os.environ.get("CASAN_PROFILE", "development"), + "execution_status": "success", "assurance_status": assurance, + "certification_status": certification, + "policy_decisions": [risk] if risk else [], + "extensions": {"input_hash": input_hash, "command_hash": command_hash, "action_class": action_class}, +}, separators=(",", ":"))) +PY +)" +KERNEL_RC=0 +KERNEL_BUNDLE="$(printf '%s' "$NATIVE_EVENT" | python3 "$KERNEL_CLI" adapt-native -)" || KERNEL_RC=$? +if [[ "$KERNEL_RC" -ne 0 ]]; then + casan_log error harness "KERNEL_CONTRACT_EMISSION_FAILED rc=$KERNEL_RC" + if [[ "$NATIVE_MODE" == "enforce" ]]; then + : > "$FINAL_OUTPUT" + exit 2 + fi + RUN_CERTIFICATION="non_certified" +else + KERNEL_PATH="$CASAN_STATE_ROOT/logs/kernel/$EXECUTION_ID.json" + python3 - "$KERNEL_PATH" "$KERNEL_BUNDLE" <<'PY' +import json, os, sys, tempfile +path, raw = sys.argv[1:] +os.makedirs(os.path.dirname(path), exist_ok=True) +payload = json.loads(raw) +fd, tmp = tempfile.mkstemp(prefix=".kernel-", dir=os.path.dirname(path)) +try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle, sort_keys=True, separators=(",", ":")) + handle.write("\n") + handle.flush(); os.fsync(handle.fileno()) + os.replace(tmp, path) +finally: + try: os.unlink(tmp) + except OSError: pass +PY +fi + write_phase_report casan_log debug harness "action=$ACTION_NAME complete cache=$CACHE_STATUS" -echo "CASAN_HARNESS_COMPLETE cache=$CACHE_STATUS key=$IDEMPOTENCY_KEY output=$FINAL_OUTPUT" +echo "CASAN_HARNESS_COMPLETE cache=$CACHE_STATUS key=$IDEMPOTENCY_KEY execution=success assurance=$RUN_ASSURANCE certification=$RUN_CERTIFICATION output=$FINAL_OUTPUT" diff --git a/packages/casan-harness/scripts/bash/governance-check.sh b/packages/casan-harness/scripts/bash/governance-check.sh index 1888959..06d0309 100755 --- a/packages/casan-harness/scripts/bash/governance-check.sh +++ b/packages/casan-harness/scripts/bash/governance-check.sh @@ -58,27 +58,48 @@ TRACE_ID="$(new_trace_id)" TIMESTAMP="$(timestamp)" INPUT="$(cat "$INPUT_FILE")" LOWER_INPUT="$(printf '%s' "$INPUT" | tr '[:upper:]' '[:lower:]')" -ACTOR="${CASAN_ACTOR:-developer}" +ACTOR="${CASAN_ACTOR:-${CASAN_AGENT:-}}" APPROVER="${CASAN_APPROVER:-}" APPROVAL_DECISION="${CASAN_APPROVAL_DECISION:-auto}" AUDIT_LOG="$AUDIT_DIR/audit.jsonl" RISK_LEVEL="low" REASONS=() - -case "$ACTION_NAME" in - deploy|launch|write_code|write_file|migration|db_write|external_api|tool_call) - RISK_LEVEL="medium" - REASONS+=("sensitive-action:$ACTION_NAME") - ;; -esac - -if printf '%s' "$LOWER_INPUT" | grep -Eq "(delete|drop table|password|api[_-]?key|secret|token|credential|migration|deploy|external api|shutdown|dump database)"; then +ACTION_CLASS="unknown" +RISK_FACTORS_JSON='{"action_risk":"high","content_risk":"high","environment_risk":"low","identity_risk":"low","resource_risk":"low"}' +EVIDENCE_REQUIREMENT="required" +RISK_POLICY_DECISION="require_approval" +KERNEL_CLI="$CASAN_HARNESS_ROOT/scripts/python/kernel_cli.py" +RISK_JSON="" +RISK_RC=0 +if [[ -f "$KERNEL_CLI" ]]; then + RISK_JSON="$(python3 "$KERNEL_CLI" risk --action "$ACTION_NAME" --tool "$ACTION_NAME" \ + --content-file "$INPUT_FILE" --actor "$ACTOR" --environment "${CASAN_PROFILE:-development}" 2>/dev/null)" || RISK_RC=$? +else + RISK_RC=127 +fi +if [[ "$RISK_RC" -eq 0 && -n "$RISK_JSON" ]]; then + RISK_FIELDS="$(python3 - "$RISK_JSON" <<'PY' +import json, sys +payload = json.loads(sys.argv[1]) +print("\t".join([ + str(payload["action_class"]), + str(payload["effective_risk"]), + json.dumps(payload["risk_factors"], sort_keys=True, separators=(",", ":")), + str(payload["evidence_requirement"]), + str(payload["decision"]), +])) +PY +)" || RISK_RC=$? +fi +if [[ "$RISK_RC" -eq 0 && -n "${RISK_FIELDS:-}" ]]; then + IFS=$'\t' read -r ACTION_CLASS RISK_LEVEL RISK_FACTORS_JSON EVIDENCE_REQUIREMENT RISK_POLICY_DECISION <<< "$RISK_FIELDS" + REASONS+=("action-risk-floor:$ACTION_CLASS") +else RISK_LEVEL="high" - REASONS+=("high-risk-content") -elif printf '%s' "$LOWER_INPUT" | grep -Eq "(internal|config|system|policy|permission)"; then - [[ "$RISK_LEVEL" == "low" ]] && RISK_LEVEL="medium" - REASONS+=("medium-risk-content") + ACTION_CLASS="unknown" + RISK_POLICY_DECISION="require_approval" + REASONS+=("action-risk-classifier-failed-closed") fi APPROVAL_STATUS="auto_approved" @@ -88,8 +109,14 @@ if [[ "$RISK_LEVEL" == "medium" ]]; then APPROVAL_STATUS="policy_auto_approved_with_audit" fi -if [[ "$RISK_LEVEL" == "high" ]]; then - if [[ "${CASAN_APPROVAL_STRICT:-0}" == "1" ]]; then +if [[ "$RISK_POLICY_DECISION" == "deny" ]]; then + APPROVAL_STATUS="actor_identity_required" + DECISION="denied" + REASONS+=("actor-identity-required") +elif [[ "$RISK_LEVEL" == "high" || "$RISK_LEVEL" == "critical" || "$RISK_POLICY_DECISION" == "require_approval" ]]; then + APPROVAL_STRICT_EFFECTIVE="${CASAN_APPROVAL_STRICT:-0}" + [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ]] && APPROVAL_STRICT_EFFECTIVE="1" + if [[ "$APPROVAL_STRICT_EFFECTIVE" == "1" ]]; then # Approval-identity mode (V20): an env-var approver is NOT enough — the # reviewer must cryptographically SIGN this exact request and their role must # be authorized for the action. SoD (actor != approver) still enforced. @@ -147,7 +174,7 @@ fi REASONS_JSON="$(printf '%s\n' "${REASONS[@]:-}" | python -c 'import json,sys; print(json.dumps([x for x in sys.stdin.read().splitlines() if x]))')" # approver and output_hash are part of the hashed core so they cannot be # silently mutated after the fact. -RECORD_CORE="$(printf '%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s' "$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTOR" "$RISK_LEVEL" "$DECISION" "$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH")" +RECORD_CORE="$(printf '%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s' "$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTION_CLASS" "$ACTOR" "$RISK_LEVEL" "$RISK_FACTORS_JSON" "$EVIDENCE_REQUIREMENT" "$DECISION" "$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH")" RECORD_HASH="$(printf '%s' "$RECORD_CORE" | hash_text)" TRACE_FILE="$TRACE_DIR/governance-$TRACE_ID.json" @@ -160,18 +187,24 @@ TRACE_FILE="$TRACE_DIR/governance-$TRACE_ID.json" # written (disk full, read-only, quota), there must be NO governed action without # its accountability record — deny and empty the output rather than proceed. if ! CASAN_GC_REASONS="$REASONS_JSON" python - "$TRACE_FILE" "$AUDIT_LOG" \ - "$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTOR" "$RISK_LEVEL" "$DECISION" \ - "$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH" "$RECORD_HASH" <<'PY' + "$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTION_CLASS" "$ACTOR" "$RISK_LEVEL" \ + "$RISK_FACTORS_JSON" "$EVIDENCE_REQUIREMENT" "$DECISION" "$APPROVAL_STATUS" \ + "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH" "$RECORD_HASH" <<'PY' import json, os, sys -(trace_file, audit_log, ts, trace_id, action, actor, risk, decision, - approval_status, approver, input_hash, output_hash, prev_hash, record_hash) = sys.argv[1:] +(trace_file, audit_log, ts, trace_id, action, action_class, actor, risk, + risk_factors_json, evidence_requirement, decision, approval_status, approver, + input_hash, output_hash, prev_hash, record_hash) = sys.argv[1:] try: reasons = json.loads(os.environ.get("CASAN_GC_REASONS") or "[]") except ValueError: reasons = [] rec = { + "schema_version": 2, "category": "runtime_control", "timestamp": ts, "trace_id": trace_id, "harness": "H5-governance", - "action": action, "actor": actor, "risk_level": risk, "decision": decision, + "action": action, "action_class": action_class, "actor": actor, + "risk_level": risk, "effective_risk": risk, + "risk_factors": json.loads(risk_factors_json), + "evidence_requirement": evidence_requirement, "decision": decision, "approval_status": approval_status, "approver": approver, "input_hash": input_hash, "output_hash": output_hash, "previous_record_hash": prev_hash, "record_hash": record_hash, @@ -196,8 +229,43 @@ fi # --- External anchor: cryptographically sign the new chain head --- # A re-forged chain (recomputed hashes) changes the head; without the private # key the attacker cannot produce a matching signature, so verification fails. -# Production note: the private key must live off-repo (KMS/HSM). It is local -# here only for self-contained demonstration. +# Development may use a local key for self-contained demonstration. Production +# refuses that path unless an explicit emergency override is visible in evidence. +PRODUCTION_PROFILE=0 +[[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ]] && PRODUCTION_PROFILE=1 +EMERGENCY_TRUST_OVERRIDE="${CASAN_TRUST_EMERGENCY_OVERRIDE:-0}" +TRUST_LOG="$CASAN_STATE_ROOT/logs/readiness/trust-capabilities.jsonl" +mkdir -p "$(dirname "$TRUST_LOG")" + +if [[ "$PRODUCTION_PROFILE" == "1" && "$EMERGENCY_TRUST_OVERRIDE" != "1" ]]; then + TRUST_RC=0 + TRUST_JSON="$(python3 "$KERNEL_CLI" trust-capabilities 2>/dev/null)" || TRUST_RC=$? + if [[ "$TRUST_RC" -ne 0 ]]; then + if [[ -n "$TRUST_JSON" ]]; then + printf '%s\n' "$TRUST_JSON" >> "$TRUST_LOG" + else + printf '{"ready":false,"severity":"critical","reason_codes":["production_trust_configuration_invalid"]}\n' >> "$TRUST_LOG" + fi + : > "$OUTPUT_FILE" + echo "GOVERNANCE_DENIED trace_id=$TRACE_ID reason=production_trust_root_unavailable" >&2 + exit 2 + fi + printf '%s\n' "$TRUST_JSON" >> "$TRUST_LOG" + if ! bash "$SCRIPT_DIR/sign-audit-head.sh" "$AUDIT_LOG" >/dev/null 2>&1; then + : > "$OUTPUT_FILE" + echo "GOVERNANCE_DENIED trace_id=$TRACE_ID reason=external_signing_failed" >&2 + exit 2 + fi + if ! bash "$SCRIPT_DIR/audit-ship-s3.sh" "$AUDIT_DIR/audit-head.txt" >/dev/null 2>&1; then + : > "$OUTPUT_FILE" + echo "GOVERNANCE_DENIED trace_id=$TRACE_ID reason=external_immutable_anchor_failed" >&2 + exit 2 + fi +else + if [[ "$PRODUCTION_PROFILE" == "1" ]]; then + printf '{"schema_version":"1.0.0","profile":"production","ready":false,"certifiable":false,"emergency_override":true,"severity":"critical","reason_codes":["emergency_local_trust_override_active"]}\n' >> "$TRUST_LOG" + echo "CRITICAL: emergency local trust override active; execution cannot be production-certified" >&2 + fi if command -v openssl >/dev/null 2>&1; then # Private signing key lives OFF-REPO (default ~/.casan/audit-keys); only the # public key is committed. Production: replace with KMS/HSM. @@ -207,7 +275,7 @@ if command -v openssl >/dev/null 2>&1; then AUDIT_PUB="$PUB_DIR/audit-public.pem" mkdir -p "$PUB_DIR" "$PRIV_DIR" if [[ ! -f "$AUDIT_PRIV" ]]; then - if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_VERIFY_STRICT:-}" == "1" ]]; then + if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" || "${CASAN_VERIFY_STRICT:-}" == "1" ]]; then # SEC-02 (H-02): in enforced mode NEVER auto-generate a local signing key. # A freshly-minted key next to the data lets any file-writer re-sign a forged # head. Prod must provision the key out-of-band (KMS/HSM — see sign-audit-head.sh @@ -229,6 +297,7 @@ if command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 -sign "$AUDIT_PRIV" -out "$AUDIT_DIR/audit-head.sig" "$AUDIT_DIR/audit-head.txt" 2>/dev/null || true fi fi +fi if [[ "$DECISION" != "approved" ]]; then : > "$OUTPUT_FILE" diff --git a/packages/casan-harness/scripts/bash/production-preflight.sh b/packages/casan-harness/scripts/bash/production-preflight.sh index 1ecc1c2..0dc46df 100755 --- a/packages/casan-harness/scripts/bash/production-preflight.sh +++ b/packages/casan-harness/scripts/bash/production-preflight.sh @@ -21,22 +21,22 @@ while IFS= read -r raw || [[ -n "$raw" ]]; do [[ "$line" =~ ^([A-Z0-9_]+)=(.*)$ ]] || fail "invalid_env_syntax" key="${BASH_REMATCH[1]}"; value="${BASH_REMATCH[2]}" case "$key" in - CASAN_PUBLIC_FQDN|CASAN_CP_HTTPS_PORT|CASAN_CP_TLS_DIR|CASAN_CP_OAUTH_ENV|CASAN_CP_RUNTIME_ENV|CASAN_CP_VAULT_ENV|CASAN_CP_STATE_DIR|CASAN_CP_OUTPUT_DIR|CASAN_CP_API_IMAGE|CASAN_CP_UI_IMAGE|CASAN_S3_BUCKET|CASAN_S3_PREFIX|CASAN_S3_REGION|CASAN_S3_RETENTION_DAYS|CASAN_S3_KMS_KEY_ID) export "$key=$value" ;; + CASAN_PUBLIC_FQDN|CASAN_CP_HTTPS_PORT|CASAN_CP_TLS_DIR|CASAN_CP_OAUTH_ENV|CASAN_CP_RUNTIME_ENV|CASAN_CP_VAULT_ENV|CASAN_CP_STATE_DIR|CASAN_CP_OUTPUT_DIR|CASAN_CP_IDP_PUBLIC_KEY|CASAN_CP_API_IMAGE|CASAN_CP_UI_IMAGE|CASAN_CP_OAUTH2_PROXY_IMAGE|CASAN_S3_BUCKET|CASAN_S3_PREFIX|CASAN_S3_REGION|CASAN_S3_RETENTION_DAYS|CASAN_S3_KMS_KEY_ID) export "$key=$value" ;; *) fail "unexpected_env_key key=$key" ;; esac done < "$ENV_FILE" -required=(CASAN_PUBLIC_FQDN CASAN_CP_TLS_DIR CASAN_CP_OAUTH_ENV CASAN_CP_RUNTIME_ENV CASAN_CP_VAULT_ENV CASAN_CP_STATE_DIR CASAN_CP_OUTPUT_DIR CASAN_CP_API_IMAGE CASAN_CP_UI_IMAGE CASAN_S3_BUCKET CASAN_S3_REGION CASAN_S3_KMS_KEY_ID) +required=(CASAN_PUBLIC_FQDN CASAN_CP_TLS_DIR CASAN_CP_OAUTH_ENV CASAN_CP_RUNTIME_ENV CASAN_CP_VAULT_ENV CASAN_CP_STATE_DIR CASAN_CP_OUTPUT_DIR CASAN_CP_IDP_PUBLIC_KEY CASAN_CP_API_IMAGE CASAN_CP_UI_IMAGE CASAN_CP_OAUTH2_PROXY_IMAGE CASAN_S3_BUCKET CASAN_S3_REGION CASAN_S3_KMS_KEY_ID) for key in "${required[@]}"; do [[ -n "${!key:-}" ]] || fail "missing_env key=$key"; done case "$CASAN_PUBLIC_FQDN" in *localhost*|*127.0.0.1*|*example.com*|*replace-with*|*/*|[0-9]* ) fail "invalid_fqdn";; esac [[ "$CASAN_PUBLIC_FQDN" == *.* ]] || fail "fqdn_required" -for image in "$CASAN_CP_API_IMAGE" "$CASAN_CP_UI_IMAGE"; do +for image in "$CASAN_CP_API_IMAGE" "$CASAN_CP_UI_IMAGE" "$CASAN_CP_OAUTH2_PROXY_IMAGE"; do [[ "$image" =~ @sha256:[a-f0-9]{64}$ ]] || fail "image_must_be_digest_pinned image=$image" done pass "public FQDN and images are production-safe" -for file in "$CASAN_CP_TLS_DIR/tls.crt" "$CASAN_CP_TLS_DIR/tls.key" "$CASAN_CP_OAUTH_ENV" "$CASAN_CP_RUNTIME_ENV" "$CASAN_CP_VAULT_ENV"; do +for file in "$CASAN_CP_TLS_DIR/tls.crt" "$CASAN_CP_TLS_DIR/tls.key" "$CASAN_CP_IDP_PUBLIC_KEY" "$CASAN_CP_OAUTH_ENV" "$CASAN_CP_RUNTIME_ENV" "$CASAN_CP_VAULT_ENV"; do [[ -s "$file" ]] || fail "missing_or_empty path=$file" done openssl x509 -in "$CASAN_CP_TLS_DIR/tls.crt" -noout >/dev/null || fail "invalid_tls_certificate" @@ -45,6 +45,7 @@ openssl x509 -in "$CASAN_CP_TLS_DIR/tls.crt" -noout -checkhost "$CASAN_PUBLIC_FQ cert_pub="$(openssl x509 -in "$CASAN_CP_TLS_DIR/tls.crt" -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha256 | awk '{print $NF}')" key_pub="$(openssl pkey -in "$CASAN_CP_TLS_DIR/tls.key" -pubout -outform DER | openssl dgst -sha256 | awk '{print $NF}')" [[ "$cert_pub" == "$key_pub" ]] || fail "tls_key_does_not_match_certificate" +openssl rsa -pubin -in "$CASAN_CP_IDP_PUBLIC_KEY" -noout -modulus >/dev/null 2>&1 || fail "invalid_idp_rsa_public_key" pass "TLS certificate is valid for at least 30 days" value_of() { sed -n -E "s/^${1}=//p" "$CASAN_CP_OAUTH_ENV" | tail -1; } @@ -56,8 +57,24 @@ done [[ "$(value_of OAUTH2_PROXY_OIDC_ISSUER_URL)" == https://* ]] || fail "oidc_issuer_https_required" [[ "$(value_of OAUTH2_PROXY_REDIRECT_URL)" == "https://$CASAN_PUBLIC_FQDN/oauth2/callback" ]] || fail "oidc_redirect_mismatch" [[ "$(value_of OAUTH2_PROXY_COOKIE_SECURE)" == true ]] || fail "oidc_secure_cookie_required" +[[ "$(value_of OAUTH2_PROXY_SET_XAUTHREQUEST)" == true ]] || fail "oidc_xauthrequest_required" +[[ "$(value_of OAUTH2_PROXY_PASS_ACCESS_TOKEN)" == true ]] || fail "oidc_access_token_forwarding_required" +[[ "$(value_of OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER)" == true ]] || fail "oidc_authorization_header_forwarding_required" pass "enterprise OIDC configuration" +runtime_value_of() { sed -n -E "s/^${1}=//p" "$CASAN_CP_RUNTIME_ENV" | tail -1; } +[[ "$(runtime_value_of CASAN_PROFILE)" == prod ]] || fail "runtime_profile_must_be_prod" +[[ "$(runtime_value_of CASAN_CP_AUTH_MODE)" == jwt ]] || fail "runtime_jwt_auth_required" +[[ "$(runtime_value_of CASAN_CP_JWT_ISSUER)" == "$(value_of OAUTH2_PROXY_OIDC_ISSUER_URL)" ]] || fail "runtime_oidc_issuer_mismatch" +[[ "$(runtime_value_of CASAN_CP_JWT_AUDIENCE)" == "$(value_of OAUTH2_PROXY_CLIENT_ID)" ]] || fail "runtime_oidc_audience_mismatch" +[[ "$(runtime_value_of CASAN_CP_JWT_PUBLIC_KEY_FILE)" == /run/casan-idp/idp-public.pem ]] || fail "runtime_idp_public_key_path_invalid" +[[ "$(runtime_value_of CASAN_SIGNING_PROVIDER)" == vault_kms ]] || fail "runtime_external_signing_required" +[[ "$(runtime_value_of CASAN_IMMUTABLE_ANCHOR_PROVIDER)" == s3_object_lock ]] || fail "runtime_immutable_anchor_required" +[[ -z "$(runtime_value_of CASAN_CP_TRUST_AUTH_PROXY)" ]] || fail "legacy_trusted_header_auth_forbidden" +clock_skew="$(runtime_value_of CASAN_CP_JWT_CLOCK_SKEW_SECONDS)" +[[ "$clock_skew" =~ ^[0-9]+$ && "$clock_skew" -le 300 ]] || fail "runtime_jwt_clock_skew_invalid" +pass "Control Plane verifies OIDC token identity cryptographically" + vault_addr="$(sed -n -E 's/^VAULT_ADDR=//p' "$CASAN_CP_VAULT_ENV" | tail -1)" vault_token="$(sed -n -E 's/^VAULT_TOKEN=//p' "$CASAN_CP_VAULT_ENV" | tail -1)" vault_cacert="$(sed -n -E 's/^VAULT_CACERT=//p' "$CASAN_CP_VAULT_ENV" | tail -1)" @@ -88,7 +105,8 @@ COMPOSE="$ROOT/docker-compose.control-panel.yml" CASAN_CP_TLS_DIR="$CASAN_CP_TLS_DIR" CASAN_CP_OAUTH_ENV="$CASAN_CP_OAUTH_ENV" \ CASAN_CP_RUNTIME_ENV="$CASAN_CP_RUNTIME_ENV" CASAN_CP_VAULT_ENV="$CASAN_CP_VAULT_ENV" \ CASAN_CP_STATE_DIR="$CASAN_CP_STATE_DIR" CASAN_CP_OUTPUT_DIR="$CASAN_CP_OUTPUT_DIR" \ -CASAN_CP_API_IMAGE="$CASAN_CP_API_IMAGE" CASAN_CP_UI_IMAGE="$CASAN_CP_UI_IMAGE" \ +CASAN_CP_IDP_PUBLIC_KEY="$CASAN_CP_IDP_PUBLIC_KEY" CASAN_CP_API_IMAGE="$CASAN_CP_API_IMAGE" \ +CASAN_CP_UI_IMAGE="$CASAN_CP_UI_IMAGE" CASAN_CP_OAUTH2_PROXY_IMAGE="$CASAN_CP_OAUTH2_PROXY_IMAGE" \ docker compose -f "$COMPOSE" config >/dev/null || fail "compose_config_invalid" pass "production compose config" diff --git a/packages/casan-harness/scripts/bash/sandbox-container.sh b/packages/casan-harness/scripts/bash/sandbox-container.sh index 3f91366..1c869bb 100755 --- a/packages/casan-harness/scripts/bash/sandbox-container.sh +++ b/packages/casan-harness/scripts/bash/sandbox-container.sh @@ -47,10 +47,15 @@ docker info >/dev/null 2>&1 || { echo "SANDBOX_CONTAINER_DOCKER_DOWN" >&2; exit # rootful Docker daemon because a compromised daemon socket defeats container # isolation. Local developer/test profiles may use a rootful daemon, but cannot # claim that configuration as a hardened production runner. -if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_SANDBOX_REQUIRE_ROOTLESS:-0}" == "1" ]]; then +if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" \ + || "${CASAN_SANDBOX_REQUIRE_ROOTLESS:-0}" == "1" ]]; then docker info --format '{{json .SecurityOptions}}' 2>/dev/null | grep -q 'rootless' \ || { echo "SANDBOX_CONTAINER_ROOTLESS_REQUIRED" >&2; exit 2; } fi +if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ]]; then + [[ "$IMAGE" =~ @sha256:[a-f0-9]{64}$ ]] \ + || { echo "SANDBOX_CONTAINER_IMAGE_DIGEST_REQUIRED image=$IMAGE" >&2; exit 2; } +fi WS_ABS="$(cd "$WORKSPACE" 2>/dev/null && pwd)" || { echo "SANDBOX_CONTAINER_BAD_WORKSPACE" >&2; exit 2; } diff --git a/packages/casan-harness/scripts/bash/sandbox-run.sh b/packages/casan-harness/scripts/bash/sandbox-run.sh index 9cf27b1..fb0ec45 100755 --- a/packages/casan-harness/scripts/bash/sandbox-run.sh +++ b/packages/casan-harness/scripts/bash/sandbox-run.sh @@ -36,6 +36,37 @@ CPU_SECONDS="${CASAN_SANDBOX_CPU_SECONDS:-30}" # check is the real gate; a container --pids-limit is the production backstop. MAX_PROCS="${CASAN_SANDBOX_MAX_PROCS:-}" TIMEOUT="${CASAN_SANDBOX_TIMEOUT:-30}" +SANDBOX_MODE="${CASAN_SANDBOX_MODE:-}" +STRICT_SANDBOX=0 +if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" \ + || "${CASAN_ENFORCEMENT_MODE:-}" == "enforce" || "${CASAN_SANDBOX_STRICT:-0}" == "1" ]]; then + STRICT_SANDBOX=1 +fi +[[ -n "$SANDBOX_MODE" ]] || { if [[ "$STRICT_SANDBOX" == "1" ]]; then SANDBOX_MODE="container"; else SANDBOX_MODE="static"; fi; } + +record_sandbox() { # decision reason backend capability-json + local decision="$1" reason="$2" backend="$3" capabilities="$4" + local log="$CASAN_STATE_ROOT/logs/sandbox/decisions.jsonl" + mkdir -p "$(dirname "$log")" + CASAN_SANDBOX_CAPABILITIES="$capabilities" python3 - "$log" "$decision" "$reason" "$backend" "${CASAN_EXECUTION_ID:-sandbox-$$}" <<'PY' +import json, os, sys +path, decision, reason, backend, execution_id = sys.argv[1:] +try: + capabilities = json.loads(os.environ.get("CASAN_SANDBOX_CAPABILITIES", "{}")) +except ValueError: + capabilities = {} +record = { + "schema_version": "1.0.0", "category": "runtime_control", + "policy_id": "casan.sandbox.backend", "decision": decision, + "reason_code": reason, "backend": backend, "execution_id": execution_id, + "capabilities": capabilities, +} +with open(path, "a", encoding="utf-8") as handle: + handle.write(json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n") + handle.flush() + os.fsync(handle.fileno()) +PY +} while [[ "$#" -gt 0 ]]; do case "$1" in @@ -54,12 +85,35 @@ if [[ "$#" -eq 0 ]]; then exit 64 fi -# C6 production form: CASAN_SANDBOX_MODE=container runs under TRUE kernel +# C6 production form: CASAN_SANDBOX_MODE=container runs under kernel-backed # isolation (sandbox-container.sh: --network=none --read-only --pids-limit …). -# Default stays the static-policy + ulimit scaffold so existing behaviour is -# unchanged. Falls back to the scaffold if Docker is unavailable. -if [[ "${CASAN_SANDBOX_MODE:-static}" == "container" ]] && command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then - exec "$SCRIPT_DIR/sandbox-container.sh" --workspace "$WORKSPACE" --timeout "$TIMEOUT" -- "$@" +# A requested/required container backend never silently falls back. +if [[ "$SANDBOX_MODE" == "container" ]]; then + if [[ "${CASAN_SANDBOX_TEST_FORCE_UNAVAILABLE:-0}" != "1" ]] \ + && command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + record_sandbox "allow" "sandbox_container_selected" "docker" '{"network_disabled":true,"read_only_root":true,"workspace_write_restricted":true,"environment_filtered":true,"non_root":true,"resource_limits":true}' + exec "$SCRIPT_DIR/sandbox-container.sh" --workspace "$WORKSPACE" --timeout "$TIMEOUT" -- "$@" + fi + if [[ "$STRICT_SANDBOX" == "1" ]]; then + record_sandbox "deny" "sandbox_isolation_backend_unavailable" "none" '{"timeout_only":false}' + echo "SANDBOX_ISOLATION_REQUIRED backend=container reason=unavailable" >&2 + exit 2 + fi + if [[ "${CASAN_SANDBOX_ALLOW_STATIC_FALLBACK:-0}" != "1" ]]; then + record_sandbox "deny" "sandbox_fallback_not_approved" "none" '{}' + echo "SANDBOX_FALLBACK_REQUIRES_EXPLICIT_DEVELOPMENT_APPROVAL" >&2 + exit 2 + fi + record_sandbox "observe_only" "sandbox_static_fallback_development_only" "static_rlimit" '{"network_disabled":false,"read_only_root":false,"workspace_write_restricted":false,"environment_filtered":false,"non_root":false,"resource_limits":true}' + echo "HIGH: container sandbox unavailable; explicit development static fallback is not production isolation" >&2 +elif [[ "$SANDBOX_MODE" != "static" ]]; then + record_sandbox "deny" "sandbox_backend_unknown" "$SANDBOX_MODE" '{}' + echo "SANDBOX_BACKEND_UNKNOWN mode=$SANDBOX_MODE" >&2 + exit 2 +elif [[ "$STRICT_SANDBOX" == "1" ]]; then + record_sandbox "deny" "sandbox_static_forbidden_in_enforce_mode" "static_rlimit" '{"network_disabled":false,"read_only_root":false}' + echo "SANDBOX_STATIC_FORBIDDEN_IN_ENFORCE_MODE" >&2 + exit 2 fi CMD_STR="$*" @@ -96,6 +150,7 @@ done < <(printf '%s\n' "$CMD_STR" | grep -oE '>>?[[:space:]]*[^[:space:];|&]+' | # ── 2. Runtime rlimits + wall-clock timeout ───────────────────────────────── casan_log debug sandbox "SANDBOX_RUN workspace=$WS_ABS file_kb=$MAX_FILE_KB cpu=$CPU_SECONDS procs=$MAX_PROCS timeout=$TIMEOUT" +record_sandbox "allow" "sandbox_static_policy_selected" "static_rlimit" '{"network_disabled":false,"read_only_root":false,"workspace_write_restricted":false,"environment_filtered":false,"non_root":false,"resource_limits":true}' ( ulimit -f "$((MAX_FILE_KB * 2))" 2>/dev/null || true # ulimit -f is in 512-byte blocks ulimit -t "$CPU_SECONDS" 2>/dev/null || true diff --git a/packages/casan-harness/scripts/bash/sign-audit-head.sh b/packages/casan-harness/scripts/bash/sign-audit-head.sh index 1544247..50b0056 100755 --- a/packages/casan-harness/scripts/bash/sign-audit-head.sh +++ b/packages/casan-harness/scripts/bash/sign-audit-head.sh @@ -45,14 +45,24 @@ with open(path, encoding="utf-8") as f: if not line.strip(): continue record = json.loads(line) - core = "|".join([ - record.get("timestamp",""), record.get("trace_id",""), - record.get("action",""), record.get("actor",""), - record.get("risk_level",""), record.get("decision",""), - record.get("approval_status",""), record.get("approver",""), - record.get("input_hash",""), record.get("output_hash",""), - previous, - ]) + if int(record.get("schema_version", 1)) >= 2: + core = "|".join([ + record.get("timestamp",""), record.get("trace_id",""), + record.get("action",""), record.get("action_class",""), + record.get("actor",""), record.get("risk_level",""), + json.dumps(record.get("risk_factors", {}), sort_keys=True, separators=(",", ":")), + record.get("evidence_requirement",""), record.get("decision",""), + record.get("approval_status",""), record.get("approver",""), + record.get("input_hash",""), record.get("output_hash",""), previous, + ]) + else: + core = "|".join([ + record.get("timestamp",""), record.get("trace_id",""), + record.get("action",""), record.get("actor",""), + record.get("risk_level",""), record.get("decision",""), + record.get("approval_status",""), record.get("approver",""), + record.get("input_hash",""), record.get("output_hash",""), previous, + ]) previous = hashlib.sha256(core.encode()).hexdigest() print(previous) PY @@ -68,6 +78,10 @@ printf '%s' "$HEAD_HASH" > "$HEAD_FILE" # ── Sign the head file ──────────────────────────────────────────────────── VAULT_KMS="$SCRIPT_DIR/vault-kms.sh" +if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ]]; then + [[ "${VAULT_ADDR:-}" == https://* ]] || { echo "SIGN_AUDIT_HEAD_FAIL reason=vault_https_required_in_prod" >&2; exit 1; } +fi + if [[ -n "${VAULT_ADDR:-}" && -n "${VAULT_TOKEN:-}" ]] && \ curl -sf "$VAULT_ADDR/v1/sys/health" >/dev/null 2>&1; then # KMS path — sign via Vault Transit, export public key @@ -103,7 +117,7 @@ PY fi else # Fallback — local key (dev environment without Vault) - if [[ "${CASAN_PROFILE:-}" == "prod" ]]; then + if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_PROFILE:-}" == "production" || "${CASAN_PROFILE:-}" == "strict" ]]; then echo "SIGN_AUDIT_HEAD_FAIL reason=vault_kms_required_in_prod" >&2 exit 1 fi diff --git a/packages/casan-harness/scripts/bash/verify-audit-chain.sh b/packages/casan-harness/scripts/bash/verify-audit-chain.sh index fc09024..b479284 100755 --- a/packages/casan-harness/scripts/bash/verify-audit-chain.sh +++ b/packages/casan-harness/scripts/bash/verify-audit-chain.sh @@ -35,21 +35,28 @@ with open(path, encoding="utf-8") as f: f"AUDIT_CHAIN_BROKEN line={line_no} expected_previous={previous} actual_previous={expected_previous}" ) - core = "|".join( - [ - record.get("timestamp", ""), - record.get("trace_id", ""), - record.get("action", ""), - record.get("actor", ""), - record.get("risk_level", ""), - record.get("decision", ""), - record.get("approval_status", ""), - record.get("approver", ""), - record.get("input_hash", ""), - record.get("output_hash", ""), + if int(record.get("schema_version", 1)) >= 2: + core = "|".join([ + record.get("timestamp", ""), record.get("trace_id", ""), + record.get("action", ""), record.get("action_class", ""), + record.get("actor", ""), record.get("risk_level", ""), + json.dumps(record.get("risk_factors", {}), sort_keys=True, separators=(",", ":")), + record.get("evidence_requirement", ""), record.get("decision", ""), + record.get("approval_status", ""), record.get("approver", ""), + record.get("input_hash", ""), record.get("output_hash", ""), expected_previous, - ] - ) + ]) + else: + core = "|".join( + [ + record.get("timestamp", ""), record.get("trace_id", ""), + record.get("action", ""), record.get("actor", ""), + record.get("risk_level", ""), record.get("decision", ""), + record.get("approval_status", ""), record.get("approver", ""), + record.get("input_hash", ""), record.get("output_hash", ""), + expected_previous, + ] + ) expected_hash = hashlib.sha256(core.encode()).hexdigest() actual_hash = record.get("record_hash", "") if expected_hash != actual_hash: diff --git a/packages/casan-harness/scripts/python/agentic_bridge.py b/packages/casan-harness/scripts/python/agentic_bridge.py index 10978f3..8ac767e 100755 --- a/packages/casan-harness/scripts/python/agentic_bridge.py +++ b/packages/casan-harness/scripts/python/agentic_bridge.py @@ -40,8 +40,23 @@ import sys import time import uuid -SCHEMA_VERSION = "20.1" -ADAPTER_DEFAULT_VERSION = "20.1.0" +HARNESS_PACKAGE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +if HARNESS_PACKAGE_ROOT not in sys.path: + sys.path.insert(0, HARNESS_PACKAGE_ROOT) + +from kernel.adapters import AgenticBridgeAdapter +from kernel.contracts import validate_bundle +from kernel.policy import ( + H2GateContext, + evaluate_failure_policy, + evaluate_h2_gate, + evaluate_registry_configuration, + evaluate_risk, + evaluate_trust_capabilities, +) + +SCHEMA_VERSION = "20.2" +ADAPTER_DEFAULT_VERSION = "20.2.0" # ── Certification strength ranking (higher == stronger) ────────────────────── STRENGTH_RANK = { @@ -77,6 +92,20 @@ TOOL_REGISTRY_ACTION = { "str_replace_editor": "write_file", } +REGISTRY_ACTION_BY_CLASS = { + "write": "write_file", + "delete": "delete_file", + "database_mutation": "db_write", + "migration": "migration", + "deployment": "deploy", + "release": "deploy", + "credential_access": "credential_access", + "identity_permission_modification": "identity_change", + "external_network_side_effect": "external_api", + "infrastructure_modification": "deploy", + "unknown": "unknown_tool", +} + # ───────────────────────────────────────────────────────────────────────────── # Path resolution (mirrors scripts/bash/casan-paths.sh so state lands in the @@ -145,6 +174,12 @@ def trace_event_dir(): return d +def kernel_trace_dir(): + d = os.path.join(state_root(), "logs", "kernel") + os.makedirs(d, exist_ok=True) + return d + + def metrics_log(): override = os.environ.get("CASAN_TELEMETRY_METRICS_LOG") or os.environ.get( "CASAN_METRICS_LOG" @@ -612,28 +647,52 @@ def h4_scan(text, mode="input"): pass -def h2_registry_gate(action, idempotency_key): - """H2 tool-registry gate for a mapped side-effect action.""" - script = os.path.join(gates_dir(), "tool-registry-gate.sh") - if not os.path.exists(script) or not bash_available(): - return True, "h2_gate_missing" - env = dict(os.environ) - env["CASAN_IDEMPOTENCY_KEY"] = idempotency_key +def h2_registry_gate(action, tool, actor, execution_id, idempotency_key): + """Return a structured H2 policy decision for every dependency outcome.""" + script = os.environ.get("CASAN_H2_GATE_PATH") or os.path.join( + gates_dir(), "tool-registry-gate.sh") + context = H2GateContext( + mode=enforcement_mode(), + actor=actor or "", + action=action, + tool=tool or action, + execution_id=execution_id, + enforcement_path="agentic_bridge.pre_tool.h2_registry", + idempotency_key=idempotency_key, + timeout_seconds=internal_timeout(), + ) + return evaluate_h2_gate( + script, + context, + env=os.environ, + bash_path=bash_bin(), + ) + + +def h5_governance_gate(action, content): + """Invoke the existing H5 gate for action-floor approval decisions.""" + script = os.path.join(gates_dir(), "governance-check.sh") + if not os.path.isfile(script) or not bash_available(): + return {"approved": False, "reason_code": "h5_gate_unavailable", "detail": "governance gate unavailable"} + tmpin = os.path.join(sessions_dir(), ".h5-in-%s" % uuid.uuid4().hex[:8]) + tmpout = os.path.join(sessions_dir(), ".h5-out-%s" % uuid.uuid4().hex[:8]) try: - proc = subprocess.run( - [bash_bin(), script, action], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - timeout=internal_timeout(), - env=env, - ) - if proc.returncode == 0: - return True, "h2_ok" - return False, "h2_denied" - except subprocess.TimeoutExpired: - return False, "h2_internal_timeout" - except (OSError, ValueError) as exc: - return False, "h2_error:%s" % exc + with open(tmpin, "w", encoding="utf-8") as handle: + handle.write(content or "") + rc, stdout, stderr = _run_gate([bash_bin(), script, tmpin, tmpout, action]) + if rc == 0 and "GOVERNANCE_APPROVED" in stdout: + return {"approved": True, "reason_code": "h5_approval_verified", "detail": stdout.strip()} + if rc == 124: + return {"approved": False, "reason_code": "h5_gate_timeout", "detail": "governance gate timed out"} + if rc == 125: + return {"approved": False, "reason_code": "h5_gate_internal_error", "detail": stderr} + return {"approved": False, "reason_code": "h5_approval_required", "detail": stderr or stdout} + finally: + for path in (tmpin, tmpout): + try: + os.unlink(path) + except OSError: + pass # ───────────────────────────────────────────────────────────────────────────── @@ -692,6 +751,8 @@ def write_trace_events(rec, evidence): "kind": evidence.get("kind"), "decision": evidence.get("decision"), "detail": evidence.get("detail"), + "category": evidence.get("category"), + "facts": evidence.get("facts", {}), "certification_strength": rec.get( "certification_strength"), }, @@ -700,13 +761,16 @@ def write_trace_events(rec, evidence): event, ensure_ascii=False, separators=(",", ":")) + "\n") -def add_evidence(rec, h, kind, decision, detail): +def add_evidence(rec, h, kind, decision, detail, facts=None, category="runtime_control"): evidence = { + "evidence_id": "%s:%s" % (rec.get("trace_id", "trace"), len(rec.get("evidence", [])) + 1), "h": h, + "category": category, "kind": kind, "decision": decision, "at": now_iso(), "detail": redact(detail, 160), + "facts": facts if isinstance(facts, dict) else {}, } rec.setdefault("evidence", []).append(evidence) write_trace_events(rec, evidence) @@ -828,6 +892,27 @@ def write_h6_record(rec, status, quality, warnings, missing): return record +def write_kernel_bundle(rec): + """Dual-emit the canonical contract without changing legacy trace readers.""" + event = dict(rec) + event.update({ + "mode": enforcement_mode(), + "execution_status": rec.get("execution_status", "unknown"), + "assurance_status": rec.get("assurance_status", "unknown"), + "environment": os.environ.get("CASAN_PROFILE", "development"), + "completed_at": rec.get("finalized_at") or now_iso(), + "policy_decisions": rec.get("policy_decisions", []), + }) + bundle = AgenticBridgeAdapter().map_execution(event) + errors = validate_bundle(bundle) + if errors: + raise ValueError("kernel_contract_invalid:%s" % ",".join(errors)) + path = os.path.join(kernel_trace_dir(), "%s.json" % rec.get("trace_id")) + atomic_write_json(path, bundle) + rec["kernel_contract_ref"] = path + return path + + def write_trace_file(rec, certified, reasons): path = os.path.join(trace_dir(), "agentic-%s.json" % rec.get("trace_id")) payload = { @@ -851,6 +936,9 @@ def write_trace_file(rec, certified, reasons): "failures": rec.get("failures", 0), "certified": certified, "certification_reasons": reasons, + "execution_outcome": rec.get("execution_status"), + "assurance_outcome": rec.get("assurance_status"), + "kernel_contract_ref": rec.get("kernel_contract_ref"), "evidence": rec.get("evidence", []), } atomic_write_json(path, payload) @@ -886,6 +974,8 @@ def op_begin(req): "client": client, "client_version": req.get("client_version"), "adapter_version": req.get("adapter_version") or ADAPTER_DEFAULT_VERSION, + "actor": req.get("actor") or os.environ.get("CASAN_ACTOR") or os.environ.get("CASAN_AGENT") or "", + "mode": enforcement_mode(), "project_root": project, "project_id": project_id(project), "session_id_hash": salted_hash(req.get("session")), @@ -900,8 +990,10 @@ def op_begin(req): "state": "Admitted", "tool_calls": 0, "failures": 0, + "side_effect_failures": 0, "retries": 0, "evidence": [], + "policy_decisions": [], "telemetry": {}, "bypass_signal": False, "finalized": False, @@ -1012,30 +1104,109 @@ def op_pre_tool(req): # record the degradation but do not block the developer. add_evidence(rec, "H4", "pre-tool", "degraded", "%s:gate_unavailable_no_bash" % tool) - # H2 registry gate for mapped side-effect actions. Opt-in via - # CASAN_AGENTIC_H2_REGISTRY=1: the tool-registry is keyed on NAMED CASAN - # agent identities, which the transparent developer flow does not carry, so - # enabling it unconditionally would deny every write. The always-on H2 - # equivalent for this flow is the admission gate above (a side effect - # without a valid admission is denied). Managed deployments that define - # agent identities can turn the registry gate on for defence in depth. - registry_on = os.environ.get("CASAN_AGENTIC_H2_REGISTRY", "0") in ("1", "true", "yes") - if side_effect and registry_on and enforcement_mode() == "enforce": - action = TOOL_REGISTRY_ACTION.get((tool or "").strip().lower()) - if action: - key = plain_hash("%s|%s|%s" % (rec["trace_id"], tool, redact(tool_input_text)))[:24] - ok, reason = h2_registry_gate(action, key) - if not ok: - add_evidence(rec, "H2", "pre-tool", "deny", "%s:%s" % (tool, reason)) - save_admission(rec) - return _base_response("pre-tool", "deny", admission_id=admission_id, - trace_id=rec.get("trace_id"), reason=reason) + tool_input_string = tool_input_text if isinstance(tool_input_text, str) else json.dumps( + tool_input_text, ensure_ascii=False, sort_keys=True) if tool_input_text is not None else "" + # Native hook payloads commonly wrap a shell command in {"command": ...}. + # Classify the actual command while retaining the complete serialized input + # for scanning, governance evidence and hashing. + risk_command = tool_input_string + if isinstance(tool_input_text, dict) and isinstance(tool_input_text.get("command"), str): + risk_command = tool_input_text["command"] + risk = evaluate_risk( + action=str(req.get("action") or ""), + tool=tool, + resource=str(req.get("resource") or ""), + command=risk_command, + content=tool_input_string, + actor=rec.get("actor", ""), + environment=os.environ.get("CASAN_PROFILE", "development"), + ) + # The canonical classifier can prove a shell command read-only; unknown + # shell commands remain side-effecting/high-risk by construction. + side_effect = bool(risk["side_effecting"]) + action = REGISTRY_ACTION_BY_CLASS.get(risk["action_class"], TOOL_REGISTRY_ACTION.get((tool or "").strip().lower(), "unknown_tool")) + rec["last_tool"] = tool + rec["last_action"] = action + rec["last_risk"] = risk + rec.setdefault("policy_decisions", []).append(risk) + add_evidence( + rec, "H5", "action-risk", risk["decision"], + "class=%s effective=%s" % (risk["action_class"], risk["effective_risk"]), + facts=risk, + ) + + if side_effect and risk["decision"] == "deny": + reason = risk["reason_codes"][-1] + add_evidence(rec, "H5", "pre-tool", "deny", reason, facts=risk) + save_admission(rec) + return _base_response("pre-tool", "deny", admission_id=admission_id, + trace_id=rec.get("trace_id"), reason=reason, + policy_decision=risk) + + if side_effect and risk["decision"] == "require_approval": + approval = h5_governance_gate(action, tool_input_string) + add_evidence( + rec, "H5", "approval", "allow" if approval["approved"] else "require_approval", + approval["reason_code"], facts={"risk": risk, "approval": approval}, + ) + if not approval["approved"]: + save_admission(rec) + return _base_response("pre-tool", "require_approval", admission_id=admission_id, + trace_id=rec.get("trace_id"), reason=approval["reason_code"], + policy_decision=risk) + + registry = evaluate_registry_configuration( + enforcement_mode(), + os.environ.get("CASAN_PROFILE", "development"), + os.environ.get("CASAN_AGENTIC_H2_REGISTRY") if "CASAN_AGENTIC_H2_REGISTRY" in os.environ else None, + ) + if side_effect and not registry["valid"]: + add_evidence(rec, "H2", "registry-configuration", "deny", registry["reason_code"], facts=registry) + save_admission(rec) + return _base_response("pre-tool", "deny", admission_id=admission_id, + trace_id=rec.get("trace_id"), reason=registry["reason_code"]) + if side_effect and registry["unsafe_bypass"]: + rec["integration_mode"] = "observed_only" + rec["certification_strength"] = "observed_only" + add_evidence(rec, "H2", "registry-configuration", "degraded", registry["reason_code"], facts=registry) + + if side_effect and registry["enabled"]: + key = plain_hash("%s|%s|%s" % (rec["trace_id"], tool, redact(tool_input_text)))[:24] + h2_decision = h2_registry_gate(action, tool, rec.get("actor", ""), rec["trace_id"], key) + rec.setdefault("policy_decisions", []).append(h2_decision) + add_evidence( + rec, "H2", "tool-registry", h2_decision["decision"], + h2_decision["reason_code"], facts=h2_decision, + ) + if not h2_decision["execution_allowed"]: + save_admission(rec) + return _base_response("pre-tool", "deny", admission_id=admission_id, + trace_id=rec.get("trace_id"), reason=h2_decision["reason_code"], + policy_decision=h2_decision) + if not h2_decision["certifiable"]: + rec["integration_mode"] = "observed_only" + rec["certification_strength"] = "observed_only" add_evidence(rec, "H2/H4", "pre-tool", "allow", - "tool=%s side_effect=%s" % (tool, side_effect)) + "tool=%s side_effect=%s action=%s" % (tool, side_effect, action), + facts={"action_risk": risk, "registry": registry}) save_admission(rec) - return _base_response("pre-tool", "allow", admission_id=admission_id, - trace_id=rec.get("trace_id"), reason="allowed") + response = _base_response("pre-tool", "allow", admission_id=admission_id, + trace_id=rec.get("trace_id"), reason="allowed", + policy_decision=risk) + if registry["unsafe_bypass"]: + response["warnings"].append("HIGH: H2 registry bypass active in development/test; execution is not certifiable") + return response + + +def single_step_failure_policy(side_effect): + decision = evaluate_failure_policy( + side_effecting=side_effect, + mode=enforcement_mode(), + profile=os.environ.get("CASAN_PROFILE", "development"), + configured=os.environ.get("CASAN_SINGLE_STEP_FAILURE_POLICY"), + ) + return decision["failure_policy"], decision["source"] def op_post_tool(req): @@ -1045,11 +1216,52 @@ def op_post_tool(req): reason="no_admission") rec["tool_calls"] = rec.get("tool_calls", 0) + 1 status = req.get("status") or "success" - if status in ("error", "timeout"): + risk = evaluate_risk( + action=str(rec.get("last_action") or ""), + tool=str(req.get("tool") or rec.get("last_tool") or ""), + command=str(req.get("command") or ""), + actor=rec.get("actor", ""), + environment=os.environ.get("CASAN_PROFILE", "development"), + ) + side_effect = bool(risk["side_effecting"]) + if status in ("error", "timeout", "denied", "failed"): rec["failures"] = rec.get("failures", 0) + 1 + if side_effect: + rec["side_effect_failures"] = rec.get("side_effect_failures", 0) + 1 + policy, source = single_step_failure_policy(side_effect) + rec["failure_policy"] = policy + rec["execution_status"] = "failed" + rec["assurance_status"] = "failed" + add_evidence( + rec, "H6", "failure-policy", policy, + "tool=%s status=%s policy=%s" % (req.get("tool"), status, policy), + facts={ + "command_executed": status not in ("denied",), + "command_outcome": status, + "telemetry_recorded": True, + "assurance_may_continue": policy == "record_only", + "failure_policy": policy, + "policy_source": source, + "side_effecting": side_effect, + }, + ) + add_evidence(rec, "H5", "post-tool", status, + "tool=%s dur_ms=%s result=%s" % (req.get("tool"), req.get("duration_ms"), + redact(req.get("result"), 80)), + 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))) + redact(req.get("result"), 80)), + facts={"risk": risk, "command_outcome": status, "telemetry_recorded": True}) save_admission(rec) return _base_response("post-tool", "recorded", admission_id=rec["admission_id"], trace_id=rec.get("trace_id"), reason="evidence_appended") @@ -1092,6 +1304,10 @@ def op_finalize(req): rec["summary_hash"] = salted_hash(req.get("assistant_summary")) stop_reason = req.get("stop_reason") or "completed" status = "success" if stop_reason in ("completed", "max_turns") else "failed" + if rec.get("failures", 0) > 0: + # Telemetry success is not execution success. A failed tool remains a + # failed execution even if the client later emits Stop(completed). + status = "failed" observed_harnesses = { harness @@ -1110,6 +1326,8 @@ def op_finalize(req): # H3/H5/H7 finalize controls: run the H4 output filter over the assistant # summary as the closing verification control. reasons = [] + if rec.get("failures", 0) > 0: + reasons.append("tool_execution_failed") if req.get("assistant_summary"): h4 = h4_scan(req.get("assistant_summary"), "output") if h4 == "ok": @@ -1158,7 +1376,17 @@ def op_finalize(req): reasons.append("coverage_bypass") if status != "success": certified = False - reasons.append("stop_%s" % stop_reason) + failure_reason = "failed_tool_outcome" if rec.get("failures", 0) > 0 else "stop_%s" % stop_reason + if failure_reason not in reasons: + reasons.append(failure_reason) + trust = evaluate_trust_capabilities() + add_evidence( + rec, "H7", "trust-capability", "pass" if trust["certifiable"] else "degraded", + ",".join(trust["reason_codes"]), facts=trust, category="readiness_check", + ) + if trust["production"] and not trust["certifiable"]: + certified = False + reasons.extend(reason for reason in trust["reason_codes"] if reason not in reasons) if not certified and not reasons: reasons.append("unknown") if certified: @@ -1168,12 +1396,15 @@ def op_finalize(req): rec["certified"] = certified rec["finalized"] = True rec["finalized_at"] = now_iso() + rec["execution_status"] = status + rec["assurance_status"] = "passed" if certified else "failed" if rec.get("failures", 0) else "degraded" add_evidence( rec, "H7", "certification", "certified" if certified else "non_certified", ",".join(reasons), + category="certification_claim", ) quality, warnings, missing = classify_telemetry(rec) @@ -1185,13 +1416,26 @@ def op_finalize(req): ",".join(warnings) if warnings else "provider_usage_complete", ) h6_record = write_h6_record(rec, status, quality, warnings, missing) + try: + write_kernel_bundle(rec) + except (OSError, ValueError, TypeError) as exc: + certified = False + rec["certified"] = False + rec["state"] = "NonCertified" + rec["assurance_status"] = "failed" + if "kernel_contract_emission_failed" not in reasons: + reasons.append("kernel_contract_emission_failed") + add_evidence(rec, "H7", "kernel-contract", "failed", + "kernel_contract_emission_failed:%s" % type(exc).__name__) trace_path = write_trace_file(rec, certified, reasons) save_admission(rec) resp = _base_response("finalize", "certified" if certified else "non_certified", admission_id=rec["admission_id"], trace_id=rec.get("trace_id"), certification_strength=strength, telemetry_quality=quality, - reason=",".join(reasons)) + reason=",".join(reasons), execution_outcome=status, + assurance_outcome=rec.get("assurance_status"), + certification_outcome="certified" if certified else "non_certified") resp["warnings"].extend(warnings) resp["context"] = "trace=%s certified=%s" % (os.path.basename(trace_path), certified) report_url = dashboard_url(rec.get("trace_id")) diff --git a/packages/casan-harness/scripts/python/kernel_cli.py b/packages/casan-harness/scripts/python/kernel_cli.py new file mode 100644 index 0000000..b87fce6 --- /dev/null +++ b/packages/casan-harness/scripts/python/kernel_cli.py @@ -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()) diff --git a/packages/casan-harness/scripts/python/readiness.py b/packages/casan-harness/scripts/python/readiness.py index 0fae32f..8205f19 100644 --- a/packages/casan-harness/scripts/python/readiness.py +++ b/packages/casan-harness/scripts/python/readiness.py @@ -199,6 +199,9 @@ def _check( ) -> dict[str, Any]: return { "gate": gate, + "legacy_gate": gate, + "category": "readiness_check", + "check_id": "ReadinessCheck.%s" % gate, "title": title, "status": status, "summary": summary, diff --git a/packages/casan-harness/tests/assurance-kernel-tests.py b/packages/casan-harness/tests/assurance-kernel-tests.py new file mode 100644 index 0000000..e5a0291 --- /dev/null +++ b/packages/casan-harness/tests/assurance-kernel-tests.py @@ -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) diff --git a/packages/casan-harness/tests/assurance-upgrade-integration-tests.py b/packages/casan-harness/tests/assurance-upgrade-integration-tests.py new file mode 100644 index 0000000..4b48501 --- /dev/null +++ b/packages/casan-harness/tests/assurance-upgrade-integration-tests.py @@ -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) diff --git a/packages/casan-harness/tests/phase-agentic-bridge-tests.sh b/packages/casan-harness/tests/phase-agentic-bridge-tests.sh index 88a41eb..ac89791 100755 --- a/packages/casan-harness/tests/phase-agentic-bridge-tests.sh +++ b/packages/casan-harness/tests/phase-agentic-bridge-tests.sh @@ -40,6 +40,8 @@ print(d.get(sys.argv[1],""))' echo "===== C1: normal turn = one admission + one trace + one metric (single model) =====" newstate export CASAN_AGENTIC_ENFORCEMENT_MODE=enforce +export CASAN_AGENT=boss +export CASAN_ACTOR=boss B=$(bridge '{"op":"begin","client":"claude-code","project":"'"$PROJ"'","session":"c1","prompt":"add a helper","integration_mode":"project_hook"}') DEC=$(printf '%s' "$B" | field decision) AID=$(printf '%s' "$B" | field admission_id) @@ -76,7 +78,8 @@ B=$(bridge '{"op":"begin","client":"claude-code","project":"'"$PROJ"'","session" AID=$(printf '%s' "$B" | field admission_id); TID=$(printf '%s' "$B" | field trace_id) OK3=1 for tool in Bash Edit Write; do - R=$(bridge '{"op":"pre-tool","admission_id":"'"$AID"'","tool":"'"$tool"'","tool_input":"x","project":"'"$PROJ"'"}') + INPUT_VALUE="x"; [[ "$tool" == "Bash" ]] && INPUT_VALUE="ls" + R=$(bridge '{"op":"pre-tool","admission_id":"'"$AID"'","tool":"'"$tool"'","tool_input":"'"$INPUT_VALUE"'","project":"'"$PROJ"'"}') [[ "$(printf '%s' "$R" | field decision)" == "allow" ]] || OK3=0 bridge '{"op":"post-tool","admission_id":"'"$AID"'","tool":"'"$tool"'","status":"success"}' >/dev/null done @@ -245,7 +248,7 @@ AID=$(printf '%s' "$B" | field admission_id) || fail "no-bash begin did not degrade gracefully ($B)" R=$(bridge '{"op":"pre-tool","admission_id":"'"$AID"'","tool":"Bash","tool_input":"ls","project":"'"$PROJ"'"}') [[ "$(printf '%s' "$R" | field decision)" == "allow" ]] \ - && pass "no-bash: side-effect tool allowed (admission gate still governs)" \ + && pass "no-bash: classified read-only shell command remains available" \ || fail "no-bash pre-tool blocked the developer ($R)" F=$(bridge '{"op":"finalize","admission_id":"'"$AID"'","stop_reason":"completed"}') [[ "$(printf '%s' "$F" | field decision)" == "non_certified" ]] \ diff --git a/packages/casan-harness/tests/phase-assurance-upgrade-tests.sh b/packages/casan-harness/tests/phase-assurance-upgrade-tests.sh new file mode 100644 index 0000000..c0367d7 --- /dev/null +++ b/packages/casan-harness/tests/phase-assurance-upgrade-tests.sh @@ -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 ]] diff --git a/packages/casan-harness/tests/phase-production-preflight-tests.sh b/packages/casan-harness/tests/phase-production-preflight-tests.sh index 708b415..6109ae5 100755 --- a/packages/casan-harness/tests/phase-production-preflight-tests.sh +++ b/packages/casan-harness/tests/phase-production-preflight-tests.sh @@ -6,6 +6,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../scripts/bash/casan-paths.sh" SHIP="$CASAN_HARNESS_ROOT/scripts/bash/audit-ship-s3.sh" +PREFLIGHT="$CASAN_HARNESS_ROOT/scripts/bash/production-preflight.sh" WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT PASS=0; FAIL=0 pass() { echo "PASS: $1"; PASS=$((PASS+1)); } @@ -52,5 +53,109 @@ printf 'not-a-digest\n' > "$WORK/bad-head.txt" [[ "$(rc env PATH="$WORK/bin:$PATH" CASAN_PROFILE=prod CASAN_S3_BUCKET=casan-production-audit CASAN_S3_REGION=ap-northeast-1 CASAN_S3_KMS_KEY_ID=alias/casan-audit bash "$SHIP" "$WORK/bad-head.txt")" -ne 0 ]] \ && pass "malformed audit head is refused" || fail "malformed audit head accepted" +echo "===== Production configuration boundary =====" +mkdir -p "$WORK/tls" "$WORK/state/logs/audit" "$WORK/output" +openssl req -x509 -newkey rsa:2048 -nodes -days 60 \ + -subj '/CN=control.casan.internal' -addext 'subjectAltName=DNS:control.casan.internal' \ + -keyout "$WORK/tls/tls.key" -out "$WORK/tls/tls.crt" >/dev/null 2>&1 +openssl genrsa -out "$WORK/idp-private.pem" 2048 >/dev/null 2>&1 +openssl rsa -in "$WORK/idp-private.pem" -pubout -out "$WORK/idp-public.pem" >/dev/null 2>&1 +printf 'test-ca\n' > "$WORK/vault-ca.pem" +printf '%064d\n' 0 > "$WORK/state/logs/audit/audit-head.txt" + +cat > "$WORK/oauth.env" <<'EOF' +OAUTH2_PROXY_PROVIDER=oidc +OAUTH2_PROXY_OIDC_ISSUER_URL=https://id.casan.internal/realms/casan +OAUTH2_PROXY_CLIENT_ID=casan-control-plane +OAUTH2_PROXY_CLIENT_SECRET=secret-manager-injected +OAUTH2_PROXY_COOKIE_SECRET=base64-cookie-secret-value +OAUTH2_PROXY_COOKIE_SECURE=true +OAUTH2_PROXY_REDIRECT_URL=https://control.casan.internal/oauth2/callback +OAUTH2_PROXY_OIDC_GROUPS_CLAIM=groups +OAUTH2_PROXY_SET_XAUTHREQUEST=true +OAUTH2_PROXY_PASS_ACCESS_TOKEN=true +OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER=true +EOF +cat > "$WORK/vault.env" < "$WORK/runtime.env" < "$WORK/casan-prod.env" < "$WORK/bin/curl" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' '{"data":{"ttl":300,"policies":["casan-audit-sign"]}}' +EOF +cat > "$WORK/bin/docker" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF +cat > "$WORK/bin/aws" <<'EOF' +#!/usr/bin/env bash +if [[ "$1 $2" == "s3api get-object-lock-configuration" ]]; then + printf '%s\n' '{"ObjectLockConfiguration":{"ObjectLockEnabled":"Enabled","Rule":{"DefaultRetention":{"Mode":"COMPLIANCE","Days":365}}}}' +elif [[ "$1 $2" == "s3api head-object" ]]; then + exit 1 +fi +exit 0 +EOF +chmod +x "$WORK/bin/curl" "$WORK/bin/docker" "$WORK/bin/aws" + +write_runtime casan-control-plane vault_kms s3_object_lock +write_prod_env "$WORK/idp-public.pem" +[[ "$(rc env PATH="$WORK/bin:$PATH" bash "$PREFLIGHT" "$WORK/casan-prod.env")" -eq 0 ]] \ + && pass "complete JWT/Vault/Object-Lock production contract passes with local service stubs" \ + || fail "valid production configuration rejected" + +write_runtime wrong-audience vault_kms s3_object_lock +[[ "$(rc env PATH="$WORK/bin:$PATH" bash "$PREFLIGHT" "$WORK/casan-prod.env")" -ne 0 ]] \ + && pass "OIDC audience mismatch is refused" || fail "OIDC audience mismatch accepted" + +write_runtime casan-control-plane local_openssl local_hash_chain +[[ "$(rc env PATH="$WORK/bin:$PATH" bash "$PREFLIGHT" "$WORK/casan-prod.env")" -ne 0 ]] \ + && pass "production local signing and local ledger configuration is refused" \ + || fail "production local trust fallback accepted" + +write_runtime casan-control-plane vault_kms s3_object_lock +write_prod_env "$WORK/missing-idp-public.pem" +[[ "$(rc env PATH="$WORK/bin:$PATH" bash "$PREFLIGHT" "$WORK/casan-prod.env")" -ne 0 ]] \ + && pass "missing IdP verification key is refused" || fail "missing IdP key accepted" + echo "===== PRODUCTION HANDOFF SUMMARY: PASS=$PASS FAIL=$FAIL =====" [[ "$FAIL" -eq 0 ]] diff --git a/packages/casan-harness/tests/run-casan4-harness-tests.sh b/packages/casan-harness/tests/run-casan4-harness-tests.sh index 42ab289..e5a9c1b 100755 --- a/packages/casan-harness/tests/run-casan4-harness-tests.sh +++ b/packages/casan-harness/tests/run-casan4-harness-tests.sh @@ -117,7 +117,7 @@ set -e # H5: high-risk action approved with explicit approver APPROVED_OUT="$EVIDENCE_DIR/04-high-risk-approved-output.txt" -CASAN_APPROVAL_DECISION=approve CASAN_APPROVER=architect@example.local \ +CASAN_ACTOR=developer CASAN_APPROVAL_DECISION=approve CASAN_APPROVER=architect@example.local \ "$SCRIPTS/governance-check.sh" "$RISK_IN" "$APPROVED_OUT" deploy > "$EVIDENCE_DIR/04-governance-approve.stdout" assert_contains "$APPROVED_OUT" "Deploy"