From 76e1980ebf9608e307d202c5058594b4a1ff1f48 Mon Sep 17 00:00:00 2001 From: AP3X Date: Tue, 8 Sep 2026 13:00:04 -0700 Subject: [PATCH 1/3] feat: ship npm-ready Python guardrails --- .gitignore | 2 + docs/TRACEABILITY.md | 112 +++++ ...2026-09-08-consumer-profile-cli-package.md | 95 +++++ docs/adr/2026-09-08-python-profile.md | 97 +++++ .../examples/python-evidence-v1.json | 1 + docs/contracts/examples/python-policy-v1.json | 1 + .../python-policy-unknown-field.json | 35 ++ docs/contracts/python-evidence-v1.schema.json | 384 ++++++++++++++++++ docs/contracts/python-policy-v1.schema.json | 251 ++++++++++++ docs/contracts/python-profile-v1.md | 5 + kernel-zero.python.policy.json | 73 ++++ package-lock.json | 21 +- package.json | 3 +- .../profile-python/fixtures/broken/service.py | 2 + .../fixtures/passing/service.py | 14 + .../fixtures/violating/service.py | 7 + packages/profile-python/package.json | 15 + packages/profile-python/python-analyzer.py | 210 ++++++++++ packages/profile-python/src/check.test.ts | 105 +++++ packages/profile-python/src/check.ts | 125 ++++++ packages/profile-python/src/evidence.ts | 29 ++ packages/profile-python/src/facts.ts | 53 +++ packages/profile-python/src/index.test.ts | 49 +++ packages/profile-python/src/index.ts | 67 +++ packages/profile-python/src/policy.ts | 54 +++ packages/profile-python/src/runner.test.ts | 100 +++++ packages/profile-python/src/runner.ts | 209 ++++++++++ packages/profile-python/tsconfig.json | 5 + packages/profiles/package.json | 1 + packages/profiles/src/index.test.ts | 1 + packages/profiles/src/index.ts | 2 + packages/validator/README.md | 52 ++- packages/validator/package.json | 11 +- packages/validator/src/commands.test.ts | 2 +- packages/validator/src/commands.ts | 2 +- packages/validator/src/runner.ts | 2 + scripts/check-architecture.mjs | 6 + scripts/check-validator-package.mjs | 192 ++++++++- scripts/generate-contracts.ts | 40 ++ scripts/generate-traceability.ts | 1 + scripts/rename-validator-binaries.mjs | 27 ++ scripts/run-manifest-validator.ts | 2 + scripts/run-python-validator.ts | 17 + scripts/run-workflow-validator.ts | 2 + tsconfig.json | 2 + 45 files changed, 2466 insertions(+), 20 deletions(-) create mode 100644 docs/adr/2026-09-08-consumer-profile-cli-package.md create mode 100644 docs/adr/2026-09-08-python-profile.md create mode 100644 docs/contracts/examples/python-evidence-v1.json create mode 100644 docs/contracts/examples/python-policy-v1.json create mode 100644 docs/contracts/malformed/python-policy-unknown-field.json create mode 100644 docs/contracts/python-evidence-v1.schema.json create mode 100644 docs/contracts/python-policy-v1.schema.json create mode 100644 docs/contracts/python-profile-v1.md create mode 100644 kernel-zero.python.policy.json create mode 100644 packages/profile-python/fixtures/broken/service.py create mode 100644 packages/profile-python/fixtures/passing/service.py create mode 100644 packages/profile-python/fixtures/violating/service.py create mode 100644 packages/profile-python/package.json create mode 100644 packages/profile-python/python-analyzer.py create mode 100644 packages/profile-python/src/check.test.ts create mode 100644 packages/profile-python/src/check.ts create mode 100644 packages/profile-python/src/evidence.ts create mode 100644 packages/profile-python/src/facts.ts create mode 100644 packages/profile-python/src/index.test.ts create mode 100644 packages/profile-python/src/index.ts create mode 100644 packages/profile-python/src/policy.ts create mode 100644 packages/profile-python/src/runner.test.ts create mode 100644 packages/profile-python/src/runner.ts create mode 100644 packages/profile-python/tsconfig.json create mode 100644 scripts/rename-validator-binaries.mjs create mode 100644 scripts/run-python-validator.ts diff --git a/.gitignore b/.gitignore index b6b0910..0350b8d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ node_modules/ .next/ dist/ +packages/validator/contracts/ +packages/validator/python-analyzer.py coverage/ .tools/ .uv-cache/ diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index eeed3da..1e3dc72 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -4,6 +4,7 @@ Generated by `npm run traceability:generate`. Each functional requirement has an | Requirement | Executable or deterministic evidence | Qualification boundary | | --- | --- | --- | +<<<<<<< HEAD | FR-POL-001 | policy contract, persistence, service, governed-action, diff, UI read-model, self-policy, and gate 3/4 PostgreSQL approval-race and single-active-revision tests | local unit/static and isolated PostgreSQL proof; maker-checker separation withdrawn with identity | | FR-POL-002 | policy contract, persistence, service, governed-action, diff, UI read-model, self-policy, and gate 3/4 PostgreSQL approval-race and single-active-revision tests | local unit/static and isolated PostgreSQL proof; maker-checker separation withdrawn with identity | | FR-POL-003 | policy contract, persistence, service, governed-action, diff, UI read-model, self-policy, and gate 3/4 PostgreSQL approval-race and single-active-revision tests | local unit/static and isolated PostgreSQL proof; maker-checker separation withdrawn with identity | @@ -77,3 +78,114 @@ Generated by `npm run traceability:generate`. Each functional requirement has an | FR-DOG-003 | self-policy uses layers and all three kinds, contracts regenerated and checked, README and validator docs examples, this traceability, benchmark rules per kind under 30 s and 1 GiB | local executable proof; benchmark corpus has no calls, so numbers are a floor for the new evaluators | | FR-DOG-004 | self-policy uses layers and all three kinds, contracts regenerated and checked, README and validator docs examples, this traceability, benchmark rules per kind under 30 s and 1 GiB | local executable proof; benchmark corpus has no calls, so numbers are a floor for the new evaluators | | FR-DOG-005 | self-policy uses layers and all three kinds, contracts regenerated and checked, README and validator docs examples, this traceability, benchmark rules per kind under 30 s and 1 GiB | local executable proof; benchmark corpus has no calls, so numbers are a floor for the new evaluators | +======= +| FR-AUTH-001 | config, auth, callback, identity-error, outbox, and redaction tests | local unit/configuration proof; live OAuth/email and database session proof pending | +| FR-AUTH-002 | config, auth, callback, identity-error, outbox, and redaction tests | local unit/configuration proof; live OAuth/email and database session proof pending | +| FR-AUTH-003 | config, auth, callback, identity-error, outbox, and redaction tests | local unit/configuration proof; live OAuth/email and database session proof pending | +| FR-AUTH-004 | config, auth, callback, identity-error, outbox, and redaction tests | local unit/configuration proof; live OAuth/email and database session proof pending | +| FR-AUTH-005 | config, auth, callback, identity-error, outbox, and redaction tests | local unit/configuration proof; live OAuth/email and database session proof pending | +| FR-AUTH-006 | config, auth, callback, identity-error, outbox, and redaction tests | local unit/configuration proof; live OAuth/email and database session proof pending | +| FR-TEN-001 | tenancy persistence tests, migration constraints, authorization tests, and architecture self-policy | local unit/schema proof; isolated PostgreSQL concurrency proof pending | +| FR-TEN-002 | tenancy persistence tests, migration constraints, authorization tests, and architecture self-policy | local unit/schema proof; isolated PostgreSQL concurrency proof pending | +| FR-TEN-003 | tenancy persistence tests, migration constraints, authorization tests, and architecture self-policy | local unit/schema proof; isolated PostgreSQL concurrency proof pending | +| FR-TEN-004 | tenancy persistence tests, migration constraints, authorization tests, and architecture self-policy | local unit/schema proof; isolated PostgreSQL concurrency proof pending | +| FR-TEN-005 | tenancy persistence tests, migration constraints, authorization tests, and architecture self-policy | local unit/schema proof; isolated PostgreSQL concurrency proof pending | +| FR-TEN-006 | tenancy persistence tests, migration constraints, authorization tests, and architecture self-policy | local unit/schema proof; isolated PostgreSQL concurrency proof pending | +| FR-TEAM-001 | invitation persistence, roster view, authorization, quota, and team service tests | local unit/schema proof; live delivery and database concurrency proof pending | +| FR-TEAM-002 | invitation persistence, roster view, authorization, quota, and team service tests | local unit/schema proof; live delivery and database concurrency proof pending | +| FR-TEAM-003 | invitation persistence, roster view, authorization, quota, and team service tests | local unit/schema proof; live delivery and database concurrency proof pending | +| FR-TEAM-004 | invitation persistence, roster view, authorization, quota, and team service tests | local unit/schema proof; live delivery and database concurrency proof pending | +| FR-TEAM-005 | invitation persistence, roster view, authorization, quota, and team service tests | local unit/schema proof; live delivery and database concurrency proof pending | +| FR-TEAM-006 | invitation persistence, roster view, authorization, quota, and team service tests | local unit/schema proof; live delivery and database concurrency proof pending | +| FR-TEAM-007 | invitation persistence, roster view, authorization, quota, and team service tests | local unit/schema proof; live delivery and database concurrency proof pending | +| FR-TEAM-008 | invitation persistence, roster view, authorization, quota, and team service tests | local unit/schema proof; live delivery and database concurrency proof pending | +| FR-TEAM-009 | invitation persistence, roster view, authorization, quota, and team service tests | local unit/schema proof; live delivery and database concurrency proof pending | +| FR-POL-001 | policy contract, persistence, service, governed-action, diff, UI read-model, self-policy, and gate 4/5 PostgreSQL maker-checker and custody-approval tests | local unit/static and isolated PostgreSQL proof | +| FR-POL-002 | policy contract, persistence, service, governed-action, diff, UI read-model, self-policy, and gate 4/5 PostgreSQL maker-checker and custody-approval tests | local unit/static and isolated PostgreSQL proof | +| FR-POL-003 | policy contract, persistence, service, governed-action, diff, UI read-model, self-policy, and gate 4/5 PostgreSQL maker-checker and custody-approval tests | local unit/static and isolated PostgreSQL proof | +| FR-POL-004 | policy contract, persistence, service, governed-action, diff, UI read-model, self-policy, and gate 4/5 PostgreSQL maker-checker and custody-approval tests | local unit/static and isolated PostgreSQL proof | +| FR-POL-005 | policy contract, persistence, service, governed-action, diff, UI read-model, self-policy, and gate 4/5 PostgreSQL maker-checker and custody-approval tests | local unit/static and isolated PostgreSQL proof | +| FR-POL-006 | policy contract, persistence, service, governed-action, diff, UI read-model, self-policy, and gate 4/5 PostgreSQL maker-checker and custody-approval tests | local unit/static and isolated PostgreSQL proof | +| FR-POL-007 | policy contract, persistence, service, governed-action, diff, UI read-model, self-policy, and gate 4/5 PostgreSQL maker-checker and custody-approval tests | local unit/static and isolated PostgreSQL proof | +| FR-POL-008 | policy contract, persistence, service, governed-action, diff, UI read-model, self-policy, and gate 4/5 PostgreSQL maker-checker and custody-approval tests | local unit/static and isolated PostgreSQL proof | +| FR-VAL-001 | validator CLI, discovery, engine, runner, golden baseline, semantic check-kind matrices, custody-flow, public artifact, self-policy, and benchmark checks | local executable proof | +| FR-VAL-002 | validator CLI, discovery, engine, runner, golden baseline, semantic check-kind matrices, custody-flow, public artifact, self-policy, and benchmark checks | local executable proof | +| FR-VAL-003 | validator CLI, discovery, engine, runner, golden baseline, semantic check-kind matrices, custody-flow, public artifact, self-policy, and benchmark checks | local executable proof | +| FR-VAL-004 | validator CLI, discovery, engine, runner, golden baseline, semantic check-kind matrices, custody-flow, public artifact, self-policy, and benchmark checks | local executable proof | +| FR-VAL-005 | validator CLI, discovery, engine, runner, golden baseline, semantic check-kind matrices, custody-flow, public artifact, self-policy, and benchmark checks | local executable proof | +| FR-VAL-006 | validator CLI, discovery, engine, runner, golden baseline, semantic check-kind matrices, custody-flow, public artifact, self-policy, and benchmark checks | local executable proof | +| FR-VAL-007 | validator CLI, discovery, engine, runner, golden baseline, semantic check-kind matrices, custody-flow, public artifact, self-policy, and benchmark checks | local executable proof | +| FR-VAL-008 | validator CLI, discovery, engine, runner, golden baseline, semantic check-kind matrices, custody-flow, public artifact, self-policy, and benchmark checks | local executable proof | +| FR-VAL-009 | validator CLI, discovery, engine, runner, golden baseline, semantic check-kind matrices, custody-flow, public artifact, self-policy, and benchmark checks | local executable proof | +| FR-VAL-010 | validator CLI, discovery, engine, runner, golden baseline, semantic check-kind matrices, custody-flow, public artifact, self-policy, and benchmark checks | local executable proof | +| FR-VAL-011 | validator CLI, discovery, engine, runner, golden baseline, semantic check-kind matrices, custody-flow, public artifact, self-policy, and benchmark checks | local executable proof | +| FR-PY-001 | Python policy, fact-contract, pure checker, CPython adapter, compatibility, runner, self-policy, generated-contract, and clean installed-package tests | local Windows CPython 3.12 and npm-tarball proof; Linux and CPython 3.11/3.13/3.14 qualification pending | +| FR-PY-002 | Python policy, fact-contract, pure checker, CPython adapter, compatibility, runner, self-policy, generated-contract, and clean installed-package tests | local Windows CPython 3.12 and npm-tarball proof; Linux and CPython 3.11/3.13/3.14 qualification pending | +| FR-PY-003 | Python policy, fact-contract, pure checker, CPython adapter, compatibility, runner, self-policy, generated-contract, and clean installed-package tests | local Windows CPython 3.12 and npm-tarball proof; Linux and CPython 3.11/3.13/3.14 qualification pending | +| FR-PY-004 | Python policy, fact-contract, pure checker, CPython adapter, compatibility, runner, self-policy, generated-contract, and clean installed-package tests | local Windows CPython 3.12 and npm-tarball proof; Linux and CPython 3.11/3.13/3.14 qualification pending | +| FR-PY-005 | Python policy, fact-contract, pure checker, CPython adapter, compatibility, runner, self-policy, generated-contract, and clean installed-package tests | local Windows CPython 3.12 and npm-tarball proof; Linux and CPython 3.11/3.13/3.14 qualification pending | +| FR-PY-006 | Python policy, fact-contract, pure checker, CPython adapter, compatibility, runner, self-policy, generated-contract, and clean installed-package tests | local Windows CPython 3.12 and npm-tarball proof; Linux and CPython 3.11/3.13/3.14 qualification pending | +| FR-PY-007 | Python policy, fact-contract, pure checker, CPython adapter, compatibility, runner, self-policy, generated-contract, and clean installed-package tests | local Windows CPython 3.12 and npm-tarball proof; Linux and CPython 3.11/3.13/3.14 qualification pending | +| FR-PY-008 | Python policy, fact-contract, pure checker, CPython adapter, compatibility, runner, self-policy, generated-contract, and clean installed-package tests | local Windows CPython 3.12 and npm-tarball proof; Linux and CPython 3.11/3.13/3.14 qualification pending | +| FR-PY-009 | Python policy, fact-contract, pure checker, CPython adapter, compatibility, runner, self-policy, generated-contract, and clean installed-package tests | local Windows CPython 3.12 and npm-tarball proof; Linux and CPython 3.11/3.13/3.14 qualification pending | +| FR-PY-010 | Python policy, fact-contract, pure checker, CPython adapter, compatibility, runner, self-policy, generated-contract, and clean installed-package tests | local Windows CPython 3.12 and npm-tarball proof; Linux and CPython 3.11/3.13/3.14 qualification pending | +| FR-PY-011 | Python policy, fact-contract, pure checker, CPython adapter, compatibility, runner, self-policy, generated-contract, and clean installed-package tests | local Windows CPython 3.12 and npm-tarball proof; Linux and CPython 3.11/3.13/3.14 qualification pending | +| FR-PY-012 | Python policy, fact-contract, pure checker, CPython adapter, compatibility, runner, self-policy, generated-contract, and clean installed-package tests | local Windows CPython 3.12 and npm-tarball proof; Linux and CPython 3.11/3.13/3.14 qualification pending | +| FR-CUS-001 | custody contract and property tests, custody-flow CLI exit/output-order tests, policy-custody persistence tests, gate 5 PostgreSQL custody tests, custody-signer tests, and the two custody self-policy rules | local unit/cryptographic and isolated PostgreSQL proof with ephemeral keys only; production authority keys, trust import, and live enforcement intentionally unavailable | +| FR-CUS-002 | custody contract and property tests, custody-flow CLI exit/output-order tests, policy-custody persistence tests, gate 5 PostgreSQL custody tests, custody-signer tests, and the two custody self-policy rules | local unit/cryptographic and isolated PostgreSQL proof with ephemeral keys only; production authority keys, trust import, and live enforcement intentionally unavailable | +| FR-CUS-003 | custody contract and property tests, custody-flow CLI exit/output-order tests, policy-custody persistence tests, gate 5 PostgreSQL custody tests, custody-signer tests, and the two custody self-policy rules | local unit/cryptographic and isolated PostgreSQL proof with ephemeral keys only; production authority keys, trust import, and live enforcement intentionally unavailable | +| FR-CUS-004 | custody contract and property tests, custody-flow CLI exit/output-order tests, policy-custody persistence tests, gate 5 PostgreSQL custody tests, custody-signer tests, and the two custody self-policy rules | local unit/cryptographic and isolated PostgreSQL proof with ephemeral keys only; production authority keys, trust import, and live enforcement intentionally unavailable | +| FR-CUS-005 | custody contract and property tests, custody-flow CLI exit/output-order tests, policy-custody persistence tests, gate 5 PostgreSQL custody tests, custody-signer tests, and the two custody self-policy rules | local unit/cryptographic and isolated PostgreSQL proof with ephemeral keys only; production authority keys, trust import, and live enforcement intentionally unavailable | +| FR-CUS-006 | custody contract and property tests, custody-flow CLI exit/output-order tests, policy-custody persistence tests, gate 5 PostgreSQL custody tests, custody-signer tests, and the two custody self-policy rules | local unit/cryptographic and isolated PostgreSQL proof with ephemeral keys only; production authority keys, trust import, and live enforcement intentionally unavailable | +| FR-CUS-007 | custody contract and property tests, custody-flow CLI exit/output-order tests, policy-custody persistence tests, gate 5 PostgreSQL custody tests, custody-signer tests, and the two custody self-policy rules | local unit/cryptographic and isolated PostgreSQL proof with ephemeral keys only; production authority keys, trust import, and live enforcement intentionally unavailable | +| FR-CUS-008 | custody contract and property tests, custody-flow CLI exit/output-order tests, policy-custody persistence tests, gate 5 PostgreSQL custody tests, custody-signer tests, and the two custody self-policy rules | local unit/cryptographic and isolated PostgreSQL proof with ephemeral keys only; production authority keys, trust import, and live enforcement intentionally unavailable | +| FR-EVD-001 | evidence transport, service, compatibility, persistence adapter, persistence, route, and UI read-model tests | local unit/contract proof; live PostgreSQL retention and ingestion proof pending | +| FR-EVD-002 | evidence transport, service, compatibility, persistence adapter, persistence, route, and UI read-model tests | local unit/contract proof; live PostgreSQL retention and ingestion proof pending | +| FR-EVD-003 | evidence transport, service, compatibility, persistence adapter, persistence, route, and UI read-model tests | local unit/contract proof; live PostgreSQL retention and ingestion proof pending | +| FR-EVD-004 | evidence transport, service, compatibility, persistence adapter, persistence, route, and UI read-model tests | local unit/contract proof; live PostgreSQL retention and ingestion proof pending | +| FR-EVD-005 | evidence transport, service, compatibility, persistence adapter, persistence, route, and UI read-model tests | local unit/contract proof; live PostgreSQL retention and ingestion proof pending | +| FR-EVD-006 | evidence transport, service, compatibility, persistence adapter, persistence, route, and UI read-model tests | local unit/contract proof; live PostgreSQL retention and ingestion proof pending | +| FR-EVD-007 | evidence transport, service, compatibility, persistence adapter, persistence, route, and UI read-model tests | local unit/contract proof; live PostgreSQL retention and ingestion proof pending | +| FR-EVD-008 | evidence transport, service, compatibility, persistence adapter, persistence, route, and UI read-model tests | local unit/contract proof; live PostgreSQL retention and ingestion proof pending | +| FR-EXC-001 | exception contract, persistence, signing-key, service, validator-consumption, and reserved-export tests | local unit/cryptographic proof; production export custody intentionally unavailable | +| FR-EXC-002 | exception contract, persistence, signing-key, service, validator-consumption, and reserved-export tests | local unit/cryptographic proof; production export custody intentionally unavailable | +| FR-EXC-003 | exception contract, persistence, signing-key, service, validator-consumption, and reserved-export tests | local unit/cryptographic proof; production export custody intentionally unavailable | +| FR-EXC-004 | exception contract, persistence, signing-key, service, validator-consumption, and reserved-export tests | local unit/cryptographic proof; production export custody intentionally unavailable | +| FR-EXC-005 | exception contract, persistence, signing-key, service, validator-consumption, and reserved-export tests | local unit/cryptographic proof; production export custody intentionally unavailable | +| FR-EXC-006 | exception contract, persistence, signing-key, service, validator-consumption, and reserved-export tests | local unit/cryptographic proof; production export custody intentionally unavailable | +| FR-ENT-001 | entitlement, quota, transaction, governed-action, invitation, policy, and billing preflight tests | local unit/schema proof; isolated PostgreSQL concurrency proof pending | +| FR-ENT-002 | entitlement, quota, transaction, governed-action, invitation, policy, and billing preflight tests | local unit/schema proof; isolated PostgreSQL concurrency proof pending | +| FR-ENT-003 | entitlement, quota, transaction, governed-action, invitation, policy, and billing preflight tests | local unit/schema proof; isolated PostgreSQL concurrency proof pending | +| FR-ENT-004 | entitlement, quota, transaction, governed-action, invitation, policy, and billing preflight tests | local unit/schema proof; isolated PostgreSQL concurrency proof pending | +| FR-ENT-005 | entitlement, quota, transaction, governed-action, invitation, policy, and billing preflight tests | local unit/schema proof; isolated PostgreSQL concurrency proof pending | +| FR-ENT-006 | entitlement, quota, transaction, governed-action, invitation, policy, and billing preflight tests | local unit/schema proof; isolated PostgreSQL concurrency proof pending | +| FR-BILL-001 | billing service/cache, Stripe adapter, signed webhook route, receipt/projection persistence, and operator-view tests | mocked-provider proof; no live Stripe qualification | +| FR-BILL-002 | billing service/cache, Stripe adapter, signed webhook route, receipt/projection persistence, and operator-view tests | mocked-provider proof; no live Stripe qualification | +| FR-BILL-003 | billing service/cache, Stripe adapter, signed webhook route, receipt/projection persistence, and operator-view tests | mocked-provider proof; no live Stripe qualification | +| FR-BILL-004 | billing service/cache, Stripe adapter, signed webhook route, receipt/projection persistence, and operator-view tests | mocked-provider proof; no live Stripe qualification | +| FR-BILL-005 | billing service/cache, Stripe adapter, signed webhook route, receipt/projection persistence, and operator-view tests | mocked-provider proof; no live Stripe qualification | +| FR-BILL-006 | billing service/cache, Stripe adapter, signed webhook route, receipt/projection persistence, and operator-view tests | mocked-provider proof; no live Stripe qualification | +| FR-BILL-007 | billing service/cache, Stripe adapter, signed webhook route, receipt/projection persistence, and operator-view tests | mocked-provider proof; no live Stripe qualification | +| FR-BILL-008 | billing service/cache, Stripe adapter, signed webhook route, receipt/projection persistence, and operator-view tests | mocked-provider proof; no live Stripe qualification | +| FR-BILL-009 | billing service/cache, Stripe adapter, signed webhook route, receipt/projection persistence, and operator-view tests | mocked-provider proof; no live Stripe qualification | +| FR-BILL-010 | billing service/cache, Stripe adapter, signed webhook route, receipt/projection persistence, and operator-view tests | mocked-provider proof; no live Stripe qualification | +| FR-AUD-001 | audit, governed-action rollback, correlation proxy, request logging, redaction, and safe read-model tests | local unit/schema proof; full hosted request tracing pending | +| FR-AUD-002 | audit, governed-action rollback, correlation proxy, request logging, redaction, and safe read-model tests | local unit/schema proof; full hosted request tracing pending | +| FR-AUD-003 | audit, governed-action rollback, correlation proxy, request logging, redaction, and safe read-model tests | local unit/schema proof; full hosted request tracing pending | +| FR-AUD-004 | audit, governed-action rollback, correlation proxy, request logging, redaction, and safe read-model tests | local unit/schema proof; full hosted request tracing pending | +| FR-AUD-005 | audit, governed-action rollback, correlation proxy, request logging, redaction, and safe read-model tests | local unit/schema proof; full hosted request tracing pending | +| FR-AUD-006 | audit, governed-action rollback, correlation proxy, request logging, redaction, and safe read-model tests | local unit/schema proof; full hosted request tracing pending | +| FR-RATE-001 | rate-limit policy/configuration and failure-mode tests | local mocked-adapter proof; no live Redis qualification | +| FR-RATE-002 | rate-limit policy/configuration and failure-mode tests | local mocked-adapter proof; no live Redis qualification | +| FR-RATE-003 | rate-limit policy/configuration and failure-mode tests | local mocked-adapter proof; no live Redis qualification | +| FR-RATE-004 | rate-limit policy/configuration and failure-mode tests | local mocked-adapter proof; no live Redis qualification | +| FR-UI-001 | route/UI contracts, capability-gated read models, production build, and desktop/320px Playwright axe checks | local build/browser proof; authenticated database-backed keyboard journeys pending | +| FR-UI-002 | route/UI contracts, capability-gated read models, production build, and desktop/320px Playwright axe checks | local build/browser proof; authenticated database-backed keyboard journeys pending | +| FR-UI-003 | route/UI contracts, capability-gated read models, production build, and desktop/320px Playwright axe checks | local build/browser proof; authenticated database-backed keyboard journeys pending | +| FR-UI-004 | route/UI contracts, capability-gated read models, production build, and desktop/320px Playwright axe checks | local build/browser proof; authenticated database-backed keyboard journeys pending | +| FR-UI-005 | route/UI contracts, capability-gated read models, production build, and desktop/320px Playwright axe checks | local build/browser proof; authenticated database-backed keyboard journeys pending | +| FR-UI-006 | route/UI contracts, capability-gated read models, production build, and desktop/320px Playwright axe checks | local build/browser proof; authenticated database-backed keyboard journeys pending | +| FR-UI-007 | route/UI contracts, capability-gated read models, production build, and desktop/320px Playwright axe checks | local build/browser proof; authenticated database-backed keyboard journeys pending | +| FR-UI-008 | route/UI contracts, capability-gated read models, production build, and desktop/320px Playwright axe checks | local build/browser proof; authenticated database-backed keyboard journeys pending | +| FR-UI-009 | route/UI contracts, capability-gated read models, production build, and desktop/320px Playwright axe checks | local build/browser proof; authenticated database-backed keyboard journeys pending | +>>>>>>> 664d5b4 (feat: ship npm-ready Python guardrails) diff --git a/docs/adr/2026-09-08-consumer-profile-cli-package.md b/docs/adr/2026-09-08-consumer-profile-cli-package.md new file mode 100644 index 0000000..e130459 --- /dev/null +++ b/docs/adr/2026-09-08-consumer-profile-cli-package.md @@ -0,0 +1,95 @@ +# Ship profile validators in the public npm package + +Date: 2026-09-08 +Status: accepted + +## Context + +The public `@kernel-zero/validator` package currently installs only the +repository architecture validator. The manifest and workflow profiles are +implemented and locally verified, but their executable adapters live in the +KERNEL ZERO monorepo. Consumer developers do not have that repository and need +the mature validators available from one npm installation. + +The team has also settled the initial operating model: each developer is +responsible for the agent they drive and may manage their own approvals and +exceptions. Stronger centralized custody is future hardening, not a prerequisite +for making the deterministic validators usable. + +## Decision + +Release `@kernel-zero/validator` version `0.2.0` with four CLI-only binaries: + +- `kernel-zero` for `RepositoryPolicy` validation and existing custody support; +- `kernel-zero-manifest` for `ManifestPolicy` validation; +- `kernel-zero-python` for `PythonPolicy` validation, under the separate Python + profile ADR; +- `kernel-zero-workflow` for `WorkflowPolicy` validation. + +The profile executables are bundled at package build time from their existing +profile packages and runner adapters. Consumers install only +`@kernel-zero/validator`; no workspace package, source checkout, hosted service, +or network access is needed at execution time. The existing `kernel-zero` +command, policy contracts, evidence contracts, finding identities, digests, and +exit-code meanings remain unchanged. + +This slice does not mint approvals or exception grants. The package continues +to consume and verify signed artifacts. Developer-owned artifact creation will +be a separate additive CLI slice because its input format, key lifecycle, and +audit fields are public contracts that should not be improvised inside a +distribution change. + +## Invariants touched + +10. All four installed validators remain deterministic and network-free. The + packed-tarball check executes each binary from a clean consumer install. +11. Public wire formats remain versioned and unchanged. This release changes + only npm distribution and executable entry points. +12. The kernel does not import manifest or workflow concepts. Their existing + runners remain profile-side build entry points and produce separate bundled + executables. + +Invariant 9 still applies: npm publication is a separate authenticated human +gate after local verification and exact-tarball checks pass. + +## Contract and compatibility impact + +The npm package version changes from `0.1.0` to `0.2.0`. Adding executable names +is backward-compatible for existing CLI consumers. The `kernel-zero` binary and +all v1 JSON artifacts retain their current syntax and meaning. Internal profile +workspace packages remain private and are not runtime dependencies of the +installed tarball. Evidence continues to identify the unchanged validator and +profile engine contract as `0.1.0`; the npm distribution version advances +independently because this slice adds package entry points without changing the +engines or their evidence semantics. + +## FR-IDs + +This distribution slice adds no new product requirement. It makes the existing +manifest and workflow profile contracts usable by external developers and +retains coverage of `FR-VAL-001` through `FR-VAL-011` for repository validation. + +## Failure modes + +- Invalid or unreadable policy/input: exit `2`, with no passing claim. +- Definite profile violation: exit `1` with normalized evidence. +- Passing validation: exit `0` with normalized evidence. +- Missing package build input or accidental tarball expansion: package check + fails before publication. + +## Human gates + +Implementation, tests, packing, and clean installation are authorized. Commit, +push, and live npm publication remain separate gates. The current npm session is +not authenticated, so publication will additionally require an npm login or +access token. + +## Verification + +```text +npm run verify +npm run validator:package:check +npm publish --workspace @kernel-zero/validator --dry-run +``` + +The pinned Node 22 runtime is authoritative and exit `0` is the only pass. diff --git a/docs/adr/2026-09-08-python-profile.md b/docs/adr/2026-09-08-python-profile.md new file mode 100644 index 0000000..e163655 --- /dev/null +++ b/docs/adr/2026-09-08-python-profile.md @@ -0,0 +1,97 @@ +# Add a CPython-backed Python architecture profile + +Date: 2026-09-08 +Status: accepted + +## Context + +KERNEL ZERO's repository architecture validator understands TypeScript and TSX. +Manifest and workflow profiles are language-independent, but they cannot judge +Python imports, calls, or function signatures. Consumer teams need Python +enforcement through npm without checking out this monorepo. + +An editor-oriented JavaScript grammar was considered. Its own published corpus +results show that it does not parse all current Python syntax, which conflicts +with the requirement for accurate fail-closed enforcement. Python developers +already have a Python runtime, so the standard-library `ast` parser is the +narrower and more accurate dependency. + +## Decision + +Add a `PythonPolicy` / `PythonEvidence` profile and a +`kernel-zero-python` executable to `@kernel-zero/validator` `0.2.0`. + +- **Parser boundary.** A bundled, dependency-free Python adapter reads a + versioned JSON request from stdin and emits versioned JSON facts to stdout. + The Node runner owns discovery and source reads, strictly parses the adapter + output, and passes immutable facts into a pure TypeScript checker. +- **Runtime.** CPython 3.11 through 3.14 is supported. The runner resolves + `KERNEL_ZERO_PYTHON` when explicitly set, otherwise `python3`, `python`, then + Windows `py -3`. The exact runtime version is included as semver build metadata + in the evidence tool version. +- **Scope.** Strict contained include/exclude globs over `.py` files. Discovery + skips symlinks and `.git`, `.venv`, `venv`, `__pycache__`, `site-packages`, + `node_modules`, `dist`, and `build` directories. +- **Rule kinds.** `forbid-import-edge`, `require-import`, + `restrict-call-site`, and `require-context-parameter`. Module matching is exact + or a dotted descendant. Call resolution covers direct names and attribute + chains rooted in statically declared import aliases. Context parameters must + be named, non-variadic, and have no default. +- **Failure behavior.** Syntax failures produce `PARSE_FAILURE` and exit `2`. + Missing/unsupported Python, malformed protocol, unreadable files, invalid + policy, or unsafe paths also exit `2`. Definite violations exit `1`; pass exits + `0`. +- **Evidence.** Evidence contains normalized findings and digests, never source. + Message codes and subjects are closed and versioned. + +### Deliberate ceilings + +- No Python type-inference, assignment-alias tracking, reflection, dynamic + import evaluation, decorator semantics, or monkey-patch analysis is claimed. +- Relative imports are represented with their leading dots because resolving + them to an absolute package requires an authoritative package root that v1 + does not accept. +- Python 2 and CPython before 3.11 are unsupported. + +## Invariants touched + +1. Definite Python violations fail closed; claimed syntax failures are errors. +7. Policy, adapter facts, and evidence cross strict schemas before use. +10. Validation is deterministic and network-free; only a local CPython process + is invoked. +11. `PythonPolicy` and `PythonEvidence` are additive v1 public wire kinds with + generated schemas and frozen message codes. +12. Python concepts remain in `packages/profile-python`, its runner entry point, + policy, fixtures, and documentation. Kernel packages do not import them. + +Invariants 2 through 6, 8, and 9 are unaffected. This adds no web route, +persistence, tenant selector, governed operation, credential, deployment, or +live publication. + +## Public message and subject contract + +- `PYTHON_IMPORT_DENIED`, subject `module:` +- `PYTHON_IMPORT_REQUIRED`, subject `module:` +- `PYTHON_CALL_RESTRICTED`, subject `call:` +- `PYTHON_CONTEXT_PARAMETER_REQUIRED`, subject + `symbol::parameter:` +- `PARSE_FAILURE`, subject `parse` + +## FR-IDs + +`FR-PY-001` through `FR-PY-012` in the clean-room PRP addendum. + +## Human gates + +Implementation and local verification are authorized. Commit, push, and live +npm publication remain separate human gates. + +## Verification + +```text +npm run validator:python +npm run validator:package:check +npm run verify +``` + +Then run an independent `kz-checker`. Pinned Node 22 and exit `0` are required. diff --git a/docs/contracts/examples/python-evidence-v1.json b/docs/contracts/examples/python-evidence-v1.json new file mode 100644 index 0000000..9d0812b --- /dev/null +++ b/docs/contracts/examples/python-evidence-v1.json @@ -0,0 +1 @@ +{"apiVersion":"kernel-zero.dev/evidence/v1","exceptionBundleDigest":null,"findings":[{"exceptionId":null,"fingerprint":"sha256:eb52abf3e5bf4418747cd749fda20949b15e2e8160b480905734963de19e2e7b","id":"sha256:79c5e90274394b419d0239a8e82db0fd74f54f4d786be373abe0fc10f8dfdbf4","level":"error","location":{"endColumn":18,"endLine":4,"startColumn":1,"startLine":4},"message":"A denied Python import edge was found.","messageCode":"PYTHON_IMPORT_DENIED","path":"src/api/handler.py","ruleId":"api-no-processes","subject":"module:subprocess"}],"generatedAt":"2026-01-15T12:00:00.000Z","integrity":{"algorithm":"sha256","digest":"sha256:07048b4d5b0b92d06d9404ee2006f654d3a15e0454509bce05471709a8d3a676"},"kind":"PythonEvidence","policy":{"digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","name":"python-boundaries","revision":1},"result":{"durationMs":0,"errors":1,"excepted":0,"filesScanned":3,"status":"fail","warnings":0},"runId":"0195f000-0000-7000-8000-000000000001","signature":null,"subject":{"manifestDigest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","repository":"example/python-service","revision":"git:0123456789abcdef0123456789abcdef01234567"},"tool":{"name":"kernel-zero-python","version":"0.1.0+cpython.3.12.10"},"workspace":"0195f000-0000-7000-8000-000000000002"} diff --git a/docs/contracts/examples/python-policy-v1.json b/docs/contracts/examples/python-policy-v1.json new file mode 100644 index 0000000..b58ff54 --- /dev/null +++ b/docs/contracts/examples/python-policy-v1.json @@ -0,0 +1 @@ +{"apiVersion":"kernel-zero.dev/v1","kind":"PythonPolicy","metadata":{"description":"Python architecture boundaries","name":"python-boundaries","revision":1},"rules":[{"check":{"deny":["subprocess"],"from":["src/api/**/*.py"],"kind":"forbid-import-edge"},"id":"api-no-processes","level":"error","remediation":"Call the isolated worker boundary.","title":"API cannot launch processes"}],"scope":{"exclude":["**/tests/**"],"include":["src/**/*.py"]}} diff --git a/docs/contracts/malformed/python-policy-unknown-field.json b/docs/contracts/malformed/python-policy-unknown-field.json new file mode 100644 index 0000000..1bd4f12 --- /dev/null +++ b/docs/contracts/malformed/python-policy-unknown-field.json @@ -0,0 +1,35 @@ +{ + "apiVersion": "kernel-zero.dev/v1", + "kind": "PythonPolicy", + "metadata": { + "description": "Python architecture boundaries", + "name": "python-boundaries", + "revision": 1 + }, + "scope": { + "exclude": [ + "**/tests/**" + ], + "include": [ + "src/**/*.py" + ] + }, + "rules": [ + { + "check": { + "deny": [ + "subprocess" + ], + "from": [ + "src/api/**/*.py" + ], + "kind": "forbid-import-edge" + }, + "id": "api-no-processes", + "level": "error", + "remediation": "Call the isolated worker boundary.", + "title": "API cannot launch processes" + } + ], + "interpreterCommand": "curl example.invalid" +} diff --git a/docs/contracts/python-evidence-v1.schema.json b/docs/contracts/python-evidence-v1.schema.json new file mode 100644 index 0000000..8ff6e15 --- /dev/null +++ b/docs/contracts/python-evidence-v1.schema.json @@ -0,0 +1,384 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "apiVersion": { + "$ref": "#/$defs/__schema0" + }, + "exceptionBundleDigest": { + "$ref": "#/$defs/__schema1" + }, + "findings": { + "$ref": "#/$defs/__schema3" + }, + "generatedAt": { + "$ref": "#/$defs/__schema10" + }, + "integrity": { + "$ref": "#/$defs/__schema11" + }, + "kind": { + "$ref": "#/$defs/__schema12" + }, + "policy": { + "$ref": "#/$defs/__schema13" + }, + "result": { + "$ref": "#/$defs/__schema14" + }, + "runId": { + "$ref": "#/$defs/__schema5" + }, + "signature": { + "$ref": "#/$defs/__schema15" + }, + "subject": { + "$ref": "#/$defs/__schema16" + }, + "tool": { + "$ref": "#/$defs/__schema17" + }, + "workspace": { + "$ref": "#/$defs/__schema5" + } + }, + "required": [ + "apiVersion", + "exceptionBundleDigest", + "findings", + "generatedAt", + "integrity", + "kind", + "policy", + "result", + "runId", + "signature", + "subject", + "tool", + "workspace" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "type": "string", + "const": "kernel-zero.dev/evidence/v1" + }, + "__schema1": { + "anyOf": [ + { + "$ref": "#/$defs/__schema2" + }, + { + "type": "null" + } + ] + }, + "__schema2": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "__schema3": { + "maxItems": 5000, + "type": "array", + "items": { + "$ref": "#/$defs/__schema4" + } + }, + "__schema4": { + "type": "object", + "properties": { + "exceptionId": { + "anyOf": [ + { + "$ref": "#/$defs/__schema5" + }, + { + "type": "null" + } + ] + }, + "fingerprint": { + "$ref": "#/$defs/__schema2" + }, + "id": { + "$ref": "#/$defs/__schema2" + }, + "level": { + "type": "string", + "enum": [ + "error", + "warning" + ] + }, + "location": { + "type": "object", + "properties": { + "endColumn": { + "$ref": "#/$defs/__schema6" + }, + "endLine": { + "$ref": "#/$defs/__schema7" + }, + "startColumn": { + "$ref": "#/$defs/__schema8" + }, + "startLine": { + "$ref": "#/$defs/__schema9" + } + }, + "required": [ + "endColumn", + "endLine", + "startColumn", + "startLine" + ], + "additionalProperties": false + }, + "message": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "messageCode": { + "type": "string", + "enum": [ + "PARSE_FAILURE", + "PYTHON_CALL_RESTRICTED", + "PYTHON_CONTEXT_PARAMETER_REQUIRED", + "PYTHON_IMPORT_DENIED", + "PYTHON_IMPORT_REQUIRED" + ] + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "ruleId": { + "type": "string", + "minLength": 3, + "maxLength": 80, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "subject": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "pattern": "^[\\x20-\\x7e]+$" + } + }, + "required": [ + "exceptionId", + "fingerprint", + "id", + "level", + "location", + "message", + "messageCode", + "path", + "ruleId", + "subject" + ], + "additionalProperties": false + }, + "__schema5": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "__schema6": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "__schema7": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "__schema8": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "__schema9": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "__schema10": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "__schema11": { + "type": "object", + "properties": { + "algorithm": { + "type": "string", + "const": "sha256" + }, + "digest": { + "$ref": "#/$defs/__schema2" + } + }, + "required": [ + "algorithm", + "digest" + ], + "additionalProperties": false + }, + "__schema12": { + "type": "string", + "const": "PythonEvidence" + }, + "__schema13": { + "type": "object", + "properties": { + "digest": { + "$ref": "#/$defs/__schema2" + }, + "name": { + "type": "string", + "minLength": 3, + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "digest", + "name", + "revision" + ], + "additionalProperties": false + }, + "__schema14": { + "type": "object", + "properties": { + "durationMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "errors": { + "type": "integer", + "minimum": 0, + "maximum": 5000 + }, + "excepted": { + "type": "integer", + "minimum": 0, + "maximum": 5000 + }, + "filesScanned": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "error", + "fail", + "pass" + ] + }, + "warnings": { + "type": "integer", + "minimum": 0, + "maximum": 5000 + } + }, + "required": [ + "durationMs", + "errors", + "excepted", + "filesScanned", + "status", + "warnings" + ], + "additionalProperties": false + }, + "__schema15": { + "anyOf": [ + { + "type": "object", + "properties": { + "algorithm": { + "type": "string", + "const": "ed25519" + }, + "keyId": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "value": { + "type": "string", + "format": "base64", + "contentEncoding": "base64", + "pattern": "^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$" + } + }, + "required": [ + "algorithm", + "keyId", + "value" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "__schema16": { + "type": "object", + "properties": { + "manifestDigest": { + "$ref": "#/$defs/__schema2" + }, + "repository": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^[\\x20-\\x7e]+$" + }, + "revision": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^[\\x20-\\x7e]+$" + } + }, + "required": [ + "manifestDigest", + "repository", + "revision" + ], + "additionalProperties": false + }, + "__schema17": { + "type": "object", + "properties": { + "name": { + "type": "string", + "const": "kernel-zero-python" + }, + "version": { + "type": "string", + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$" + } + }, + "required": [ + "name", + "version" + ], + "additionalProperties": false + } + } +} diff --git a/docs/contracts/python-policy-v1.schema.json b/docs/contracts/python-policy-v1.schema.json new file mode 100644 index 0000000..d146e17 --- /dev/null +++ b/docs/contracts/python-policy-v1.schema.json @@ -0,0 +1,251 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "apiVersion": { + "type": "string", + "const": "kernel-zero.dev/v1" + }, + "kind": { + "type": "string", + "const": "PythonPolicy" + }, + "metadata": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 3, + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + }, + "required": [ + "name", + "revision", + "description" + ], + "additionalProperties": false + }, + "scope": { + "type": "object", + "properties": { + "include": { + "$ref": "#/$defs/__schema0" + }, + "exclude": { + "$ref": "#/$defs/__schema2" + } + }, + "required": [ + "include", + "exclude" + ], + "additionalProperties": false + }, + "rules": { + "minItems": 1, + "maxItems": 500, + "type": "array", + "items": { + "$ref": "#/$defs/__schema3" + } + } + }, + "required": [ + "apiVersion", + "kind", + "metadata", + "scope", + "rules" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "$ref": "#/$defs/__schema1" + } + }, + "__schema1": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "__schema2": { + "minItems": 0, + "maxItems": 100, + "type": "array", + "items": { + "$ref": "#/$defs/__schema1" + } + }, + "__schema3": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 3, + "maxLength": 80, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "level": { + "type": "string", + "enum": [ + "error", + "warning" + ] + }, + "check": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "forbid-import-edge" + }, + "from": { + "$ref": "#/$defs/__schema0" + }, + "deny": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "$ref": "#/$defs/__schema4" + } + } + }, + "required": [ + "kind", + "from", + "deny" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "require-import" + }, + "files": { + "$ref": "#/$defs/__schema0" + }, + "module": { + "$ref": "#/$defs/__schema4" + } + }, + "required": [ + "kind", + "files", + "module" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "restrict-call-site" + }, + "callee": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "$ref": "#/$defs/__schema5" + } + }, + "allowFrom": { + "$ref": "#/$defs/__schema2" + } + }, + "required": [ + "kind", + "callee", + "allowFrom" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "require-context-parameter" + }, + "files": { + "$ref": "#/$defs/__schema0" + }, + "symbols": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "parameter": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + } + }, + "required": [ + "kind", + "files", + "symbols", + "parameter" + ], + "additionalProperties": false + } + ] + }, + "remediation": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + }, + "required": [ + "id", + "title", + "level", + "check", + "remediation" + ], + "additionalProperties": false + }, + "__schema4": { + "type": "string", + "minLength": 1, + "maxLength": 300, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*$" + }, + "__schema5": { + "type": "string", + "minLength": 1, + "maxLength": 300, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*$" + } + } +} diff --git a/docs/contracts/python-profile-v1.md b/docs/contracts/python-profile-v1.md new file mode 100644 index 0000000..d319939 --- /dev/null +++ b/docs/contracts/python-profile-v1.md @@ -0,0 +1,5 @@ +# PythonPolicy v1 / PythonEvidence v1 + +Media types: `application/vnd.kernel-zero.policy+json;version=1` (policy), `application/vnd.kernel-zero.evidence+json;version=1` (evidence). + +The closed rule kinds are `forbid-import-edge`, `require-import`, `restrict-call-site`, and `require-context-parameter`. The closed message codes are `PYTHON_IMPORT_DENIED`, `PYTHON_IMPORT_REQUIRED`, `PYTHON_CALL_RESTRICTED`, `PYTHON_CONTEXT_PARAMETER_REQUIRED`, and `PARSE_FAILURE`. Source is parsed locally by CPython 3.11 through 3.14 using the standard-library AST; dynamic runtime behavior is not claimed. diff --git a/kernel-zero.python.policy.json b/kernel-zero.python.policy.json new file mode 100644 index 0000000..8db675e --- /dev/null +++ b/kernel-zero.python.policy.json @@ -0,0 +1,73 @@ +{ + "apiVersion": "kernel-zero.dev/v1", + "kind": "PythonPolicy", + "metadata": { + "name": "python-analyzer-boundaries", + "revision": 1, + "description": "Govern the bundled CPython AST adapter with its own Python profile." + }, + "scope": { + "include": [ + "packages/profile-python/python-analyzer.py" + ], + "exclude": [] + }, + "rules": [ + { + "id": "no-process-execution", + "title": "Python analyzer cannot launch processes", + "level": "error", + "check": { + "kind": "forbid-import-edge", + "from": [ + "packages/profile-python/python-analyzer.py" + ], + "deny": [ + "subprocess" + ] + }, + "remediation": "Keep execution ownership in the Node runner." + }, + { + "id": "required-ast-import", + "title": "Python analyzer uses CPython AST", + "level": "error", + "check": { + "kind": "require-import", + "files": [ + "packages/profile-python/python-analyzer.py" + ], + "module": "ast" + }, + "remediation": "Parse Python with the standard-library ast module." + }, + { + "id": "no-dynamic-execution", + "title": "Python analyzer cannot dynamically execute source", + "level": "error", + "check": { + "kind": "restrict-call-site", + "callee": [ + "eval", + "exec" + ], + "allowFrom": [] + }, + "remediation": "Inspect syntax through ast without evaluating source." + }, + { + "id": "analysis-source-boundary", + "title": "Analysis entry point requires source", + "level": "error", + "check": { + "kind": "require-context-parameter", + "files": [ + "packages/profile-python/python-analyzer.py" + ], + "symbols": "analyze", + "parameter": "source" + }, + "remediation": "Keep source as an explicit required analysis input." + } + ] +} diff --git a/package-lock.json b/package-lock.json index cecec69..cb34773 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1408,6 +1408,10 @@ "resolved": "packages/profile-manifest", "link": true }, + "node_modules/@kernel-zero/profile-python": { + "resolved": "packages/profile-python", + "link": true + }, "node_modules/@kernel-zero/profile-software-architecture": { "resolved": "packages/profile-software-architecture", "link": true @@ -4945,6 +4949,15 @@ "zod": "^4.1.0" } }, + "packages/profile-python": { + "name": "@kernel-zero/profile-python", + "version": "0.1.0", + "dependencies": { + "@kernel-zero/contracts": "*", + "@kernel-zero/domain": "*", + "zod": "^4.1.0" + } + }, "packages/profile-software-architecture": { "name": "@kernel-zero/profile-software-architecture", "version": "0.1.0", @@ -4973,6 +4986,7 @@ "dependencies": { "@kernel-zero/contracts": "*", "@kernel-zero/profile-manifest": "*", + "@kernel-zero/profile-python": "*", "@kernel-zero/profile-software-architecture": "*", "@kernel-zero/profile-workflow": "*" } @@ -4988,13 +5002,16 @@ }, "packages/validator": { "name": "@kernel-zero/validator", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "typescript": "5.9.3" }, "bin": { - "kernel-zero": "dist/kernel-zero.js" + "kernel-zero": "dist/kernel-zero.js", + "kernel-zero-manifest": "dist/kernel-zero-manifest.js", + "kernel-zero-python": "dist/kernel-zero-python.js", + "kernel-zero-workflow": "dist/kernel-zero-workflow.js" }, "devDependencies": { "@kernel-zero/contracts": "*", diff --git a/package.json b/package.json index 99ce02f..f2efeb5 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,9 @@ "validator:pack": "node -e \"require('fs').mkdirSync('dist',{recursive:true})\" && npm pack -w @kernel-zero/validator --pack-destination dist", "validator:package:check": "node scripts/check-validator-package.mjs", "validator:self": "vite-node scripts/run-validator.ts -- validate --policy kernel-zero.policy.json --root . --workspace 00000000-0000-7000-8000-000000000000 --out .kernel-zero/evidence.json", + "validator:python": "vite-node scripts/run-python-validator.ts -- kernel-zero.python.policy.json . 00000000-0000-7000-8000-000000000000 .kernel-zero/python-evidence.json", "validator:workflow": "vite-node scripts/run-workflow-validator.ts -- kernel-zero.workflow.policy.json . 00000000-0000-7000-8000-000000000000 .kernel-zero/workflow-evidence.json", - "verify": "npm run prisma:generate && npm run contracts:check && npm run check:architecture && npm run validator:self && npm run validator:workflow && npm run typecheck && npm run lint && npm run test:unit && npm run test:architecture && npm run test:integration" + "verify": "npm run prisma:generate && npm run contracts:check && npm run check:architecture && npm run validator:self && npm run validator:workflow && npm run validator:python && npm run typecheck && npm run lint && npm run test:unit && npm run test:architecture && npm run test:integration" }, "devDependencies": { "@axe-core/playwright": "^4.13.0", diff --git a/packages/profile-python/fixtures/broken/service.py b/packages/profile-python/fixtures/broken/service.py new file mode 100644 index 0000000..0dbb012 --- /dev/null +++ b/packages/profile-python/fixtures/broken/service.py @@ -0,0 +1,2 @@ +def broken(: + pass diff --git a/packages/profile-python/fixtures/passing/service.py b/packages/profile-python/fixtures/passing/service.py new file mode 100644 index 0000000..943555f --- /dev/null +++ b/packages/profile-python/fixtures/passing/service.py @@ -0,0 +1,14 @@ +import json as codec + + +async def handler(workspace_id, /, *, request_id): + match request_id: + case str() as value: + return codec.dumps({"request": value, "workspace": workspace_id}) + case _: + return codec.dumps({"workspace": workspace_id}) + + +class Service: + def execute(self, *, workspace_id): + return codec.dumps({"workspace": workspace_id}) diff --git a/packages/profile-python/fixtures/violating/service.py b/packages/profile-python/fixtures/violating/service.py new file mode 100644 index 0000000..a70a6b4 --- /dev/null +++ b/packages/profile-python/fixtures/violating/service.py @@ -0,0 +1,7 @@ +import subprocess as process +from os import system as shell + + +def handler(workspace_id=None): + shell("echo unsafe") + return process.run(["echo", "unsafe"]) diff --git a/packages/profile-python/package.json b/packages/profile-python/package.json new file mode 100644 index 0000000..399e5d2 --- /dev/null +++ b/packages/profile-python/package.json @@ -0,0 +1,15 @@ +{ + "name": "@kernel-zero/profile-python", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./runner": "./src/runner.ts" + }, + "dependencies": { + "@kernel-zero/contracts": "*", + "@kernel-zero/domain": "*", + "zod": "^4.1.0" + } +} diff --git a/packages/profile-python/python-analyzer.py b/packages/profile-python/python-analyzer.py new file mode 100644 index 0000000..d172cc3 --- /dev/null +++ b/packages/profile-python/python-analyzer.py @@ -0,0 +1,210 @@ +"""Dependency-free CPython AST adapter for the KERNEL ZERO Python profile.""" + +from __future__ import annotations + +import ast +import json +import platform +import sys +from typing import Any + +PROTOCOL = "kernel-zero.python-analysis/v1" + + +def location(node: ast.AST) -> dict[str, int]: + start_line = max(1, int(getattr(node, "lineno", 1))) + start_column = max(1, int(getattr(node, "col_offset", 0)) + 1) + end_line = max(start_line, int(getattr(node, "end_lineno", start_line))) + end_column = max(start_column, int(getattr(node, "end_col_offset", start_column - 1)) + 1) + return { + "endColumn": end_column, + "endLine": end_line, + "startColumn": start_column, + "startLine": start_line, + } + + +def syntax_location(error: SyntaxError) -> dict[str, int]: + start_line = max(1, int(error.lineno or 1)) + start_column = max(1, int(error.offset or 1)) + end_line = max(start_line, int(error.end_lineno or start_line)) + end_column = max(start_column, int(error.end_offset or start_column)) + return { + "endColumn": end_column, + "endLine": end_line, + "startColumn": start_column, + "startLine": start_line, + } + + +def dotted_name(node: ast.AST) -> str | None: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + owner = dotted_name(node.value) + return None if owner is None else f"{owner}.{node.attr}" + return None + + +class FactVisitor(ast.NodeVisitor): + def __init__(self) -> None: + self.alias_scopes: list[dict[str, str]] = [{}] + self.name_stack: list[str] = [] + self.imports: list[dict[str, Any]] = [] + self.calls: list[dict[str, Any]] = [] + self.functions: list[dict[str, Any]] = [] + + @property + def aliases(self) -> dict[str, str]: + return self.alias_scopes[-1] + + def resolve(self, name: str) -> str: + head, separator, tail = name.partition(".") + target = self.aliases.get(head, head) + return target if not separator else f"{target}.{tail}" + + def visit_Import(self, node: ast.Import) -> None: + for alias in node.names: + self.imports.append({"module": alias.name, "location": location(node)}) + local = alias.asname or alias.name.split(".", 1)[0] + self.aliases[local] = alias.name if alias.asname else local + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + module = f"{'.' * node.level}{node.module or ''}" + self.imports.append({"module": module, "location": location(node)}) + for alias in node.names: + if alias.name == "*": + continue + local = alias.asname or alias.name + self.aliases[local] = f"{module}.{alias.name}" if module else alias.name + + def visit_Call(self, node: ast.Call) -> None: + name = dotted_name(node.func) + if name is not None: + self.calls.append({"callee": self.resolve(name), "location": location(node.func)}) + self.generic_visit(node) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._visit_function(node) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._visit_function(node) + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + for decorator in node.decorator_list: + self.visit(decorator) + self._visit_type_parameters(node) + for base in node.bases: + self.visit(base) + for keyword in node.keywords: + self.visit(keyword) + self.name_stack.append(node.name) + self.alias_scopes.append(dict(self.aliases)) + for statement in node.body: + self.visit(statement) + self.alias_scopes.pop() + self.name_stack.pop() + + def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + qualified_name = ".".join([*self.name_stack, node.name]) + self.functions.append({ + "location": location(node), + "name": qualified_name, + "parameters": parameters(node.args), + }) + for decorator in node.decorator_list: + self.visit(decorator) + for default in [*node.args.defaults, *(item for item in node.args.kw_defaults if item is not None)]: + self.visit(default) + signature_arguments = [*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs] + if node.args.vararg is not None: + signature_arguments.append(node.args.vararg) + if node.args.kwarg is not None: + signature_arguments.append(node.args.kwarg) + for argument in signature_arguments: + if argument.annotation is not None: + self.visit(argument.annotation) + if node.returns is not None: + self.visit(node.returns) + self._visit_type_parameters(node) + self.name_stack.append(node.name) + self.alias_scopes.append(dict(self.aliases)) + for statement in node.body: + self.visit(statement) + self.alias_scopes.pop() + self.name_stack.pop() + + def _visit_type_parameters(self, node: ast.AST) -> None: + for type_parameter in getattr(node, "type_params", []): + self.visit(type_parameter) + + +def parameters(arguments: ast.arguments) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + positional = [*arguments.posonlyargs, *arguments.args] + default_start = len(positional) - len(arguments.defaults) + for index, argument in enumerate(positional): + kind = "positional-only" if index < len(arguments.posonlyargs) else "positional" + result.append({"kind": kind, "name": argument.arg, "required": index < default_start}) + if arguments.vararg is not None: + result.append({"kind": "vararg", "name": arguments.vararg.arg, "required": False}) + for argument, default in zip(arguments.kwonlyargs, arguments.kw_defaults, strict=True): + result.append({"kind": "keyword-only", "name": argument.arg, "required": default is None}) + if arguments.kwarg is not None: + result.append({"kind": "kwarg", "name": arguments.kwarg.arg, "required": False}) + return result + + +def analyze(path: str, source: str) -> dict[str, Any]: + try: + tree = ast.parse(source, filename=path, type_comments=True) + except SyntaxError as error: + return {"calls": [], "functions": [], "imports": [], "parseError": syntax_location(error), "path": path} + except ValueError: + return {"calls": [], "functions": [], "imports": [], "parseError": location(ast.Module()), "path": path} + visitor = FactVisitor() + visitor.visit(tree) + return { + "calls": sorted(visitor.calls, key=lambda item: (item["location"]["startLine"], item["location"]["startColumn"], item["callee"])), + "functions": sorted(visitor.functions, key=lambda item: (item["location"]["startLine"], item["location"]["startColumn"], item["name"])), + "imports": sorted(visitor.imports, key=lambda item: (item["location"]["startLine"], item["location"]["startColumn"], item["module"])), + "parseError": None, + "path": path, + } + + +def main() -> int: + if platform.python_implementation() != "CPython": + raise RuntimeError("CPython is required") + request = json.load(sys.stdin) + if not isinstance(request, dict) or set(request) != {"files", "protocolVersion"} or request.get("protocolVersion") != PROTOCOL: + raise ValueError("invalid analysis request") + files = request.get("files") + if not isinstance(files, list) or len(files) > 5000: + raise ValueError("invalid analysis file list") + analyzed: list[dict[str, Any]] = [] + for item in files: + if not isinstance(item, dict) or set(item) != {"path", "source"}: + raise ValueError("invalid analysis file") + path = item.get("path") + source = item.get("source") + if not isinstance(path, str) or not isinstance(source, str): + raise ValueError("invalid analysis file values") + analyzed.append(analyze(path, source)) + version = sys.version_info + response = { + "files": analyzed, + "protocolVersion": PROTOCOL, + "python": {"implementation": "CPython", "version": [version.major, version.minor, version.micro]}, + } + json.dump(response, sys.stdout, ensure_ascii=True, separators=(",", ":"), sort_keys=True) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as error: # The Node runner owns the public error surface. + sys.stderr.write(f"python-analyzer: {type(error).__name__}: {error}\n") + raise SystemExit(2) diff --git a/packages/profile-python/src/check.test.ts b/packages/profile-python/src/check.test.ts new file mode 100644 index 0000000..95e6072 --- /dev/null +++ b/packages/profile-python/src/check.test.ts @@ -0,0 +1,105 @@ +import { deriveEvidenceSummary } from "@kernel-zero/contracts"; +import { describe, expect, it } from "vitest"; + +import { checkPython } from "./check"; +import type { PythonFileFacts } from "./facts"; +import { pythonFindingCompatibilityReason } from "./index"; +import { PythonPolicySchema, type PythonPolicy } from "./policy"; + +const digest = `sha256:${"1".repeat(64)}` as const; +const location = Object.freeze({ endColumn: 10, endLine: 4, startColumn: 1, startLine: 4 }); + +function policy(): PythonPolicy { + return PythonPolicySchema.parse({ + apiVersion: "kernel-zero.dev/v1", + kind: "PythonPolicy", + metadata: { description: "Python architecture rules", name: "python-rules", revision: 1 }, + scope: { exclude: [], include: ["src/**/*.py"] }, + rules: [ + { check: { deny: ["subprocess"], from: ["src/api/**/*.py"], kind: "forbid-import-edge" }, id: "deny-process", level: "error", remediation: "Use the worker boundary.", title: "No processes" }, + { check: { files: ["src/api/**/*.py"], kind: "require-import", module: "json" }, id: "require-json", level: "error", remediation: "Import json.", title: "JSON required" }, + { check: { allowFrom: ["src/workers/**/*.py"], callee: ["os.system", "subprocess.run"], kind: "restrict-call-site" }, id: "restrict-calls", level: "warning", remediation: "Call from a worker.", title: "Restricted calls" }, + { check: { files: ["src/api/**/*.py"], kind: "require-context-parameter", parameter: "workspace_id", symbols: "*" }, id: "workspace-context", level: "error", remediation: "Require workspace_id.", title: "Workspace context" }, + ], + }); +} + +function violatingFacts(): PythonFileFacts { + return { + calls: [{ callee: "os.system", location }], + functions: [{ location, name: "Handler.run", parameters: [{ kind: "keyword-only", name: "workspace_id", required: false }] }], + imports: [{ module: "subprocess.asyncio", location }], + parseError: null, + path: "src/api/handler.py", + }; +} + +describe("PythonPolicy", () => { + it("strictly parses the closed rule kinds and rejects unknown fields", () => { + const current = policy(); + const contextRule = current.rules.find((rule) => rule.check.kind === "require-context-parameter"); + if (contextRule === undefined) throw new Error("Expected the context fixture rule."); + expect(current.kind).toBe("PythonPolicy"); + expect(PythonPolicySchema.parse({ ...current, rules: [{ ...contextRule, check: { ...contextRule.check, symbols: "*.run" } }] }).rules[0]?.check.kind).toBe("require-context-parameter"); + expect(() => PythonPolicySchema.parse({ ...current, command: "python arbitrary.py" })).toThrow(); + expect(() => PythonPolicySchema.parse({ ...current, rules: [{ ...current.rules[0], check: { kind: "made-up" } }] })).toThrow(); + expect(() => PythonPolicySchema.parse({ ...current, scope: { include: ["../escape.py"], exclude: [] } })).toThrow(); + expect(() => PythonPolicySchema.parse({ ...current, rules: current.rules.map((rule) => ({ ...rule, level: "warning" })) })).toThrow(/error-level/u); + }); +}); + +describe("checkPython", () => { + it("reports every closed violation with stable subjects and compatibility", () => { + const current = policy(); + const findings = checkPython({ files: [violatingFacts()], policy: current, policyDigest: digest }); + expect(findings.map((finding) => [finding.ruleId, finding.messageCode, finding.subject])).toEqual([ + ["deny-process", "PYTHON_IMPORT_DENIED", "module:subprocess.asyncio"], + ["require-json", "PYTHON_IMPORT_REQUIRED", "module:json"], + ["restrict-calls", "PYTHON_CALL_RESTRICTED", "call:os.system"], + ["workspace-context", "PYTHON_CONTEXT_PARAMETER_REQUIRED", "symbol:Handler.run:parameter:workspace_id"], + ]); + expect(findings.every((finding) => pythonFindingCompatibilityReason(current, finding) === null)).toBe(true); + expect(deriveEvidenceSummary(findings, 1).status).toBe("fail"); + }); + + it("accepts descendant imports, required positional and keyword-only context, and allowed call locations", () => { + const current = policy(); + const files: PythonFileFacts[] = [ + { + calls: [], + functions: [{ location, name: "handler", parameters: [{ kind: "positional-only", name: "workspace_id", required: true }] }], + imports: [{ module: "json.tool", location }], + parseError: null, + path: "src/api/handler.py", + }, + { + calls: [{ callee: "os.system", location }], + functions: [], + imports: [], + parseError: null, + path: "src/workers/job.py", + }, + ]; + expect(checkPython({ files, policy: current, policyDigest: digest })).toEqual([]); + }); + + it("turns one claimed syntax failure into a non-exceptable error and is deterministic across file order", () => { + const current = policy(); + const broken: PythonFileFacts = { calls: [], functions: [], imports: [], parseError: location, path: "src/api/broken.py" }; + const first = checkPython({ files: [violatingFacts(), broken], policy: current, policyDigest: digest }); + const second = checkPython({ files: [broken, violatingFacts()], policy: current, policyDigest: digest }); + expect(first).toEqual(second); + const parse = first.find((finding) => finding.messageCode === "PARSE_FAILURE"); + expect(parse).toMatchObject({ exceptionId: null, level: "error", subject: "parse" }); + expect(deriveEvidenceSummary(first, 2).status).toBe("error"); + }); + + it("rejects stale or semantically incompatible findings", () => { + const current = policy(); + const finding = checkPython({ files: [violatingFacts()], policy: current, policyDigest: digest })[0]; + expect(finding).toBeDefined(); + if (finding === undefined) return; + expect(pythonFindingCompatibilityReason(current, { ...finding, subject: "module:json" })).toBe("rule_subject_mismatch"); + expect(pythonFindingCompatibilityReason(current, { ...finding, ruleId: "missing" })).toBe("rule_not_found"); + }); +}); diff --git a/packages/profile-python/src/check.ts b/packages/profile-python/src/check.ts new file mode 100644 index 0000000..d8edde0 --- /dev/null +++ b/packages/profile-python/src/check.ts @@ -0,0 +1,125 @@ +import { findingIdentity, sortFindings, type EvidenceFinding, type FindingLocation } from "@kernel-zero/contracts"; +import type { Sha256Digest } from "@kernel-zero/domain"; + +import { pythonMessages, type PythonMessageCode } from "./evidence"; +import type { PythonFileFacts } from "./facts"; +import type { PythonPolicy, PythonPolicyRule } from "./policy"; + +const WHOLE_FILE: FindingLocation = Object.freeze({ endColumn: 1, endLine: 1, startColumn: 1, startLine: 1 }); + +export function checkPython(input: Readonly<{ + files: readonly PythonFileFacts[]; + policy: PythonPolicy; + policyDigest: Sha256Digest; +}>): readonly EvidenceFinding[] { + const findings = new Map(); + for (const file of [...input.files].sort((left, right) => left.path.localeCompare(right.path))) { + if (file.parseError !== null) { + const rule = input.policy.rules.find((candidate) => candidate.level === "error") ?? input.policy.rules[0]; + if (rule !== undefined) remember(findings, finding(rule, "PARSE_FAILURE", "parse", file.path, file.parseError, input.policyDigest)); + continue; + } + for (const rule of input.policy.rules) { + for (const violation of violations(rule, file)) { + remember(findings, finding(rule, violation.code, violation.subject, file.path, violation.location, input.policyDigest)); + } + } + } + return Object.freeze(sortFindings([...findings.values()])); +} + +type Violation = Readonly<{ code: PythonMessageCode; location: FindingLocation; subject: string }>; + +function* violations(rule: PythonPolicyRule, file: PythonFileFacts): Generator { + if (rule.check.kind === "forbid-import-edge") { + if (!rule.check.from.some((glob) => matchesPythonGlob(file.path, glob))) return; + for (const imported of file.imports) { + if (rule.check.deny.some((denied) => moduleMatches(imported.module, denied))) { + yield { code: "PYTHON_IMPORT_DENIED", location: imported.location, subject: `module:${safeSubject(imported.module)}` }; + } + } + return; + } + if (rule.check.kind === "require-import") { + if (!rule.check.files.some((glob) => matchesPythonGlob(file.path, glob))) return; + const requiredModule = rule.check.module; + if (!file.imports.some((imported) => moduleMatches(imported.module, requiredModule))) { + yield { code: "PYTHON_IMPORT_REQUIRED", location: WHOLE_FILE, subject: `module:${requiredModule}` }; + } + return; + } + if (rule.check.kind === "restrict-call-site") { + if (rule.check.allowFrom.some((glob) => matchesPythonGlob(file.path, glob))) return; + for (const call of file.calls) { + if (rule.check.callee.includes(call.callee)) { + yield { code: "PYTHON_CALL_RESTRICTED", location: call.location, subject: `call:${safeSubject(call.callee)}` }; + } + } + return; + } + if (!rule.check.files.some((glob) => matchesPythonGlob(file.path, glob))) return; + const parameterName = rule.check.parameter; + for (const candidate of file.functions) { + const shortName = candidate.name.split(".").at(-1) ?? candidate.name; + if (!matchesPythonSymbol(candidate.name, rule.check.symbols) && !matchesPythonSymbol(shortName, rule.check.symbols)) continue; + const parameter = candidate.parameters.find((item) => item.name === parameterName); + if (parameter === undefined || !parameter.required || parameter.kind === "vararg" || parameter.kind === "kwarg") { + yield { + code: "PYTHON_CONTEXT_PARAMETER_REQUIRED", + location: candidate.location, + subject: `symbol:${safeSubject(candidate.name)}:parameter:${parameterName}`, + }; + } + } +} + +export function moduleMatches(candidate: string, expected: string): boolean { + return candidate === expected || candidate.startsWith(`${expected}.`); +} + +export function matchesPythonSymbol(value: string, pattern: string): boolean { + const expected = pattern.split("."); + const actual = value.split("."); + return expected.length === actual.length && expected.every((segment, index) => segment === "*" || segment === actual[index]); +} + +export function matchesPythonGlob(value: string, glob: string): boolean { + const expression = glob.replace(/\*\*\/|\*\*|\*|[.+^${}()|[\]\\?]/gu, (token) => { + if (token === "**/") return "(?:[^\\0]*/)?"; + if (token === "**") return "[^\\0]*"; + if (token === "*") return "[^/]*"; + return `\\${token}`; + }); + return new RegExp(`^${expression}$`, "u").test(value); +} + +function finding( + rule: PythonPolicyRule, + messageCode: PythonMessageCode, + subject: string, + path: string, + location: FindingLocation, + policyDigest: Sha256Digest, +): EvidenceFinding { + const identity = findingIdentity({ location, messageCode, path, policyDigest, ruleId: rule.id, subject }); + return Object.freeze({ + exceptionId: null, + fingerprint: identity.fingerprint, + id: identity.id, + level: rule.level, + location, + message: pythonMessages[messageCode], + messageCode, + path, + ruleId: rule.id, + subject, + }); +} + +function safeSubject(value: string): string { + return value.replace(/[^\x20-\x7e]/gu, "?").slice(0, 300); +} + +function remember(findings: Map, candidate: EvidenceFinding): void { + if (!findings.has(candidate.id)) findings.set(candidate.id, candidate); +} diff --git a/packages/profile-python/src/evidence.ts b/packages/profile-python/src/evidence.ts new file mode 100644 index 0000000..04e1fc9 --- /dev/null +++ b/packages/profile-python/src/evidence.ts @@ -0,0 +1,29 @@ +import { createEvidenceSchema, type EvidenceMessages } from "@kernel-zero/contracts"; +import { z } from "zod"; + +export const PYTHON_MESSAGE_CODES = Object.freeze([ + "PARSE_FAILURE", + "PYTHON_CALL_RESTRICTED", + "PYTHON_CONTEXT_PARAMETER_REQUIRED", + "PYTHON_IMPORT_DENIED", + "PYTHON_IMPORT_REQUIRED", +] as const); + +export type PythonMessageCode = (typeof PYTHON_MESSAGE_CODES)[number]; + +export const pythonMessages: Readonly> & EvidenceMessages = Object.freeze({ + PARSE_FAILURE: "A claimed Python source file could not be parsed.", + PYTHON_CALL_RESTRICTED: "A restricted Python call was found outside its allowed location.", + PYTHON_CONTEXT_PARAMETER_REQUIRED: "A governed Python function is missing a required context parameter.", + PYTHON_IMPORT_DENIED: "A denied Python import edge was found.", + PYTHON_IMPORT_REQUIRED: "A required Python module import is missing.", +}); + +const built = createEvidenceSchema({ evidenceKind: "PythonEvidence", toolName: "kernel-zero-python", messages: pythonMessages }); + +export const PythonEvidenceSchema = built.schema; +export type PythonEvidence = z.infer; + +export function pythonEvidenceJsonSchema(): Record { + return z.toJSONSchema(built.schema, { io: "input", reused: "ref" }); +} diff --git a/packages/profile-python/src/facts.ts b/packages/profile-python/src/facts.ts new file mode 100644 index 0000000..c77ff3c --- /dev/null +++ b/packages/profile-python/src/facts.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +import { FindingLocationSchema } from "@kernel-zero/contracts"; + +const RelativePythonPathSchema = z.string().min(1).max(1_000).superRefine((value, context) => { + const segments = value.split("/"); + if (value.includes("\\") || value.includes("\0") || value.startsWith("/") || /^[A-Za-z]:/u.test(value) || segments.includes("..") || !value.endsWith(".py")) { + context.addIssue({ code: "custom", message: "Python fact paths must be contained relative POSIX .py paths." }); + } +}); + +const ImportFactSchema = z.strictObject({ + module: z.string().min(1).max(300), + location: FindingLocationSchema, +}); + +const CallFactSchema = z.strictObject({ + callee: z.string().min(1).max(300), + location: FindingLocationSchema, +}); + +const ParameterFactSchema = z.strictObject({ + kind: z.enum(["positional", "positional-only", "keyword-only", "vararg", "kwarg"]), + name: z.string().min(1).max(200), + required: z.boolean(), +}); + +const FunctionFactSchema = z.strictObject({ + location: FindingLocationSchema, + name: z.string().min(1).max(500), + parameters: z.array(ParameterFactSchema).max(500), +}); + +export const PythonFileFactsSchema = z.strictObject({ + calls: z.array(CallFactSchema).max(20_000), + functions: z.array(FunctionFactSchema).max(20_000), + imports: z.array(ImportFactSchema).max(20_000), + parseError: FindingLocationSchema.nullable(), + path: RelativePythonPathSchema, +}); + +export type PythonFileFacts = z.infer; + +export const PythonAnalysisSchema = z.strictObject({ + files: z.array(PythonFileFactsSchema).max(5_000), + protocolVersion: z.literal("kernel-zero.python-analysis/v1"), + python: z.strictObject({ + implementation: z.literal("CPython"), + version: z.tuple([z.number().int().nonnegative(), z.number().int().nonnegative(), z.number().int().nonnegative()]), + }), +}); + +export type PythonAnalysis = z.infer; diff --git a/packages/profile-python/src/index.test.ts b/packages/profile-python/src/index.test.ts new file mode 100644 index 0000000..9d69f12 --- /dev/null +++ b/packages/profile-python/src/index.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { diffPythonRules, pythonProfile, type PythonPolicy } from "./index"; + +const policy: PythonPolicy = { + apiVersion: "kernel-zero.dev/v1", + kind: "PythonPolicy", + metadata: { description: "Python profile contract", name: "python-profile", revision: 1 }, + scope: { exclude: [], include: ["src/**/*.py"] }, + rules: [{ + check: { deny: ["subprocess"], from: ["src/**/*.py"], kind: "forbid-import-edge" }, + id: "no-processes", + level: "error", + remediation: "Use a worker boundary.", + title: "No processes", + }], +}; + +describe("pythonProfile", () => { + it("publishes unique stable profile identities and JSON schemas", () => { + expect([pythonProfile.policyKind, pythonProfile.evidenceKind, pythonProfile.toolName]).toEqual([ + "PythonPolicy", + "PythonEvidence", + "kernel-zero-python", + ]); + expect(pythonProfile.policyJsonSchema()).toMatchObject({ type: "object" }); + expect(pythonProfile.evidenceJsonSchema()).toMatchObject({ type: "object" }); + }); + + it("diffs added, changed, and removed rules by stable rule id", () => { + const baseRule = policy.rules[0]; + if (baseRule === undefined) throw new Error("Expected the fixture rule."); + const changed: PythonPolicy = { + ...policy, + rules: [ + { ...baseRule, level: "warning" }, + { check: { files: ["src/**/*.py"], kind: "require-import", module: "typing" }, id: "typing-required", level: "error", remediation: "Import typing.", title: "Typing required" }, + ], + }; + expect(diffPythonRules(policy, changed).map((item) => [item.id, item.status])).toEqual([ + ["no-processes", "changed"], + ["typing-required", "added"], + ]); + expect(diffPythonRules(changed, policy).map((item) => [item.id, item.status])).toEqual([ + ["no-processes", "changed"], + ["typing-required", "removed"], + ]); + }); +}); diff --git a/packages/profile-python/src/index.ts b/packages/profile-python/src/index.ts new file mode 100644 index 0000000..554c85b --- /dev/null +++ b/packages/profile-python/src/index.ts @@ -0,0 +1,67 @@ +import type { EvidenceFinding, PolicyRuleDiff, Profile } from "@kernel-zero/contracts"; +import { diffRulesById } from "@kernel-zero/contracts"; + +import { matchesPythonGlob, matchesPythonSymbol, moduleMatches } from "./check"; +import { PythonEvidenceSchema, pythonEvidenceJsonSchema, type PythonEvidence } from "./evidence"; +import { PythonPolicySchema, pythonPolicyJsonSchema, type PythonPolicy } from "./policy"; + +export * from "./check"; +export * from "./evidence"; +export * from "./facts"; +export * from "./policy"; + +export function pythonFindingCompatibilityReason(policy: PythonPolicy, finding: EvidenceFinding): string | null { + const rule = policy.rules.find((candidate) => candidate.id === finding.ruleId); + if (rule === undefined) return "rule_not_found"; + if (finding.level !== rule.level) return "rule_level_mismatch"; + if (finding.messageCode === "PARSE_FAILURE") return rule.level === "error" && finding.subject === "parse" ? null : "rule_code_mismatch"; + if (rule.check.kind === "forbid-import-edge") { + const module = subjectValue(finding.subject, "module:"); + return finding.messageCode === "PYTHON_IMPORT_DENIED" + && rule.check.from.some((glob) => matchesPythonGlob(finding.path, glob)) + && module !== null && rule.check.deny.some((denied) => moduleMatches(module, denied)) + ? null : "rule_subject_mismatch"; + } + if (rule.check.kind === "require-import") { + return finding.messageCode === "PYTHON_IMPORT_REQUIRED" + && rule.check.files.some((glob) => matchesPythonGlob(finding.path, glob)) + && finding.subject === `module:${rule.check.module}` ? null : "rule_subject_mismatch"; + } + if (rule.check.kind === "restrict-call-site") { + const callee = subjectValue(finding.subject, "call:"); + return finding.messageCode === "PYTHON_CALL_RESTRICTED" + && !rule.check.allowFrom.some((glob) => matchesPythonGlob(finding.path, glob)) + && callee !== null && rule.check.callee.includes(callee) + ? null : "rule_subject_mismatch"; + } + const suffix = `:parameter:${rule.check.parameter}`; + const symbol = finding.subject.startsWith("symbol:") && finding.subject.endsWith(suffix) + ? finding.subject.slice("symbol:".length, -suffix.length) + : null; + const shortName = symbol?.split(".").at(-1) ?? null; + return finding.messageCode === "PYTHON_CONTEXT_PARAMETER_REQUIRED" + && rule.check.files.some((glob) => matchesPythonGlob(finding.path, glob)) + && symbol !== null && shortName !== null + && (matchesPythonSymbol(symbol, rule.check.symbols) || matchesPythonSymbol(shortName, rule.check.symbols)) + ? null : "rule_subject_mismatch"; +} + +function subjectValue(subject: string, prefix: string): string | null { + return subject.startsWith(prefix) && subject.length > prefix.length ? subject.slice(prefix.length) : null; +} + +export function diffPythonRules(before: PythonPolicy, after: PythonPolicy): readonly PolicyRuleDiff[] { + return diffRulesById(before, after); +} + +export const pythonProfile: Profile = Object.freeze({ + policyKind: "PythonPolicy", + evidenceKind: "PythonEvidence", + toolName: "kernel-zero-python", + policySchema: PythonPolicySchema, + evidenceSchema: PythonEvidenceSchema, + findingCompatibilityReason: pythonFindingCompatibilityReason, + diffRules: diffPythonRules, + policyJsonSchema: pythonPolicyJsonSchema, + evidenceJsonSchema: pythonEvidenceJsonSchema, +}); diff --git a/packages/profile-python/src/policy.ts b/packages/profile-python/src/policy.ts new file mode 100644 index 0000000..2863cdc --- /dev/null +++ b/packages/profile-python/src/policy.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +import { NonemptyExactStringSchema, RelativeGlobSchema, SlugSchema, uniqueArray } from "@kernel-zero/contracts"; + +const GlobList = uniqueArray(RelativeGlobSchema, 1, 100); +const OptionalGlobList = uniqueArray(RelativeGlobSchema, 0, 100); +const PythonIdentifierSchema = z.string().min(1).max(200).regex(/^[A-Za-z_][A-Za-z0-9_]*$/u); +const PythonModuleSchema = z.string().min(1).max(300).regex(/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/u); +const PythonCalleeSchema = z.string().min(1).max(300).regex(/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/u); +const PythonSymbolPatternSchema = NonemptyExactStringSchema.refine( + (value) => /^(?:\*|[A-Za-z_][A-Za-z0-9_]*)(?:\.(?:\*|[A-Za-z_][A-Za-z0-9_]*))*$/u.test(value), + "Python symbol patterns contain identifiers separated by dots and may use a whole-segment wildcard.", +); + +export const PythonPolicyCheckSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("forbid-import-edge"), from: GlobList, deny: uniqueArray(PythonModuleSchema, 1, 100) }), + z.strictObject({ kind: z.literal("require-import"), files: GlobList, module: PythonModuleSchema }), + z.strictObject({ kind: z.literal("restrict-call-site"), callee: uniqueArray(PythonCalleeSchema, 1, 100), allowFrom: OptionalGlobList }), + z.strictObject({ kind: z.literal("require-context-parameter"), files: GlobList, symbols: PythonSymbolPatternSchema, parameter: PythonIdentifierSchema }), +]); + +const PythonPolicyRuleSchema = z.strictObject({ + id: SlugSchema(3, 80), + title: z.string().trim().min(1).max(120), + level: z.enum(["error", "warning"]), + check: PythonPolicyCheckSchema, + remediation: z.string().trim().min(1).max(500), +}); + +export const PythonPolicySchema = z.strictObject({ + apiVersion: z.literal("kernel-zero.dev/v1"), + kind: z.literal("PythonPolicy"), + metadata: z.strictObject({ + name: SlugSchema(3, 64), + revision: z.number().int().min(1), + description: z.string().trim().min(1).max(500), + }), + scope: z.strictObject({ include: GlobList, exclude: OptionalGlobList }), + rules: uniqueArray(PythonPolicyRuleSchema, 1, 500).superRefine((rules, context) => { + if (new Set(rules.map((rule) => rule.id)).size !== rules.length) { + context.addIssue({ code: "custom", message: "Rule IDs must be unique." }); + } + if (!rules.some((rule) => rule.level === "error")) { + context.addIssue({ code: "custom", message: "At least one error-level rule is required so parse failures fail closed." }); + } + }), +}); + +export type PythonPolicy = z.infer; +export type PythonPolicyRule = PythonPolicy["rules"][number]; + +export function pythonPolicyJsonSchema(): Record { + return z.toJSONSchema(PythonPolicySchema, { io: "input", reused: "ref" }); +} diff --git a/packages/profile-python/src/runner.test.ts b/packages/profile-python/src/runner.test.ts new file mode 100644 index 0000000..4004794 --- /dev/null +++ b/packages/profile-python/src/runner.test.ts @@ -0,0 +1,100 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { PythonEvidenceSchema } from "./evidence"; +import { runPythonValidation } from "./runner"; + +const WORKSPACE = "0195f000-0000-7000-8000-000000000002"; + +function testPolicy() { + return { + apiVersion: "kernel-zero.dev/v1", + kind: "PythonPolicy", + metadata: { description: "Python runner test", name: "python-runner", revision: 1 }, + scope: { exclude: [], include: ["src/**/*.py"] }, + rules: [ + { check: { deny: ["subprocess"], from: ["src/**/*.py"], kind: "forbid-import-edge" }, id: "deny-process", level: "error", remediation: "Use a worker.", title: "No processes" }, + { check: { allowFrom: [], callee: ["eval", "subprocess.run"], kind: "restrict-call-site" }, id: "deny-run", level: "error", remediation: "Use a worker.", title: "No dynamic or process calls" }, + { check: { files: ["src/**/*.py"], kind: "require-context-parameter", parameter: "workspace_id", symbols: "handler" }, id: "workspace-context", level: "error", remediation: "Require workspace context.", title: "Workspace context" }, + ], + }; +} + +async function workspace(source: string) { + const root = await mkdtemp(path.join(tmpdir(), "kernel-zero-python-")); + await mkdir(path.join(root, "src"), { recursive: true }); + await Promise.all([ + writeFile(path.join(root, "python-policy.json"), `${JSON.stringify(testPolicy(), null, 2)}\n`, "utf8"), + writeFile(path.join(root, "src", "handler.py"), source, "utf8"), + ]); + return root; +} + +describe("Python profile runner", () => { + it("uses CPython AST, resolves import aliases, and reproduces its canonical digest", async () => { + const root = await workspace("import json as codec\n\ndef handler(workspace_id, /):\n return codec.dumps({'ok': True})\n\nclass Service:\n async def handler(self, *, workspace_id):\n return codec.dumps({'workspace': workspace_id})\n"); + const first = path.join(root, ".kernel-zero", "first.json"); + const second = path.join(root, ".kernel-zero", "second.json"); + expect(runPythonValidation({ out: first, policy: "python-policy.json", root, workspace: WORKSPACE })).toBe(0); + expect(runPythonValidation({ out: second, policy: "python-policy.json", root, workspace: WORKSPACE })).toBe(0); + const left = PythonEvidenceSchema.parse(JSON.parse(await readFile(first, "utf8")) as unknown); + const right = PythonEvidenceSchema.parse(JSON.parse(await readFile(second, "utf8")) as unknown); + expect(left.integrity.digest).toBe(right.integrity.digest); + expect(left.tool.version).toMatch(/^0\.1\.0\+cpython\.3\.(?:11|12|13|14)\.\d+$/u); + expect(JSON.stringify(left)).not.toContain("codec.dumps"); + }); + + it("reports denied aliased imports, resolved calls, and optional context as definite violations", async () => { + const root = await workspace("import subprocess as sp\n\ndef handler(workspace_id=None):\n return sp.run(['true'])\n"); + const out = path.join(root, "evidence.json"); + expect(runPythonValidation({ out, policy: "python-policy.json", root, workspace: WORKSPACE })).toBe(1); + const evidence = PythonEvidenceSchema.parse(JSON.parse(await readFile(out, "utf8")) as unknown); + expect(evidence.findings.map((finding) => [finding.messageCode, finding.subject])).toEqual([ + ["PYTHON_IMPORT_DENIED", "module:subprocess"], + ["PYTHON_CALL_RESTRICTED", "call:subprocess.run"], + ["PYTHON_CONTEXT_PARAMETER_REQUIRED", "symbol:handler:parameter:workspace_id"], + ]); + }); + + it.skipIf(Number(execFileSync(process.env.KERNEL_ZERO_PYTHON ?? "python", ["-c", "import sys; print(sys.version_info.minor)"], { encoding: "utf8" }).trim()) < 12)("blocks restricted calls in generic class type parameters", async () => { + const root = await workspace("class Service[T: eval('class-bound')]:\n pass\n"); + const out = path.join(root, "evidence.json"); + expect(runPythonValidation({ out, policy: "python-policy.json", root, workspace: WORKSPACE })).toBe(1); + const evidence = PythonEvidenceSchema.parse(JSON.parse(await readFile(out, "utf8")) as unknown); + expect(evidence.findings.map((finding) => [finding.messageCode, finding.subject])).toEqual([ + ["PYTHON_CALL_RESTRICTED", "call:eval"], + ]); + }); + + it("fails closed on Python syntax errors", async () => { + const root = await workspace("def broken(:\n pass\n"); + const out = path.join(root, "evidence.json"); + expect(runPythonValidation({ out, policy: "python-policy.json", root, workspace: WORKSPACE })).toBe(2); + const evidence = PythonEvidenceSchema.parse(JSON.parse(await readFile(out, "utf8")) as unknown); + expect(evidence).toMatchObject({ result: { status: "error" }, findings: [{ exceptionId: null, messageCode: "PARSE_FAILURE", subject: "parse" }] }); + }); + + it("fails when no configured Python runtime can start", async () => { + const root = await workspace("def handler(workspace_id):\n return workspace_id\n"); + const previous = process.env.KERNEL_ZERO_PYTHON; + process.env.KERNEL_ZERO_PYTHON = path.join(root, "missing-python"); + try { + expect(() => runPythonValidation({ out: "evidence.json", policy: "python-policy.json", root, workspace: WORKSPACE })).toThrow(/No supported CPython/u); + } finally { + if (previous === undefined) delete process.env.KERNEL_ZERO_PYTHON; + else process.env.KERNEL_ZERO_PYTHON = previous; + } + }); + + it("rejects invalid workspace, escaping output, and input-output collisions before analysis", async () => { + const root = await workspace("def handler(workspace_id):\n return workspace_id\n"); + expect(() => runPythonValidation({ out: "evidence.json", policy: "python-policy.json", root, workspace: "not-a-workspace" })).toThrow(/UUIDv7/u); + expect(() => runPythonValidation({ out: "../evidence.json", policy: "python-policy.json", root, workspace: WORKSPACE })).toThrow(/escapes/u); + expect(() => runPythonValidation({ out: "python-policy.json", policy: "python-policy.json", root, workspace: WORKSPACE })).toThrow(/collides/u); + expect(() => runPythonValidation({ out: "src/handler.py", policy: "python-policy.json", root, workspace: WORKSPACE })).toThrow(/collides/u); + }); +}); diff --git a/packages/profile-python/src/runner.ts b/packages/profile-python/src/runner.ts new file mode 100644 index 0000000..f9f2e0d --- /dev/null +++ b/packages/profile-python/src/runner.ts @@ -0,0 +1,209 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync, type Dirent } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { canonicalEvidenceDigest, deriveEvidenceSummary } from "@kernel-zero/contracts"; +import { canonicalSha256, generateUuidV7, isUuidV7 } from "@kernel-zero/domain"; + +import { checkPython } from "./check"; +import { PythonEvidenceSchema } from "./evidence"; +import { PythonAnalysisSchema, type PythonAnalysis } from "./facts"; +import { PythonPolicySchema } from "./policy"; + +const MAX_FILE_BYTES = 2 * 1024 * 1024; +const MAX_TOTAL_BYTES = 20 * 1024 * 1024; +const SKIPPED_DIRECTORIES = new Set([".git", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".venv", "__pycache__", "build", "dist", "node_modules", "site-packages", "venv"]); + +export class PythonRunnerError extends Error { + public constructor(message: string) { + super(message); + this.name = "PythonRunnerError"; + } +} + +export type PythonRunInput = Readonly<{ + analyzerPath?: string; + out: string; + policy: string; + root: string; + workspace: string; +}>; + +export function runPythonValidation(input: PythonRunInput): 0 | 1 | 2 { + if (!isUuidV7(input.workspace)) throw new PythonRunnerError("Workspace must be a lowercase UUIDv7."); + const root = realpathSync(resolve(input.root)); + const policyPath = containedExistingFile(root, input.policy, "Policy"); + const outPath = containedOutput(root, input.out); + if (samePath(policyPath, outPath)) throw new PythonRunnerError("Output path collides with the policy input."); + const policy = PythonPolicySchema.parse(JSON.parse(readFileSync(policyPath, "utf8"))); + const policyDigest = canonicalSha256(policy); + const files = collectPythonSources(root, policy.scope.include, policy.scope.exclude); + if (files.some((file) => samePath(resolve(root, file.path), outPath))) throw new PythonRunnerError("Output path collides with a Python source input."); + const analysis = analyzePython(files, input.analyzerPath ?? fileURLToPath(new URL("../python-analyzer.py", import.meta.url))); + const [major, minor, micro] = analysis.python.version; + const findings = checkPython({ files: analysis.files, policy, policyDigest }); + const manifestDigest = canonicalSha256(Object.fromEntries(files.map((file) => [file.path, canonicalSha256(file.source)]))); + const base = { + apiVersion: "kernel-zero.dev/evidence/v1" as const, + exceptionBundleDigest: null, + findings: [...findings], + generatedAt: new Date().toISOString(), + kind: "PythonEvidence" as const, + policy: { digest: policyDigest, name: policy.metadata.name, revision: policy.metadata.revision }, + result: { ...deriveEvidenceSummary(findings, files.length), durationMs: 0 }, + runId: generateUuidV7(), + signature: null, + subject: { manifestDigest, repository: "local", revision: "working-tree" }, + tool: { name: "kernel-zero-python" as const, version: `0.1.0+cpython.${String(major)}.${String(minor)}.${String(micro)}` }, + workspace: input.workspace, + }; + const evidence = PythonEvidenceSchema.parse({ ...base, integrity: { algorithm: "sha256", digest: canonicalEvidenceDigest(base) } }); + mkdirSync(dirname(outPath), { recursive: true }); + writeFileSync(outPath, `${JSON.stringify(evidence, null, 2)}\n`, "utf8"); + for (const finding of evidence.findings) { + process.stdout.write(`python: ${finding.level} ${finding.ruleId} ${finding.path}:${String(finding.location.startLine)}:${String(finding.location.startColumn)} ${finding.messageCode} ${finding.subject}\n`); + } + process.stdout.write(`python: ${evidence.result.status} (${String(evidence.result.errors)} errors, ${String(evidence.result.warnings)} warnings, ${String(files.length)} files, CPython ${String(major)}.${String(minor)}.${String(micro)})\n`); + return evidence.result.status === "pass" ? 0 : evidence.result.status === "fail" ? 1 : 2; +} + +type PythonSource = Readonly<{ path: string; source: string }>; + +function collectPythonSources(root: string, include: readonly string[], exclude: readonly string[]): readonly PythonSource[] { + const includePatterns = include.map(globToRegExp); + const excludePatterns = exclude.map(globToRegExp); + const relativePaths = listPythonFiles(root) + .map((absolute) => relative(root, absolute).split(sep).join("/")) + .filter((path) => includePatterns.some((pattern) => pattern.test(path)) && !excludePatterns.some((pattern) => pattern.test(path))) + .sort(); + let totalBytes = 0; + return relativePaths.map((path) => { + const absolute = containedExistingFile(root, path, "Python source"); + const size = lstatSync(absolute).size; + if (size > MAX_FILE_BYTES) throw new PythonRunnerError(`Python source exceeds ${String(MAX_FILE_BYTES)} bytes: ${path}`); + totalBytes += size; + if (totalBytes > MAX_TOTAL_BYTES) throw new PythonRunnerError(`Python source set exceeds ${String(MAX_TOTAL_BYTES)} bytes.`); + return Object.freeze({ path, source: readFileSync(absolute, "utf8") }); + }); +} + +function listPythonFiles(directory: string): readonly string[] { + const files: string[] = []; + const pending = [directory]; + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined) continue; + let entries: readonly Dirent[]; + try { + entries = readdirSync(current, { withFileTypes: true }); + } catch (error) { + throw new PythonRunnerError(`Cannot read Python source directory: ${error instanceof Error ? error.message : "unknown error"}`); + } + for (const entry of entries) { + if (entry.isSymbolicLink()) continue; + const absolute = join(current, entry.name); + if (entry.isDirectory() && !SKIPPED_DIRECTORIES.has(entry.name)) pending.push(absolute); + else if (entry.isFile() && entry.name.endsWith(".py")) files.push(absolute); + } + } + return files; +} + +function analyzePython(files: readonly PythonSource[], analyzerPath: string): PythonAnalysis { + const request = JSON.stringify({ files, protocolVersion: "kernel-zero.python-analysis/v1" }); + const configured = process.env.KERNEL_ZERO_PYTHON; + const candidates: readonly Readonly<{ args: readonly string[]; command: string }>[] = configured === undefined || configured.trim() === "" + ? [{ command: "python3", args: [] }, { command: "python", args: [] }, ...(process.platform === "win32" ? [{ command: "py", args: ["-3"] }] : [])] + : [{ command: configured, args: [] }]; + const failures: string[] = []; + for (const candidate of candidates) { + const result = spawnSync(candidate.command, [...candidate.args, analyzerPath], { + encoding: "utf8", + input: request, + maxBuffer: 64 * 1024 * 1024, + shell: false, + windowsHide: true, + }); + if (result.error !== undefined) { + failures.push(`${candidate.command}: ${result.error.message}`); + continue; + } + if (result.status !== 0) { + failures.push(`${candidate.command}: ${normalizeError(result.stderr)}`); + continue; + } + let value: unknown; + try { + value = JSON.parse(result.stdout); + } catch { + failures.push(`${candidate.command}: analyzer returned malformed JSON`); + continue; + } + const parsed = PythonAnalysisSchema.safeParse(value); + if (!parsed.success) { + failures.push(`${candidate.command}: analyzer response failed its strict contract`); + continue; + } + const [major, minor] = parsed.data.python.version; + if (major !== 3 || minor < 11 || minor >= 15) { + failures.push(`${candidate.command}: unsupported CPython ${String(major)}.${String(minor)}`); + continue; + } + if (parsed.data.files.length !== files.length || parsed.data.files.some((file, index) => file.path !== files[index]?.path)) { + failures.push(`${candidate.command}: analyzer response does not match the requested sorted files`); + continue; + } + return parsed.data; + } + throw new PythonRunnerError(`No supported CPython 3.11-3.14 analyzer completed: ${failures.join("; ")}`); +} + +function containedExistingFile(root: string, candidate: string, label: string): string { + const lexical = resolve(root, candidate); + if (!isContained(root, lexical) || !existsSync(lexical)) throw new PythonRunnerError(`${label} path is missing or escapes the repository root.`); + const actual = realpathSync(lexical); + if (!isContained(root, actual) || !lstatSync(actual).isFile()) throw new PythonRunnerError(`${label} path is not a contained file.`); + return actual; +} + +function containedOutput(root: string, candidate: string): string { + const output = resolve(root, candidate); + if (!isContained(root, output)) throw new PythonRunnerError("Output path escapes the repository root."); + if (existsSync(output)) { + if (lstatSync(output).isSymbolicLink() || !isContained(root, realpathSync(output))) { + throw new PythonRunnerError("Output path is a symlink, junction, or escape."); + } + } + let ancestor = dirname(output); + while (!existsSync(ancestor)) { + const parent = dirname(ancestor); + if (parent === ancestor) throw new PythonRunnerError("Output path has no contained existing ancestor."); + ancestor = parent; + } + if (!isContained(root, realpathSync(ancestor))) throw new PythonRunnerError("Output path escapes through a symlink or junction."); + return output; +} + +function samePath(left: string, right: string): boolean { + return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right; +} + +function isContained(root: string, candidate: string): boolean { + const relation = relative(root, candidate); + return relation === "" || (!relation.startsWith(`..${sep}`) && relation !== ".." && !isAbsolute(relation)); +} + +function globToRegExp(glob: string): RegExp { + const expression = glob.replace(/\*\*\/|\*\*|\*|[.+^${}()|[\]\\?]/gu, (token) => { + if (token === "**/") return "(?:[^\\0]*/)?"; + if (token === "**") return "[^\\0]*"; + if (token === "*") return "[^/]*"; + return `\\${token}`; + }); + return new RegExp(`^${expression}$`, "u"); +} + +function normalizeError(value: string): string { + return value.replace(/\s+/gu, " ").trim().slice(0, 300) || "analyzer failed"; +} diff --git a/packages/profile-python/tsconfig.json b/packages/profile-python/tsconfig.json new file mode 100644 index 0000000..a552e34 --- /dev/null +++ b/packages/profile-python/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true }, + "include": ["src/**/*.ts"] +} diff --git a/packages/profiles/package.json b/packages/profiles/package.json index cd0477f..9cdd8a3 100644 --- a/packages/profiles/package.json +++ b/packages/profiles/package.json @@ -7,6 +7,7 @@ "dependencies": { "@kernel-zero/contracts": "*", "@kernel-zero/profile-manifest": "*", + "@kernel-zero/profile-python": "*", "@kernel-zero/profile-software-architecture": "*", "@kernel-zero/profile-workflow": "*" } diff --git a/packages/profiles/src/index.test.ts b/packages/profiles/src/index.test.ts index db26272..d33a8de 100644 --- a/packages/profiles/src/index.test.ts +++ b/packages/profiles/src/index.test.ts @@ -26,6 +26,7 @@ describe("profile registry", () => { it("resolves a profile by policy kind and refuses unknown kinds", () => { expect(profileForPolicyKind("RepositoryPolicy")?.toolName).toBe("kernel-zero-validator"); expect(profileForPolicyKind("ManifestPolicy")?.toolName).toBe("kernel-zero-manifest"); + expect(profileForPolicyKind("PythonPolicy")?.toolName).toBe("kernel-zero-python"); expect(profileForPolicyKind("WorkflowPolicy")?.toolName).toBe("kernel-zero-workflow"); expect(profileForPolicyKind("NopePolicy")).toBeNull(); }); diff --git a/packages/profiles/src/index.ts b/packages/profiles/src/index.ts index 22c3cf6..81abb49 100644 --- a/packages/profiles/src/index.ts +++ b/packages/profiles/src/index.ts @@ -1,11 +1,13 @@ import { PolicyEnvelopeSchema, type PolicyEnvelope, type Profile } from "@kernel-zero/contracts"; import { manifestProfile } from "@kernel-zero/profile-manifest"; +import { pythonProfile } from "@kernel-zero/profile-python"; import { softwareArchitectureProfile } from "@kernel-zero/profile-software-architecture"; import { workflowProfile } from "@kernel-zero/profile-workflow"; export const PROFILES: readonly Profile[] = Object.freeze([ softwareArchitectureProfile, manifestProfile, + pythonProfile, workflowProfile, ]); diff --git a/packages/validator/README.md b/packages/validator/README.md index 27fc14a..0031ee5 100644 --- a/packages/validator/README.md +++ b/packages/validator/README.md @@ -1,8 +1,10 @@ # `@kernel-zero/validator` The KERNEL ZERO validator is a deterministic, network-free command-line checker -for versioned repository architecture policy. Version `0.1.0` intentionally -provides a CLI only; it has no JavaScript library API. +for versioned repository, package-manifest, Python, and workflow policies. +Version `0.2.0` provides four CLI executables and intentionally has no JavaScript +library API. Everything needed at runtime is bundled; consumers do not need the +KERNEL ZERO repository or its workspace packages. ## Install @@ -15,6 +17,11 @@ npm install --save-dev @kernel-zero/validator The package installs the exact TypeScript compiler version used by the validator. +Versioned schemas, valid examples, deliberately malformed fixtures, and +contract documentation are installed at +`node_modules/@kernel-zero/validator/contracts`. Agents can consume these files +without accessing the KERNEL ZERO repository. + ## Run ```text @@ -25,15 +32,44 @@ kernel-zero validate \ --out .kernel-zero/evidence.json ``` +Validate `package.json` with a `ManifestPolicy`: + +```text +kernel-zero-manifest kernel-zero.manifest.policy.json . \ + 00000000-0000-7000-8000-000000000000 \ + .kernel-zero/manifest-evidence.json +``` + +Validate GitHub Actions files with a `WorkflowPolicy`: + +```text +kernel-zero-workflow kernel-zero.workflow.policy.json . \ + 00000000-0000-7000-8000-000000000000 \ + .kernel-zero/workflow-evidence.json +``` + +Validate Python 3.11–3.14 source with a `PythonPolicy`: + +```text +kernel-zero-python kernel-zero.python.policy.json . \ + 00000000-0000-7000-8000-000000000000 \ + .kernel-zero/python-evidence.json +``` + +The Python executable uses CPython's standard-library AST parser. It requires a +local CPython 3.11 through 3.14 runtime but no Python packages. Set +`KERNEL_ZERO_PYTHON` to an exact interpreter path when automatic `python3`, +`python`, or Windows `py -3` discovery is not appropriate. + Replace the workspace value with the governed workspace identifier. A policy example and the complete hook and CI setup are available in the [KERNEL ZERO repository](https://github.com/AP3X-Dev/KERNEL-ZERO/blob/main/docs/validator-and-hooks.md). -The command exits `0` when policy passes, `1` for definite violations, and `2` +Every command exits `0` when policy passes, `1` for definite violations, and `2` when validation cannot complete safely. Both nonzero outcomes should block the -protected operation. The JSON evidence at `--out` is authoritative; the command -also prints one summary line and one deterministic line per finding (level, -rule, path:line:column, code, subject) followed by the policy remediation. +protected operation. Each JSON evidence artifact is authoritative. Repository +validation also prints one deterministic line per finding (level, rule, +path:line:column, code, subject) followed by the policy remediation. ## Layers @@ -164,8 +200,8 @@ existing file, and prints the AGENTS/CLAUDE snippet, the `validator:self` script, and the exact validator version to install. It never edits `package.json`, agent instructions, git configuration, or branch protection. -Validation reads contained TypeScript and TSX files, writes normalized evidence, -and performs no network requests or source upload. +Validation reads only contained claimed files, writes normalized evidence, and +performs no network requests or source upload. ## License diff --git a/packages/validator/package.json b/packages/validator/package.json index d6dc534..d6c525c 100644 --- a/packages/validator/package.json +++ b/packages/validator/package.json @@ -1,6 +1,6 @@ { "name": "@kernel-zero/validator", - "version": "0.1.0", + "version": "0.2.0", "description": "Network-free deterministic architecture policy validator for KERNEL ZERO.", "license": "MIT", "type": "module", @@ -27,15 +27,20 @@ "access": "public" }, "scripts": { - "build": "esbuild src/cli.ts --bundle --platform=node --format=esm --target=node22 --external:typescript --outfile=dist/kernel-zero.js", + "build": "esbuild src/cli.ts ../../scripts/run-manifest-validator.ts ../../scripts/run-python-validator.ts ../../scripts/run-workflow-validator.ts --bundle --platform=node --format=esm --target=node22 --external:typescript --outdir=dist --entry-names=[name] && node ../../scripts/rename-validator-binaries.mjs", "prepack": "npm run build" }, "bin": { - "kernel-zero": "dist/kernel-zero.js" + "kernel-zero": "dist/kernel-zero.js", + "kernel-zero-manifest": "dist/kernel-zero-manifest.js", + "kernel-zero-python": "dist/kernel-zero-python.js", + "kernel-zero-workflow": "dist/kernel-zero-workflow.js" }, "files": [ "dist", + "contracts", "LICENSE", + "python-analyzer.py", "README.md", "THIRD_PARTY_NOTICES.md" ], diff --git a/packages/validator/src/commands.test.ts b/packages/validator/src/commands.test.ts index fa63edf..5c3bf37 100644 --- a/packages/validator/src/commands.test.ts +++ b/packages/validator/src/commands.test.ts @@ -77,7 +77,7 @@ describe("init", () => { const output = await init({ command: "init", root, workspace: WORKSPACE }); expect(output).toContain("kernel-zero init wrote:"); expect(output).toContain(`--workspace ${WORKSPACE}`); - expect(output).toContain("@kernel-zero/validator@0.1.0"); + expect(output).toContain("@kernel-zero/validator@0.2.0"); expect(output).toContain("## KERNEL ZERO enforcement"); const written = await Promise.all(scaffoldTargets(root).map(async (target) => readFile(path.join(root, ...target.relative.split("/")), "utf8"))); expect(written).toEqual(scaffoldTargets(root).map((target) => target.content)); diff --git a/packages/validator/src/commands.ts b/packages/validator/src/commands.ts index 7ee7457..9b6ede6 100644 --- a/packages/validator/src/commands.ts +++ b/packages/validator/src/commands.ts @@ -10,7 +10,7 @@ import { renderEvidence, renderPolicy } from "./render"; export type ExplainCommand = Readonly<{ command: "explain"; artifact: string; kind: "policy" | "evidence" }>; export type InitCommand = Readonly<{ command: "init"; root: string; workspace: string }>; -export const VALIDATOR_VERSION = "0.1.0"; +export const VALIDATOR_VERSION = "0.2.0"; const PLACEHOLDER_WORKSPACE = "00000000-0000-7000-8000-000000000000"; export class CommandError extends Error { diff --git a/packages/validator/src/runner.ts b/packages/validator/src/runner.ts index e625f2e..263e4b3 100644 --- a/packages/validator/src/runner.ts +++ b/packages/validator/src/runner.ts @@ -36,6 +36,8 @@ export type ValidatorRuntimeOptions = Readonly<{ export type ValidationRun = ValidationOutcome & Readonly<{ evidence: RepositoryEvidence; policy: RepositoryPolicy }>; +// Evidence identifies the stable validator engine contract, which remains v0.1.0. +// The npm distribution version may advance independently when adding package entry points. const TOOL_VERSION = "0.1.0"; export class ValidatorRunError extends Error { diff --git a/scripts/check-architecture.mjs b/scripts/check-architecture.mjs index 99cd8ec..ce2f8cd 100644 --- a/scripts/check-architecture.mjs +++ b/scripts/check-architecture.mjs @@ -10,6 +10,7 @@ const workspaces = Object.freeze({ "@kernel-zero/domain": "packages/domain", "@kernel-zero/persistence": "packages/persistence", "@kernel-zero/profile-manifest": "packages/profile-manifest", + "@kernel-zero/profile-python": "packages/profile-python", "@kernel-zero/profile-software-architecture": "packages/profile-software-architecture", "@kernel-zero/profile-workflow": "packages/profile-workflow", "@kernel-zero/profiles": "packages/profiles", @@ -34,6 +35,10 @@ const allowedWorkspaceDependencies = Object.freeze({ "@kernel-zero/contracts", "@kernel-zero/domain", ]), + "@kernel-zero/profile-python": new Set([ + "@kernel-zero/contracts", + "@kernel-zero/domain", + ]), "@kernel-zero/profile-software-architecture": new Set([ "@kernel-zero/contracts", "@kernel-zero/domain", @@ -45,6 +50,7 @@ const allowedWorkspaceDependencies = Object.freeze({ "@kernel-zero/profiles": new Set([ "@kernel-zero/contracts", "@kernel-zero/profile-manifest", + "@kernel-zero/profile-python", "@kernel-zero/profile-software-architecture", "@kernel-zero/profile-workflow", ]), diff --git a/scripts/check-validator-package.mjs b/scripts/check-validator-package.mjs index 2a1351b..9de3b6c 100644 --- a/scripts/check-validator-package.mjs +++ b/scripts/check-validator-package.mjs @@ -10,13 +10,55 @@ if (npmCli === undefined || npmCli.length === 0) { throw new Error("Run this check through npm so npm_execpath is available."); } +const expectedContractFiles = [ + "README.md", + "examples/exception-grant-set-v1.json", + "examples/manifest-evidence-v1.json", + "examples/manifest-policy-v1.json", + "examples/policy-approval-v1.json", + "examples/policy-custody-evidence-v1.json", + "examples/python-evidence-v1.json", + "examples/python-policy-v1.json", + "examples/repository-evidence-v1.json", + "examples/repository-policy-v1.json", + "examples/workflow-evidence-v1.json", + "examples/workflow-policy-v1.json", + "examples/workspace-trust-bundle-v1.json", + "exception-grant-set-v1.schema.json", + "malformed/exception-grant-set-private-data.json", + "malformed/policy-approval-unknown-field.json", + "malformed/policy-custody-evidence-wall-clock.json", + "malformed/python-policy-unknown-field.json", + "malformed/repository-evidence-source-content.json", + "malformed/repository-policy-path-escape.json", + "malformed/repository-policy-unknown-field.json", + "malformed/workspace-trust-bundle-unsorted-keys.json", + "manifest-evidence-v1.schema.json", + "manifest-policy-v1.schema.json", + "policy-approval-v1.schema.json", + "policy-custody-evidence-v1.schema.json", + "python-evidence-v1.schema.json", + "python-policy-v1.schema.json", + "python-profile-v1.md", + "repository-evidence-v1.schema.json", + "repository-policy-v1.schema.json", + "workflow-evidence-v1.schema.json", + "workflow-policy-v1.schema.json", + "workspace-trust-bundle-v1.schema.json", +]; + const expectedPackageFiles = [ "LICENSE", "README.md", "THIRD_PARTY_NOTICES.md", + ...expectedContractFiles.map((file) => `contracts/${file}`), + "dist/kernel-zero-manifest.js", + "dist/kernel-zero-python.js", + "dist/kernel-zero-workflow.js", "dist/kernel-zero.js", "package.json", -]; + "python-analyzer.py", +].sort(); const work = await mkdtemp(path.join(tmpdir(), "kernel-zero-package-check-")); try { @@ -26,6 +68,8 @@ try { await Promise.all([ mkdir(packDirectory, { recursive: true }), mkdir(path.join(fixture, "src"), { recursive: true }), + mkdir(path.join(fixture, "python"), { recursive: true }), + mkdir(path.join(fixture, ".github", "workflows"), { recursive: true }), ]); const pack = runNpm([ @@ -87,9 +131,92 @@ try { languages: ["typescript"], }, }; + const manifestPolicy = { + apiVersion: "kernel-zero.dev/v1", + kind: "ManifestPolicy", + metadata: { + description: "Package installation manifest smoke policy.", + name: "package-smoke-manifest", + revision: 1, + }, + rules: [ + { + check: { allowed: ["MIT"], kind: "allowed-licenses" }, + id: "allowed-license", + level: "error", + remediation: "Use an approved license.", + title: "Approved license", + }, + { + check: { fields: ["dependencies", "devDependencies"], kind: "pinned-dependencies" }, + id: "pinned-dependencies", + level: "error", + remediation: "Pin dependency versions.", + title: "Pinned dependencies", + }, + ], + }; + const workflowPolicy = { + apiVersion: "kernel-zero.dev/v1", + kind: "WorkflowPolicy", + metadata: { + description: "Package installation workflow smoke policy.", + name: "package-smoke-workflow", + revision: 1, + }, + rules: [ + { + check: { kind: "pinned-actions", mode: "tag" }, + id: "pinned-actions", + level: "error", + remediation: "Pin every action reference.", + title: "Pinned actions", + }, + { + check: { allowWrite: [], kind: "restricted-permissions" }, + id: "restricted-permissions", + level: "error", + remediation: "Use read-only workflow permissions.", + title: "Restricted permissions", + }, + ], + scope: { include: [".github/workflows/*.yml"] }, + }; + const pythonPolicy = { + apiVersion: "kernel-zero.dev/v1", + kind: "PythonPolicy", + metadata: { + description: "Package installation Python smoke policy.", + name: "package-smoke-python", + revision: 1, + }, + rules: [ + { + check: { deny: ["subprocess"], from: ["python/**/*.py"], kind: "forbid-import-edge" }, + id: "no-process-import", + level: "error", + remediation: "Use an isolated worker.", + title: "No process imports", + }, + { + check: { files: ["python/**/*.py"], kind: "require-context-parameter", parameter: "workspace_id", symbols: "handler" }, + id: "workspace-context", + level: "error", + remediation: "Require workspace_id.", + title: "Workspace context", + }, + ], + scope: { exclude: [], include: ["python/**/*.py"] }, + }; await Promise.all([ writeFile(path.join(fixture, "kernel-zero.policy.json"), `${JSON.stringify(policy, null, 2)}\n`, "utf8"), + writeFile(path.join(fixture, "kernel-zero.manifest.policy.json"), `${JSON.stringify(manifestPolicy, null, 2)}\n`, "utf8"), + writeFile(path.join(fixture, "kernel-zero.workflow.policy.json"), `${JSON.stringify(workflowPolicy, null, 2)}\n`, "utf8"), + writeFile(path.join(fixture, "kernel-zero.python.policy.json"), `${JSON.stringify(pythonPolicy, null, 2)}\n`, "utf8"), + writeFile(path.join(fixture, "package.json"), `${JSON.stringify({ name: "package-smoke", version: "1.0.0", license: "MIT", dependencies: { zod: "4.1.11" }, devDependencies: {} }, null, 2)}\n`, "utf8"), writeFile(path.join(fixture, "src", "index.ts"), "export const packageReady = true;\n", "utf8"), + writeFile(path.join(fixture, ".github", "workflows", "ci.yml"), "name: CI\non: push\npermissions:\n contents: read\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n", "utf8"), + writeFile(path.join(fixture, "python", "handler.py"), "import json\n\ndef handler(workspace_id, /):\n return json.dumps({'workspace': workspace_id})\n", "utf8"), ]); const evidencePath = path.join(fixture, ".kernel-zero", "evidence.json"); @@ -123,7 +250,43 @@ try { throw new Error("Installed validator did not produce the expected passing one-file evidence."); } - console.log(`validator package: pass (${artifact.filename}, ${String(artifact.entryCount)} files, installed CLI exit 0)`); + const manifestEvidencePath = path.join(fixture, ".kernel-zero", "manifest-evidence.json"); + runInstalledBinary(consumer, "kernel-zero-manifest", [ + "kernel-zero.manifest.policy.json", + ".", + "00000000-0000-7000-8000-000000000000", + manifestEvidencePath, + ], fixture); + const manifestEvidence = JSON.parse(await readFile(manifestEvidencePath, "utf8")); + if (manifestEvidence.kind !== "ManifestEvidence" || manifestEvidence.result?.status !== "pass" || manifestEvidence.result?.filesScanned !== 1) { + throw new Error("Installed manifest validator did not produce expected passing evidence."); + } + + const workflowEvidencePath = path.join(fixture, ".kernel-zero", "workflow-evidence.json"); + runInstalledBinary(consumer, "kernel-zero-workflow", [ + "kernel-zero.workflow.policy.json", + ".", + "00000000-0000-7000-8000-000000000000", + workflowEvidencePath, + ], fixture); + const workflowEvidence = JSON.parse(await readFile(workflowEvidencePath, "utf8")); + if (workflowEvidence.kind !== "WorkflowEvidence" || workflowEvidence.result?.status !== "pass" || workflowEvidence.result?.filesScanned !== 1) { + throw new Error("Installed workflow validator did not produce expected passing evidence."); + } + + const pythonEvidencePath = path.join(fixture, ".kernel-zero", "python-evidence.json"); + runInstalledBinary(consumer, "kernel-zero-python", [ + "kernel-zero.python.policy.json", + ".", + "00000000-0000-7000-8000-000000000000", + pythonEvidencePath, + ], fixture); + const pythonEvidence = JSON.parse(await readFile(pythonEvidencePath, "utf8")); + if (pythonEvidence.kind !== "PythonEvidence" || pythonEvidence.result?.status !== "pass" || pythonEvidence.result?.filesScanned !== 1) { + throw new Error("Installed Python validator did not produce expected passing evidence."); + } + + console.log(`validator package: pass (${artifact.filename}, ${String(artifact.entryCount)} files, 4 installed CLIs exit 0)`); } finally { await rm(work, { force: true, recursive: true }); } @@ -145,6 +308,24 @@ function runNpm(args, cwd, capture) { return result; } +function runInstalledBinary(consumer, name, args, cwd) { + const executable = path.join( + consumer, + "node_modules", + ".bin", + process.platform === "win32" ? `${name}.cmd` : name, + ); + const result = spawnSync(executable, args, { + cwd, + encoding: "utf8", + env: process.env, + shell: process.platform === "win32", + }); + if (result.status !== 0) { + throw new Error(`Installed ${name} exited ${String(result.status)}.\n${result.stdout}${result.stderr}`); + } +} + function parsePackJson(stdout) { const trimmed = stdout.trim(); const start = trimmed.lastIndexOf("\n["); @@ -165,11 +346,14 @@ async function listFiles(directory, prefix = "") { function assertManifest(manifest) { const failures = []; if (manifest.name !== "@kernel-zero/validator") failures.push("name"); - if (manifest.version !== "0.1.0") failures.push("version"); + if (manifest.version !== "0.2.0") failures.push("version"); if (manifest.private !== undefined) failures.push("private"); if (manifest.exports !== undefined) failures.push("exports"); if (manifest.optionalDependencies !== undefined) failures.push("optionalDependencies"); - if (manifest.bin?.["kernel-zero"] !== "dist/kernel-zero.js") failures.push("bin"); + if (manifest.bin?.["kernel-zero"] !== "dist/kernel-zero.js" + || manifest.bin?.["kernel-zero-manifest"] !== "dist/kernel-zero-manifest.js" + || manifest.bin?.["kernel-zero-python"] !== "dist/kernel-zero-python.js" + || manifest.bin?.["kernel-zero-workflow"] !== "dist/kernel-zero-workflow.js") failures.push("bin"); if (manifest.license !== "MIT") failures.push("license"); if (manifest.engines?.node !== ">=22 <23") failures.push("engines.node"); if (manifest.publishConfig?.access !== "public") failures.push("publishConfig.access"); diff --git a/scripts/generate-contracts.ts b/scripts/generate-contracts.ts index a69639a..337003f 100644 --- a/scripts/generate-contracts.ts +++ b/scripts/generate-contracts.ts @@ -16,6 +16,7 @@ import { softwareArchitectureProfile, } from "@kernel-zero/profile-software-architecture"; import { manifestMessages, manifestProfile } from "@kernel-zero/profile-manifest"; +import { pythonMessages, pythonProfile } from "@kernel-zero/profile-python"; import { workflowMessages, workflowProfile } from "@kernel-zero/profile-workflow"; import { PROFILES } from "@kernel-zero/profiles"; @@ -101,6 +102,39 @@ const manifestEvidenceBase = { }; const manifestEvidenceExample = { ...manifestEvidenceBase, integrity: { algorithm: "sha256" as const, digest: canonicalEvidenceDigest(manifestEvidenceBase) } }; +const pythonPolicyExample = { + apiVersion: "kernel-zero.dev/v1", + kind: "PythonPolicy", + metadata: { description: "Python architecture boundaries", name: "python-boundaries", revision: 1 }, + scope: { exclude: ["**/tests/**"], include: ["src/**/*.py"] }, + rules: [{ + check: { deny: ["subprocess"], from: ["src/api/**/*.py"], kind: "forbid-import-edge" }, + id: "api-no-processes", level: "error", remediation: "Call the isolated worker boundary.", title: "API cannot launch processes", + }], +}; +const pythonLocation = { endColumn: 18, endLine: 4, startColumn: 1, startLine: 4 }; +const pythonFindingIdentity = findingIdentity({ + location: pythonLocation, messageCode: "PYTHON_IMPORT_DENIED", path: "src/api/handler.py", + policyDigest: digest("1"), ruleId: "api-no-processes", subject: "module:subprocess", +}); +const pythonFinding = { + ...pythonFindingIdentity, exceptionId: null, level: "error" as const, location: pythonLocation, + message: pythonMessages.PYTHON_IMPORT_DENIED, messageCode: "PYTHON_IMPORT_DENIED" as const, + path: "src/api/handler.py", ruleId: "api-no-processes", subject: "module:subprocess", +}; +const pythonEvidenceBase = { + apiVersion: "kernel-zero.dev/evidence/v1" as const, exceptionBundleDigest: null, + findings: [pythonFinding], generatedAt: "2026-01-15T12:00:00.000Z", + kind: "PythonEvidence" as const, + policy: { digest: digest("1"), name: "python-boundaries", revision: 1 }, + result: { ...deriveEvidenceSummary([pythonFinding], 3), durationMs: 0 }, + runId: "0195f000-0000-7000-8000-000000000001", signature: null, + subject: { manifestDigest: digest("1"), repository: "example/python-service", revision: "git:0123456789abcdef0123456789abcdef01234567" }, + tool: { name: pythonProfile.toolName, version: "0.1.0+cpython.3.12.10" }, + workspace: "0195f000-0000-7000-8000-000000000002", +}; +const pythonEvidenceExample = { ...pythonEvidenceBase, integrity: { algorithm: "sha256" as const, digest: canonicalEvidenceDigest(pythonEvidenceBase) } }; + const workflowPolicyExample = { apiVersion: "kernel-zero.dev/v1", kind: "WorkflowPolicy", @@ -144,6 +178,8 @@ softwareArchitectureProfile.policySchema.parse(policyExample); softwareArchitectureProfile.evidenceSchema.parse(evidenceExample); manifestProfile.policySchema.parse(manifestPolicyExample); manifestProfile.evidenceSchema.parse(manifestEvidenceExample); +pythonProfile.policySchema.parse(pythonPolicyExample); +pythonProfile.evidenceSchema.parse(pythonEvidenceExample); workflowProfile.policySchema.parse(workflowPolicyExample); workflowProfile.evidenceSchema.parse(workflowEvidenceExample); @@ -158,12 +194,16 @@ const outputs = new Map([ ["docs/contracts/examples/repository-evidence-v1.json", `${canonicalJson(evidenceExample)}\n`], ["docs/contracts/examples/manifest-policy-v1.json", `${canonicalJson(manifestPolicyExample)}\n`], ["docs/contracts/examples/manifest-evidence-v1.json", `${canonicalJson(manifestEvidenceExample)}\n`], + ["docs/contracts/examples/python-policy-v1.json", `${canonicalJson(pythonPolicyExample)}\n`], + ["docs/contracts/examples/python-evidence-v1.json", `${canonicalJson(pythonEvidenceExample)}\n`], + ["docs/contracts/malformed/python-policy-unknown-field.json", `${JSON.stringify({ ...pythonPolicyExample, interpreterCommand: "curl example.invalid" }, null, 2)}\n`], ["docs/contracts/examples/workflow-policy-v1.json", `${canonicalJson(workflowPolicyExample)}\n`], ["docs/contracts/examples/workflow-evidence-v1.json", `${canonicalJson(workflowEvidenceExample)}\n`], ["docs/contracts/malformed/repository-policy-unknown-field.json", `${JSON.stringify({ ...policyExample, command: "npm test" }, null, 2)}\n`], ["docs/contracts/malformed/repository-policy-path-escape.json", `${JSON.stringify({ ...policyExample, scope: { ...policyExample.scope, include: ["../private.ts"] } }, null, 2)}\n`], ["docs/contracts/malformed/exception-grant-set-private-data.json", `${JSON.stringify({ ...exceptionExample, rationale: "must never be exported" }, null, 2)}\n`], ["docs/contracts/malformed/repository-evidence-source-content.json", `${JSON.stringify({ ...evidenceExample, source: "private source text" }, null, 2)}\n`], + ["docs/contracts/python-profile-v1.md", `# PythonPolicy v1 / PythonEvidence v1\n\nMedia types: \`${REPOSITORY_POLICY_MEDIA_TYPE}\` (policy), \`${EVIDENCE_MEDIA_TYPE}\` (evidence).\n\nThe closed rule kinds are \`forbid-import-edge\`, \`require-import\`, \`restrict-call-site\`, and \`require-context-parameter\`. The closed message codes are \`PYTHON_IMPORT_DENIED\`, \`PYTHON_IMPORT_REQUIRED\`, \`PYTHON_CALL_RESTRICTED\`, \`PYTHON_CONTEXT_PARAMETER_REQUIRED\`, and \`PARSE_FAILURE\`. Source is parsed locally by CPython 3.11 through 3.14 using the standard-library AST; dynamic runtime behavior is not claimed.\n`], ["docs/contracts/README.md", `# Public contracts\n\nGenerated by \`npm run contracts:generate\`. Do not hand-edit generated files.\n\n## RepositoryPolicy v1\n\nMedia type: \`${REPOSITORY_POLICY_MEDIA_TYPE}\`\n\nA strict, non-executable repository policy. Unknown fields, unknown check kinds, path escapes, duplicate IDs, arbitrary regular-expression fields, and unsupported languages are rejected. Canonical policy bytes use RFC 8785 before SHA-256.\n\nThe closed check kinds are \`forbid-import-edge\`, \`require-import\`, \`restrict-call-site\`, \`require-export-keys\`, \`require-tenant-parameter\`, \`require-boundary-parse\`, \`require-governed-operation\`, \`require-context-parameter\`, \`require-closed-registry\`, \`restrict-property-write\`, \`require-call-argument\`, \`restrict-state-transition\`, and \`require-ingress-parse\`. \`layers\` is an optional map of at most 50 slug names (2 to 40 characters) to glob lists. Rule fields that name files (\`from\`, \`files\`, \`allowFrom\`, \`declarationFiles\`) may carry \`layer:\` entries beside globs; the profile expands every reference to the layer's globs in declaration order, dropping duplicates and keeping the first occurrence, before the engine evaluates the policy. \`scope.include\` and \`scope.exclude\` accept globs only, and a layer value is a glob list, never another reference. A reference whose name is not a slug, a reference inside \`scope\`, or a reference to an undeclared layer is a schema error (validator exit 2). The policy digest is computed over the parsed document with its references intact, so a policy without \`layers\` keeps its digest byte for byte, and finding subjects and fingerprints never contain layer names. diff --git a/scripts/generate-traceability.ts b/scripts/generate-traceability.ts index 5d1a461..36e2a62 100644 --- a/scripts/generate-traceability.ts +++ b/scripts/generate-traceability.ts @@ -22,6 +22,7 @@ const groups: readonly RequirementGroup[] = [ { count: 5, evidence: "state-transition fixture matrix, compatibility two-subject grammar tests, self-policy rule policy-revision-state-is-governed with rogue-writer and unlisted-pair bite proofs", prefix: "FR-STA", qualification: "local unit/static proof; literal predicates only" }, { count: 6, evidence: "ingress fixture matrix incl. closure, monotone widening, factory-bound export, route test via mocked runtime, five self-policy verb rules with request.json escape and factory-bound proof bites", prefix: "FR-ING", qualification: "local unit/static proof; intra-procedural, allowed-call results trusted by declaration" }, { count: 5, evidence: "self-policy uses layers and all three kinds, contracts regenerated and checked, README and validator docs examples, this traceability, benchmark rules per kind under 30 s and 1 GiB", prefix: "FR-DOG", qualification: "local executable proof; benchmark corpus has no calls, so numbers are a floor for the new evaluators" }, + { count: 12, evidence: "Python policy, fact-contract, pure checker, CPython adapter, compatibility, runner, self-policy, generated-contract, and clean installed-package tests", prefix: "FR-PY", qualification: "local Windows CPython 3.12 and npm-tarball proof; Linux and CPython 3.11/3.13/3.14 qualification pending" }, ]; const rows = groups.flatMap((group) => Array.from({ length: group.count }, (_, index) => { diff --git a/scripts/rename-validator-binaries.mjs b/scripts/rename-validator-binaries.mjs new file mode 100644 index 0000000..543b5be --- /dev/null +++ b/scripts/rename-validator-binaries.mjs @@ -0,0 +1,27 @@ +import { chmod, copyFile, cp, rename, rm } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "packages", "validator"); +const dist = path.join(packageRoot, "dist"); +const binaries = [ + ["cli.js", "kernel-zero.js"], + ["run-manifest-validator.js", "kernel-zero-manifest.js"], + ["run-python-validator.js", "kernel-zero-python.js"], + ["run-workflow-validator.js", "kernel-zero-workflow.js"], +]; + +for (const [source, target] of binaries) { + const output = path.join(dist, target); + await rename(path.join(dist, source), output); + await chmod(output, 0o755); +} + +await copyFile( + path.resolve(packageRoot, "..", "profile-python", "python-analyzer.py"), + path.join(packageRoot, "python-analyzer.py"), +); + +const contracts = path.join(packageRoot, "contracts"); +await rm(contracts, { force: true, recursive: true }); +await cp(path.resolve(packageRoot, "..", "..", "docs", "contracts"), contracts, { recursive: true }); diff --git a/scripts/run-manifest-validator.ts b/scripts/run-manifest-validator.ts index 48ecdb9..6d60f95 100644 --- a/scripts/run-manifest-validator.ts +++ b/scripts/run-manifest-validator.ts @@ -1,3 +1,5 @@ +#!/usr/bin/env node + import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; diff --git a/scripts/run-python-validator.ts b/scripts/run-python-validator.ts new file mode 100644 index 0000000..bb07078 --- /dev/null +++ b/scripts/run-python-validator.ts @@ -0,0 +1,17 @@ +#!/usr/bin/env node + +import { runPythonValidation } from "@kernel-zero/profile-python/runner"; + +const [policy, root, workspace, out, ...extra] = process.argv.slice(2); +if (policy === undefined || root === undefined || workspace === undefined || out === undefined || extra.length > 0) { + process.stderr.write("usage: kernel-zero-python \n"); + process.exit(2); +} + +try { + process.exit(runPythonValidation({ out, policy, root, workspace })); +} catch (error) { + const message = error instanceof Error ? error.message : "failed"; + process.stderr.write(`python-validator: ${message.replace(/\s+/gu, " ").trim().slice(0, 500)}\n`); + process.exit(2); +} diff --git a/scripts/run-workflow-validator.ts b/scripts/run-workflow-validator.ts index 26f2bab..db476a0 100644 --- a/scripts/run-workflow-validator.ts +++ b/scripts/run-workflow-validator.ts @@ -1,3 +1,5 @@ +#!/usr/bin/env node + import { mkdirSync, readFileSync, readdirSync, writeFileSync, type Dirent } from "node:fs"; import { dirname, join, resolve } from "node:path"; diff --git a/tsconfig.json b/tsconfig.json index cb71d9d..2921050 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,8 @@ "@kernel-zero/domain/*": ["packages/domain/src/*"], "@kernel-zero/persistence": ["packages/persistence/src/index.ts"], "@kernel-zero/profile-manifest": ["packages/profile-manifest/src/index.ts"], + "@kernel-zero/profile-python": ["packages/profile-python/src/index.ts"], + "@kernel-zero/profile-python/*": ["packages/profile-python/src/*"], "@kernel-zero/profile-software-architecture": ["packages/profile-software-architecture/src/index.ts"], "@kernel-zero/profile-workflow": ["packages/profile-workflow/src/index.ts"], "@kernel-zero/profiles": ["packages/profiles/src/index.ts"], From b0bf2f06c6d50abf5658484d727a6891ca8daea8 Mon Sep 17 00:00:00 2001 From: AP3X Date: Wed, 9 Sep 2026 14:14:28 -0700 Subject: [PATCH 2/3] Release 0.3.0 of the validator package: Python profile, four binaries, layers, and the new check kinds --- docs/TRACEABILITY.md | 100 ------------------------ package-lock.json | 2 +- packages/validator/README.md | 2 +- packages/validator/package.json | 2 +- packages/validator/src/commands.test.ts | 2 +- packages/validator/src/commands.ts | 2 +- scripts/check-validator-package.mjs | 11 +-- 7 files changed, 6 insertions(+), 115 deletions(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 1e3dc72..4968ffb 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -4,7 +4,6 @@ Generated by `npm run traceability:generate`. Each functional requirement has an | Requirement | Executable or deterministic evidence | Qualification boundary | | --- | --- | --- | -<<<<<<< HEAD | FR-POL-001 | policy contract, persistence, service, governed-action, diff, UI read-model, self-policy, and gate 3/4 PostgreSQL approval-race and single-active-revision tests | local unit/static and isolated PostgreSQL proof; maker-checker separation withdrawn with identity | | FR-POL-002 | policy contract, persistence, service, governed-action, diff, UI read-model, self-policy, and gate 3/4 PostgreSQL approval-race and single-active-revision tests | local unit/static and isolated PostgreSQL proof; maker-checker separation withdrawn with identity | | FR-POL-003 | policy contract, persistence, service, governed-action, diff, UI read-model, self-policy, and gate 3/4 PostgreSQL approval-race and single-active-revision tests | local unit/static and isolated PostgreSQL proof; maker-checker separation withdrawn with identity | @@ -78,47 +77,6 @@ Generated by `npm run traceability:generate`. Each functional requirement has an | FR-DOG-003 | self-policy uses layers and all three kinds, contracts regenerated and checked, README and validator docs examples, this traceability, benchmark rules per kind under 30 s and 1 GiB | local executable proof; benchmark corpus has no calls, so numbers are a floor for the new evaluators | | FR-DOG-004 | self-policy uses layers and all three kinds, contracts regenerated and checked, README and validator docs examples, this traceability, benchmark rules per kind under 30 s and 1 GiB | local executable proof; benchmark corpus has no calls, so numbers are a floor for the new evaluators | | FR-DOG-005 | self-policy uses layers and all three kinds, contracts regenerated and checked, README and validator docs examples, this traceability, benchmark rules per kind under 30 s and 1 GiB | local executable proof; benchmark corpus has no calls, so numbers are a floor for the new evaluators | -======= -| FR-AUTH-001 | config, auth, callback, identity-error, outbox, and redaction tests | local unit/configuration proof; live OAuth/email and database session proof pending | -| FR-AUTH-002 | config, auth, callback, identity-error, outbox, and redaction tests | local unit/configuration proof; live OAuth/email and database session proof pending | -| FR-AUTH-003 | config, auth, callback, identity-error, outbox, and redaction tests | local unit/configuration proof; live OAuth/email and database session proof pending | -| FR-AUTH-004 | config, auth, callback, identity-error, outbox, and redaction tests | local unit/configuration proof; live OAuth/email and database session proof pending | -| FR-AUTH-005 | config, auth, callback, identity-error, outbox, and redaction tests | local unit/configuration proof; live OAuth/email and database session proof pending | -| FR-AUTH-006 | config, auth, callback, identity-error, outbox, and redaction tests | local unit/configuration proof; live OAuth/email and database session proof pending | -| FR-TEN-001 | tenancy persistence tests, migration constraints, authorization tests, and architecture self-policy | local unit/schema proof; isolated PostgreSQL concurrency proof pending | -| FR-TEN-002 | tenancy persistence tests, migration constraints, authorization tests, and architecture self-policy | local unit/schema proof; isolated PostgreSQL concurrency proof pending | -| FR-TEN-003 | tenancy persistence tests, migration constraints, authorization tests, and architecture self-policy | local unit/schema proof; isolated PostgreSQL concurrency proof pending | -| FR-TEN-004 | tenancy persistence tests, migration constraints, authorization tests, and architecture self-policy | local unit/schema proof; isolated PostgreSQL concurrency proof pending | -| FR-TEN-005 | tenancy persistence tests, migration constraints, authorization tests, and architecture self-policy | local unit/schema proof; isolated PostgreSQL concurrency proof pending | -| FR-TEN-006 | tenancy persistence tests, migration constraints, authorization tests, and architecture self-policy | local unit/schema proof; isolated PostgreSQL concurrency proof pending | -| FR-TEAM-001 | invitation persistence, roster view, authorization, quota, and team service tests | local unit/schema proof; live delivery and database concurrency proof pending | -| FR-TEAM-002 | invitation persistence, roster view, authorization, quota, and team service tests | local unit/schema proof; live delivery and database concurrency proof pending | -| FR-TEAM-003 | invitation persistence, roster view, authorization, quota, and team service tests | local unit/schema proof; live delivery and database concurrency proof pending | -| FR-TEAM-004 | invitation persistence, roster view, authorization, quota, and team service tests | local unit/schema proof; live delivery and database concurrency proof pending | -| FR-TEAM-005 | invitation persistence, roster view, authorization, quota, and team service tests | local unit/schema proof; live delivery and database concurrency proof pending | -| FR-TEAM-006 | invitation persistence, roster view, authorization, quota, and team service tests | local unit/schema proof; live delivery and database concurrency proof pending | -| FR-TEAM-007 | invitation persistence, roster view, authorization, quota, and team service tests | local unit/schema proof; live delivery and database concurrency proof pending | -| FR-TEAM-008 | invitation persistence, roster view, authorization, quota, and team service tests | local unit/schema proof; live delivery and database concurrency proof pending | -| FR-TEAM-009 | invitation persistence, roster view, authorization, quota, and team service tests | local unit/schema proof; live delivery and database concurrency proof pending | -| FR-POL-001 | policy contract, persistence, service, governed-action, diff, UI read-model, self-policy, and gate 4/5 PostgreSQL maker-checker and custody-approval tests | local unit/static and isolated PostgreSQL proof | -| FR-POL-002 | policy contract, persistence, service, governed-action, diff, UI read-model, self-policy, and gate 4/5 PostgreSQL maker-checker and custody-approval tests | local unit/static and isolated PostgreSQL proof | -| FR-POL-003 | policy contract, persistence, service, governed-action, diff, UI read-model, self-policy, and gate 4/5 PostgreSQL maker-checker and custody-approval tests | local unit/static and isolated PostgreSQL proof | -| FR-POL-004 | policy contract, persistence, service, governed-action, diff, UI read-model, self-policy, and gate 4/5 PostgreSQL maker-checker and custody-approval tests | local unit/static and isolated PostgreSQL proof | -| FR-POL-005 | policy contract, persistence, service, governed-action, diff, UI read-model, self-policy, and gate 4/5 PostgreSQL maker-checker and custody-approval tests | local unit/static and isolated PostgreSQL proof | -| FR-POL-006 | policy contract, persistence, service, governed-action, diff, UI read-model, self-policy, and gate 4/5 PostgreSQL maker-checker and custody-approval tests | local unit/static and isolated PostgreSQL proof | -| FR-POL-007 | policy contract, persistence, service, governed-action, diff, UI read-model, self-policy, and gate 4/5 PostgreSQL maker-checker and custody-approval tests | local unit/static and isolated PostgreSQL proof | -| FR-POL-008 | policy contract, persistence, service, governed-action, diff, UI read-model, self-policy, and gate 4/5 PostgreSQL maker-checker and custody-approval tests | local unit/static and isolated PostgreSQL proof | -| FR-VAL-001 | validator CLI, discovery, engine, runner, golden baseline, semantic check-kind matrices, custody-flow, public artifact, self-policy, and benchmark checks | local executable proof | -| FR-VAL-002 | validator CLI, discovery, engine, runner, golden baseline, semantic check-kind matrices, custody-flow, public artifact, self-policy, and benchmark checks | local executable proof | -| FR-VAL-003 | validator CLI, discovery, engine, runner, golden baseline, semantic check-kind matrices, custody-flow, public artifact, self-policy, and benchmark checks | local executable proof | -| FR-VAL-004 | validator CLI, discovery, engine, runner, golden baseline, semantic check-kind matrices, custody-flow, public artifact, self-policy, and benchmark checks | local executable proof | -| FR-VAL-005 | validator CLI, discovery, engine, runner, golden baseline, semantic check-kind matrices, custody-flow, public artifact, self-policy, and benchmark checks | local executable proof | -| FR-VAL-006 | validator CLI, discovery, engine, runner, golden baseline, semantic check-kind matrices, custody-flow, public artifact, self-policy, and benchmark checks | local executable proof | -| FR-VAL-007 | validator CLI, discovery, engine, runner, golden baseline, semantic check-kind matrices, custody-flow, public artifact, self-policy, and benchmark checks | local executable proof | -| FR-VAL-008 | validator CLI, discovery, engine, runner, golden baseline, semantic check-kind matrices, custody-flow, public artifact, self-policy, and benchmark checks | local executable proof | -| FR-VAL-009 | validator CLI, discovery, engine, runner, golden baseline, semantic check-kind matrices, custody-flow, public artifact, self-policy, and benchmark checks | local executable proof | -| FR-VAL-010 | validator CLI, discovery, engine, runner, golden baseline, semantic check-kind matrices, custody-flow, public artifact, self-policy, and benchmark checks | local executable proof | -| FR-VAL-011 | validator CLI, discovery, engine, runner, golden baseline, semantic check-kind matrices, custody-flow, public artifact, self-policy, and benchmark checks | local executable proof | | FR-PY-001 | Python policy, fact-contract, pure checker, CPython adapter, compatibility, runner, self-policy, generated-contract, and clean installed-package tests | local Windows CPython 3.12 and npm-tarball proof; Linux and CPython 3.11/3.13/3.14 qualification pending | | FR-PY-002 | Python policy, fact-contract, pure checker, CPython adapter, compatibility, runner, self-policy, generated-contract, and clean installed-package tests | local Windows CPython 3.12 and npm-tarball proof; Linux and CPython 3.11/3.13/3.14 qualification pending | | FR-PY-003 | Python policy, fact-contract, pure checker, CPython adapter, compatibility, runner, self-policy, generated-contract, and clean installed-package tests | local Windows CPython 3.12 and npm-tarball proof; Linux and CPython 3.11/3.13/3.14 qualification pending | @@ -131,61 +89,3 @@ Generated by `npm run traceability:generate`. Each functional requirement has an | FR-PY-010 | Python policy, fact-contract, pure checker, CPython adapter, compatibility, runner, self-policy, generated-contract, and clean installed-package tests | local Windows CPython 3.12 and npm-tarball proof; Linux and CPython 3.11/3.13/3.14 qualification pending | | FR-PY-011 | Python policy, fact-contract, pure checker, CPython adapter, compatibility, runner, self-policy, generated-contract, and clean installed-package tests | local Windows CPython 3.12 and npm-tarball proof; Linux and CPython 3.11/3.13/3.14 qualification pending | | FR-PY-012 | Python policy, fact-contract, pure checker, CPython adapter, compatibility, runner, self-policy, generated-contract, and clean installed-package tests | local Windows CPython 3.12 and npm-tarball proof; Linux and CPython 3.11/3.13/3.14 qualification pending | -| FR-CUS-001 | custody contract and property tests, custody-flow CLI exit/output-order tests, policy-custody persistence tests, gate 5 PostgreSQL custody tests, custody-signer tests, and the two custody self-policy rules | local unit/cryptographic and isolated PostgreSQL proof with ephemeral keys only; production authority keys, trust import, and live enforcement intentionally unavailable | -| FR-CUS-002 | custody contract and property tests, custody-flow CLI exit/output-order tests, policy-custody persistence tests, gate 5 PostgreSQL custody tests, custody-signer tests, and the two custody self-policy rules | local unit/cryptographic and isolated PostgreSQL proof with ephemeral keys only; production authority keys, trust import, and live enforcement intentionally unavailable | -| FR-CUS-003 | custody contract and property tests, custody-flow CLI exit/output-order tests, policy-custody persistence tests, gate 5 PostgreSQL custody tests, custody-signer tests, and the two custody self-policy rules | local unit/cryptographic and isolated PostgreSQL proof with ephemeral keys only; production authority keys, trust import, and live enforcement intentionally unavailable | -| FR-CUS-004 | custody contract and property tests, custody-flow CLI exit/output-order tests, policy-custody persistence tests, gate 5 PostgreSQL custody tests, custody-signer tests, and the two custody self-policy rules | local unit/cryptographic and isolated PostgreSQL proof with ephemeral keys only; production authority keys, trust import, and live enforcement intentionally unavailable | -| FR-CUS-005 | custody contract and property tests, custody-flow CLI exit/output-order tests, policy-custody persistence tests, gate 5 PostgreSQL custody tests, custody-signer tests, and the two custody self-policy rules | local unit/cryptographic and isolated PostgreSQL proof with ephemeral keys only; production authority keys, trust import, and live enforcement intentionally unavailable | -| FR-CUS-006 | custody contract and property tests, custody-flow CLI exit/output-order tests, policy-custody persistence tests, gate 5 PostgreSQL custody tests, custody-signer tests, and the two custody self-policy rules | local unit/cryptographic and isolated PostgreSQL proof with ephemeral keys only; production authority keys, trust import, and live enforcement intentionally unavailable | -| FR-CUS-007 | custody contract and property tests, custody-flow CLI exit/output-order tests, policy-custody persistence tests, gate 5 PostgreSQL custody tests, custody-signer tests, and the two custody self-policy rules | local unit/cryptographic and isolated PostgreSQL proof with ephemeral keys only; production authority keys, trust import, and live enforcement intentionally unavailable | -| FR-CUS-008 | custody contract and property tests, custody-flow CLI exit/output-order tests, policy-custody persistence tests, gate 5 PostgreSQL custody tests, custody-signer tests, and the two custody self-policy rules | local unit/cryptographic and isolated PostgreSQL proof with ephemeral keys only; production authority keys, trust import, and live enforcement intentionally unavailable | -| FR-EVD-001 | evidence transport, service, compatibility, persistence adapter, persistence, route, and UI read-model tests | local unit/contract proof; live PostgreSQL retention and ingestion proof pending | -| FR-EVD-002 | evidence transport, service, compatibility, persistence adapter, persistence, route, and UI read-model tests | local unit/contract proof; live PostgreSQL retention and ingestion proof pending | -| FR-EVD-003 | evidence transport, service, compatibility, persistence adapter, persistence, route, and UI read-model tests | local unit/contract proof; live PostgreSQL retention and ingestion proof pending | -| FR-EVD-004 | evidence transport, service, compatibility, persistence adapter, persistence, route, and UI read-model tests | local unit/contract proof; live PostgreSQL retention and ingestion proof pending | -| FR-EVD-005 | evidence transport, service, compatibility, persistence adapter, persistence, route, and UI read-model tests | local unit/contract proof; live PostgreSQL retention and ingestion proof pending | -| FR-EVD-006 | evidence transport, service, compatibility, persistence adapter, persistence, route, and UI read-model tests | local unit/contract proof; live PostgreSQL retention and ingestion proof pending | -| FR-EVD-007 | evidence transport, service, compatibility, persistence adapter, persistence, route, and UI read-model tests | local unit/contract proof; live PostgreSQL retention and ingestion proof pending | -| FR-EVD-008 | evidence transport, service, compatibility, persistence adapter, persistence, route, and UI read-model tests | local unit/contract proof; live PostgreSQL retention and ingestion proof pending | -| FR-EXC-001 | exception contract, persistence, signing-key, service, validator-consumption, and reserved-export tests | local unit/cryptographic proof; production export custody intentionally unavailable | -| FR-EXC-002 | exception contract, persistence, signing-key, service, validator-consumption, and reserved-export tests | local unit/cryptographic proof; production export custody intentionally unavailable | -| FR-EXC-003 | exception contract, persistence, signing-key, service, validator-consumption, and reserved-export tests | local unit/cryptographic proof; production export custody intentionally unavailable | -| FR-EXC-004 | exception contract, persistence, signing-key, service, validator-consumption, and reserved-export tests | local unit/cryptographic proof; production export custody intentionally unavailable | -| FR-EXC-005 | exception contract, persistence, signing-key, service, validator-consumption, and reserved-export tests | local unit/cryptographic proof; production export custody intentionally unavailable | -| FR-EXC-006 | exception contract, persistence, signing-key, service, validator-consumption, and reserved-export tests | local unit/cryptographic proof; production export custody intentionally unavailable | -| FR-ENT-001 | entitlement, quota, transaction, governed-action, invitation, policy, and billing preflight tests | local unit/schema proof; isolated PostgreSQL concurrency proof pending | -| FR-ENT-002 | entitlement, quota, transaction, governed-action, invitation, policy, and billing preflight tests | local unit/schema proof; isolated PostgreSQL concurrency proof pending | -| FR-ENT-003 | entitlement, quota, transaction, governed-action, invitation, policy, and billing preflight tests | local unit/schema proof; isolated PostgreSQL concurrency proof pending | -| FR-ENT-004 | entitlement, quota, transaction, governed-action, invitation, policy, and billing preflight tests | local unit/schema proof; isolated PostgreSQL concurrency proof pending | -| FR-ENT-005 | entitlement, quota, transaction, governed-action, invitation, policy, and billing preflight tests | local unit/schema proof; isolated PostgreSQL concurrency proof pending | -| FR-ENT-006 | entitlement, quota, transaction, governed-action, invitation, policy, and billing preflight tests | local unit/schema proof; isolated PostgreSQL concurrency proof pending | -| FR-BILL-001 | billing service/cache, Stripe adapter, signed webhook route, receipt/projection persistence, and operator-view tests | mocked-provider proof; no live Stripe qualification | -| FR-BILL-002 | billing service/cache, Stripe adapter, signed webhook route, receipt/projection persistence, and operator-view tests | mocked-provider proof; no live Stripe qualification | -| FR-BILL-003 | billing service/cache, Stripe adapter, signed webhook route, receipt/projection persistence, and operator-view tests | mocked-provider proof; no live Stripe qualification | -| FR-BILL-004 | billing service/cache, Stripe adapter, signed webhook route, receipt/projection persistence, and operator-view tests | mocked-provider proof; no live Stripe qualification | -| FR-BILL-005 | billing service/cache, Stripe adapter, signed webhook route, receipt/projection persistence, and operator-view tests | mocked-provider proof; no live Stripe qualification | -| FR-BILL-006 | billing service/cache, Stripe adapter, signed webhook route, receipt/projection persistence, and operator-view tests | mocked-provider proof; no live Stripe qualification | -| FR-BILL-007 | billing service/cache, Stripe adapter, signed webhook route, receipt/projection persistence, and operator-view tests | mocked-provider proof; no live Stripe qualification | -| FR-BILL-008 | billing service/cache, Stripe adapter, signed webhook route, receipt/projection persistence, and operator-view tests | mocked-provider proof; no live Stripe qualification | -| FR-BILL-009 | billing service/cache, Stripe adapter, signed webhook route, receipt/projection persistence, and operator-view tests | mocked-provider proof; no live Stripe qualification | -| FR-BILL-010 | billing service/cache, Stripe adapter, signed webhook route, receipt/projection persistence, and operator-view tests | mocked-provider proof; no live Stripe qualification | -| FR-AUD-001 | audit, governed-action rollback, correlation proxy, request logging, redaction, and safe read-model tests | local unit/schema proof; full hosted request tracing pending | -| FR-AUD-002 | audit, governed-action rollback, correlation proxy, request logging, redaction, and safe read-model tests | local unit/schema proof; full hosted request tracing pending | -| FR-AUD-003 | audit, governed-action rollback, correlation proxy, request logging, redaction, and safe read-model tests | local unit/schema proof; full hosted request tracing pending | -| FR-AUD-004 | audit, governed-action rollback, correlation proxy, request logging, redaction, and safe read-model tests | local unit/schema proof; full hosted request tracing pending | -| FR-AUD-005 | audit, governed-action rollback, correlation proxy, request logging, redaction, and safe read-model tests | local unit/schema proof; full hosted request tracing pending | -| FR-AUD-006 | audit, governed-action rollback, correlation proxy, request logging, redaction, and safe read-model tests | local unit/schema proof; full hosted request tracing pending | -| FR-RATE-001 | rate-limit policy/configuration and failure-mode tests | local mocked-adapter proof; no live Redis qualification | -| FR-RATE-002 | rate-limit policy/configuration and failure-mode tests | local mocked-adapter proof; no live Redis qualification | -| FR-RATE-003 | rate-limit policy/configuration and failure-mode tests | local mocked-adapter proof; no live Redis qualification | -| FR-RATE-004 | rate-limit policy/configuration and failure-mode tests | local mocked-adapter proof; no live Redis qualification | -| FR-UI-001 | route/UI contracts, capability-gated read models, production build, and desktop/320px Playwright axe checks | local build/browser proof; authenticated database-backed keyboard journeys pending | -| FR-UI-002 | route/UI contracts, capability-gated read models, production build, and desktop/320px Playwright axe checks | local build/browser proof; authenticated database-backed keyboard journeys pending | -| FR-UI-003 | route/UI contracts, capability-gated read models, production build, and desktop/320px Playwright axe checks | local build/browser proof; authenticated database-backed keyboard journeys pending | -| FR-UI-004 | route/UI contracts, capability-gated read models, production build, and desktop/320px Playwright axe checks | local build/browser proof; authenticated database-backed keyboard journeys pending | -| FR-UI-005 | route/UI contracts, capability-gated read models, production build, and desktop/320px Playwright axe checks | local build/browser proof; authenticated database-backed keyboard journeys pending | -| FR-UI-006 | route/UI contracts, capability-gated read models, production build, and desktop/320px Playwright axe checks | local build/browser proof; authenticated database-backed keyboard journeys pending | -| FR-UI-007 | route/UI contracts, capability-gated read models, production build, and desktop/320px Playwright axe checks | local build/browser proof; authenticated database-backed keyboard journeys pending | -| FR-UI-008 | route/UI contracts, capability-gated read models, production build, and desktop/320px Playwright axe checks | local build/browser proof; authenticated database-backed keyboard journeys pending | -| FR-UI-009 | route/UI contracts, capability-gated read models, production build, and desktop/320px Playwright axe checks | local build/browser proof; authenticated database-backed keyboard journeys pending | ->>>>>>> 664d5b4 (feat: ship npm-ready Python guardrails) diff --git a/package-lock.json b/package-lock.json index cb34773..10d38b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5002,7 +5002,7 @@ }, "packages/validator": { "name": "@kernel-zero/validator", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "typescript": "5.9.3" diff --git a/packages/validator/README.md b/packages/validator/README.md index 0031ee5..c47d150 100644 --- a/packages/validator/README.md +++ b/packages/validator/README.md @@ -2,7 +2,7 @@ The KERNEL ZERO validator is a deterministic, network-free command-line checker for versioned repository, package-manifest, Python, and workflow policies. -Version `0.2.0` provides four CLI executables and intentionally has no JavaScript +Version `0.3.0` provides four CLI executables, named policy layers, and the `require-call-argument`, `restrict-state-transition`, and `require-ingress-parse` check kinds, and intentionally has no JavaScript library API. Everything needed at runtime is bundled; consumers do not need the KERNEL ZERO repository or its workspace packages. diff --git a/packages/validator/package.json b/packages/validator/package.json index d6c525c..c146f23 100644 --- a/packages/validator/package.json +++ b/packages/validator/package.json @@ -1,6 +1,6 @@ { "name": "@kernel-zero/validator", - "version": "0.2.0", + "version": "0.3.0", "description": "Network-free deterministic architecture policy validator for KERNEL ZERO.", "license": "MIT", "type": "module", diff --git a/packages/validator/src/commands.test.ts b/packages/validator/src/commands.test.ts index 5c3bf37..f113abd 100644 --- a/packages/validator/src/commands.test.ts +++ b/packages/validator/src/commands.test.ts @@ -77,7 +77,7 @@ describe("init", () => { const output = await init({ command: "init", root, workspace: WORKSPACE }); expect(output).toContain("kernel-zero init wrote:"); expect(output).toContain(`--workspace ${WORKSPACE}`); - expect(output).toContain("@kernel-zero/validator@0.2.0"); + expect(output).toContain("@kernel-zero/validator@0.3.0"); expect(output).toContain("## KERNEL ZERO enforcement"); const written = await Promise.all(scaffoldTargets(root).map(async (target) => readFile(path.join(root, ...target.relative.split("/")), "utf8"))); expect(written).toEqual(scaffoldTargets(root).map((target) => target.content)); diff --git a/packages/validator/src/commands.ts b/packages/validator/src/commands.ts index 9b6ede6..095fb87 100644 --- a/packages/validator/src/commands.ts +++ b/packages/validator/src/commands.ts @@ -10,7 +10,7 @@ import { renderEvidence, renderPolicy } from "./render"; export type ExplainCommand = Readonly<{ command: "explain"; artifact: string; kind: "policy" | "evidence" }>; export type InitCommand = Readonly<{ command: "init"; root: string; workspace: string }>; -export const VALIDATOR_VERSION = "0.2.0"; +export const VALIDATOR_VERSION = "0.3.0"; const PLACEHOLDER_WORKSPACE = "00000000-0000-7000-8000-000000000000"; export class CommandError extends Error { diff --git a/scripts/check-validator-package.mjs b/scripts/check-validator-package.mjs index 9de3b6c..a976f51 100644 --- a/scripts/check-validator-package.mjs +++ b/scripts/check-validator-package.mjs @@ -15,28 +15,20 @@ const expectedContractFiles = [ "examples/exception-grant-set-v1.json", "examples/manifest-evidence-v1.json", "examples/manifest-policy-v1.json", - "examples/policy-approval-v1.json", - "examples/policy-custody-evidence-v1.json", "examples/python-evidence-v1.json", "examples/python-policy-v1.json", "examples/repository-evidence-v1.json", "examples/repository-policy-v1.json", "examples/workflow-evidence-v1.json", "examples/workflow-policy-v1.json", - "examples/workspace-trust-bundle-v1.json", "exception-grant-set-v1.schema.json", "malformed/exception-grant-set-private-data.json", - "malformed/policy-approval-unknown-field.json", - "malformed/policy-custody-evidence-wall-clock.json", "malformed/python-policy-unknown-field.json", "malformed/repository-evidence-source-content.json", "malformed/repository-policy-path-escape.json", "malformed/repository-policy-unknown-field.json", - "malformed/workspace-trust-bundle-unsorted-keys.json", "manifest-evidence-v1.schema.json", "manifest-policy-v1.schema.json", - "policy-approval-v1.schema.json", - "policy-custody-evidence-v1.schema.json", "python-evidence-v1.schema.json", "python-policy-v1.schema.json", "python-profile-v1.md", @@ -44,7 +36,6 @@ const expectedContractFiles = [ "repository-policy-v1.schema.json", "workflow-evidence-v1.schema.json", "workflow-policy-v1.schema.json", - "workspace-trust-bundle-v1.schema.json", ]; const expectedPackageFiles = [ @@ -346,7 +337,7 @@ async function listFiles(directory, prefix = "") { function assertManifest(manifest) { const failures = []; if (manifest.name !== "@kernel-zero/validator") failures.push("name"); - if (manifest.version !== "0.2.0") failures.push("version"); + if (manifest.version !== "0.3.0") failures.push("version"); if (manifest.private !== undefined) failures.push("private"); if (manifest.exports !== undefined) failures.push("exports"); if (manifest.optionalDependencies !== undefined) failures.push("optionalDependencies"); From 8dcdf75d8adef1c90f2d52d3db3e49b844b30e39 Mon Sep 17 00:00:00 2001 From: AP3X Date: Wed, 9 Sep 2026 14:21:26 -0700 Subject: [PATCH 3/3] Run the Python profile fixture in CI so the workflow matches the local verify gate --- .github/workflows/kernel-zero.yml | 2 ++ docs/plans/2026-09-09-validator-rule-language-run-state.md | 1 + 2 files changed, 3 insertions(+) diff --git a/.github/workflows/kernel-zero.yml b/.github/workflows/kernel-zero.yml index cd7e5af..87f05bc 100644 --- a/.github/workflows/kernel-zero.yml +++ b/.github/workflows/kernel-zero.yml @@ -35,6 +35,8 @@ jobs: run: npm run validator:self - name: Validate workflow hygiene run: npm run validator:workflow + - name: Validate the Python profile fixture + run: npm run validator:python - name: Preserve deterministic evidence if: always() uses: actions/upload-artifact@v4 diff --git a/docs/plans/2026-09-09-validator-rule-language-run-state.md b/docs/plans/2026-09-09-validator-rule-language-run-state.md index 2f53d29..d72bf81 100644 --- a/docs/plans/2026-09-09-validator-rule-language-run-state.md +++ b/docs/plans/2026-09-09-validator-rule-language-run-state.md @@ -30,4 +30,5 @@ Full log: docs/plans/2026-09-09-validator-rule-language-advisor-log.md |---|---|---| | F1 | step 2 check | `manifestDigest` hashes raw working-tree bytes; 26 in-scope files are CRLF in this checkout (`core.autocrlf=true`), so the self-policy integrity digest is deterministic here but differs on a fresh LF clone. Pre-existing. Options: normalize EOL in the manifest digest input (contract change, needs kz-grill) or `git add --renormalize` once. Owner decision. | | F3 | step 3 check | Benchmark headroom: on this machine under unrelated 97 percent load a first run reached 27 s against the 30 s limit; the evaluator is at parity with step 2 on an idle sample. Environment risk for CI runners under contention; a quiet-machine sample belongs in the PR body. | +| F4 | 0.3.0 release check | `kernel-does-not-import-profiles` denies `@kernel-zero/profiles`, `profile-software-architecture`, and `profile-manifest` but not `profile-workflow` or `profile-python`; check-architecture still guards the kernel. Adding the two names moves the self-policy digest, so it is its own small ADR. | | F2 | step 2 check | A bare-identifier alias to a matching callee (`const q = tx.policyRevision.findFirst; q({...})`) resolves to chain `q` and passes `require-call-argument` silently; inherited from `resolveCalleeName`, shared with `restrict-call-site`, pinned by a test. Follow-up: alias-following for bare identifiers, or at least `UNRESOLVED` when the const initializer is a matching chain. Changing it moves golden fixtures, so it is its own ADR. |