diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 3fe05e49..1535760c 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -9,7 +9,7 @@ "source": { "source": "url", "url": "https://github.com/avksp/agent-lifecycle-kit.git", - "ref": "v1.50.1" + "ref": "v1.50.2" }, "policy": { "installation": "AVAILABLE", diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ca0938a8..6c3973d7 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,10 +9,10 @@ "source": { "source": "github", "repo": "avksp/agent-lifecycle-kit", - "ref": "v1.50.1" + "ref": "v1.50.2" }, "description": "Reviewed SDD planning, budgeted execution, adapter conformance, implementation audit, and final proof.", - "version": "1.50.1", + "version": "1.50.2", "author": { "name": "Agent Lifecycle Kit contributors" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 023d47b6..689d6537 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-lifecycle-kit", - "version": "1.50.1", + "version": "1.50.2", "description": "Provider-neutral lifecycle kit for reviewed SDD planning, budgeted execution, adapter conformance, independent audits, and final proof.", "author": { "name": "Agent Lifecycle Kit contributors" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 22a34094..124c3535 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-lifecycle-kit", - "version": "1.50.1", + "version": "1.50.2", "description": "Provider-neutral lifecycle kit for reviewed SDD planning, budgeted execution, adapter conformance, independent audits, and final proof.", "author": { "name": "Agent Lifecycle Kit contributors", diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 84b0f105..b7ae8f2d 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -5,14 +5,14 @@ }, "metadata": { "description": "Provider-neutral lifecycle kit for reviewed SDD planning, budgeted execution, adapter conformance, independent audits, and final proof.", - "version": "1.50.1" + "version": "1.50.2" }, "plugins": [ { "name": "agent-lifecycle-kit", "source": ".", "description": "Reviewed SDD planning, budgeted execution, adapter conformance, implementation audit, and final proof.", - "version": "1.50.1", + "version": "1.50.2", "author": { "name": "Agent Lifecycle Kit contributors" }, diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 10bc0d08..068ea3a6 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-lifecycle-kit", - "version": "1.50.1", + "version": "1.50.2", "description": "Provider-neutral lifecycle kit for reviewed SDD planning, budgeted execution, adapter conformance, independent audits, and final proof.", "author": { "name": "Agent Lifecycle Kit contributors" diff --git a/CHANGELOG.md b/CHANGELOG.md index 59d5dcea..64a12bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ - No changes yet. +## 1.50.2 - 2026-08-09 + +- Blocked generic descriptor-driven host launches and wildcard environment + forwarding until a separately qualified local-launch path exists. +- Added shared receipt redaction, fail-closed neutrality completeness checks, + descriptor installation metadata, and dependency-boundary validation. +- Split the CLI dispatcher into bounded routing modules without changing the + public command contract. +- Updated package, plugin, marketplace, and quickstart publication metadata + to `1.50.2`. + ## 1.50.1 - 2026-08-07 - Added a PyPI Trusted Publisher workflow (`.github/workflows/publish.yml`) diff --git a/README.md b/README.md index 6edf71b2..62da7b6a 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ agent-lifecycle adapter validate --descriptor adapters/codex/adapter.descriptor. ``` Package install, when available for the release: -`python -m pip install agent-lifecycle-kit==1.50.1`. +`python -m pip install agent-lifecycle-kit==1.50.2`. For a short walkthrough, use [Quickstart](docs/guides/quickstart.md) and the [Lifecycle cookbook](docs/guides/lifecycle-cookbook.md). For structure and positioning, see [System architecture](docs/architecture/system-architecture.md) and [Project comparison](docs/reference/project-comparison.md). Russian documentation starts at [Документация на русском](docs/ru/README.md). @@ -89,7 +89,7 @@ For a short walkthrough, use [Quickstart](docs/guides/quickstart.md) and the [Li ### Security and containment - Neutrality filters use host-supplied deny rules to keep local paths, secrets, trust roots, and signing keys out of portable artifacts. -- Host env files require explicit `--host-env-allow`; receipts store redacted metadata, not secret values or private env-file paths. +- Host env files require explicit `--host-env-allow`; receipts store redacted metadata, and generic descriptor-driven native launch is blocked before process creation. - Imports, diagnostics, and usage exports redact local paths and common secret markers before validation. - Sandbox receipts separate runtime filesystem, network, process, and environment containment from git write-scope. - Release security gates reject leaked local paths, credentials, and unsupported adapter or production claims. diff --git a/adapters/claude/.claude-plugin/plugin.json b/adapters/claude/.claude-plugin/plugin.json index 20114bbe..4bd4f5e9 100644 --- a/adapters/claude/.claude-plugin/plugin.json +++ b/adapters/claude/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-lifecycle-kit", - "version": "1.50.1", + "version": "1.50.2", "description": "Lifecycle skills and adapter metadata for reviewed planning, budgeted execution, adapter conformance, audit, and final proof.", "author": { "name": "Agent Lifecycle Kit contributors" diff --git a/adapters/claude/adapter.descriptor.json b/adapters/claude/adapter.descriptor.json index 4cf95afd..d1a99758 100644 --- a/adapters/claude/adapter.descriptor.json +++ b/adapters/claude/adapter.descriptor.json @@ -188,5 +188,21 @@ }, "writesNativeConfig": false, "promptInjectionDefault": false + }, + "installation": { + "schemaVersion": "agent-adapter-installation-facts.v1", + "binaryAliases": ["claude", "claude-code"], + "files": [ + {"path": ".claude-plugin/plugin.json", "action": "read", "required": true}, + {"path": ".claude-plugin/marketplace.json", "action": "read", "required": true} + ], + "commands": [ + {"argv": ["claude", "plugin", "marketplace", "add", "avksp/agent-lifecycle-kit"], "purpose": "add the repository marketplace", "mutatesHost": true, "requiresOperator": true}, + {"argv": ["claude", "plugin", "install", "agent-lifecycle-kit@agent-lifecycle-kit"], "purpose": "install the plugin from the configured marketplace", "mutatesHost": true, "requiresOperator": true} + ], + "operatorActions": [ + "Run the host reload command after installation.", + "Keep lifecycle proof separate from plugin installation." + ] } } diff --git a/adapters/claude/capabilities.manifest.json b/adapters/claude/capabilities.manifest.json index 7919b611..05448466 100644 --- a/adapters/claude/capabilities.manifest.json +++ b/adapters/claude/capabilities.manifest.json @@ -129,7 +129,7 @@ } ], "coreSemantics": "delegated-to-agent-lifecycle-core", - "descriptorDigest": "bb8ace8186cb4505b2a98cf707fb7ec4e9f7756bddb2587a84fd9335087f0279", + "descriptorDigest": "779b39814c064e295fdba01ea6a81b5db70f123e361f23a18840379d3c5d85cd", "eventCapture": { "categories": [ "command", @@ -156,6 +156,21 @@ "usageReceiptRequired": true }, "promotion": { + "liveTestedHostRange": { + "evidence": [ + "docs/adapters/evidence/claude-code-0.5.0.md", + "work/release-0-5/evidence/live-host-conformance-claude-code.json", + "work/release-0-5/evidence/live-calibration-verification-claude-code.json", + "work/release-0-5/evidence/0.5.1-claude-live-promotion/full-lifecycle/final/final-proof.json" + ], + "host": "claude-code", + "maximumVersion": "2.1.220", + "minimumVersion": "2.1.220", + "productionPromotionClaimed": false, + "publicDirectoryApprovalClaimed": false, + "sourceRevision": "6bb3b58ee01d028fe21cef209c284efc79e55ceb", + "testedAt": "2026-07-29" + }, "productionPromotionClaimed": false, "verifiedRequiresLiveTestedHostRange": true }, diff --git a/adapters/codex/.codex-plugin/plugin.json b/adapters/codex/.codex-plugin/plugin.json index 6356fc89..86531108 100644 --- a/adapters/codex/.codex-plugin/plugin.json +++ b/adapters/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-lifecycle-kit", - "version": "1.50.1", + "version": "1.50.2", "description": "Provider-neutral lifecycle kit and adapter metadata for reviewed planning, budgeted execution, adapter conformance, audit, and final proof.", "author": { "name": "Agent Lifecycle Kit contributors" diff --git a/adapters/codex/adapter.descriptor.json b/adapters/codex/adapter.descriptor.json index 1940af64..4a62fcb6 100644 --- a/adapters/codex/adapter.descriptor.json +++ b/adapters/codex/adapter.descriptor.json @@ -188,5 +188,21 @@ }, "writesNativeConfig": false, "promptInjectionDefault": false + }, + "installation": { + "schemaVersion": "agent-adapter-installation-facts.v1", + "binaryAliases": ["codex"], + "files": [ + {"path": ".codex-plugin/plugin.json", "action": "read", "required": true}, + {"path": ".agents/plugins/marketplace.json", "action": "read", "required": true} + ], + "commands": [ + {"argv": ["codex", "plugin", "marketplace", "add", "avksp/agent-lifecycle-kit", "--ref", "vX.Y.Z"], "purpose": "add a trusted tagged source marketplace", "mutatesHost": true, "requiresOperator": true}, + {"argv": ["codex", "plugin", "add", "agent-lifecycle-kit@agent-lifecycle-kit"], "purpose": "install the plugin from the configured marketplace", "mutatesHost": true, "requiresOperator": true} + ], + "operatorActions": [ + "Choose the release tag before installation.", + "Restart the host session after installation." + ] } } diff --git a/adapters/codex/capabilities.manifest.json b/adapters/codex/capabilities.manifest.json index 9e7e7664..51902dd9 100644 --- a/adapters/codex/capabilities.manifest.json +++ b/adapters/codex/capabilities.manifest.json @@ -129,7 +129,7 @@ } ], "coreSemantics": "delegated-to-agent-lifecycle-core", - "descriptorDigest": "d00b0c9d573244db8e17d1f2d2c45c9d65ae5bb9d542dd37592233c62c4658fb", + "descriptorDigest": "5d09ad38b5535b6773118b6419a63d5baf34a6d071a4172080de54ca1f46cc20", "eventCapture": { "categories": [ "command", @@ -156,6 +156,21 @@ "usageReceiptRequired": true }, "promotion": { + "liveTestedHostRange": { + "evidence": [ + "docs/adapters/evidence/codex-cli-0.6.0.md", + "work/release-0-6/evidence/codex-live-promotion/live-host-conformance-codex.json", + "work/release-0-6/evidence/codex-live-promotion/live-calibration-verification-codex.json", + "work/release-0-6/evidence/codex-live-promotion/full-lifecycle/final/final-proof.json" + ], + "host": "codex", + "maximumVersion": "0.145.0", + "minimumVersion": "0.145.0", + "productionPromotionClaimed": false, + "publicDirectoryApprovalClaimed": false, + "sourceRevision": "b01a1793e42f52e20077a7aa26b8e4e25c3bd216", + "testedAt": "2026-07-29" + }, "productionPromotionClaimed": false, "verifiedRequiresLiveTestedHostRange": true }, diff --git a/adapters/cursor/.cursor-plugin/plugin.json b/adapters/cursor/.cursor-plugin/plugin.json index 3924b2dd..0ca102d4 100644 --- a/adapters/cursor/.cursor-plugin/plugin.json +++ b/adapters/cursor/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-lifecycle-kit", - "version": "1.50.1", + "version": "1.50.2", "description": "Agent lifecycle planning, budgeted execution, adapter conformance, audit, and final proof for Cursor.", "author": { "name": "Agent Lifecycle Kit contributors" diff --git a/adapters/cursor/adapter.descriptor.json b/adapters/cursor/adapter.descriptor.json index c33f6f0b..ebf4cb9c 100644 --- a/adapters/cursor/adapter.descriptor.json +++ b/adapters/cursor/adapter.descriptor.json @@ -174,5 +174,20 @@ }, "writesNativeConfig": false, "promptInjectionDefault": false + }, + "installation": { + "schemaVersion": "agent-adapter-installation-facts.v1", + "binaryAliases": ["cursor"], + "files": [ + {"path": ".cursor-plugin/plugin.json", "action": "read", "required": true}, + {"path": ".cursor-plugin/marketplace.json", "action": "read", "required": true} + ], + "commands": [ + {"argv": ["ln", "-s", "", "/agent-lifecycle-kit"], "purpose": "link a trusted checkout into the local plugin directory", "mutatesHost": true, "requiresOperator": true} + ], + "operatorActions": [ + "Replace the local plugin directory placeholder before running the command.", + "Reload the host window and do not claim VERIFIED without accepted live receipts." + ] } } diff --git a/adapters/cursor/capabilities.manifest.json b/adapters/cursor/capabilities.manifest.json index 933c3d42..b2a2732b 100644 --- a/adapters/cursor/capabilities.manifest.json +++ b/adapters/cursor/capabilities.manifest.json @@ -129,7 +129,7 @@ } ], "coreSemantics": "delegated-to-agent-lifecycle-core", - "descriptorDigest": "5ff041ad04f654403ff738e3b8b9de91b933c8d95ee1ae4d6be0be363943682a", + "descriptorDigest": "5d7b27811f88afccb58552807aa48573fdff1665f8e0c55408cd2d5a1868f1eb", "eventCapture": { "categories": [ "command", diff --git a/adapters/gemini-cli/adapter.descriptor.json b/adapters/gemini-cli/adapter.descriptor.json index dd556246..3ec3d08a 100644 --- a/adapters/gemini-cli/adapter.descriptor.json +++ b/adapters/gemini-cli/adapter.descriptor.json @@ -169,5 +169,22 @@ }, "writesNativeConfig": false, "promptInjectionDefault": false + }, + "installation": { + "schemaVersion": "agent-adapter-installation-facts.v1", + "binaryAliases": ["gemini", "gemini-cli"], + "files": [ + {"path": "adapters/gemini-cli/adapter.descriptor.json", "action": "read", "required": true}, + {"path": "adapters/gemini-cli/capabilities.manifest.json", "action": "read", "required": true}, + {"path": "adapters/gemini-cli/README.md", "action": "read", "required": true} + ], + "commands": [ + {"argv": ["python", "-m", "pip", "install", "-e", ""], "purpose": "install the core CLI from a trusted checkout", "mutatesHost": true, "requiresOperator": true}, + {"argv": ["gemini", "--version"], "purpose": "confirm the host CLI is available before live proof", "mutatesHost": false, "requiresOperator": true} + ], + "operatorActions": [ + "Configure host-local model and profile settings outside the portable core.", + "Keep the adapter EXPERIMENTAL until receipts are accepted." + ] } } diff --git a/adapters/gemini-cli/capabilities.manifest.json b/adapters/gemini-cli/capabilities.manifest.json index 5b1d2a16..382a78b8 100644 --- a/adapters/gemini-cli/capabilities.manifest.json +++ b/adapters/gemini-cli/capabilities.manifest.json @@ -129,7 +129,7 @@ } ], "coreSemantics": "delegated-to-agent-lifecycle-core", - "descriptorDigest": "afd08c78e68156ddaf00f916970397ede34cd5ce948a67495b85d2229ef843e6", + "descriptorDigest": "3d64f81bbcf3196735c81ed0c61846111a1547f0542f69bc6605607bb3d976d5", "eventCapture": { "categories": [ "command", diff --git a/adapters/goose/adapter.descriptor.json b/adapters/goose/adapter.descriptor.json index 2e8fa374..f9cd537c 100644 --- a/adapters/goose/adapter.descriptor.json +++ b/adapters/goose/adapter.descriptor.json @@ -212,5 +212,22 @@ }, "writesNativeConfig": false, "promptInjectionDefault": false + }, + "installation": { + "schemaVersion": "agent-adapter-installation-facts.v1", + "binaryAliases": ["goose"], + "files": [ + {"path": "adapters/goose/adapter.descriptor.json", "action": "read", "required": true}, + {"path": "adapters/goose/capabilities.manifest.json", "action": "read", "required": true}, + {"path": "adapters/goose/README.md", "action": "read", "required": true} + ], + "commands": [ + {"argv": ["python", "-m", "pip", "install", "-e", ""], "purpose": "install the core CLI from a trusted checkout", "mutatesHost": true, "requiresOperator": true}, + {"argv": ["goose", "--version"], "purpose": "confirm the host CLI is available before live proof", "mutatesHost": false, "requiresOperator": true} + ], + "operatorActions": [ + "Configure host-local model and profile settings outside the portable core.", + "Keep the adapter EXPERIMENTAL until receipts are accepted." + ] } } diff --git a/adapters/goose/capabilities.manifest.json b/adapters/goose/capabilities.manifest.json index e5ff34c2..1e3c2f9a 100644 --- a/adapters/goose/capabilities.manifest.json +++ b/adapters/goose/capabilities.manifest.json @@ -129,7 +129,7 @@ } ], "coreSemantics": "delegated-to-agent-lifecycle-core", - "descriptorDigest": "398b61525c3e5615d65f0529a2864181cbc93f0aa984c370619334751baedc31", + "descriptorDigest": "d1268c61be190f3edf8ae3053ebf01978720ff8c67270b9808f059693157a9a3", "eventCapture": { "categories": [ "command", @@ -180,12 +180,7 @@ "usageReceiptRequired": true }, "promotion": { - "productionPromotionClaimed": false, - "verifiedRequiresLiveTestedHostRange": true, "liveTestedHostRange": { - "host": "goose", - "minimumVersion": "1.45.0", - "maximumVersion": "1.45.0", "evidence": [ "docs/adapters/evidence/goose-live-verified.md", "work/release-1-16/evidence/preflight/goose-preflight-report.json", @@ -196,11 +191,16 @@ "work/release-1-16/evidence/live-calibration-verification-goose.json", "work/release-1-16/evidence/goose/full-lifecycle/final/final-proof-r5.json" ], - "testedAt": "2026-07-31", + "host": "goose", + "maximumVersion": "1.45.0", + "minimumVersion": "1.45.0", "productionPromotionClaimed": false, "publicDirectoryApprovalClaimed": false, - "sourceRevision": "87fb1ce58612efbd2121d8eb56f9d54de8fbbcfb" - } + "sourceRevision": "87fb1ce58612efbd2121d8eb56f9d54de8fbbcfb", + "testedAt": "2026-07-31" + }, + "productionPromotionClaimed": false, + "verifiedRequiresLiveTestedHostRange": true }, "runtimeBoundary": { "hostSpecificCode": "adapter-owned", diff --git a/adapters/grok-build/adapter.descriptor.json b/adapters/grok-build/adapter.descriptor.json index d2720c01..b93d1282 100644 --- a/adapters/grok-build/adapter.descriptor.json +++ b/adapters/grok-build/adapter.descriptor.json @@ -214,5 +214,22 @@ }, "writesNativeConfig": false, "promptInjectionDefault": false + }, + "installation": { + "schemaVersion": "agent-adapter-installation-facts.v1", + "binaryAliases": ["grok-build"], + "files": [ + {"path": "adapters/grok-build/adapter.descriptor.json", "action": "read", "required": true}, + {"path": "adapters/grok-build/capabilities.manifest.json", "action": "read", "required": true}, + {"path": "adapters/grok-build/README.md", "action": "read", "required": true} + ], + "commands": [ + {"argv": ["python", "-m", "pip", "install", "-e", ""], "purpose": "install the core CLI from a trusted checkout", "mutatesHost": true, "requiresOperator": true}, + {"argv": ["grok-build", "--version"], "purpose": "confirm the host CLI is available before live proof", "mutatesHost": false, "requiresOperator": true} + ], + "operatorActions": [ + "Configure host-local model and profile settings outside the portable core.", + "Keep the adapter EXPERIMENTAL until receipts are accepted." + ] } } diff --git a/adapters/grok-build/capabilities.manifest.json b/adapters/grok-build/capabilities.manifest.json index c1eb4d96..eacc7d4d 100644 --- a/adapters/grok-build/capabilities.manifest.json +++ b/adapters/grok-build/capabilities.manifest.json @@ -1,153 +1,150 @@ { - "schemaVersion": "agent-adapter-capability-manifest.v1", "adapterId": "grok-build", - "host": "grok-build", - "maturity": "VERIFIED", - "descriptorDigest": "58eef00223913a841f1eb139561deb4cb4ee2072c5da46c965b8516e85851474", - "unsupportedOperationPolicy": "fail-closed", - "coreSemantics": "delegated-to-agent-lifecycle-core", "capabilities": [ { - "name": "install", + "lifecycleSemantics": "delegated-to-agent-lifecycle-core", + "liveEvidenceRequiredForVerified": true, "mapping": "grok-build-acp-probe-gated-install", + "name": "install", "offlineConformance": "synthetic", "support": "declared", - "unsupportedOperationPolicy": "fail-closed", - "lifecycleSemantics": "delegated-to-agent-lifecycle-core", - "liveEvidenceRequiredForVerified": true + "unsupportedOperationPolicy": "fail-closed" }, { - "name": "discover", + "lifecycleSemantics": "delegated-to-agent-lifecycle-core", + "liveEvidenceRequiredForVerified": true, "mapping": "grok-build-acp-probe-gated-discover", + "name": "discover", "offlineConformance": "synthetic", "support": "declared", - "unsupportedOperationPolicy": "fail-closed", - "lifecycleSemantics": "delegated-to-agent-lifecycle-core", - "liveEvidenceRequiredForVerified": true + "unsupportedOperationPolicy": "fail-closed" }, { - "name": "validate-envelope", + "lifecycleSemantics": "delegated-to-agent-lifecycle-core", + "liveEvidenceRequiredForVerified": false, "mapping": "core-schema-validation", + "name": "validate-envelope", "offlineConformance": "deterministic", "support": "declared", - "unsupportedOperationPolicy": "fail-closed", - "lifecycleSemantics": "delegated-to-agent-lifecycle-core", - "liveEvidenceRequiredForVerified": false + "unsupportedOperationPolicy": "fail-closed" }, { - "name": "launch", + "lifecycleSemantics": "delegated-to-agent-lifecycle-core", + "liveEvidenceRequiredForVerified": true, "mapping": "grok-build-acp-probe-gated-launch", + "name": "launch", "offlineConformance": "synthetic", "support": "declared", - "unsupportedOperationPolicy": "fail-closed", - "lifecycleSemantics": "delegated-to-agent-lifecycle-core", - "liveEvidenceRequiredForVerified": true + "unsupportedOperationPolicy": "fail-closed" }, { - "name": "model-route-execution", + "lifecycleSemantics": "delegated-to-agent-lifecycle-core", + "liveEvidenceRequiredForVerified": true, "mapping": "attemptModelRoute-to-host-model", + "name": "model-route-execution", "offlineConformance": "synthetic", "support": "declared", - "unsupportedOperationPolicy": "fail-closed", - "lifecycleSemantics": "delegated-to-agent-lifecycle-core", - "liveEvidenceRequiredForVerified": true + "unsupportedOperationPolicy": "fail-closed" }, { - "name": "wait", + "lifecycleSemantics": "delegated-to-agent-lifecycle-core", + "liveEvidenceRequiredForVerified": true, "mapping": "grok-build-acp-probe-gated-wait", + "name": "wait", "offlineConformance": "synthetic", "support": "declared", - "unsupportedOperationPolicy": "fail-closed", - "lifecycleSemantics": "delegated-to-agent-lifecycle-core", - "liveEvidenceRequiredForVerified": true + "unsupportedOperationPolicy": "fail-closed" }, { - "name": "cancel", + "lifecycleSemantics": "delegated-to-agent-lifecycle-core", + "liveEvidenceRequiredForVerified": true, "mapping": "grok-build-acp-probe-gated-cancel", + "name": "cancel", "offlineConformance": "synthetic", "support": "declared", - "unsupportedOperationPolicy": "fail-closed", - "lifecycleSemantics": "delegated-to-agent-lifecycle-core", - "liveEvidenceRequiredForVerified": true + "unsupportedOperationPolicy": "fail-closed" }, { - "name": "resume", + "lifecycleSemantics": "delegated-to-agent-lifecycle-core", + "liveEvidenceRequiredForVerified": true, "mapping": "grok-build-acp-probe-gated-resume", + "name": "resume", "offlineConformance": "synthetic", "support": "declared", - "unsupportedOperationPolicy": "fail-closed", - "lifecycleSemantics": "delegated-to-agent-lifecycle-core", - "liveEvidenceRequiredForVerified": true + "unsupportedOperationPolicy": "fail-closed" }, { - "name": "tool-execution", + "lifecycleSemantics": "delegated-to-agent-lifecycle-core", + "liveEvidenceRequiredForVerified": true, "mapping": "grok-build-acp-probe-gated-tool-execution", + "name": "tool-execution", "offlineConformance": "synthetic", "support": "declared", - "unsupportedOperationPolicy": "fail-closed", - "lifecycleSemantics": "delegated-to-agent-lifecycle-core", - "liveEvidenceRequiredForVerified": true + "unsupportedOperationPolicy": "fail-closed" }, { - "name": "adapter-event-stream", + "lifecycleSemantics": "delegated-to-agent-lifecycle-core", + "liveEvidenceRequiredForVerified": false, "mapping": "agent-adapter-event.v1", + "name": "adapter-event-stream", "offlineConformance": "deterministic", "support": "declared", - "unsupportedOperationPolicy": "fail-closed", - "lifecycleSemantics": "delegated-to-agent-lifecycle-core", - "liveEvidenceRequiredForVerified": false + "unsupportedOperationPolicy": "fail-closed" }, { - "name": "result-collection", + "lifecycleSemantics": "delegated-to-agent-lifecycle-core", + "liveEvidenceRequiredForVerified": false, "mapping": "task-result-artifact", + "name": "result-collection", "offlineConformance": "deterministic", "support": "declared", - "unsupportedOperationPolicy": "fail-closed", - "lifecycleSemantics": "delegated-to-agent-lifecycle-core", - "liveEvidenceRequiredForVerified": false + "unsupportedOperationPolicy": "fail-closed" }, { - "name": "usage-attestation", + "lifecycleSemantics": "delegated-to-agent-lifecycle-core", + "liveEvidenceRequiredForVerified": true, "mapping": "grok-build-acp-probe-gated-usage-attestation", + "name": "usage-attestation", "offlineConformance": "synthetic", "support": "declared", - "unsupportedOperationPolicy": "fail-closed", - "lifecycleSemantics": "delegated-to-agent-lifecycle-core", - "liveEvidenceRequiredForVerified": true + "unsupportedOperationPolicy": "fail-closed" }, { - "name": "task-audit", + "lifecycleSemantics": "delegated-to-agent-lifecycle-core", + "liveEvidenceRequiredForVerified": false, "mapping": "audit-plan-implementation", + "name": "task-audit", "offlineConformance": "deterministic", "support": "declared", - "unsupportedOperationPolicy": "fail-closed", - "lifecycleSemantics": "delegated-to-agent-lifecycle-core", - "liveEvidenceRequiredForVerified": false + "unsupportedOperationPolicy": "fail-closed" }, { - "name": "final-audit", + "lifecycleSemantics": "delegated-to-agent-lifecycle-core", + "liveEvidenceRequiredForVerified": false, "mapping": "audit-plan-implementation", + "name": "final-audit", "offlineConformance": "deterministic", "support": "declared", - "unsupportedOperationPolicy": "fail-closed", - "lifecycleSemantics": "delegated-to-agent-lifecycle-core", - "liveEvidenceRequiredForVerified": false + "unsupportedOperationPolicy": "fail-closed" } ], - "modelRouting": { - "profileSupport": "host-local", - "attemptRoutePolicy": "must-execute-or-fail-closed", - "usageReceiptRequired": true, - "unsupportedClassPolicy": "fail-closed", - "providerModelNamesInCore": false, - "liveVerified": true - }, - "runtimeBoundary": { - "hostSpecificCode": "adapter-owned", - "lifecycleSemantics": "delegated-to-agent-lifecycle-core", - "unsupportedOperations": "fail-closed", - "providerModelNamesInCore": false + "coreSemantics": "delegated-to-agent-lifecycle-core", + "descriptorDigest": "2adc7973ef6af38c23995c6fa7fdb911b7e65a5bebb1477fe1bbb65ce14e7639", + "eventCapture": { + "categories": [ + "command", + "file-change", + "lifecycle-transition", + "model-usage", + "user-decision", + "validation" + ], + "portableEventSchema": "agent-adapter-event.v1", + "producerBoundary": "adapter-owned", + "promotionRequired": false, + "status": "DECLARED" }, + "host": "grok-build", "hostCapabilities": [ { "adapterId": "grok-build", @@ -174,6 +171,45 @@ "transport": "acp" } ], + "maturity": "VERIFIED", + "modelRouting": { + "attemptRoutePolicy": "must-execute-or-fail-closed", + "liveVerified": true, + "profileSupport": "host-local", + "providerModelNamesInCore": false, + "unsupportedClassPolicy": "fail-closed", + "usageReceiptRequired": true + }, + "promotion": { + "liveTestedHostRange": { + "evidence": [ + "docs/adapters/evidence/grok-build-live-verified.md", + "conformance/adapters/grok-build/grok-acp-probe-positive-fixture.json", + "work/release-1-17/evidence/preflight/grok-build-preflight-report.json", + "work/release-1-17/evidence/grok-build-containment-receipt.json", + "work/release-1-17/evidence/live-host-receipts/grok-build.json", + "work/release-1-17/evidence/live-host-conformance-grok-build.json", + "work/release-1-17/evidence/live-calibration-receipts/grok-build.json", + "work/release-1-17/evidence/live-calibration-verification-grok-build.json", + "work/release-1-17/evidence/grok-build/full-lifecycle/final/final-proof-r2.json" + ], + "host": "grok-build", + "maximumVersion": "0.2.117", + "minimumVersion": "0.2.117", + "productionPromotionClaimed": false, + "publicDirectoryApprovalClaimed": false, + "sourceRevision": "fbee9cca9b68ace522089cc0de9c77df0d0c356b", + "testedAt": "2026-07-31" + }, + "productionPromotionClaimed": false, + "verifiedRequiresLiveTestedHostRange": true + }, + "runtimeBoundary": { + "hostSpecificCode": "adapter-owned", + "lifecycleSemantics": "delegated-to-agent-lifecycle-core", + "providerModelNamesInCore": false, + "unsupportedOperations": "fail-closed" + }, "sandboxCapabilities": { "boundaries": { "environment": { @@ -218,42 +254,6 @@ "statement": "Git write scope limits repository paths; sandbox containment limits runtime filesystem, network, process and environment access." } }, - "eventCapture": { - "status": "DECLARED", - "portableEventSchema": "agent-adapter-event.v1", - "categories": [ - "command", - "file-change", - "lifecycle-transition", - "model-usage", - "user-decision", - "validation" - ], - "producerBoundary": "adapter-owned", - "promotionRequired": false - }, - "promotion": { - "verifiedRequiresLiveTestedHostRange": true, - "productionPromotionClaimed": false, - "liveTestedHostRange": { - "host": "grok-build", - "minimumVersion": "0.2.117", - "maximumVersion": "0.2.117", - "evidence": [ - "docs/adapters/evidence/grok-build-live-verified.md", - "conformance/adapters/grok-build/grok-acp-probe-positive-fixture.json", - "work/release-1-17/evidence/preflight/grok-build-preflight-report.json", - "work/release-1-17/evidence/grok-build-containment-receipt.json", - "work/release-1-17/evidence/live-host-receipts/grok-build.json", - "work/release-1-17/evidence/live-host-conformance-grok-build.json", - "work/release-1-17/evidence/live-calibration-receipts/grok-build.json", - "work/release-1-17/evidence/live-calibration-verification-grok-build.json", - "work/release-1-17/evidence/grok-build/full-lifecycle/final/final-proof-r2.json" - ], - "testedAt": "2026-07-31", - "productionPromotionClaimed": false, - "publicDirectoryApprovalClaimed": false, - "sourceRevision": "fbee9cca9b68ace522089cc0de9c77df0d0c356b" - } - } + "schemaVersion": "agent-adapter-capability-manifest.v1", + "unsupportedOperationPolicy": "fail-closed" } diff --git a/adapters/hermes/adapter.descriptor.json b/adapters/hermes/adapter.descriptor.json index b5cac9bb..37c8d4b9 100644 --- a/adapters/hermes/adapter.descriptor.json +++ b/adapters/hermes/adapter.descriptor.json @@ -190,5 +190,21 @@ }, "writesNativeConfig": false, "promptInjectionDefault": false + }, + "installation": { + "schemaVersion": "agent-adapter-installation-facts.v1", + "binaryAliases": ["hermes"], + "files": [ + {"path": "skills.sh.json", "action": "read", "required": true}, + {"path": "adapters/hermes/hermes.registry.json", "action": "read", "required": true}, + {"path": "adapters/hermes/slash-commands.json", "action": "read", "required": true} + ], + "commands": [ + {"argv": ["hermes", "skills", "install", "https://raw.githubusercontent.com/avksp/agent-lifecycle-kit/vX.Y.Z/skills/agent-workflow-orchestrator/SKILL.md"], "purpose": "install a tagged lifecycle skill", "mutatesHost": true, "requiresOperator": true} + ], + "operatorActions": [ + "Choose a release tag before installation.", + "Install every required lifecycle skill from that tag." + ] } } diff --git a/adapters/hermes/capabilities.manifest.json b/adapters/hermes/capabilities.manifest.json index bdfff2a6..63f0ce62 100644 --- a/adapters/hermes/capabilities.manifest.json +++ b/adapters/hermes/capabilities.manifest.json @@ -129,7 +129,7 @@ } ], "coreSemantics": "delegated-to-agent-lifecycle-core", - "descriptorDigest": "4c388d8c2a5ec1b19743dc3cbd7b7811eaab72a84f167a78ed0ae23e6a14cf2a", + "descriptorDigest": "b99a5a1302fa2b91cbc5e6450feec80f092b6228cca208090403ef8043e0deac", "eventCapture": { "categories": [ "command", @@ -156,6 +156,21 @@ "usageReceiptRequired": true }, "promotion": { + "liveTestedHostRange": { + "evidence": [ + "docs/adapters/evidence/hermes-host-local-live-2026-07-29.md", + "work/release-0-8/evidence/hermes/live-host-conformance-hermes.json", + "work/release-0-8/evidence/hermes/live-calibration-verification-hermes.json", + "work/release-0-8/evidence/hermes/full-lifecycle/final/final-proof.json" + ], + "host": "hermes", + "maximumVersion": "0.19.0", + "minimumVersion": "0.19.0", + "productionPromotionClaimed": false, + "publicDirectoryApprovalClaimed": false, + "sourceRevision": "d71033a4", + "testedAt": "2026-07-29" + }, "productionPromotionClaimed": false, "verifiedRequiresLiveTestedHostRange": true }, diff --git a/adapters/kimi-code/adapter.descriptor.json b/adapters/kimi-code/adapter.descriptor.json index 31a656ce..a716a7b5 100644 --- a/adapters/kimi-code/adapter.descriptor.json +++ b/adapters/kimi-code/adapter.descriptor.json @@ -169,5 +169,22 @@ }, "writesNativeConfig": false, "promptInjectionDefault": false + }, + "installation": { + "schemaVersion": "agent-adapter-installation-facts.v1", + "binaryAliases": ["kimi", "kimi-code"], + "files": [ + {"path": "adapters/kimi-code/adapter.descriptor.json", "action": "read", "required": true}, + {"path": "adapters/kimi-code/capabilities.manifest.json", "action": "read", "required": true}, + {"path": "adapters/kimi-code/README.md", "action": "read", "required": true} + ], + "commands": [ + {"argv": ["python", "-m", "pip", "install", "-e", ""], "purpose": "install the core CLI from a trusted checkout", "mutatesHost": true, "requiresOperator": true}, + {"argv": ["kimi", "--version"], "purpose": "confirm the host CLI is available before live proof", "mutatesHost": false, "requiresOperator": true} + ], + "operatorActions": [ + "Configure host-local model and profile settings outside the portable core.", + "Keep the adapter EXPERIMENTAL until receipts are accepted." + ] } } diff --git a/adapters/kimi-code/capabilities.manifest.json b/adapters/kimi-code/capabilities.manifest.json index 07418e5a..43fc7a30 100644 --- a/adapters/kimi-code/capabilities.manifest.json +++ b/adapters/kimi-code/capabilities.manifest.json @@ -129,7 +129,7 @@ } ], "coreSemantics": "delegated-to-agent-lifecycle-core", - "descriptorDigest": "bfda1d16e95e0baef346232e951958bfb5ecbe32f40c89350199486d6eabdb64", + "descriptorDigest": "bc34c5099ef0b02a6af5367270d587b784ed8ae41857d6e7e751647cff389246", "eventCapture": { "categories": [ "command", diff --git a/adapters/opencode/adapter.descriptor.json b/adapters/opencode/adapter.descriptor.json index 1c667719..3f0b8153 100644 --- a/adapters/opencode/adapter.descriptor.json +++ b/adapters/opencode/adapter.descriptor.json @@ -188,5 +188,21 @@ }, "writesNativeConfig": false, "promptInjectionDefault": false + }, + "installation": { + "schemaVersion": "agent-adapter-installation-facts.v1", + "binaryAliases": ["opencode"], + "files": [ + {"path": "skills", "action": "copy-preview", "required": true}, + {"path": "adapters/opencode/opencode.json", "action": "read", "required": true}, + {"path": "opencode.json", "action": "read", "required": true} + ], + "commands": [ + {"argv": ["cp", "-R", "/skills/", ".opencode/skills/"], "purpose": "copy shared skills into the target project", "mutatesHost": true, "requiresOperator": true} + ], + "operatorActions": [ + "Choose project-level or user-level installation before copying files.", + "Keep plugin installation separate from managed lifecycle proof." + ] } } diff --git a/adapters/opencode/capabilities.manifest.json b/adapters/opencode/capabilities.manifest.json index 84fe9b30..56e444f0 100644 --- a/adapters/opencode/capabilities.manifest.json +++ b/adapters/opencode/capabilities.manifest.json @@ -129,7 +129,7 @@ } ], "coreSemantics": "delegated-to-agent-lifecycle-core", - "descriptorDigest": "918b93ca5281f38e8588d9168aca02e66301fcc82d03a14f61b3c341abc6785a", + "descriptorDigest": "6dabf2615a1d14678540a78952f3ca89674419b685908df310a8912d4f4638f9", "eventCapture": { "categories": [ "command", @@ -156,6 +156,21 @@ "usageReceiptRequired": true }, "promotion": { + "liveTestedHostRange": { + "evidence": [ + "docs/adapters/evidence/opencode-host-local-live-2026-07-29.md", + "work/release-0-7/evidence/opencode/live-host-conformance-opencode.json", + "work/release-0-7/evidence/opencode/live-calibration-verification-opencode.json", + "work/release-0-7/evidence/opencode/full-lifecycle/final/final-proof.json" + ], + "host": "opencode", + "maximumVersion": "1.18.9", + "minimumVersion": "1.18.9", + "productionPromotionClaimed": false, + "publicDirectoryApprovalClaimed": false, + "sourceRevision": "6c6b40210ee28de4b6a5993367af89e629fb99ff", + "testedAt": "2026-07-29" + }, "productionPromotionClaimed": false, "verifiedRequiresLiveTestedHostRange": true }, diff --git a/adapters/openinterpreter/adapter.descriptor.json b/adapters/openinterpreter/adapter.descriptor.json index 27a00d9d..8e4fcd55 100644 --- a/adapters/openinterpreter/adapter.descriptor.json +++ b/adapters/openinterpreter/adapter.descriptor.json @@ -188,5 +188,22 @@ }, "writesNativeConfig": false, "promptInjectionDefault": false + }, + "installation": { + "schemaVersion": "agent-adapter-installation-facts.v1", + "binaryAliases": ["interpreter", "openinterpreter"], + "files": [ + {"path": "adapters/openinterpreter/adapter.descriptor.json", "action": "read", "required": true}, + {"path": "adapters/openinterpreter/capabilities.manifest.json", "action": "read", "required": true}, + {"path": "adapters/openinterpreter/README.md", "action": "read", "required": true} + ], + "commands": [ + {"argv": ["python", "-m", "pip", "install", "-e", ""], "purpose": "install the core CLI from a trusted checkout", "mutatesHost": true, "requiresOperator": true}, + {"argv": ["interpreter", "--version"], "purpose": "confirm the host CLI is available before live proof", "mutatesHost": false, "requiresOperator": true} + ], + "operatorActions": [ + "Configure host-local model and profile settings outside the portable core.", + "Keep the adapter EXPERIMENTAL until receipts are accepted." + ] } } diff --git a/adapters/openinterpreter/capabilities.manifest.json b/adapters/openinterpreter/capabilities.manifest.json index 6cc1df85..eecd6a7c 100644 --- a/adapters/openinterpreter/capabilities.manifest.json +++ b/adapters/openinterpreter/capabilities.manifest.json @@ -129,7 +129,7 @@ } ], "coreSemantics": "delegated-to-agent-lifecycle-core", - "descriptorDigest": "f606b090e46b1a56b90378327c762d86ea01b27427a1ded850b2d74680394cfa", + "descriptorDigest": "89c27c047c0321a76f4cf9b109f07763feae09eb6bcf50ea0dfd096b96a38102", "eventCapture": { "categories": [ "command", @@ -156,12 +156,7 @@ "usageReceiptRequired": true }, "promotion": { - "verifiedRequiresLiveTestedHostRange": true, - "productionPromotionClaimed": false, "liveTestedHostRange": { - "host": "openinterpreter", - "minimumVersion": "0.0.34", - "maximumVersion": "0.0.34", "evidence": [ "docs/adapters/evidence/openinterpreter-live-verified.md", "work/release-1-18/evidence/preflight/openinterpreter-preflight-report-live-ready.json", @@ -172,11 +167,16 @@ "work/release-1-18/evidence/live-calibration-verification-openinterpreter.json", "work/release-1-18/evidence/openinterpreter/full-lifecycle/final/final-proof.json" ], - "testedAt": "2026-08-01", + "host": "openinterpreter", + "maximumVersion": "0.0.34", + "minimumVersion": "0.0.34", "productionPromotionClaimed": false, "publicDirectoryApprovalClaimed": false, - "sourceRevision": "52cfa2fd5a97823155c552cb9ae27b735fc85713" - } + "sourceRevision": "52cfa2fd5a97823155c552cb9ae27b735fc85713", + "testedAt": "2026-08-01" + }, + "productionPromotionClaimed": false, + "verifiedRequiresLiveTestedHostRange": true }, "runtimeBoundary": { "hostSpecificCode": "adapter-owned", diff --git a/adapters/pi/adapter.descriptor.json b/adapters/pi/adapter.descriptor.json index 2773938f..6a6942f9 100644 --- a/adapters/pi/adapter.descriptor.json +++ b/adapters/pi/adapter.descriptor.json @@ -204,5 +204,22 @@ }, "writesNativeConfig": false, "promptInjectionDefault": false + }, + "installation": { + "schemaVersion": "agent-adapter-installation-facts.v1", + "binaryAliases": ["pi"], + "files": [ + {"path": "adapters/pi/adapter.descriptor.json", "action": "read", "required": true}, + {"path": "adapters/pi/capabilities.manifest.json", "action": "read", "required": true}, + {"path": "adapters/pi/README.md", "action": "read", "required": true} + ], + "commands": [ + {"argv": ["python", "-m", "pip", "install", "-e", ""], "purpose": "install the core CLI from a trusted checkout", "mutatesHost": true, "requiresOperator": true}, + {"argv": ["pi", "--version"], "purpose": "confirm the host CLI is available before live proof", "mutatesHost": false, "requiresOperator": true} + ], + "operatorActions": [ + "Configure host-local model and profile settings outside the portable core.", + "Keep the adapter EXPERIMENTAL until receipts are accepted." + ] } } diff --git a/adapters/pi/capabilities.manifest.json b/adapters/pi/capabilities.manifest.json index 88b22b32..dff19488 100644 --- a/adapters/pi/capabilities.manifest.json +++ b/adapters/pi/capabilities.manifest.json @@ -129,7 +129,7 @@ } ], "coreSemantics": "delegated-to-agent-lifecycle-core", - "descriptorDigest": "1f55e2f940c1a6dacc06373430b9c83f1dc4eb7b78bf2532bb2522ae4a07a019", + "descriptorDigest": "1b7615d4b2a6e8d7362036ee6ff102c70306aeaf97784be97f25930aee751894", "eventCapture": { "categories": [ "command", @@ -169,12 +169,7 @@ "usageReceiptRequired": true }, "promotion": { - "productionPromotionClaimed": false, - "verifiedRequiresLiveTestedHostRange": true, "liveTestedHostRange": { - "host": "pi", - "minimumVersion": "0.83.0", - "maximumVersion": "0.83.0", "evidence": [ "docs/adapters/evidence/pi-live-verified.md", "work/release-1-19/evidence/pi-install-probe.json", @@ -188,11 +183,16 @@ "work/release-1-19/evidence/host-env-hygiene-pi-all-scanned.json", "work/release-1-19/evidence/pi/full-lifecycle/final/final-proof.json" ], - "testedAt": "2026-08-01", + "host": "pi", + "maximumVersion": "0.83.0", + "minimumVersion": "0.83.0", "productionPromotionClaimed": false, "publicDirectoryApprovalClaimed": false, - "sourceRevision": "75317878358a3dffa4b503cdb8bd8fff40de770b" - } + "sourceRevision": "75317878358a3dffa4b503cdb8bd8fff40de770b", + "testedAt": "2026-08-01" + }, + "productionPromotionClaimed": false, + "verifiedRequiresLiveTestedHostRange": true }, "runtimeBoundary": { "hostSpecificCode": "adapter-owned", diff --git a/adapters/qwen-code/adapter.descriptor.json b/adapters/qwen-code/adapter.descriptor.json index 89e1a649..8ea45bcf 100644 --- a/adapters/qwen-code/adapter.descriptor.json +++ b/adapters/qwen-code/adapter.descriptor.json @@ -183,5 +183,22 @@ }, "writesNativeConfig": false, "promptInjectionDefault": false + }, + "installation": { + "schemaVersion": "agent-adapter-installation-facts.v1", + "binaryAliases": ["qwen", "qwen-code"], + "files": [ + {"path": "adapters/qwen-code/adapter.descriptor.json", "action": "read", "required": true}, + {"path": "adapters/qwen-code/capabilities.manifest.json", "action": "read", "required": true}, + {"path": "adapters/qwen-code/README.md", "action": "read", "required": true} + ], + "commands": [ + {"argv": ["python", "-m", "pip", "install", "-e", ""], "purpose": "install the core CLI from a trusted checkout", "mutatesHost": true, "requiresOperator": true}, + {"argv": ["qwen", "--version"], "purpose": "confirm the host CLI is available before live proof", "mutatesHost": false, "requiresOperator": true} + ], + "operatorActions": [ + "Configure host-local model and profile settings outside the portable core.", + "Keep the adapter EXPERIMENTAL until receipts are accepted." + ] } } diff --git a/adapters/qwen-code/capabilities.manifest.json b/adapters/qwen-code/capabilities.manifest.json index 69160c0e..e3d24645 100644 --- a/adapters/qwen-code/capabilities.manifest.json +++ b/adapters/qwen-code/capabilities.manifest.json @@ -129,7 +129,7 @@ } ], "coreSemantics": "delegated-to-agent-lifecycle-core", - "descriptorDigest": "21389ddebb806504d4a1547573a8533ed961142b741bfbed8b4a018b358c6683", + "descriptorDigest": "d8fe2356bd240d53ea4cbae4d8fa855aa241c5d73ccea5d7b08a44f582e56fff", "eventCapture": { "categories": [ "command", @@ -156,6 +156,21 @@ "usageReceiptRequired": true }, "promotion": { + "liveTestedHostRange": { + "evidence": [ + "docs/adapters/evidence/qwen-code-host-local-live-2026-07-29.md", + "work/release-0-11/evidence/qwen-code/live-host-conformance-qwen-code.json", + "work/release-0-11/evidence/qwen-code/live-calibration-verification-qwen-code.json", + "work/release-0-11/evidence/qwen-code/full-lifecycle/final/final-proof.json" + ], + "host": "qwen-code", + "maximumVersion": "0.21.0", + "minimumVersion": "0.21.0", + "productionPromotionClaimed": false, + "publicDirectoryApprovalClaimed": false, + "sourceRevision": "6c6b40210ee28de4b6a5993367af89e629fb99ff", + "testedAt": "2026-07-29" + }, "productionPromotionClaimed": false, "verifiedRequiresLiveTestedHostRange": true }, diff --git a/conformance/adapters/claude/event-stream-receipt.json b/conformance/adapters/claude/event-stream-receipt.json index ef5c679e..cdbea88b 100644 --- a/conformance/adapters/claude/event-stream-receipt.json +++ b/conformance/adapters/claude/event-stream-receipt.json @@ -1,6 +1,6 @@ { "adapterId": "claude", - "descriptorDigest": "bb8ace8186cb4505b2a98cf707fb7ec4e9f7756bddb2587a84fd9335087f0279", + "descriptorDigest": "779b39814c064e295fdba01ea6a81b5db70f123e361f23a18840379d3c5d85cd", "emittedAt": "2026-07-30T00:18:01Z", "eventCount": 6, "eventStreamDigest": "48cd8b568dff5dcae91b27ed93cbdb37c4dd4409aa11294877b9e9d60362ec08", @@ -20,7 +20,7 @@ "portableEventSchema": "agent-adapter-event.v1" }, "productionPromotionClaimed": false, - "receiptDigest": "c440d9fb24d5f5d827fde7e465c833b3d7c4cd99cbdeee3e54f6e75c34e5c65f", + "receiptDigest": "0904f557eaefa4c6d7877aabcfe2d728229f017ef646f69b22dfb7e054d21a6d", "runId": "offline-conformance", "schemaVersion": "agent-adapter-event-stream-receipt.v1", "status": "PASS", diff --git a/conformance/adapters/codex/event-stream-receipt.json b/conformance/adapters/codex/event-stream-receipt.json index 8096726d..33f9a787 100644 --- a/conformance/adapters/codex/event-stream-receipt.json +++ b/conformance/adapters/codex/event-stream-receipt.json @@ -1,6 +1,6 @@ { "adapterId": "codex", - "descriptorDigest": "d00b0c9d573244db8e17d1f2d2c45c9d65ae5bb9d542dd37592233c62c4658fb", + "descriptorDigest": "5d09ad38b5535b6773118b6419a63d5baf34a6d071a4172080de54ca1f46cc20", "emittedAt": "2026-07-30T00:18:01Z", "eventCount": 6, "eventStreamDigest": "bb71b139dfb4bf3d922b6932b62d7c30974bed0b1f40c138434c923cf2a2a27a", @@ -20,7 +20,7 @@ "portableEventSchema": "agent-adapter-event.v1" }, "productionPromotionClaimed": false, - "receiptDigest": "48658545ad91c427ea73e696b1f28fad69867d99f1e4e68917edd615c5921dfe", + "receiptDigest": "4b86ee4e86afd81e4166a5253298d4ac933b78827182f6dfbc2e83be9bacf7f8", "runId": "offline-conformance", "schemaVersion": "agent-adapter-event-stream-receipt.v1", "status": "PASS", diff --git a/conformance/adapters/cursor/event-stream-receipt.json b/conformance/adapters/cursor/event-stream-receipt.json index 46421b95..b7b0a007 100644 --- a/conformance/adapters/cursor/event-stream-receipt.json +++ b/conformance/adapters/cursor/event-stream-receipt.json @@ -1,6 +1,6 @@ { "adapterId": "cursor", - "descriptorDigest": "5ff041ad04f654403ff738e3b8b9de91b933c8d95ee1ae4d6be0be363943682a", + "descriptorDigest": "5d7b27811f88afccb58552807aa48573fdff1665f8e0c55408cd2d5a1868f1eb", "emittedAt": "2026-07-30T00:18:01Z", "eventCount": 6, "eventStreamDigest": "f93f100d087f0d95b57bdf023b14a912bed5cfb5b795cb7f8ee006f63cf409a9", @@ -20,7 +20,7 @@ "portableEventSchema": "agent-adapter-event.v1" }, "productionPromotionClaimed": false, - "receiptDigest": "06b8ca8e41462b610068c349960d80919c2d3fa27539bc68b2e2e1eb3afb1bf4", + "receiptDigest": "ddc32fafa2e90d34946d9f570dbb456ec04e66b34c5ba89ce43c944213cd159f", "runId": "offline-conformance", "schemaVersion": "agent-adapter-event-stream-receipt.v1", "status": "PASS", diff --git a/conformance/adapters/gemini-cli/event-stream-receipt.json b/conformance/adapters/gemini-cli/event-stream-receipt.json index ea33850f..65bde8bf 100644 --- a/conformance/adapters/gemini-cli/event-stream-receipt.json +++ b/conformance/adapters/gemini-cli/event-stream-receipt.json @@ -1,6 +1,6 @@ { "adapterId": "gemini-cli", - "descriptorDigest": "afd08c78e68156ddaf00f916970397ede34cd5ce948a67495b85d2229ef843e6", + "descriptorDigest": "3d64f81bbcf3196735c81ed0c61846111a1547f0542f69bc6605607bb3d976d5", "emittedAt": "2026-07-30T00:18:01Z", "eventCount": 6, "eventStreamDigest": "eda59557bd6e6a5da397ec27a3bc7accf81ca9aa00e9454c5f0d3b6c9c295855", @@ -20,7 +20,7 @@ "portableEventSchema": "agent-adapter-event.v1" }, "productionPromotionClaimed": false, - "receiptDigest": "0ae3a986c9ed8d3aa1e77375869b395d84e184b9980e144e094817724c204289", + "receiptDigest": "c4a3dfe01f01a5e2377d3665325c762f5f4d7ac5a57742dd31114bc2305c08b8", "runId": "offline-conformance", "schemaVersion": "agent-adapter-event-stream-receipt.v1", "status": "PASS", diff --git a/conformance/adapters/goose/event-stream-receipt.json b/conformance/adapters/goose/event-stream-receipt.json index c6c1e94e..cecdb432 100644 --- a/conformance/adapters/goose/event-stream-receipt.json +++ b/conformance/adapters/goose/event-stream-receipt.json @@ -1,6 +1,6 @@ { "adapterId": "goose", - "descriptorDigest": "398b61525c3e5615d65f0529a2864181cbc93f0aa984c370619334751baedc31", + "descriptorDigest": "d1268c61be190f3edf8ae3053ebf01978720ff8c67270b9808f059693157a9a3", "emittedAt": "2026-07-31T00:17:01Z", "eventCount": 6, "eventStreamDigest": "ebb96d43e5b99aafd88a1d708a81b9924313b5a382f1060a8e5e397fee93cebf", @@ -20,7 +20,7 @@ "portableEventSchema": "agent-adapter-event.v1" }, "productionPromotionClaimed": false, - "receiptDigest": "afbe688450f4b55e305e0cad576117fbabd1b1a68fcbff51f3d20886e18ac1e4", + "receiptDigest": "55ad14af00589bcd023069c6e59f386bb71038e1240b33c21af595f7b07f0a4f", "runId": "offline-conformance", "schemaVersion": "agent-adapter-event-stream-receipt.v1", "status": "PASS", diff --git a/conformance/adapters/grok-build/event-stream-receipt.json b/conformance/adapters/grok-build/event-stream-receipt.json index 96cf8ed8..bf896f3b 100644 --- a/conformance/adapters/grok-build/event-stream-receipt.json +++ b/conformance/adapters/grok-build/event-stream-receipt.json @@ -1,19 +1,9 @@ { - "schemaVersion": "agent-adapter-event-stream-receipt.v1", - "status": "PASS", "adapterId": "grok-build", - "host": "grok-build", - "runId": "offline-conformance", - "taskId": "adapter-event-capture", - "operationId": "offline-event-capture-grok-build", - "producer": { - "id": "grok-build-neutral-event-producer", - "boundary": "adapter-owned", - "portableEventSchema": "agent-adapter-event.v1" - }, - "descriptorDigest": "58eef00223913a841f1eb139561deb4cb4ee2072c5da46c965b8516e85851474", - "eventStreamDigest": "1dd4889f819b335ddb3574cca11ec94ffb6a4714eb23f47f5eaf452f62182006", + "descriptorDigest": "2adc7973ef6af38c23995c6fa7fdb911b7e65a5bebb1477fe1bbb65ce14e7639", + "emittedAt": "2026-07-31T00:23:00Z", "eventCount": 6, + "eventStreamDigest": "1dd4889f819b335ddb3574cca11ec94ffb6a4714eb23f47f5eaf452f62182006", "eventTypes": [ "session.started", "task.launched", @@ -22,8 +12,18 @@ "usage.reported", "task.completed" ], - "terminalEvent": "task.completed", - "emittedAt": "2026-07-31T00:23:00Z", + "host": "grok-build", + "operationId": "offline-event-capture-grok-build", + "producer": { + "boundary": "adapter-owned", + "id": "grok-build-neutral-event-producer", + "portableEventSchema": "agent-adapter-event.v1" + }, "productionPromotionClaimed": false, - "receiptDigest": "bf574e2c601392b0aa0a6a5de3af5320a17a40928a2339f4cd6e4b1b5b14923d" + "receiptDigest": "8cc0555e99f6c9a9aa708629a96aa16837f7591a2964662ad43e6c9d6ea4a2cb", + "runId": "offline-conformance", + "schemaVersion": "agent-adapter-event-stream-receipt.v1", + "status": "PASS", + "taskId": "adapter-event-capture", + "terminalEvent": "task.completed" } diff --git a/conformance/adapters/hermes/event-stream-receipt.json b/conformance/adapters/hermes/event-stream-receipt.json index 69385149..d67a7225 100644 --- a/conformance/adapters/hermes/event-stream-receipt.json +++ b/conformance/adapters/hermes/event-stream-receipt.json @@ -1,6 +1,6 @@ { "adapterId": "hermes", - "descriptorDigest": "4c388d8c2a5ec1b19743dc3cbd7b7811eaab72a84f167a78ed0ae23e6a14cf2a", + "descriptorDigest": "b99a5a1302fa2b91cbc5e6450feec80f092b6228cca208090403ef8043e0deac", "emittedAt": "2026-07-30T00:18:01Z", "eventCount": 6, "eventStreamDigest": "81576ea72843f6cc49c60ff113d3977f35cacab32555ee1df67a6f04d6fc7f2b", @@ -20,10 +20,10 @@ "portableEventSchema": "agent-adapter-event.v1" }, "productionPromotionClaimed": false, + "receiptDigest": "80be3edaa0fe7cb8c726fe99768317562ab941acef26b76b93d268e478083adc", "runId": "offline-conformance", "schemaVersion": "agent-adapter-event-stream-receipt.v1", "status": "PASS", "taskId": "adapter-event-capture", - "terminalEvent": "task.completed", - "receiptDigest": "f56ff99e07ffdf6ccaac7f4f60bb7a5ca5df0bfc7ed8e854df66000b236c1f78" + "terminalEvent": "task.completed" } diff --git a/conformance/adapters/kimi-code/event-stream-receipt.json b/conformance/adapters/kimi-code/event-stream-receipt.json index a2a52fcf..5d1d7fef 100644 --- a/conformance/adapters/kimi-code/event-stream-receipt.json +++ b/conformance/adapters/kimi-code/event-stream-receipt.json @@ -1,6 +1,6 @@ { "adapterId": "kimi-code", - "descriptorDigest": "bfda1d16e95e0baef346232e951958bfb5ecbe32f40c89350199486d6eabdb64", + "descriptorDigest": "bc34c5099ef0b02a6af5367270d587b784ed8ae41857d6e7e751647cff389246", "emittedAt": "2026-07-30T00:18:01Z", "eventCount": 6, "eventStreamDigest": "3c84ff6836ea5436f873b557cbef735660d0134e1326791025e2b2253cc70f88", @@ -20,7 +20,7 @@ "portableEventSchema": "agent-adapter-event.v1" }, "productionPromotionClaimed": false, - "receiptDigest": "adb1def5da211b1e9111af8579bdd1b075e377e1664e0c2018c8e724356dd113", + "receiptDigest": "0ab2e1a7a63e6b63a9025841b33e1309adb04ae68087fc0df97a585a0c5fc09e", "runId": "offline-conformance", "schemaVersion": "agent-adapter-event-stream-receipt.v1", "status": "PASS", diff --git a/conformance/adapters/opencode/event-stream-receipt.json b/conformance/adapters/opencode/event-stream-receipt.json index c070957b..501d1acc 100644 --- a/conformance/adapters/opencode/event-stream-receipt.json +++ b/conformance/adapters/opencode/event-stream-receipt.json @@ -1,6 +1,6 @@ { "adapterId": "opencode", - "descriptorDigest": "918b93ca5281f38e8588d9168aca02e66301fcc82d03a14f61b3c341abc6785a", + "descriptorDigest": "6dabf2615a1d14678540a78952f3ca89674419b685908df310a8912d4f4638f9", "emittedAt": "2026-07-30T00:18:01Z", "eventCount": 6, "eventStreamDigest": "2660e01336059be098c7787788f6e87a05441efd05c85b650900b742dd01071e", @@ -20,10 +20,10 @@ "portableEventSchema": "agent-adapter-event.v1" }, "productionPromotionClaimed": false, + "receiptDigest": "fb1d1b1061ef12190275a6abdc096f8e49ae4f619c631d6b61feeb1137b01602", "runId": "offline-conformance", "schemaVersion": "agent-adapter-event-stream-receipt.v1", "status": "PASS", "taskId": "adapter-event-capture", - "terminalEvent": "task.completed", - "receiptDigest": "0804068cd7d96adb719bb1a7c7ffd7cc17fe735b352f1c485be83212842ece7f" + "terminalEvent": "task.completed" } diff --git a/conformance/adapters/openinterpreter/event-stream-receipt.json b/conformance/adapters/openinterpreter/event-stream-receipt.json index 6498ae8a..5ca67202 100644 --- a/conformance/adapters/openinterpreter/event-stream-receipt.json +++ b/conformance/adapters/openinterpreter/event-stream-receipt.json @@ -1,6 +1,6 @@ { "adapterId": "openinterpreter", - "descriptorDigest": "f606b090e46b1a56b90378327c762d86ea01b27427a1ded850b2d74680394cfa", + "descriptorDigest": "89c27c047c0321a76f4cf9b109f07763feae09eb6bcf50ea0dfd096b96a38102", "emittedAt": "2026-07-31T00:23:00Z", "eventCount": 6, "eventStreamDigest": "ecc4e8dbd5dd37e24ba52bc6bb6270aa485f846d42eedcabce67fa351de0cda9", @@ -20,10 +20,10 @@ "portableEventSchema": "agent-adapter-event.v1" }, "productionPromotionClaimed": false, + "receiptDigest": "9b756a40c48e2daf6d33142eb1406ac3d1f552f08e6a4269b0bac793906ce7f3", "runId": "offline-conformance", "schemaVersion": "agent-adapter-event-stream-receipt.v1", "status": "PASS", "taskId": "adapter-event-capture", - "terminalEvent": "task.completed", - "receiptDigest": "2869e033beffc0d0d2879d6d3c0284344b420d84050e96f8302636ee8e60bd15" + "terminalEvent": "task.completed" } diff --git a/conformance/adapters/pi/event-stream-receipt.json b/conformance/adapters/pi/event-stream-receipt.json index 1dafbcd9..c2f2fdb8 100644 --- a/conformance/adapters/pi/event-stream-receipt.json +++ b/conformance/adapters/pi/event-stream-receipt.json @@ -1,6 +1,6 @@ { "adapterId": "pi", - "descriptorDigest": "1f55e2f940c1a6dacc06373430b9c83f1dc4eb7b78bf2532bb2522ae4a07a019", + "descriptorDigest": "1b7615d4b2a6e8d7362036ee6ff102c70306aeaf97784be97f25930aee751894", "emittedAt": "2026-07-31T00:23:00Z", "eventCount": 6, "eventStreamDigest": "d810ba2ada1f51876732fa74dbcb472f25355438839a00899c7d924b2d216c36", @@ -20,10 +20,10 @@ "portableEventSchema": "agent-adapter-event.v1" }, "productionPromotionClaimed": false, + "receiptDigest": "f01fc22595ac0cdd29431a43fc4f82e39e3492fd2879fa9d2a42452a149521c4", "runId": "offline-conformance", "schemaVersion": "agent-adapter-event-stream-receipt.v1", "status": "PASS", "taskId": "adapter-event-capture", - "terminalEvent": "task.completed", - "receiptDigest": "d20c7cdb0a08a0252e068856a57597037f4d44bcfc769167759a7be31ef612b6" + "terminalEvent": "task.completed" } diff --git a/conformance/adapters/qwen-code/event-stream-receipt.json b/conformance/adapters/qwen-code/event-stream-receipt.json index 26b977df..4332053f 100644 --- a/conformance/adapters/qwen-code/event-stream-receipt.json +++ b/conformance/adapters/qwen-code/event-stream-receipt.json @@ -1,6 +1,6 @@ { "adapterId": "qwen-code", - "descriptorDigest": "21389ddebb806504d4a1547573a8533ed961142b741bfbed8b4a018b358c6683", + "descriptorDigest": "d8fe2356bd240d53ea4cbae4d8fa855aa241c5d73ccea5d7b08a44f582e56fff", "emittedAt": "2026-07-30T00:18:01Z", "eventCount": 6, "eventStreamDigest": "c23a411fab3dd994eb2898cb483eac3eb8fb19109812881844fa728dd464df0f", @@ -20,10 +20,10 @@ "portableEventSchema": "agent-adapter-event.v1" }, "productionPromotionClaimed": false, + "receiptDigest": "1b731dfb563e99d14921f71172ac0d239924cb82153698b4b1cde87c6ada0dfd", "runId": "offline-conformance", "schemaVersion": "agent-adapter-event-stream-receipt.v1", "status": "PASS", "taskId": "adapter-event-capture", - "terminalEvent": "task.completed", - "receiptDigest": "7ee66150df390077b2a1d7c30b9f3057f6cb832bf9d7447b2494f6d50cf648d8" + "terminalEvent": "task.completed" } diff --git a/conformance/fixtures.index.json b/conformance/fixtures.index.json index 1235026b..191c4c8b 100644 --- a/conformance/fixtures.index.json +++ b/conformance/fixtures.index.json @@ -89,7 +89,7 @@ { "bytes": 975, "path": "conformance/adapters/grok-build/event-stream-receipt.json", - "sha256": "06e0f90bfbe9eb3cb11c5ff6653b8393a7b2fa364b940e0da989d05ab14c8fbe" + "sha256": "9302e3fb544ab6d8342c7a473d5b63b250c14c3d352cdde225d4b9eaeb9e53a7" }, { "bytes": 3144, @@ -114,7 +114,7 @@ { "bytes": 995, "path": "conformance/adapters/openinterpreter/event-stream-receipt.json", - "sha256": "cc9a07baa5fa140c0787d44729b5623e7e5a93384133ad21463ccceb3508d919" + "sha256": "edf033195f79d012fa4b167142b8cdbeaf6468340a00310c78f223c511c6c5eb" }, { "bytes": 3274, @@ -129,7 +129,7 @@ { "bytes": 943, "path": "conformance/adapters/pi/event-stream-receipt.json", - "sha256": "f46f7b831eaf20a870dd94b6f7263e4e09d24119c61f63b6f27700206a93e353" + "sha256": "fa9542dcb23a20ae39ae4207898d72c7a1b6a94427a0c272649794aa58e9a9fb" }, { "bytes": 2936, diff --git a/docs/README.md b/docs/README.md index 5bb937dc..71ca36b8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -97,3 +97,4 @@ project. - [Production promotion contract](reference/production-promotion.md) - [Live cost calibration](reference/live-cost-calibration.md) - [Release security](security/release-security.md) +- [Neutrality authority contract](security/neutrality-contract.md) diff --git a/docs/adapters/support-matrix.md b/docs/adapters/support-matrix.md index 8772f809..c1f2cf71 100644 --- a/docs/adapters/support-matrix.md +++ b/docs/adapters/support-matrix.md @@ -20,6 +20,9 @@ Managed session support is documented separately from maturity. Current bundled adapters declare `managedLaunch.status: WRAPPER_ONLY`: ALK can bind lifecycle proof through managed commands or wrappers, but it does not claim safe native argv launch. See [Managed adapter session support](managed-session-support.md). +Every generic descriptor-driven launch request is blocked with +`adapter-generic-launch-disabled` before a host process is created; a descriptor +status cannot bypass that boundary. ## Runtime support diff --git a/docs/architecture/modular-controller.md b/docs/architecture/modular-controller.md index 415e2d97..7b11c6d9 100644 --- a/docs/architecture/modular-controller.md +++ b/docs/architecture/modular-controller.md @@ -46,18 +46,18 @@ This is the current source map for the standalone package: | Optional multi-review coordination, operator templates, prepared packets, assignments, result import, synthesis and quorum | `review_mesh/*`, `review_mesh/operator_templates.py` | Implemented | | Controlled runner, attempt snapshots and sandbox receipts | `runner/*` | Implemented | | Worktree isolation receipts | `worktree/*` | Implemented | -| Root CLI dispatch | `cli/main.py`, `cli/parsers.py`, `cli/dispatch.py` | Implemented thin entrypoint with split parser and dispatcher modules; no lifecycle semantics should move here | +| Root CLI dispatch | `cli/main.py`, `cli/parsers.py`, `cli/dispatch.py`, `cli/dispatch_adapters.py`, `cli/dispatch_contracts.py`, `cli/dispatch_lifecycle.py`, `cli/dispatch_observability.py`, `cli/dispatch_planning.py` | Implemented thin entrypoint and command-group handlers; no lifecycle semantics should move here | | Release checks and live adapter promotion evidence | `tools/release/*`, `tools/live_hosts/*`, metadata and docs | Implemented as release validators and host-local evidence tooling | -Current size check: `cli/main.py` is a thin entrypoint at 41 lines after the -parser/dispatcher split. Most production Python files are below the hard limits -in this document. `cli/dispatch.py` is the active exception: recent command -growth pushed it to about 1220 lines, above the 1200-line hard limit. Treat it -as split debt, not as a pattern to extend. `workflow/plan_adoption.py`, -`workflow/finalization.py` and `workflow/task_transitions.py` remain about -420-440 lines. New lifecycle behavior should prefer a focused domain module -and a small dispatch branch or dispatcher submodule instead of expanding -`cli/dispatch.py` further. +Current size check: `cli/main.py` and `cli/dispatch.py` are thin entrypoints at +41 lines each. The root dispatcher selects a focused adapter/readiness, +contract/evidence, lifecycle, observability or planning handler; policy, +follow-up and worktree handlers remain dedicated command modules. Most +production Python files are below the hard limits in this document. +`workflow/plan_adoption.py`, `workflow/finalization.py` and +`workflow/task_transitions.py` remain about 420-440 lines. New lifecycle +behavior should prefer a focused domain module and a small dispatcher submodule +instead of expanding `cli/dispatch.py`. ## Target shape diff --git a/docs/architecture/system-architecture.md b/docs/architecture/system-architecture.md index 852b3232..328bd272 100644 --- a/docs/architecture/system-architecture.md +++ b/docs/architecture/system-architecture.md @@ -201,7 +201,7 @@ flowchart LR | Component | Main modules | Called when | | --- | --- | --- | -| CLI routing | `cli/main.py`, `cli/parsers.py`, `cli/dispatch.py`, `cli/adapter.py` | Any `agent-lifecycle ...` command starts here. | +| CLI routing | `cli/main.py`, `cli/parsers.py`, `cli/dispatch.py`, `cli/dispatch_adapters.py`, `cli/dispatch_contracts.py`, `cli/dispatch_lifecycle.py`, `cli/dispatch_observability.py`, `cli/dispatch_planning.py`, `cli/adapter.py` | Any `agent-lifecycle ...` command starts here; the root dispatcher selects a focused command-group handler. | | Contracts | `contracts/*` | Every public receipt, schema, digest and validation envelope. | | Change discovery | `changesets/git.py` | Ownership and implementation audit over Git diffs. | | Compilation | `compiler/task_packets.py`, `compiler/small_model_packets.py` | Frozen DAG to task packets and compact model packets. | @@ -231,21 +231,26 @@ sequenceDiagram participant Main as cli/main.py participant Parser as cli/parsers.py participant Dispatch as cli/dispatch.py + participant Handler as cli/dispatch_*.py participant Service as Domain service participant Contracts as contracts/* User->>Main: agent-lifecycle Main->>Parser: build_parser() Main->>Dispatch: dispatch(args, remainder) - Dispatch->>Service: call selected service + Dispatch->>Handler: route command group + Handler->>Service: call selected service Service->>Contracts: validate, digest, read/write JSON - Service-->>Dispatch: typed receipt or report + Service-->>Handler: typed receipt or report + Handler-->>Dispatch: JSON-compatible object Dispatch-->>Main: JSON-compatible object Main-->>User: stable JSON stdout ``` -Pattern: command dispatcher plus functional core. CLI modules should stay thin; -domain services own behavior and tests. +Pattern: command dispatcher plus functional core. `cli/dispatch.py` only selects +one of five command-group handlers: adapters/readiness, contracts/evidence, +lifecycle, observability or planning. CLI modules should stay thin; domain +services own behavior and tests. ### Raw task or Markdown intake @@ -440,7 +445,7 @@ does not parse host-specific telemetry in the core. | --- | --- | --- | | Ports and adapters | `adapters/*`, `host_protocol/*`, `adapter_sessions/*` | Keep host-specific commands, secrets and capabilities outside lifecycle core. | | Contract-first design | `contracts/*`, `schemas.py`, public `.v1` receipts | Make every lifecycle claim machine-checkable and portable. | -| Command dispatcher | `cli/main.py`, `cli/parsers.py`, `cli/dispatch.py` | Keep CLI thin and route behavior to domain services. | +| Command dispatcher | `cli/main.py`, `cli/parsers.py`, `cli/dispatch.py`, `cli/dispatch_*.py` | Keep the root CLI thin and route each command group to its domain handler. | | Functional core, imperative shell | Builders and validators return dictionaries; CLI handles paths and output. | Improve testability and small-model readability. | | State machine | `workflow/state.py`, `workflow/task_transitions.py`, `runner/core.py` | Make lifecycle phases explicit and fail closed on invalid transitions. | | Operation kernel | `workflow/operation_kernel.py` | Centralize expected revision checks, idempotency and state/event commits. | diff --git a/docs/guides/quickstart.md b/docs/guides/quickstart.md index 00d0291b..1bd6dc8c 100644 --- a/docs/guides/quickstart.md +++ b/docs/guides/quickstart.md @@ -26,7 +26,7 @@ PYTHONPATH=src python -m agent_lifecycle version When the package is available for the release, install the exact semver version: ```bash -python -m pip install agent-lifecycle-kit==1.50.1 +python -m pip install agent-lifecycle-kit==1.50.2 agent-lifecycle version ``` @@ -60,8 +60,9 @@ agent-lifecycle adapter install-plan \ --descriptor adapters/opencode/adapter.descriptor.json ``` -The output is a dry run. It lists files, commands, and operator actions, but it -does not mutate the host and does not change adapter maturity. +The output is a dry run. It projects validated installation facts from the +adapter descriptor as files, argv arrays and operator actions. It does not +execute the arrays, mutate the host or change adapter maturity. ## Run a plan gate diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 3e3932aa..3a97c87f 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -138,6 +138,11 @@ read-only unless their own help says otherwise. - `agent-lifecycle adapter session start/status/resume/promote`: record and resume adapter sessions. Plain interactive sessions return `WAITING_FOR_TASK`; promoted sessions bind to workflow state and task lineage. +- `agent-lifecycle adapter session start --launch`: validates the requested + launch profile, then returns `adapter-generic-launch-disabled` before process + creation. A descriptor alone never authorizes a generic native host launch. + Generic environment selection accepts exact allowlisted variable names only; + wildcard patterns are rejected. - `agent-lifecycle adapter task start --adapter (--file task.md | --text "...")`: accept task input for a selected adapter. Raw text and Markdown produce `agent-adapter-task-start-receipt.v1` with @@ -150,7 +155,8 @@ read-only unless their own help says otherwise. - `agent-lifecycle adapter run`: bind an adapter session to a frozen workflow state and return an ALK-managed next action. Progress is shown on stderr by default for this managed path, while JSON stdout stays - `agent-adapter-session-receipt.v1`. + `agent-adapter-session-receipt.v1`. It does not bypass the generic launch + block or start a native host process. ## Diagnostics and evidence diff --git a/docs/reference/managed-adapter-sessions.md b/docs/reference/managed-adapter-sessions.md index c6c56db0..5363f4c4 100644 --- a/docs/reference/managed-adapter-sessions.md +++ b/docs/reference/managed-adapter-sessions.md @@ -60,26 +60,31 @@ to persist `agent-progress-hook-receipt.v1`. ## Launch profile -Native launch is descriptor-driven. The adapter descriptor field -`managedLaunch` declares one of: +The adapter descriptor field `managedLaunch` declares one of the following +profiles. It is descriptive data, not generic process-execution authority: | Status | Meaning | | --- | --- | -| `SUPPORTED` | The descriptor supplies safe argv templates for managed launch. | +| `SUPPORTED` | The descriptor may supply argv templates for a separately qualified local route. The generic library and CLI route do not execute them. | | `WRAPPER_ONLY` | A wrapper or operator flow can use ALK managed sessions, but native argv launch is not claimed. | | `UNSUPPORTED` | No managed launch route is declared. | Current bundled adapters declare `WRAPPER_ONLY`. That keeps lifecycle proof available through ALK-managed commands without claiming unsupported native host -hooks or command lines. +hooks or command lines. `adapter session start --launch` and direct generic +descriptor launch both return `adapter-generic-launch-disabled` before process +creation, regardless of the declared profile status. ## Security boundary Managed adapter sessions are fail-closed and display-safe: - launch uses argv arrays with `shell: false`; +- generic environment selection accepts exact names only; wildcard patterns are + rejected; - provider secrets are selected only from descriptor/project allowlists; -- secret values are redacted from receipts; +- shared redaction removes tested secret and local-path forms from receipts and + records whether a stored value changed; - tracked native host config files are not written; - ALK does not inject prompts into host CLIs; - ALK does not parse host-specific telemetry in core; diff --git a/docs/reference/readiness-diagnostics.md b/docs/reference/readiness-diagnostics.md index 3196a141..637b26fd 100644 --- a/docs/reference/readiness-diagnostics.md +++ b/docs/reference/readiness-diagnostics.md @@ -49,8 +49,10 @@ must not launch model work or produce promotion claims. agent-lifecycle adapter install-plan --descriptor adapters/opencode/adapter.descriptor.json ``` -The output schema is `agent-adapter-install-plan.v1`. The plan records files, -commands and operator actions, but it is always a dry run: +The output schema is `agent-adapter-install-plan.v1`. The plan projects +schema-validated installation facts from the adapter descriptor: binary aliases, +files, argv arrays and operator actions. Diagnostics never interpret the argv +arrays as a shell command or execute them. It is always a dry run: - `writesStarted: false`; - `liveCallsStarted: false`; diff --git a/docs/ru/README.md b/docs/ru/README.md index f292d0d1..8553b9ed 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -16,7 +16,7 @@ OpenInterpreter, Pi, Grok Build и других. Ядро не зависит о Главная цель проекта - закрывать задачу полностью, с максимально возможным качеством для выбранной модели, без оверинжиринга и с контролем расхода токенов. -**Лицензия:** Apache-2.0 · **Версия:** 1.50.1 · **Python:** 3.11-3.13 +**Лицензия:** Apache-2.0 · **Версия:** 1.50.2 · **Python:** 3.11-3.13 ## Быстрый старт @@ -93,7 +93,7 @@ agent-lifecycle adapter validate --descriptor adapters/codex/adapter.descriptor. ### Безопасность и ограничения среды - Фильтры нейтральности используют запрещающие правила хоста и не дают локальным путям, секретам, корням доверия и ключам подписи попасть в переносимые артефакты. -- Файлы окружения для хостов требуют явного `--host-env-allow`; артефакты сохраняют только обезличенные метаданные, без значений секретов и приватных путей. +- Файлы окружения для хостов требуют явного `--host-env-allow`; артефакты сохраняют только обезличенные метаданные, а общий запуск CLI по дескриптору блокируется до создания процесса. - Импорт, диагностика и экспорт использования маскируют локальные пути и типовые признаки секретов до проверки. - Артефакты песочницы отделяют ограничения файловой системы, сети, процессов и окружения от разрешённых путей записи в Git. - Проверки безопасности релиза отклоняют утечки локальных путей, учётных данных и неподтверждённые заявления об адаптерах или промышленной готовности. @@ -186,5 +186,5 @@ agent-lifecycle adapter validate --descriptor adapters/codex/adapter.descriptor. - Старт: [Быстрый старт](quickstart.md), [практические сценарии жизненного цикла](lifecycle-cookbook.md), [сценарии проверки кода](code-review-workflows.md), [практические сценарии групповой проверки](review-mesh-workflow.md) и [черновик спецификации из тикета](issue-to-spec.md). - Адаптеры: [Установка](adapters/install.md), [поддержка](adapters/support-matrix.md), [управляемые сессии](adapters/managed-session-support.md), [прогресс](adapters/progress-bridge-matrix.md), [захват событий](adapters/event-capture-matrix.md), [создание адаптера](adapters/authoring.md), [порядок перевода в VERIFIED](adapters/live-promotion-runbook.md), [Codex](adapters/codex.md), [Claude Code](adapters/claude.md), [Cursor](adapters/cursor.md), [Gemini CLI](adapters/gemini-cli.md), [Goose](adapters/goose.md), [Grok Build](adapters/grok-build.md), [Hermes](adapters/hermes.md), [Kimi Code](adapters/kimi-code.md), [OpenCode](adapters/opencode.md), [OpenInterpreter](adapters/openinterpreter.md), [Pi](adapters/pi.md) и [Qwen Code](adapters/qwen-code.md). -- Основной справочник: [архитектура системы](architecture/system-architecture.md), [команды CLI](reference/cli.md), [источник правды](reference/source-of-truth.md), [сравнение проекта](reference/project-comparison.md), [управляемый шаг жизненного цикла](reference/managed-lifecycle-runner.md), [управляемые сессии адаптеров](reference/managed-adapter-sessions.md), [аудит реализации](reference/implementation-audit.md), [полнота плана](reference/plan-completeness.md), [публикация плагинов](reference/plugin-publication.md), [публичные контракты](reference/public-contracts.md), [диагностика готовности](reference/readiness-diagnostics.md), [компактные пакеты](reference/small-model-packets.md), [адаптивные правила](reference/adaptive-lifecycle-policy.md), [локальная статистика](reference/quality-cost-learning.md), [учёт расхода](reference/lifecycle-cost.md), [экспорт использования](reference/usage-export.md), [целостность подтверждений](reference/evidence-integrity.md), [границы песочницы](reference/sandbox-boundaries.md) и [безопасность релиза](security/release-security.md). +- Основной справочник: [архитектура системы](architecture/system-architecture.md), [команды CLI](reference/cli.md), [источник правды](reference/source-of-truth.md), [сравнение проекта](reference/project-comparison.md), [управляемый шаг жизненного цикла](reference/managed-lifecycle-runner.md), [управляемые сессии адаптеров](reference/managed-adapter-sessions.md), [аудит реализации](reference/implementation-audit.md), [полнота плана](reference/plan-completeness.md), [публикация плагинов](reference/plugin-publication.md), [публичные контракты](reference/public-contracts.md), [диагностика готовности](reference/readiness-diagnostics.md), [компактные пакеты](reference/small-model-packets.md), [адаптивные правила](reference/adaptive-lifecycle-policy.md), [локальная статистика](reference/quality-cost-learning.md), [учёт расхода](reference/lifecycle-cost.md), [экспорт использования](reference/usage-export.md), [целостность подтверждений](reference/evidence-integrity.md), [границы песочницы](reference/sandbox-boundaries.md), [безопасность релиза](security/release-security.md) и [контракт нейтральности](security/neutrality-contract.md). - Дополнительный справочник: [статус без записи](reference/read-only-status-view.md), [прогресс](reference/automatic-progress-bridge.md), [индекс подтверждений и импорт](reference/evidence-imports.md), [импорт внешних форматов](reference/import-mappers.md), [эпизоды](reference/episode-retrieval.md), [восстановление](reference/runner-recovery.md), [перепроверка](reference/cross-check-profile.md), [групповая проверка](reference/review-mesh.md), [рекомендация групповой проверки](reference/review-mesh.md#рекомендация-режима), [подготовка пакетов групповой проверки](reference/review-mesh.md#шаблоны-и-подготовка), [профиль расследования ошибок](reference/bug-forensics.md), [бюджет контекста для него](reference/bug-forensics-context-budget.md), [проверка завершения](reference/completion-check.md), [непрерывность цели](reference/goal-continuity.md), [реестр отложенной работы](reference/follow-up-register.md), [изоляция рабочего дерева](reference/worktree-isolation.md), [возможности хоста](reference/host-capabilities.md), [захват событий адаптера](reference/adapter-event-capture.md), [вердикт проверки](reference/review-verdict.md), [дополнительные пакеты качества](reference/optional-quality-packs.md), [маршрутизация моделей](reference/model-routing.md), [предложения правил](reference/policy.md), [промышленная готовность](reference/production-promotion.md), [калибровка реального расхода](reference/live-cost-calibration.md), [непрерывность плана](reference/plan-continuity.md), [диагностические пакеты](reference/diagnostic-bundles.md), [контролируемый запуск](reference/runner.md) и [шаблоны задач](reference/task-templates.md). diff --git a/docs/ru/adapters/support-matrix.md b/docs/ru/adapters/support-matrix.md index 32e9d736..f20d7ad6 100644 --- a/docs/ru/adapters/support-matrix.md +++ b/docs/ru/adapters/support-matrix.md @@ -9,6 +9,9 @@ работу с подтверждением жизненного цикла через управляемые команды или обёртки, но не заявляет безопасный прямой argv-запуск CLI. Подробнее: [управляемые сессии адаптеров](managed-session-support.md). +Любой общий запрос запуска через дескриптор блокируется кодом +`adapter-generic-launch-disabled` до создания процесса хоста; статус +дескриптора не может обойти эту границу. | Хост | Зрелость | Управляемый запуск | Что это значит | | --- | --- | --- | --- | diff --git a/docs/ru/architecture/system-architecture.md b/docs/ru/architecture/system-architecture.md index 66d66af7..e082da62 100644 --- a/docs/ru/architecture/system-architecture.md +++ b/docs/ru/architecture/system-architecture.md @@ -205,7 +205,7 @@ flowchart LR | Компонент | Основные модули | Когда вызывается | | --- | --- | --- | -| Маршрутизация CLI | `cli/main.py`, `cli/parsers.py`, `cli/dispatch.py`, `cli/adapter.py` | Любая команда `agent-lifecycle ...` начинается здесь. | +| Маршрутизация CLI | `cli/main.py`, `cli/parsers.py`, `cli/dispatch.py`, `cli/dispatch_adapters.py`, `cli/dispatch_contracts.py`, `cli/dispatch_lifecycle.py`, `cli/dispatch_observability.py`, `cli/dispatch_planning.py`, `cli/adapter.py` | Любая команда `agent-lifecycle ...` начинается здесь; корневой диспетчер выбирает специализированный обработчик группы команд. | | Контракты | `contracts/*` | Все публичные подтверждения, схемы, отпечатки и проверочные конверты. | | Поиск изменений | `changesets/git.py` | Аудит владения и реализации по Git-изменениям. | | Компиляция заданий | `compiler/task_packets.py`, `compiler/small_model_packets.py` | Преобразование зафиксированного графа задач в рабочие и компактные пакеты. | @@ -235,21 +235,27 @@ sequenceDiagram participant Main as cli/main.py participant Parser as cli/parsers.py participant Dispatch as cli/dispatch.py + participant Handler as cli/dispatch_*.py participant Service as Доменный сервис participant Contracts as contracts/* User->>Main: agent-lifecycle Main->>Parser: build_parser() Main->>Dispatch: dispatch(args, remainder) - Dispatch->>Service: вызов выбранного сервиса + Dispatch->>Handler: выбор группы команд + Handler->>Service: вызов выбранного сервиса Service->>Contracts: проверка, отпечаток, чтение и запись JSON - Service-->>Dispatch: типизированное подтверждение или отчёт + Service-->>Handler: типизированное подтверждение или отчёт + Handler-->>Dispatch: JSON-совместимый объект Dispatch-->>Main: JSON-совместимый объект Main-->>User: стабильный JSON в stdout ``` -Паттерн: диспетчер команд и функциональное ядро. CLI-модули должны оставаться -тонкими; поведение и тесты живут в доменных сервисах. +Паттерн: диспетчер команд и функциональное ядро. `cli/dispatch.py` только +выбирает один из пяти обработчиков групп команд: адаптеры и готовность, +контракты и подтверждения, жизненный цикл, наблюдаемость или планирование. +CLI-модули должны оставаться тонкими; поведение и тесты живут в доменных +сервисах. ### Приём обычной задачи или Markdown @@ -450,7 +456,7 @@ flowchart LR | --- | --- | --- | | Порты и адаптеры | `adapters/*`, `host_protocol/*`, `adapter_sessions/*` | Держать команды хоста, секреты и возможности вне ядра жизненного цикла. | | Контракты сначала | `contracts/*`, `schemas.py`, публичные `.v1` подтверждения | Делать каждое заявление жизненного цикла машинно проверяемым и переносимым. | -| Диспетчер команд | `cli/main.py`, `cli/parsers.py`, `cli/dispatch.py` | Оставлять CLI тонким и направлять поведение в доменные сервисы. | +| Диспетчер команд | `cli/main.py`, `cli/parsers.py`, `cli/dispatch.py`, `cli/dispatch_*.py` | Оставлять корневой CLI тонким и направлять каждую группу команд в её доменный обработчик. | | Функциональное ядро и императивная оболочка | Функции создания и проверки возвращают словари; CLI работает с путями и выводом. | Упростить тестирование и чтение небольшими моделями. | | Конечный автомат | `workflow/state.py`, `workflow/task_transitions.py`, `runner/core.py` | Сделать фазы жизненного цикла явными и отказывать при недопустимых переходах. | | Ядро операции | `workflow/operation_kernel.py` | Централизовать проверку ревизии, идемпотентность и запись состояния/событий. | diff --git a/docs/ru/quickstart.md b/docs/ru/quickstart.md index 58a2bb1c..7dbc4486 100644 --- a/docs/ru/quickstart.md +++ b/docs/ru/quickstart.md @@ -26,7 +26,7 @@ PYTHONPATH=src python -m agent_lifecycle version семантическую версию: ```bash -python -m pip install agent-lifecycle-kit==1.50.1 +python -m pip install agent-lifecycle-kit==1.50.2 agent-lifecycle version ``` @@ -60,8 +60,9 @@ agent-lifecycle adapter install-plan \ --descriptor adapters/opencode/adapter.descriptor.json ``` -Команда показывает, какие файлы, команды и действия оператора понадобятся. Она -не меняет настройки локальной среды и не повышает зрелость адаптера. +Команда показывает проверенные данные дескриптора: файлы, argv-массивы и +действия оператора. Она не выполняет эти массивы, не меняет настройки локальной +среды и не повышает зрелость адаптера. ## Проверка плана diff --git a/docs/ru/reference/cli.md b/docs/ru/reference/cli.md index 6d0d3308..08d86932 100644 --- a/docs/ru/reference/cli.md +++ b/docs/ru/reference/cli.md @@ -117,6 +117,11 @@ JSON, чтобы результат можно было проверять ав возобновление сессий адаптеров. Обычная интерактивная сессия возвращает `WAITING_FOR_TASK`; повышенная сессия связывается с состоянием рабочего цикла и задачей. +- `agent-lifecycle adapter session start --launch`: проверяет запрошенный + профиль запуска и затем возвращает `adapter-generic-launch-disabled` до + создания процесса. Один дескриптор не является полномочием на общий прямой + запуск CLI хоста. Общий выбор окружения принимает только точные имена из + разрешённого списка, а шаблоны отклоняются. - `agent-lifecycle adapter task start --adapter (--file task.md | --text "...")`: принимает задачу для выбранного адаптера. Обычный текст и Markdown возвращают `agent-adapter-task-start-receipt.v1` со статусом @@ -129,7 +134,8 @@ JSON, чтобы результат можно было проверять ав - `agent-lifecycle adapter run`: связывает сессию адаптера с зафиксированным состоянием рабочего цикла и возвращает управляемый следующий шаг ALK. Для этого управляемого пути прогресс по умолчанию показывается в stderr, а JSON stdout - остаётся `agent-adapter-session-receipt.v1`. + остаётся `agent-adapter-session-receipt.v1`. Команда не обходит общий запрет + запуска и не создаёт процесс CLI хоста. ## Контекст и продолжение diff --git a/docs/ru/reference/managed-adapter-sessions.md b/docs/ru/reference/managed-adapter-sessions.md index 7db51032..898cc999 100644 --- a/docs/ru/reference/managed-adapter-sessions.md +++ b/docs/ru/reference/managed-adapter-sessions.md @@ -65,27 +65,32 @@ receipt --progress-receipt `, чтобы сохранить ## Профиль запуска -Прямой запуск берётся из дескриптора. Поле `managedLaunch` в дескрипторе -адаптера объявляет один из статусов: +Поле `managedLaunch` в дескрипторе адаптера объявляет один из статусов. Это +описательные данные, а не полномочие на общий запуск процесса: | Статус | Значение | | --- | --- | -| `SUPPORTED` | В дескрипторе есть безопасные argv-шаблоны для управляемого запуска. | +| `SUPPORTED` | Дескриптор может содержать argv-шаблоны для отдельно проверенного локального маршрута. Общий маршрут библиотеки и CLI их не выполняет. | | `WRAPPER_ONLY` | Обёртка или операторский сценарий может использовать управляемые сессии ALK, но прямой argv-запуск не заявляется. | | `UNSUPPORTED` | Маршрут управляемого запуска не объявлен. | Текущие встроенные адаптеры объявляют `WRAPPER_ONLY`. Так ALK может давать доказательство управляемого жизненного цикла через свои команды, не заявляя -неподтверждённые прямые hooks или командные строки хоста. +неподтверждённые прямые hooks или командные строки хоста. `adapter session +start --launch` и прямой общий запуск через дескриптор всегда возвращают +`adapter-generic-launch-disabled` до создания процесса, независимо от статуса +профиля. ## Граница безопасности Управляемые сессии адаптеров закрываются при сомнении и не раскрывают секреты: - запуск использует argv-массивы с `shell: false`; +- общий выбор окружения принимает только точные имена, а шаблоны отклоняются; - секреты провайдера выбираются только по allowlist из дескриптора или локальной политики проекта; -- значения секретов не попадают в подтверждения; +- общий механизм очистки удаляет из подтверждений проверенные формы секретов и + локальных путей и фиксирует факт изменения значения; - отслеживаемые файлы прямой конфигурации хоста не записываются; - ALK не вставляет промпты в CLI хоста; - ALK не разбирает телеметрию конкретного хоста в ядре; diff --git a/docs/ru/reference/readiness-diagnostics.md b/docs/ru/reference/readiness-diagnostics.md index 63ec3078..d99c227e 100644 --- a/docs/ru/reference/readiness-diagnostics.md +++ b/docs/ru/reference/readiness-diagnostics.md @@ -19,3 +19,12 @@ Если статус не `PASS`, нужно сначала закрыть блокеры из отчёта. Молчаливое понижение требований не допускается. + +## Пробный план установки + +`agent-lifecycle adapter install-plan` возвращает +`agent-adapter-install-plan.v1`. Команда читает из дескриптора проверенные +данные установки: псевдонимы бинарных файлов, файлы, argv-массивы и действия +оператора. Диагностика не трактует argv-массивы как строку shell и не выполняет +их. Результат всегда является пробным: он не меняет настройки хоста, не +запускает модель и не повышает зрелость адаптера. diff --git a/docs/ru/security/neutrality-contract.md b/docs/ru/security/neutrality-contract.md new file mode 100644 index 00000000..0ccedeb1 --- /dev/null +++ b/docs/ru/security/neutrality-contract.md @@ -0,0 +1,28 @@ +# Контракт нейтральности + +Граница нейтральности не позволяет сведениям о происхождении репозитория, +секретам, внедрённым значениям запрета, корням доверия и ключам подписи попасть +в переносимый комплект. + +В отслеживаемом репозитории находятся только общие правила и код проверки. +Конкретные значения запрета и материалы подписи передаются хостом во время +работы через переменные `AGENT_LIFECYCLE_NEUTRALITY_*`. Подтверждения могут +содержать только отпечатки, подписи, пути вывода, счётчики с нулевым результатом, +время и идентификаторы рабочего цикла. В них не должны попадать значения правил +запрета, ключи, учётные данные или найденные фрагменты исходного текста. + +Сначала создаётся основной отчёт без замены существующего файла, затем как +маркер завершения публикуется отдельное подписанное подтверждение. Повтор той +же операции только читает существующие данные и должен давать тот же результат. +Занятый, подменённый, частичный, псевдонимный или изменившийся при чтении вывод +отклоняется. + +## Счётчики полноты + +Подписать отчёт или повторно использовать существующее подписанное +подтверждение можно только тогда, когда каждый обязательный счётчик присутствует +и равен целому нулю: `findings`, `skippedInputs`, `opaqueInputs`, `readRaces`, +`incompleteScans`, `unsupportedArchives`, `archiveLimitBreaches`, +`occupiedOutputConflicts` и `pathAliasConflicts`. Отсутствующее, нецелое или +ненулевое значение приводит к отказу. Это правило действует и без отдельного +флага, требующего нулевое число находок. diff --git a/docs/security/neutrality-contract.md b/docs/security/neutrality-contract.md index 45b1b1f5..e5ea1362 100644 --- a/docs/security/neutrality-contract.md +++ b/docs/security/neutrality-contract.md @@ -27,3 +27,13 @@ The producer writes the primary report first with create-no-replace semantics and publishes a detached signed receipt last as the commit marker. A same-operation replay is read-only and byte-identical; occupied, substituted, partial, aliased, or raced outputs fail closed. + +## Completeness counters + +A report is eligible for signing, and an existing signed receipt is eligible +for reuse, only when every required counter is present as the integer zero: +`findings`, `skippedInputs`, `opaqueInputs`, `readRaces`, `incompleteScans`, +`unsupportedArchives`, `archiveLimitBreaches`, `occupiedOutputConflicts`, and +`pathAliasConflicts`. A missing, non-integer, or nonzero value makes the +operation fail closed. The rule applies even when the caller did not request a +separate zero-findings convenience flag. diff --git a/pyproject.toml b/pyproject.toml index 06f0c309..5ce205cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agent-lifecycle-kit" -version = "1.50.1" +version = "1.50.2" description = "Provider-neutral lifecycle control layer that helps coding agents plan, execute, validate, and prove software tasks to completion." readme = "README.md" requires-python = ">=3.11,<3.14" diff --git a/src/agent_lifecycle/_version.py b/src/agent_lifecycle/_version.py index 633e83d3..e08d067b 100644 --- a/src/agent_lifecycle/_version.py +++ b/src/agent_lifecycle/_version.py @@ -1,3 +1,3 @@ """Package version.""" -__version__ = "1.50.1" +__version__ = "1.50.2" diff --git a/src/agent_lifecycle/adapter_sessions/contracts.py b/src/agent_lifecycle/adapter_sessions/contracts.py index b16bad4a..5fe16fbb 100644 --- a/src/agent_lifecycle/adapter_sessions/contracts.py +++ b/src/agent_lifecycle/adapter_sessions/contracts.py @@ -5,6 +5,7 @@ from typing import Any from agent_lifecycle.contracts import canonical_digest +from agent_lifecycle.contracts.redaction import redact_text ADAPTER_SESSION_RECEIPT_SCHEMA = "agent-adapter-session-receipt.v1" MANAGED_ADAPTER_LAUNCH_RECEIPT_SCHEMA = "agent-managed-adapter-launch-receipt.v1" @@ -67,9 +68,13 @@ def build_launch_receipt( cancelled: bool = False, stdout_tail: str = "", stderr_tail: str = "", + stdout_redacted: bool = False, + stderr_redacted: bool = False, host_launch_started: bool = False, blockers: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: + stdout_tail, stdout_changed = redact_text(stdout_tail[-2000:]) + stderr_tail, stderr_changed = redact_text(stderr_tail[-2000:]) body = { "schemaVersion": MANAGED_ADAPTER_LAUNCH_RECEIPT_SCHEMA, "status": status, @@ -83,8 +88,8 @@ def build_launch_receipt( "exitCode": exit_code, "timedOut": timed_out, "cancelled": cancelled, - "stdout": {"tail": stdout_tail[-2000:], "redacted": True}, - "stderr": {"tail": stderr_tail[-2000:], "redacted": True}, + "stdout": {"tail": stdout_tail, "redacted": stdout_redacted or stdout_changed}, + "stderr": {"tail": stderr_tail, "redacted": stderr_redacted or stderr_changed}, "hostLaunchStarted": host_launch_started, "modelCallsStarted": False, "secretsWritten": False, diff --git a/src/agent_lifecycle/adapter_sessions/env.py b/src/agent_lifecycle/adapter_sessions/env.py index 423fffb5..6b98113e 100644 --- a/src/agent_lifecycle/adapter_sessions/env.py +++ b/src/agent_lifecycle/adapter_sessions/env.py @@ -2,7 +2,6 @@ from __future__ import annotations -import fnmatch import os from pathlib import Path from typing import Any @@ -21,17 +20,17 @@ def resolve_launch_env( env_policy = profile.get("env", {}) if isinstance(profile.get("env"), dict) else {} names = set(_strings(env_policy.get("allow"))) - patterns = set(_strings(env_policy.get("allowPatterns"))) + _reject_wildcards(_strings(env_policy.get("allowPatterns"))) if policy_path is not None: if not env_policy.get("projectPolicyAllowed", False): raise LifecycleError("adapter-env-policy-not-allowed", "descriptor does not allow project env policy extension") policy = read_json_object(policy_path, label="adapter env policy") names.update(_strings(policy.get("allow"))) - patterns.update(_strings(policy.get("allowPatterns"))) + _reject_wildcards(_strings(policy.get("allowPatterns"))) source = dict(os.environ if process_env is None else process_env) selected: dict[str, str] = {} for name, value in source.items(): - if name in names or any(fnmatch.fnmatchcase(name, pattern) for pattern in patterns): + if name in names: selected[name] = value return selected, redact_env_names(list(selected)) @@ -42,3 +41,12 @@ def _strings(value: Any) -> list[str]: if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value): raise LifecycleError("adapter-env-policy-invalid", "env policy entries must be non-empty strings") return value + + +def _reject_wildcards(patterns: list[str]) -> None: + if patterns: + raise LifecycleError( + "adapter-env-wildcard-disallowed", + "generic launcher accepts only exact environment variable names", + {"patterns": sorted(set(patterns))}, + ) diff --git a/src/agent_lifecycle/adapter_sessions/launcher.py b/src/agent_lifecycle/adapter_sessions/launcher.py index 2915571e..b27cc65c 100644 --- a/src/agent_lifecycle/adapter_sessions/launcher.py +++ b/src/agent_lifecycle/adapter_sessions/launcher.py @@ -6,9 +6,8 @@ from typing import Any from agent_lifecycle.adapter_sessions.contracts import build_launch_receipt -from agent_lifecycle.adapter_sessions.env import resolve_launch_env -from agent_lifecycle.adapter_sessions.process import run_process from agent_lifecycle.contracts import LifecycleError, read_json_object +from agent_lifecycle.host_protocol.validation import validate_managed_launch_profile def load_adapter_descriptor(adapter_id: str, descriptor_path: Path | None = None) -> tuple[Path, dict[str, Any]]: @@ -36,66 +35,45 @@ def launch_from_descriptor( policy_path: Path | None = None, process_env: dict[str, str] | None = None, ) -> dict[str, Any]: - profile = managed_launch_profile(descriptor) - adapter_id = descriptor["adapterId"] - timeout_seconds = float(profile.get("timeoutSeconds", 30.0)) - status = profile.get("status") - if status != "SUPPORTED": - reason = profile.get("reason") or f"managed launch profile is {status}" - return build_launch_receipt( - status="BLOCKED", - adapter_id=adapter_id, - session_id=session_id, - launch_mode=launch_mode, - argv=[], - timeout_seconds=timeout_seconds, - env={"includedNames": [], "valuesRedacted": True, "secretValuesStored": False}, - exit_code=None, - timed_out=False, - blockers=[{"code": "adapter-managed-launch-unsupported", "profileStatus": status, "reason": reason}], + """Block generic descriptor-driven launch until the qualified local route exists.""" + + del task_id, state_path, policy_path, process_env + raw_profile = descriptor.get("managedLaunch") + profile = raw_profile if isinstance(raw_profile, dict) else {} + validation = validate_managed_launch_profile(profile) + adapter_id = descriptor.get("adapterId") if isinstance(descriptor.get("adapterId"), str) else "unknown" + timeout_seconds = _timeout_seconds(profile) + blockers: list[dict[str, Any]] = [] + if validation["status"] != "PASS": + blockers.append( + { + "code": "adapter-generic-launch-invalid-descriptor", + "validationBlockers": validation["blockers"], + } ) - argv = _resolve_argv(profile, launch_mode=launch_mode, task_id=task_id, state_path=state_path) - env, env_receipt = resolve_launch_env(profile, policy_path=policy_path, process_env=process_env) - result = run_process(argv, env=env, timeout_seconds=timeout_seconds) + blockers.append( + { + "code": "adapter-generic-launch-disabled", + "profileStatus": profile.get("status"), + "reason": "generic descriptor-driven launch is disabled until a qualified local-profile route exists", + } + ) return build_launch_receipt( - status=result["status"], + status="BLOCKED", adapter_id=adapter_id, session_id=session_id, launch_mode=launch_mode, - argv=argv, + argv=[], timeout_seconds=timeout_seconds, - env=env_receipt, - exit_code=result["exitCode"], - timed_out=result["timedOut"], - stdout_tail=result["stdoutTail"], - stderr_tail=result["stderrTail"], - host_launch_started=True, - blockers=result["blockers"], + env={"includedNames": [], "valuesRedacted": True, "secretValuesStored": False}, + exit_code=None, + timed_out=False, + blockers=blockers, ) -def _resolve_argv( - profile: dict[str, Any], - *, - launch_mode: str, - task_id: str | None, - state_path: Path | None, -) -> list[str]: - templates = profile.get("argvTemplates") - if not isinstance(templates, dict): - raise LifecycleError("adapter-managed-launch-argv-missing", "managed launch profile needs argvTemplates") - raw = templates.get(launch_mode) - if not isinstance(raw, list) or not all(isinstance(item, str) and item for item in raw): - raise LifecycleError("adapter-managed-launch-argv-invalid", f"argv template missing for {launch_mode}") - values = { - "taskId": task_id or "", - "state": state_path.as_posix() if state_path else "", - } - return [_render_template(item, values) for item in raw] - - -def _render_template(value: str, values: dict[str, str]) -> str: - rendered = value - for key, replacement in values.items(): - rendered = rendered.replace("{{" + key + "}}", replacement) - return rendered +def _timeout_seconds(profile: dict[str, Any]) -> float: + timeout = profile.get("timeoutSeconds") + if isinstance(timeout, (int, float)) and not isinstance(timeout, bool) and timeout > 0: + return float(timeout) + return 30.0 diff --git a/src/agent_lifecycle/adapter_sessions/process.py b/src/agent_lifecycle/adapter_sessions/process.py index 09e64342..a80d862c 100644 --- a/src/agent_lifecycle/adapter_sessions/process.py +++ b/src/agent_lifecycle/adapter_sessions/process.py @@ -5,7 +5,7 @@ import subprocess from typing import Any -from agent_lifecycle.adapter_sessions.redaction import redact_text +from agent_lifecycle.contracts.redaction import redact_text def run_process(argv: list[str], *, env: dict[str, str], timeout_seconds: float) -> dict[str, Any]: @@ -20,20 +20,28 @@ def run_process(argv: list[str], *, env: dict[str, str], timeout_seconds: float) timeout=timeout_seconds, check=False, ) + stdout_tail, stdout_redacted = redact_text(result.stdout[-2000:]) + stderr_tail, stderr_redacted = redact_text(result.stderr[-2000:]) return { "status": "PASS" if result.returncode == 0 else "FAIL", "exitCode": result.returncode, "timedOut": False, - "stdoutTail": redact_text(result.stdout[-2000:]), - "stderrTail": redact_text(result.stderr[-2000:]), + "stdoutTail": stdout_tail, + "stdoutRedacted": stdout_redacted, + "stderrTail": stderr_tail, + "stderrRedacted": stderr_redacted, "blockers": [] if result.returncode == 0 else [{"code": "adapter-process-nonzero-exit", "exitCode": result.returncode}], } except subprocess.TimeoutExpired as exc: + stdout_tail, stdout_redacted = redact_text((exc.stdout or "")[-2000:] if isinstance(exc.stdout, str) else "") + stderr_tail, stderr_redacted = redact_text((exc.stderr or "")[-2000:] if isinstance(exc.stderr, str) else "") return { "status": "FAIL", "exitCode": None, "timedOut": True, - "stdoutTail": redact_text((exc.stdout or "")[-2000:] if isinstance(exc.stdout, str) else ""), - "stderrTail": redact_text((exc.stderr or "")[-2000:] if isinstance(exc.stderr, str) else ""), + "stdoutTail": stdout_tail, + "stdoutRedacted": stdout_redacted, + "stderrTail": stderr_tail, + "stderrRedacted": stderr_redacted, "blockers": [{"code": "adapter-process-timeout", "timeoutSeconds": timeout_seconds}], } diff --git a/src/agent_lifecycle/adapter_sessions/redaction.py b/src/agent_lifecycle/adapter_sessions/redaction.py index 8255866e..51723582 100644 --- a/src/agent_lifecycle/adapter_sessions/redaction.py +++ b/src/agent_lifecycle/adapter_sessions/redaction.py @@ -1,8 +1,8 @@ -"""Redaction helpers for adapter session receipts.""" +"""Adapter-session compatibility helpers for shared receipt redaction.""" from __future__ import annotations -SENSITIVE_MARKERS = ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL") +from agent_lifecycle.contracts.redaction import redact_text as _redact_text def redact_env_names(names: list[str]) -> dict[str, object]: @@ -14,17 +14,6 @@ def redact_env_names(names: list[str]) -> dict[str, object]: def redact_text(value: str) -> str: - text = value - for marker in SENSITIVE_MARKERS: - text = _redact_marker(text, marker) - return text + """Return the shared redacted text for legacy adapter-session callers.""" - -def _redact_marker(text: str, marker: str) -> str: - words = [] - for word in text.split(): - if marker in word.upper() and "=" in word: - words.append(word.split("=", 1)[0] + "=") - else: - words.append(word) - return " ".join(words) + return _redact_text(value)[0] diff --git a/src/agent_lifecycle/audit/review_verdict.py b/src/agent_lifecycle/audit/review_verdict.py index 4c718fcc..40a10609 100644 --- a/src/agent_lifecycle/audit/review_verdict.py +++ b/src/agent_lifecycle/audit/review_verdict.py @@ -1,116 +1,13 @@ -"""Structured review verdicts for targeted remediation routing.""" - -from __future__ import annotations - -from typing import Any - -from agent_lifecycle.contracts import LifecycleError, canonical_digest - -REVIEW_VERDICT_SCHEMA = "agent-review-verdict.v1" -REVIEW_VERDICT_VALIDATION_SCHEMA = "agent-review-verdict-validation.v1" -DIMENSIONS = ("requirementFit", "implementationQuality", "evidenceQuality", "residualRisk") -DIMENSION_STATUSES = {"PASS", "WARN", "FAIL"} -OVERALL_VERDICTS = {"ACCEPTED", "REWORK", "CONTRACT_CHANGE", "BLOCKED"} -NEXT_ACTIONS = {"accept", "fix-implementation", "strengthen-evidence", "reopen-contract", "block-external"} - - -def validate_review_verdict(verdict: dict[str, Any], *, findings: list[dict[str, Any]] | None = None) -> dict[str, Any]: - if not isinstance(verdict, dict): - raise LifecycleError("invalid-review-verdict", "review verdict must be an object") - blockers: list[dict[str, Any]] = [] - if verdict.get("schemaVersion") != REVIEW_VERDICT_SCHEMA: - blockers.append({"code": "invalid-review-verdict-schema", "message": "unsupported review verdict schemaVersion"}) - overall = verdict.get("overall") - if overall not in OVERALL_VERDICTS: - blockers.append({"code": "invalid-review-verdict-overall", "message": "overall verdict is unsupported"}) - dimensions = verdict.get("dimensions") - if not isinstance(dimensions, dict): - blockers.append({"code": "invalid-review-verdict-dimensions", "message": "dimensions must be an object"}) - dimensions = {} - missing = sorted(set(DIMENSIONS).difference(dimensions)) - if missing: - blockers.append({"code": "review-verdict-dimension-missing", "dimensions": missing}) - failing_dimensions: list[str] = [] - warning_dimensions: list[str] = [] - for name in DIMENSIONS: - dimension = dimensions.get(name) - if not isinstance(dimension, dict): - continue - status = dimension.get("status") - if status not in DIMENSION_STATUSES: - blockers.append({"code": "review-verdict-dimension-status", "dimension": name, "status": status}) - continue - reason = dimension.get("reasonCode") - if not isinstance(reason, str) or not reason: - blockers.append({"code": "review-verdict-dimension-reason-missing", "dimension": name}) - summary = dimension.get("summary") - if not isinstance(summary, str) or not summary: - blockers.append({"code": "review-verdict-dimension-summary-missing", "dimension": name}) - if status == "FAIL": - failing_dimensions.append(name) - elif status == "WARN": - warning_dimensions.append(name) - routing = verdict.get("routing") - if not isinstance(routing, dict): - blockers.append({"code": "invalid-review-verdict-routing", "message": "routing must be an object"}) - routing = {} - next_action = routing.get("nextAction") - if next_action not in NEXT_ACTIONS: - blockers.append({"code": "review-verdict-routing-action", "nextAction": next_action}) - target = routing.get("target") - if target is not None and (not isinstance(target, str) or not target): - blockers.append({"code": "review-verdict-routing-target", "message": "routing target must be a non-empty string"}) - open_medium_plus = _open_medium_plus(findings or []) - if open_medium_plus: - blockers.append({"code": "review-verdict-open-findings", "findings": open_medium_plus}) - if overall == "ACCEPTED" and (failing_dimensions or open_medium_plus): - blockers.append({"code": "review-verdict-accepted-with-blockers", "dimensions": failing_dimensions, "findings": open_medium_plus}) - if overall == "ACCEPTED" and next_action != "accept": - blockers.append({"code": "review-verdict-routing-mismatch", "overall": overall, "nextAction": next_action}) - if overall != "ACCEPTED" and next_action == "accept": - blockers.append({"code": "review-verdict-routing-mismatch", "overall": overall, "nextAction": next_action}) - body = { - "schemaVersion": REVIEW_VERDICT_VALIDATION_SCHEMA, - "status": "PASS" if not blockers else "FAIL", - "overall": overall, - "failingDimensions": failing_dimensions, - "warningDimensions": warning_dimensions, - "nextAction": next_action, - "blockers": blockers, - } - return {**body, "verdictDigest": canonical_digest(verdict), "validationDigest": canonical_digest(body)} - - -def require_review_verdict_pass(payload: dict[str, Any]) -> dict[str, Any]: - if payload.get("status") == "FAIL": - raise LifecycleError("review-verdict-validation-failed", "review verdict validation failed", {"validation": payload}) - return payload - - -def compact_review_routing(verdict: dict[str, Any]) -> dict[str, Any]: - validation = validate_review_verdict(verdict) - dimensions = verdict.get("dimensions", {}) - return { - "schemaVersion": "agent-review-routing-summary.v1", - "status": validation["status"], - "overall": validation["overall"], - "nextAction": validation["nextAction"], - "failingDimensions": validation["failingDimensions"], - "warningDimensions": validation["warningDimensions"], - "dimensionStatus": { - key: dimensions.get(key, {}).get("status") - for key in DIMENSIONS - if isinstance(dimensions.get(key), dict) - }, - "verdictDigest": validation["verdictDigest"], - } - - -def _open_medium_plus(findings: list[dict[str, Any]]) -> list[str]: - return [ - str(finding.get("id") or "") - for finding in findings - if isinstance(finding, dict) - and finding.get("status") == "open" - and finding.get("severity") in {"BLOCKER", "HIGH", "MEDIUM"} - ] +"""Compatibility facade for the lower review-verdict contract layer.""" + +from agent_lifecycle.contracts.review_verdict import ( + compact_review_routing, + require_review_verdict_pass, + validate_review_verdict, +) + +__all__ = [ + "compact_review_routing", + "require_review_verdict_pass", + "validate_review_verdict", +] diff --git a/src/agent_lifecycle/cli/dispatch.py b/src/agent_lifecycle/cli/dispatch.py index 2e13b333..4183e7dc 100644 --- a/src/agent_lifecycle/cli/dispatch.py +++ b/src/agent_lifecycle/cli/dispatch.py @@ -1,1220 +1,41 @@ -"""Root CLI dispatch handlers.""" +"""Thin root CLI dispatcher for Agent Lifecycle Kit.""" from __future__ import annotations import argparse -from pathlib import Path from typing import Any -from agent_lifecycle import __version__ -from agent_lifecycle.audit import ( - build_final_implementation_audit, - build_implementation_audit_report, - build_ownership_report, - require_review_verdict_pass, - validate_review_verdict, -) -from agent_lifecycle.audit.ownership import report_has_category -from agent_lifecycle.changesets import changed_files -from agent_lifecycle.compiler import compile_small_model_packets, compile_task_packets -from agent_lifecycle.contracts import LifecycleError, canonical_digest, read_json_object, write_json_create -from agent_lifecycle.contracts.compatibility import ( - build_contract_policy, - require_contract_policy_pass, - validate_contract_policy, -) -from agent_lifecycle.contracts.schemas import get_schema, list_schemas -from agent_lifecycle.context import check_context, load_context_profile, render_context -from agent_lifecycle.context.external_memory import build_episode_retrieval_with_external_context, import_external_memory_context -from agent_lifecycle.diagnostics import build_diagnostic_bundle, build_readiness_report -from agent_lifecycle.evidence_index import ( - build_evidence_index, - require_evidence_index_pass, - require_evidence_search_pass, - search_evidence_index, - validate_evidence_index, -) -from agent_lifecycle.freeze import verify_plan_lock -from agent_lifecycle.cli.adapter import dispatch_adapter +from agent_lifecycle.cli.dispatch_adapters import dispatch_adapters +from agent_lifecycle.cli.dispatch_contracts import dispatch_contracts +from agent_lifecycle.cli.dispatch_lifecycle import dispatch_lifecycle +from agent_lifecycle.cli.dispatch_observability import dispatch_observability +from agent_lifecycle.cli.dispatch_planning import dispatch_planning from agent_lifecycle.cli.followup import dispatch_followup from agent_lifecycle.cli.policy import dispatch_policy -from agent_lifecycle.cli.progress_hooks import maybe_emit_workflow_progress_hook, validate_workflow_progress_hook_request from agent_lifecycle.cli.worktree import dispatch_worktree -from agent_lifecycle.goal import build_goal_progress_view, build_objective_snapshot, update_goal_record, validate_goal_record -from agent_lifecycle.imports import ( - bmad_profile, - external_dialect_registry, - import_external_dialect, - import_markdown_collection, - import_planning_input, - openspec_profile, - require_external_import_pass, - require_import_validation_pass, - require_skill_proposal_pass, - spec_kit_profile, - spec_kitty_profile, - validate_external_import_result, - validate_import_result, - validate_skill_improvement_proposal, -) -from agent_lifecycle.model_routing import ( - resolve_model_route, - validate_host_model_profile, - validate_model_routing_profile, - validate_usage_receipt, -) -from agent_lifecycle.metrics import ( - build_quality_cost_signals, - build_task_outcome_index, - build_usage_export, - build_lifecycle_cost_summary, - build_lifecycle_recommendation_summary, - generate_lifecycle_cost_report, - recommend_from_quality_cost_signals, - recommend_lifecycle_mode, - require_lifecycle_cost_pass, - require_lifecycle_recommendation_pass, - require_usage_export_pass, - validate_lifecycle_cost_report, - validate_usage_export, -) -from agent_lifecycle.planning import ( - load_plan_completeness_profile, - build_plan_snapshot, - build_task_template_library, - reconcile_plan_snapshot, - require_task_template_validation_pass, - render_plan_handoff, - require_reconciliation_pass, - require_repository_references_pass, - resolve_sdd_tier, - validate_acceptance_checklist, - validate_plan_completeness, - validate_plan_manifest, - validate_repository_references, - validate_task_template_library, -) -from agent_lifecycle.quality import ( - build_bug_forensics_recipe_library, - build_default_quality_pack, - require_behavior_checks_pass, - require_bug_forensics_recipe_pass, - require_quality_pack_pass, - run_behavior_checks, - validate_bug_forensics_recipe_library, - validate_quality_pack, -) -from agent_lifecycle.review_mesh import ( - build_quorum_from_synthesis, - build_review_mesh_assignment_packet, - build_review_mesh_profile, - import_review_mesh_result, - list_review_mesh_operator_templates, - parse_reviewer_spec, - prepare_review_mesh_operator_packets, - recommend_review_mesh_for_intake, - recommend_review_mesh_for_plan_manifest, - recommend_review_mesh_for_text, - require_review_mesh_profile_pass, - require_review_mesh_recommendation_pass, - source_from_handoff, - source_from_intake, - source_from_manifest, - synthesize_review_mesh_results, - validate_review_mesh_profile, - validate_review_mesh_recommendation, -) -from agent_lifecycle.reporting import ( - build_change_summary_receipt, - build_lifecycle_progress_view, - build_lifecycle_progress_watch, - build_progress_bridge_receipt, - build_status_view, - build_workflow_event_feed, - render_progress_bridge_terminal, - render_goal_view_terminal, - render_progress_terminal, - render_usage_export_json, - render_usage_export_table, -) -from agent_lifecycle.runner import ( - build_runner_snapshot, - initialize_runner_state, - load_runner_policy, - load_runner_state, - request_runner_stop, - resume_runner, - transition_runner, - validate_runner_state, -) -from agent_lifecycle.runner.core import write_runner_state, write_runner_state_create -from agent_lifecycle.specification import build_completion_gate_receipt, validate_specification -from agent_lifecycle.workflow import ( - accept_task, - adopt_plan, - apply_budget_decision, - block_run, - commit_task_result, - finalize_run, - next_action, - pause_for_budget_decision, - resolve_blocker, - run_managed_lifecycle_step, - start_execution, - start_task, - status, - validate_budget_exceeded_policy, -) +from agent_lifecycle.contracts import LifecycleError def dispatch(args: argparse.Namespace, remainder: list[str]) -> dict[str, Any] | str | None: - if args.command == "version": - return {"schemaVersion": "agent-lifecycle-version.v1", "version": __version__} - if args.command == "schema": - if args.schema_command == "list": - return list_schemas() - if args.schema_command == "show": - return get_schema(args.schema_id) - if args.command == "contract": - return _dispatch_contract(args) - if args.command == "diagnose": - return build_readiness_report( - project_root=Path(args.project_root), - adapter_paths=[Path(item) for item in args.adapter] if args.adapter else None, - include_install_plans=not args.no_install_plans, - include_host_probes=args.include_host_probes, - timeout_seconds=args.timeout_seconds, - max_host_probes=args.max_host_probes, - context_profile=Path(args.context_profile) if args.context_profile else None, - model_profile=Path(args.model_profile) if args.model_profile else None, - adapter_baseline=Path(args.adapter_baseline) if args.adapter_baseline else None, - ) - if args.command == "diagnostics": - return _dispatch_diagnostics(args) - if args.command == "evidence": - return _dispatch_evidence(args) - if args.command == "import": - return _dispatch_import(args) - if args.command == "quality": - return _dispatch_quality(args) - if args.command == "review-mesh": - return _dispatch_review_mesh(args) - if args.command == "report": - return _dispatch_report(args) + """Route a parsed command without owning domain behavior or CLI output.""" + del remainder + if args.command in {"diagnose", "diagnostics", "adapter"}: + return dispatch_adapters(args) + if args.command in {"version", "schema", "contract", "evidence", "import", "quality", "review-mesh"}: + return dispatch_contracts(args) + if args.command in {"workflow", "audit", "runner"}: + return dispatch_lifecycle(args) + if args.command in {"report", "context", "goal", "model", "metrics"}: + return dispatch_observability(args) + if args.command in {"tier", "specification", "plan", "task"}: + return dispatch_planning(args) if args.command == "policy": return dispatch_policy(args) - if args.command == "workflow": - return _dispatch_workflow(args) - if args.command == "audit": - return _dispatch_audit(args) - if args.command == "context": - return _dispatch_context(args) - if args.command == "goal": - return _dispatch_goal(args) if args.command == "followup": return dispatch_followup(args) if args.command == "worktree": return dispatch_worktree(args) - if args.command == "model": - return _dispatch_model(args) - if args.command == "metrics": - return _dispatch_metrics(args) - if args.command == "runner": - return _dispatch_runner(args) - if args.command == "tier": - return _dispatch_tier(args) - if args.command == "specification": - return _dispatch_specification(args) - if args.command == "plan": - return _dispatch_plan(args) - if args.command == "task": - return _dispatch_task(args) - if args.command == "adapter": - return dispatch_adapter(args) raise LifecycleError( "command-not-implemented", f"{args.command} command group is reserved but not implemented in this build", ) - - -def _dispatch_diagnostics(args: argparse.Namespace) -> dict[str, Any]: - if args.diagnostics_command == "bundle": - payload = build_diagnostic_bundle( - project_root=Path(args.project_root), - artifact_paths=[Path(item) for item in args.artifact], - max_artifacts=args.max_artifacts, - max_input_bytes=args.max_input_bytes, - ) - if args.out: - write_json_create(Path(args.out), payload) - return payload - raise LifecycleError("command-not-implemented", "diagnostics command is not implemented") - - -def _dispatch_contract(args: argparse.Namespace) -> dict[str, Any]: - if args.contract_command == "policy": - payload = build_contract_policy() - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.contract_command == "check": - policy = read_json_object(Path(args.policy), label="public contract policy") if args.policy else build_contract_policy() - return require_contract_policy_pass(validate_contract_policy(policy)) - raise LifecycleError("command-not-implemented", "contract command is not implemented") - - -def _dispatch_evidence(args: argparse.Namespace) -> dict[str, Any]: - if args.evidence_command == "index": - payload = build_evidence_index( - Path(args.project_root), - list(args.artifact), - max_artifacts=args.max_artifacts, - max_input_bytes=args.max_input_bytes, - target_tokens=args.target_tokens, - ) - require_evidence_index_pass(validate_evidence_index(payload)) - require_evidence_index_pass(payload) - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.evidence_command == "search": - payload = search_evidence_index( - read_json_object(Path(args.index), label="evidence index"), - query=args.query or "", - max_results=args.max_results, - target_tokens=args.target_tokens, - ) - require_evidence_search_pass(payload) - if args.out: - write_json_create(Path(args.out), payload) - return payload - raise LifecycleError("command-not-implemented", "evidence command is not implemented") - - -def _dispatch_import(args: argparse.Namespace) -> dict[str, Any]: - if args.import_command == "profile-list": - payload = external_dialect_registry() - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.import_command == "plan": - source = Path(args.source) - dialect_profile = _planning_import_profile(args.dialect, max_input_bytes=args.max_input_bytes, target_tokens=args.target_tokens) - if source.is_dir() or dialect_profile is not None: - payload = import_markdown_collection( - source, - package_id=args.package_id, - max_input_bytes=args.max_input_bytes, - target_tokens=args.target_tokens, - max_files=args.max_files, - dialect_profile=dialect_profile, - ) - else: - payload = import_planning_input( - source, - package_id=args.package_id, - max_input_bytes=args.max_input_bytes, - target_tokens=args.target_tokens, - ) - require_import_validation_pass(validate_import_result(payload)) - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.import_command == "external": - payload = import_external_dialect( - Path(args.source), - family=args.family, - profile_id=args.profile, - package_id=args.package_id, - max_input_bytes=args.max_input_bytes, - target_tokens=args.target_tokens, - ) - require_external_import_pass(validate_external_import_result(payload)) - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.import_command == "check": - return require_import_validation_pass(validate_import_result(read_json_object(Path(args.candidate), label="planning import result"))) - if args.import_command == "external-check": - return require_external_import_pass(validate_external_import_result(read_json_object(Path(args.candidate), label="external dialect import result"))) - if args.import_command == "proposal-check": - return require_skill_proposal_pass(validate_skill_improvement_proposal(read_json_object(Path(args.proposal), label="skill proposal"))) - raise LifecycleError("command-not-implemented", "import command is not implemented") - - -def _planning_import_profile( - dialect: str | None, - *, - max_input_bytes: int, - target_tokens: int, -) -> dict[str, Any] | None: - if dialect is None: - return None - builders = { - "openspec": openspec_profile, - "spec-kit": spec_kit_profile, - "bmad": bmad_profile, - "spec-kitty": spec_kitty_profile, - } - return builders[dialect](max_input_bytes=max_input_bytes, target_tokens=target_tokens) - - -def _dispatch_quality(args: argparse.Namespace) -> dict[str, Any]: - if args.quality_command == "pack-check": - manifest = read_json_object(Path(args.manifest), label="quality pack") if args.manifest else build_default_quality_pack() - return require_quality_pack_pass(validate_quality_pack(manifest)) - if args.quality_command == "behavior-check": - manifest = read_json_object(Path(args.manifest), label="quality pack") if args.manifest else build_default_quality_pack() - fixtures = [read_json_object(Path(item), label="behavior fixture") for item in args.fixture] - return require_behavior_checks_pass(run_behavior_checks(manifest, fixtures)) - if args.quality_command == "template-list": - payload = build_task_template_library() - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.quality_command == "template-check": - payload = validate_task_template_library(project_root=Path(args.project_root), template_id=args.template_id) - require_task_template_validation_pass(payload) - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.quality_command == "bug-recipe-list": - payload = build_bug_forensics_recipe_library() - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.quality_command == "bug-recipe-check": - payload = validate_bug_forensics_recipe_library(recipe_id=args.recipe_id) - require_bug_forensics_recipe_pass(payload) - if args.out: - write_json_create(Path(args.out), payload) - return payload - raise LifecycleError("command-not-implemented", "quality command is not implemented") - - -def _dispatch_review_mesh(args: argparse.Namespace) -> dict[str, Any]: - if args.review_mesh_command == "template-list": - payload = list_review_mesh_operator_templates() - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.review_mesh_command == "profile": - budget_cap = _review_mesh_budget_cap(args) - payload = build_review_mesh_profile( - profile_id=args.profile_id, - modes=args.mode or None, - default_mode=args.default_mode, - budget_cap=budget_cap, - live_calls_allowed=args.allow_live_calls, - independence_required=args.require_independence, - independence_dimensions=args.independence_dimension or None, - reviewer_model_classes=args.reviewer_model_class or None, - ) - require_review_mesh_profile_pass(validate_review_mesh_profile(payload)) - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.review_mesh_command == "recommend": - if args.text is not None: - payload = recommend_review_mesh_for_text( - args.text, - sdd_tier=args.sdd_tier, - risk_flags=args.risk_flag, - ) - elif args.file: - path = Path(args.file) - payload = recommend_review_mesh_for_text( - path.read_text(encoding="utf-8"), - source_label=path.name, - sdd_tier=args.sdd_tier, - risk_flags=args.risk_flag, - ) - elif args.intake: - payload = recommend_review_mesh_for_intake(read_json_object(Path(args.intake), label="adapter task intake receipt")) - elif args.manifest: - payload = recommend_review_mesh_for_plan_manifest(read_json_object(Path(args.manifest), label="plan manifest")) - else: - raise LifecycleError("review-mesh-recommendation-source-missing", "one recommendation source is required") - require_review_mesh_recommendation_pass(validate_review_mesh_recommendation(payload)) - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.review_mesh_command == "prepare": - if args.manifest: - source = source_from_manifest(read_json_object(Path(args.manifest), label="plan manifest")) - elif args.intake: - source = source_from_intake(read_json_object(Path(args.intake), label="adapter task intake receipt")) - elif args.handoff: - source = source_from_handoff(read_json_object(Path(args.handoff), label="plan handoff")) - else: - raise LifecycleError("review-mesh-prepare-source-missing", "one prepare source is required") - payload = prepare_review_mesh_operator_packets( - source=source, - template_id=args.template, - reviewers=[parse_reviewer_spec(spec) for spec in args.reviewer] if args.reviewer else None, - profile_id=args.profile_id, - phase=args.phase, - blocking=args.blocking, - evidence_ids=args.evidence_id, - out_dir=Path(args.out_dir) if args.out_dir else None, - ) - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.review_mesh_command == "assign": - if args.manifest: - source = source_from_manifest(read_json_object(Path(args.manifest), label="plan manifest")) - elif args.intake: - source = source_from_intake(read_json_object(Path(args.intake), label="adapter task intake receipt")) - elif args.handoff: - source = source_from_handoff(read_json_object(Path(args.handoff), label="plan handoff")) - else: - raise LifecycleError("review-mesh-assignment-source-missing", "one assignment source is required") - profile = read_json_object(Path(args.profile), label="review mesh profile") if args.profile else build_review_mesh_profile(default_mode=args.mode, independence_required=False) - payload = build_review_mesh_assignment_packet( - source=source, - mode=args.mode, - phase=args.phase, - assignment_id=args.assignment_id, - reviewer_id=args.reviewer_id, - reviewer_role=args.reviewer_role, - reviewer_model_class=args.reviewer_model_class, - reviewer_host_identity_hash=args.reviewer_host_identity_hash, - reviewer_model_identity_hash=args.reviewer_model_identity_hash, - blocking=args.blocking, - profile=profile, - evidence_ids=args.evidence_id, - ) - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.review_mesh_command == "import-result": - assignment_payload = read_json_object(Path(args.assignment), label="review mesh assignment") - assignment = assignment_payload.get("assignment") if isinstance(assignment_payload.get("assignment"), dict) else assignment_payload - payload = import_review_mesh_result( - profile=read_json_object(Path(args.profile), label="review mesh profile"), - assignment=assignment, - reviewer_output=read_json_object(Path(args.reviewer_output), label="reviewer output"), - allow_local_evidence_refs=args.allow_local_evidence_ref, - ) - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.review_mesh_command == "synthesize": - payload = synthesize_review_mesh_results( - profile=read_json_object(Path(args.profile), label="review mesh profile"), - results=[read_json_object(Path(path), label="review mesh result") for path in args.result], - mode=args.mode, - accepted_finding_ids=args.accepted_finding_id, - rejected_finding_ids=args.rejected_finding_id, - ) - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.review_mesh_command == "quorum": - profile = read_json_object(Path(args.profile), label="review mesh profile") - synthesis = read_json_object(Path(args.synthesis), label="review mesh synthesis") - payload = build_quorum_from_synthesis( - profile=profile, - synthesis=synthesis, - quorum_policy={"minReviewers": args.min_reviewers, "requiredRoles": args.required_role}, - reviewer_roles=args.reviewer_role, - ) - if args.out: - write_json_create(Path(args.out), payload) - return payload - raise LifecycleError("command-not-implemented", "review-mesh command is not implemented") - - -def _review_mesh_budget_cap(args: argparse.Namespace) -> dict[str, int] | None: - budget_cap = { - key: value - for key, value in { - "maxInvocations": args.max_invocations, - "maxInputTokens": args.max_input_tokens, - "maxOutputTokens": args.max_output_tokens, - "maxWallSeconds": args.max_wall_seconds, - }.items() - if value is not None - } - return budget_cap or None - - -def _dispatch_report(args: argparse.Namespace) -> dict[str, Any] | str: - if args.report_command == "status-view": - payload = build_status_view( - project_root=Path(args.project_root), - artifact_paths=[Path(item) for item in args.artifact], - max_items=args.max_items, - target_window=args.target_window, - ) - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.report_command == "progress-bridge": - payload = build_progress_bridge_receipt( - adapter_id=args.adapter, - support_level=args.support_level, - hook_point=args.hook_point, - state_path=Path(args.state), - usage_receipt_paths=[Path(item) for item in args.usage_receipt], - change_summary_path=Path(args.change_summary) if args.change_summary else None, - display_mode=args.display_mode, - watch=args.watch, - iterations=args.watch_iterations, - interval_seconds=args.watch_interval, - ) - if args.out: - write_json_create(Path(args.out), payload) - if args.terminal: - return render_progress_bridge_terminal(payload) - return payload - if args.report_command == "event-feed": - payload = build_workflow_event_feed(state_path=Path(args.state)) - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.report_command == "progress": - if args.watch: - payload = build_lifecycle_progress_watch( - state_path=Path(args.state), - usage_receipt_paths=[Path(item) for item in args.usage_receipt], - change_summary_path=Path(args.change_summary) if args.change_summary else None, - iterations=args.watch_iterations, - interval_seconds=args.watch_interval, - ) - else: - payload = build_lifecycle_progress_view( - state_path=Path(args.state), - usage_receipt_paths=[Path(item) for item in args.usage_receipt], - change_summary_path=Path(args.change_summary) if args.change_summary else None, - ) - if args.out: - write_json_create(Path(args.out), payload) - if args.terminal: - return render_progress_terminal(payload) - return payload - if args.report_command == "change-summary": - payload = build_change_summary_receipt( - project_root=Path(args.project_root), - base=args.base, - head=args.head, - staged=args.staged, - paths=args.path, - ) - if args.out: - write_json_create(Path(args.out), payload) - return payload - raise LifecycleError("command-not-implemented", "report command is not implemented") - - -def _dispatch_workflow(args: argparse.Namespace) -> dict[str, Any]: - if args.workflow_command == "budget-policy-check": - return validate_budget_exceeded_policy(read_json_object(Path(args.policy), label="budget policy")) - state_path = Path(args.state) - if args.workflow_command == "status": - return status(state_path, full=args.full) - if args.workflow_command == "next": - return next_action(status(state_path, full=True)["state"]) - if args.workflow_command == "run": - validate_workflow_progress_hook_request(args, command="workflow run") - payload = run_managed_lifecycle_step( - state_path=state_path, - manifest_path=Path(args.manifest), - lock_path=Path(args.lock) if args.lock else None, - operation_id=args.operation_id, - expected_revision=args.expected_revision, - source_revision=args.source_revision, - reason=args.reason, - ) - if args.out: - write_json_create(Path(args.out), payload) - maybe_emit_workflow_progress_hook(args, command="workflow run", state_path=state_path) - return payload - if args.workflow_command == "adopt-plan": - return adopt_plan( - state_path, - manifest_path=Path(args.manifest), - operation_id=args.operation_id, - expected_revision=args.expected_revision, - source_revision=args.source_revision, - reset_tasks=args.reset_tasks, - preserve_accepted_compatible=args.preserve_accepted_compatible, - start_mode=args.start_mode, - authorized_by=args.authorized_by, - ) - if args.workflow_command == "run-start": - return start_execution( - state_path, - operation_id=args.operation_id, - expected_revision=args.expected_revision, - source_revision=args.source_revision, - reason=args.reason, - ) - if args.workflow_command == "finalize": - validate_workflow_progress_hook_request(args, command="workflow finalize") - payload = finalize_run( - state_path, - operation_id=args.operation_id, - expected_revision=args.expected_revision, - source_revision=args.source_revision, - final_audit_path=args.final_audit, - proof_path=args.proof, - proof_integrity_path=args.proof_integrity, - goal_record_path=args.goal_record, - follow_up_register_path=args.follow_up_register, - completion_gate_receipt_path=args.completion_gate_receipt, - final_implementation_audit_path=args.final_implementation_audit, - review_mesh_quorum_paths=args.review_mesh_quorum, - reason=args.reason, - ) - maybe_emit_workflow_progress_hook(args, command="workflow finalize", state_path=state_path) - return payload - return _dispatch_workflow_task(args, state_path) - - -def _dispatch_workflow_task(args: argparse.Namespace, state_path: Path) -> dict[str, Any]: - if args.workflow_command == "block": - return block_run( - state_path, - operation_id=args.operation_id, - expected_revision=args.expected_revision, - blocker_code=args.blocker_code, - reason=args.reason, - ) - if args.workflow_command == "resolve": - return resolve_blocker( - state_path, - operation_id=args.operation_id, - expected_revision=args.expected_revision, - reason=args.reason, - ) - if args.workflow_command == "task-start": - return start_task( - state_path, - task_id=args.task, - operation_id=args.operation_id, - expected_revision=args.expected_revision, - source_revision=args.source_revision, - reason=args.reason, - ) - if args.workflow_command == "task-result": - validate_workflow_progress_hook_request(args, command="workflow task-result") - payload = commit_task_result( - state_path, - task_id=args.task, - operation_id=args.operation_id, - expected_revision=args.expected_revision, - source_revision=args.source_revision, - result_path=args.result, - model_usage_receipt_path=args.model_usage_receipt, - budget_targets_path=args.budget_targets, - reason=args.reason, - ) - maybe_emit_workflow_progress_hook(args, command="workflow task-result", state_path=state_path) - return payload - if args.workflow_command == "budget-decision": - if args.action: - _require_args(args, ["decision_receipt", "receipt"], mode="budget-decision apply") - return apply_budget_decision( - state_path, - task_id=args.task, - operation_id=args.operation_id, - expected_revision=args.expected_revision, - source_revision=args.source_revision, - decision_receipt_path=args.decision_receipt, - action=args.action, - applied_receipt_path=args.receipt, - route_decision_path=args.route_decision, - split_packet_path=args.split_packet, - cap_deltas_path=args.cap_deltas, - operator_identity_hash=args.operator_identity_hash, - reason=args.reason, - ) - _require_args(args, ["model_usage_receipt", "budget_policy", "receipt"], mode="budget-decision pause") - return pause_for_budget_decision( - state_path, - task_id=args.task, - operation_id=args.operation_id, - expected_revision=args.expected_revision, - source_revision=args.source_revision, - usage_receipt_path=args.model_usage_receipt, - budget_policy_path=args.budget_policy, - decision_receipt_path=args.receipt, - reason=args.reason, - ) - if args.workflow_command == "task-accept": - validate_workflow_progress_hook_request(args, command="workflow task-accept") - payload = accept_task( - state_path, - task_id=args.task, - operation_id=args.operation_id, - expected_revision=args.expected_revision, - review_path=args.review, - implementation_audit_path=args.implementation_audit, - reason=args.reason, - ) - maybe_emit_workflow_progress_hook(args, command="workflow task-accept", state_path=state_path) - return payload - raise LifecycleError("command-not-implemented", "workflow command is not implemented") - - -def _require_args(args: argparse.Namespace, names: list[str], *, mode: str) -> None: - missing = [name.replace("_", "-") for name in names if not getattr(args, name, None)] - if missing: - raise LifecycleError("missing-cli-argument", f"{mode} requires arguments", {"missing": missing}) - - -def _dispatch_audit(args: argparse.Namespace) -> dict[str, Any]: - if args.audit_command == "review-check": - review = read_json_object(Path(args.review), label="task review") - verdict = review.get("reviewVerdict", review) - findings = review.get("findings", []) if isinstance(review.get("findings", []), list) else [] - return require_review_verdict_pass(validate_review_verdict(verdict, findings=findings)) - if args.audit_command == "implementation": - payload = build_implementation_audit_report( - manifest_path=Path(args.manifest), - state_path=Path(args.state), - task_id=args.task, - result_path=args.result, - review_path=args.review, - evidence_paths=args.evidence, - sandbox_receipt_paths=args.sandbox_receipt, - review_mesh_quorum_paths=args.review_mesh_quorum, - changed_paths=args.path or None, - expected_revision=args.expected_revision, - base=args.base, - auditor_id=args.auditor_id, - auditor_surface=args.auditor_surface, - ) - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.audit_command == "final-implementation": - payload = build_final_implementation_audit( - manifest_path=Path(args.manifest), - state_path=Path(args.state), - report_paths=args.report, - auditor_id=args.auditor_id, - auditor_surface=args.auditor_surface, - ) - if args.out: - write_json_create(Path(args.out), payload) - return payload - paths = args.path or changed_files(Path.cwd(), base=args.base) - report = build_ownership_report(Path(args.manifest), paths, base=args.base) - if args.fail_on_forbidden and report_has_category(report, {"forbidden"}): - raise LifecycleError( - "forbidden-write-detected", - "ownership report contains forbidden writes", - report["summary"], - ) - if args.fail_on_unowned and report_has_category(report, {"unowned"}): - raise LifecycleError( - "unowned-write-detected", - "ownership report contains unowned writes", - report["summary"], - ) - return report - - -def _dispatch_context(args: argparse.Namespace) -> dict[str, Any]: - if args.context_command == "profile-check": - return load_context_profile(Path(args.profile)) - if args.context_command == "check": - result = check_context( - Path(args.profile), - Path(args.task_packet), - Path(args.summary), - latest_user=args.latest_user, - window=args.target_window, - ) - return _require_context_pass(result) - if args.context_command == "render": - profile = read_json_object(Path(args.profile), label="context profile") - load_context_profile(Path(args.profile)) - result = render_context( - profile, - read_json_object(Path(args.task_packet), label="task packet"), - read_json_object(Path(args.summary), label="state summary"), - latest_user=args.latest_user, - window=args.target_window, - ) - return _require_context_pass(result) - if args.context_command == "external-import": - payload = import_external_memory_context( - Path(args.source), - citation=args.citation, - source_id=args.source_id, - max_input_bytes=args.max_input_bytes, - target_tokens=args.target_tokens, - ) - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.context_command == "episode-retrieve": - payload = build_episode_retrieval_with_external_context( - Path(args.project_root), - list(args.artifact), - external_context_paths=[Path(item) for item in args.external_context], - query=args.query, - max_results=args.max_results, - max_external_context_hints=args.max_external_context_hints, - target_tokens=args.target_tokens, - ) - if args.out: - write_json_create(Path(args.out), payload) - return payload - raise LifecycleError("command-not-implemented", "context command is not implemented") - - -def _dispatch_goal(args: argparse.Namespace) -> dict[str, Any]: - record = read_json_object(Path(args.record), label="goal record") - if args.goal_command == "check": - state = read_json_object(Path(args.state), label="workflow state") if args.state else None - return validate_goal_record(record, state=state, require_current=args.current) - if args.goal_command == "summarize": - state = read_json_object(Path(args.state), label="workflow state") - profile = read_json_object(Path(args.profile), label="context profile") if args.profile else None - return build_objective_snapshot(record, state, profile=profile, window=args.target_window) - if args.goal_command == "view": - payload = build_goal_progress_view( - record_path=Path(args.record), - state_path=Path(args.state), - usage_receipt_paths=[Path(item) for item in args.usage_receipt], - change_summary_path=Path(args.change_summary) if args.change_summary else None, - require_current=not args.allow_stale_goal, - ) - if args.out: - write_json_create(Path(args.out), payload) - if args.terminal: - return render_goal_view_terminal(payload) - return payload - if args.goal_command == "update": - state = read_json_object(Path(args.state), label="workflow state") - updated = update_goal_record( - record, - state, - status=args.status, - reason=args.reason, - evidence_ids=args.evidence_id, - ) - if args.out: - write_json_create(Path(args.out), updated) - return updated - raise LifecycleError("command-not-implemented", "goal command is not implemented") - - -def _dispatch_model(args: argparse.Namespace) -> dict[str, Any]: - if args.model_command == "profile-check": - profile = read_json_object(Path(args.profile), label="model profile") - if args.type == "routing": - return validate_model_routing_profile(profile) - if args.type == "host": - return validate_host_model_profile(profile) - if profile.get("schemaVersion") in { - "agent-lifecycle-host-model-profile.v1", - "agent-host-model-selection-profile.v1", - }: - return validate_host_model_profile(profile) - return validate_model_routing_profile(profile) - if args.model_command == "route": - routing_profile = read_json_object(Path(args.profile), label="model routing profile") - host_profile = read_json_object(Path(args.host_profile), label="host model profile") if args.host_profile else None - request = read_json_object(Path(args.request), label="model route request") - return resolve_model_route(request, routing_profile, host_profile=host_profile) - if args.model_command == "usage-check": - receipt = read_json_object(Path(args.receipt), label="model usage receipt") - decision = read_json_object(Path(args.route_decision), label="model route decision") if args.route_decision else None - targets = read_json_object(Path(args.budget_targets), label="budget targets") if args.budget_targets else None - result = validate_usage_receipt(receipt, budget_targets=targets, route_decision=decision) - if result["status"] == "FAIL": - raise LifecycleError("model-usage-validation-failed", "model usage receipt validation failed", {"validation": result}) - return result - raise LifecycleError("command-not-implemented", "model command is not implemented") - - -def _dispatch_metrics(args: argparse.Namespace) -> dict[str, Any]: - if args.metrics_command == "cost-check": - report = read_json_object(Path(args.receipt), label="lifecycle cost report") - return require_lifecycle_cost_pass(validate_lifecycle_cost_report(report)) - if args.metrics_command == "cost-report": - report = generate_lifecycle_cost_report( - artifact_paths=[Path(item) for item in args.artifact], - mode=args.mode, - root=Path(args.project_root), - ) - validation = require_lifecycle_cost_pass(validate_lifecycle_cost_report(report)) - report_bytes = write_json_create(Path(args.out), report) - summary_path = None - summary_digest = None - if args.summary_out: - summary = build_lifecycle_cost_summary(report) - write_json_create(Path(args.summary_out), summary) - summary_path = args.summary_out - summary_digest = canonical_digest(summary) - return { - "schemaVersion": "agent-lifecycle-cost-generation.v1", - "status": validation["status"], - "reportPath": args.out, - "reportBytes": len(report_bytes), - "reportDigest": canonical_digest(report), - "summaryPath": summary_path, - "summaryDigest": summary_digest, - "validation": validation, - "liveCallsStarted": False, - "productionPromotionClaimed": False, - } - if args.metrics_command == "usage-export": - export = build_usage_export( - artifact_paths=[Path(item) for item in args.artifact], - project_root=Path(args.project_root), - ) - validation = require_usage_export_pass(validate_usage_export(export)) - rendered = render_usage_export_json(export) if args.format == "json" else render_usage_export_table(export) - output_path = Path(args.out) - _write_text_create(output_path, rendered) - return { - "schemaVersion": "agent-usage-export-generation.v1", - "status": validation["status"], - "format": args.format, - "outputPath": args.out, - "outputBytes": len(rendered.encode("utf-8")), - "exportDigest": canonical_digest(export), - "validation": validation, - "liveCallsStarted": False, - "productionPromotionClaimed": False, - } - if args.metrics_command == "outcome-index": - artifacts = [read_json_object(Path(item), label="outcome artifact") for item in args.artifact] - payload = build_task_outcome_index(artifacts, source_paths=list(args.artifact)) - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.metrics_command == "quality-signals": - payload = build_quality_cost_signals(read_json_object(Path(args.index), label="task outcome index")) - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.metrics_command == "recommend": - reports = [read_json_object(Path(item), label="lifecycle cost report") for item in args.report] - baseline_profile = read_json_object(Path(args.baseline_profile), label="lifecycle baseline profile") - recommendation = recommend_lifecycle_mode( - reports=reports, - baseline_profile=baseline_profile, - task_shape=args.task_shape, - current_mode=args.current_mode, - sdd_tier=args.sdd_tier, - risk_flags=args.risk, - ) - require_lifecycle_recommendation_pass(recommendation) - if args.out: - write_json_create(Path(args.out), recommendation) - if args.summary_out: - write_json_create(Path(args.summary_out), build_lifecycle_recommendation_summary(recommendation)) - return recommendation - if args.metrics_command == "learn-recommend": - signals = read_json_object(Path(args.signals), label="quality-cost signals") - baseline_profile = read_json_object(Path(args.baseline_profile), label="lifecycle baseline profile") - recommendation = recommend_from_quality_cost_signals( - signals=signals, - baseline_profile=baseline_profile, - task_shape=args.task_shape, - current_mode=args.current_mode, - sdd_tier=args.sdd_tier, - risk_flags=args.risk, - ) - require_lifecycle_recommendation_pass(recommendation) - if args.out: - write_json_create(Path(args.out), recommendation) - if args.summary_out: - write_json_create(Path(args.summary_out), build_lifecycle_recommendation_summary(recommendation)) - return recommendation - raise LifecycleError("command-not-implemented", "metrics command is not implemented") - - -def _write_text_create(path: Path, text: str) -> bytes: - data = text.encode("utf-8") - path.parent.mkdir(parents=True, exist_ok=True) - try: - with path.open("x", encoding="utf-8") as handle: - handle.write(text) - except FileExistsError as exc: - raise LifecycleError("output-already-exists", "output artifact already exists", {"path": path.as_posix()}) from exc - return data - - -def _dispatch_runner(args: argparse.Namespace) -> dict[str, Any]: - runner_path = Path(args.runner) - if args.runner_command == "start": - workflow_state = read_json_object(Path(args.state), label="workflow state") - policy = load_runner_policy(Path(args.policy) if args.policy else None) - runner_state = initialize_runner_state( - workflow_state, - policy=policy, - operation_id=args.operation_id, - reason=args.reason, - ) - write_runner_state_create(runner_path, runner_state) - return validate_runner_state(runner_state, workflow_state=workflow_state) - runner_state = load_runner_state(runner_path) - if args.runner_command == "status": - workflow_state = read_json_object(Path(args.state), label="workflow state") if args.state else None - if args.profile: - if workflow_state is None: - raise LifecycleError("missing-cli-argument", "runner status with --profile requires --state") - profile = read_json_object(Path(args.profile), label="context profile") - return build_runner_snapshot(runner_state, workflow_state, profile=profile, window=args.target_window) - return validate_runner_state(runner_state, workflow_state=workflow_state) - workflow_state = read_json_object(Path(args.state), label="workflow state") - if args.runner_command == "transition": - request = read_json_object(Path(args.request), label="runner transition request") - payload = transition_runner(runner_state, workflow_state, request) - write_runner_state(runner_path, payload["state"]) - return payload["result"] - if args.runner_command == "stop": - payload = request_runner_stop( - runner_state, - workflow_state, - operation_id=args.operation_id, - expected_runner_revision=args.expected_runner_revision, - reason=args.reason, - ) - write_runner_state(runner_path, payload["state"]) - return payload["result"] - if args.runner_command == "resume": - payload = resume_runner( - runner_state, - workflow_state, - operation_id=args.operation_id, - expected_runner_revision=args.expected_runner_revision, - reason=args.reason, - ) - write_runner_state(runner_path, payload["state"]) - return payload["result"] - raise LifecycleError("command-not-implemented", "runner command is not implemented") - - -def _require_context_pass(result: dict[str, Any]) -> dict[str, Any]: - if result.get("status") == "FAIL": - raise LifecycleError( - "context-overflow", - "compact context exceeds target window", - {"receipt": result.get("receipt")}, - ) - return result - - -def _dispatch_tier(args: argparse.Namespace) -> dict[str, Any]: - if args.tier_command == "resolve": - return resolve_sdd_tier(read_json_object(Path(args.request), label="tier request")) - raise LifecycleError("command-not-implemented", "tier command is not implemented") - - -def _dispatch_specification(args: argparse.Namespace) -> dict[str, Any]: - if args.specification_command == "check": - return validate_specification(read_json_object(Path(args.specification), label="specification")) - if args.specification_command == "completion-gate": - gate_input = read_json_object(Path(args.input), label="completion gate input") if args.input else {} - payload = build_completion_gate_receipt( - state=read_json_object(Path(args.state), label="workflow state"), - final_audit=read_json_object(Path(args.final_audit), label="final audit") if args.final_audit else None, - follow_up_register=read_json_object(Path(args.follow_up_register), label="follow-up register") if args.follow_up_register else None, - validation_results=gate_input.get("validationResults", []), - required_validation_ids=gate_input.get("requiredValidationIds", []), - follow_up_candidates=gate_input.get("followUpCandidates", []), - regression_signals=gate_input.get("regressionSignals", []), - risk_flags=gate_input.get("riskFlags", []), - split_candidates=gate_input.get("splitCandidates", []), - verifier_id=args.verifier, - ) - if args.out: - write_json_create(Path(args.out), payload) - return payload - raise LifecycleError("command-not-implemented", "specification command is not implemented") - - -def _dispatch_plan(args: argparse.Namespace) -> dict[str, Any]: - if args.plan_command == "check": - manifest = read_json_object(Path(args.manifest), label="plan manifest") - lock = read_json_object(Path(args.lock), label="plan lock") if args.lock else None - completeness_profile = ( - load_plan_completeness_profile(Path(args.completeness_profile)) - if args.completeness_profile - else None - ) - return { - "schemaVersion": "agent-plan-check.v1", - "manifest": validate_plan_manifest( - manifest, - require_completeness=args.require_completeness, - completeness_profile=completeness_profile, - ), - "lock": verify_plan_lock(manifest, lock) if lock else None, - } - if args.plan_command == "completeness-check": - manifest = read_json_object(Path(args.manifest), label="plan manifest") - profile = load_plan_completeness_profile(Path(args.profile)) if args.profile else None - payload = validate_plan_completeness(manifest, profile=profile) - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.plan_command == "acceptance-check": - manifest_path = Path(args.manifest) - acceptance_path = Path(args.acceptance) - return validate_acceptance_checklist( - read_json_object(manifest_path, label="plan manifest"), - acceptance_path.read_text(encoding="utf-8"), - ) - if args.plan_command == "refs-check": - manifest = read_json_object(Path(args.manifest), label="plan manifest") - return require_repository_references_pass(validate_repository_references(manifest)) - if args.plan_command == "snapshot": - manifest = read_json_object(Path(args.manifest), label="plan manifest") - payload = build_plan_snapshot(manifest) - if args.out: - write_json_create(Path(args.out), payload) - return payload - if args.plan_command == "reconcile": - manifest = read_json_object(Path(args.manifest), label="plan manifest") - snapshot = read_json_object(Path(args.snapshot), label="plan snapshot") - return require_reconciliation_pass(reconcile_plan_snapshot(snapshot, manifest)) - if args.plan_command == "handoff": - manifest = read_json_object(Path(args.manifest), label="plan manifest") - snapshot = read_json_object(Path(args.snapshot), label="plan snapshot") if args.snapshot else None - payload = render_plan_handoff( - manifest, - snapshot=snapshot, - max_workstreams=args.max_workstreams, - target_tokens=args.target_tokens, - ) - if payload.get("status") != "PASS": - raise LifecycleError("plan-handoff-render-failed", "plan handoff did not fit the requested limits", {"handoff": payload}) - if args.out: - write_json_create(Path(args.out), payload) - return payload - raise LifecycleError("command-not-implemented", "plan command is not implemented") - - -def _dispatch_task(args: argparse.Namespace) -> dict[str, Any]: - if args.task_command == "compile": - result = compile_task_packets( - Path(args.manifest), - out_dir=Path(args.out_dir) if args.out_dir else None, - write=args.write, - ) - return {"schemaVersion": "agent-task-packet-compile-result.v1", **result} - if args.task_command == "compile-small": - adaptive_decision = read_json_object(Path(args.adaptive_decision), label="adaptive lifecycle decision") if args.adaptive_decision else None - result = compile_small_model_packets( - Path(args.manifest), - context_profile_path=Path(args.context_profile), - out_dir=Path(args.out_dir) if args.out_dir else None, - target_window=args.target_window, - adaptive_decision=adaptive_decision, - write=args.write, - ) - if result["status"] != "PASS": - raise LifecycleError("small-model-packet-compile-failed", "small-model packet compilation failed", {"result": result}) - return result - raise LifecycleError("command-not-implemented", "task command is not implemented") diff --git a/src/agent_lifecycle/cli/dispatch_adapters.py b/src/agent_lifecycle/cli/dispatch_adapters.py new file mode 100644 index 00000000..c8c1bfab --- /dev/null +++ b/src/agent_lifecycle/cli/dispatch_adapters.py @@ -0,0 +1,46 @@ +"""Adapter and readiness CLI dispatch handlers.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Any + +from agent_lifecycle.cli.adapter import dispatch_adapter +from agent_lifecycle.contracts import LifecycleError, write_json_create +from agent_lifecycle.diagnostics import build_diagnostic_bundle, build_readiness_report + + +def dispatch_adapters(args: argparse.Namespace) -> dict[str, Any]: + """Dispatch adapter-facing and readiness command groups.""" + if args.command == "diagnose": + return build_readiness_report( + project_root=Path(args.project_root), + adapter_paths=[Path(item) for item in args.adapter] if args.adapter else None, + include_install_plans=not args.no_install_plans, + include_host_probes=args.include_host_probes, + timeout_seconds=args.timeout_seconds, + max_host_probes=args.max_host_probes, + context_profile=Path(args.context_profile) if args.context_profile else None, + model_profile=Path(args.model_profile) if args.model_profile else None, + adapter_baseline=Path(args.adapter_baseline) if args.adapter_baseline else None, + ) + if args.command == "diagnostics": + return _dispatch_diagnostics(args) + if args.command == "adapter": + return dispatch_adapter(args) + raise LifecycleError("command-not-implemented", "adapter command is not implemented") + + +def _dispatch_diagnostics(args: argparse.Namespace) -> dict[str, Any]: + if args.diagnostics_command == "bundle": + payload = build_diagnostic_bundle( + project_root=Path(args.project_root), + artifact_paths=[Path(item) for item in args.artifact], + max_artifacts=args.max_artifacts, + max_input_bytes=args.max_input_bytes, + ) + if args.out: + write_json_create(Path(args.out), payload) + return payload + raise LifecycleError("command-not-implemented", "diagnostics command is not implemented") diff --git a/src/agent_lifecycle/cli/dispatch_contracts.py b/src/agent_lifecycle/cli/dispatch_contracts.py new file mode 100644 index 00000000..f27d1e33 --- /dev/null +++ b/src/agent_lifecycle/cli/dispatch_contracts.py @@ -0,0 +1,396 @@ +"""Contract, evidence, import, quality, and review-mesh CLI handlers.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Any + +from agent_lifecycle import __version__ +from agent_lifecycle.contracts import LifecycleError, read_json_object, write_json_create +from agent_lifecycle.contracts.compatibility import ( + build_contract_policy, + require_contract_policy_pass, + validate_contract_policy, +) +from agent_lifecycle.contracts.schemas import get_schema, list_schemas +from agent_lifecycle.evidence_index import ( + build_evidence_index, + require_evidence_index_pass, + require_evidence_search_pass, + search_evidence_index, + validate_evidence_index, +) +from agent_lifecycle.imports import ( + bmad_profile, + external_dialect_registry, + import_external_dialect, + import_markdown_collection, + import_planning_input, + openspec_profile, + require_external_import_pass, + require_import_validation_pass, + require_skill_proposal_pass, + spec_kit_profile, + spec_kitty_profile, + validate_external_import_result, + validate_import_result, + validate_skill_improvement_proposal, +) +from agent_lifecycle.planning import ( + build_task_template_library, + require_task_template_validation_pass, + validate_task_template_library, +) +from agent_lifecycle.quality import ( + build_bug_forensics_recipe_library, + build_default_quality_pack, + require_behavior_checks_pass, + require_bug_forensics_recipe_pass, + require_quality_pack_pass, + run_behavior_checks, + validate_bug_forensics_recipe_library, + validate_quality_pack, +) +from agent_lifecycle.review_mesh import ( + build_quorum_from_synthesis, + build_review_mesh_assignment_packet, + build_review_mesh_profile, + import_review_mesh_result, + list_review_mesh_operator_templates, + parse_reviewer_spec, + prepare_review_mesh_operator_packets, + recommend_review_mesh_for_intake, + recommend_review_mesh_for_plan_manifest, + recommend_review_mesh_for_text, + require_review_mesh_profile_pass, + require_review_mesh_recommendation_pass, + source_from_handoff, + source_from_intake, + source_from_manifest, + synthesize_review_mesh_results, + validate_review_mesh_profile, + validate_review_mesh_recommendation, +) + + +def dispatch_contracts(args: argparse.Namespace) -> dict[str, Any]: + """Dispatch schema, contract, evidence, import, quality, and review commands.""" + if args.command == "version": + return {"schemaVersion": "agent-lifecycle-version.v1", "version": __version__} + if args.command == "schema": + if args.schema_command == "list": + return list_schemas() + if args.schema_command == "show": + return get_schema(args.schema_id) + raise LifecycleError("command-not-implemented", "schema command is not implemented") + if args.command == "contract": + return _dispatch_contract(args) + if args.command == "evidence": + return _dispatch_evidence(args) + if args.command == "import": + return _dispatch_import(args) + if args.command == "quality": + return _dispatch_quality(args) + if args.command == "review-mesh": + return _dispatch_review_mesh(args) + raise LifecycleError("command-not-implemented", "contract command is not implemented") + + +def _dispatch_contract(args: argparse.Namespace) -> dict[str, Any]: + if args.contract_command == "policy": + payload = build_contract_policy() + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.contract_command == "check": + policy = read_json_object(Path(args.policy), label="public contract policy") if args.policy else build_contract_policy() + return require_contract_policy_pass(validate_contract_policy(policy)) + raise LifecycleError("command-not-implemented", "contract command is not implemented") + + +def _dispatch_evidence(args: argparse.Namespace) -> dict[str, Any]: + if args.evidence_command == "index": + payload = build_evidence_index( + Path(args.project_root), + list(args.artifact), + max_artifacts=args.max_artifacts, + max_input_bytes=args.max_input_bytes, + target_tokens=args.target_tokens, + ) + require_evidence_index_pass(validate_evidence_index(payload)) + require_evidence_index_pass(payload) + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.evidence_command == "search": + payload = search_evidence_index( + read_json_object(Path(args.index), label="evidence index"), + query=args.query or "", + max_results=args.max_results, + target_tokens=args.target_tokens, + ) + require_evidence_search_pass(payload) + if args.out: + write_json_create(Path(args.out), payload) + return payload + raise LifecycleError("command-not-implemented", "evidence command is not implemented") + + +def _dispatch_import(args: argparse.Namespace) -> dict[str, Any]: + if args.import_command == "profile-list": + payload = external_dialect_registry() + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.import_command == "plan": + source = Path(args.source) + dialect_profile = _planning_import_profile( + args.dialect, + max_input_bytes=args.max_input_bytes, + target_tokens=args.target_tokens, + ) + if source.is_dir() or dialect_profile is not None: + payload = import_markdown_collection( + source, + package_id=args.package_id, + max_input_bytes=args.max_input_bytes, + target_tokens=args.target_tokens, + max_files=args.max_files, + dialect_profile=dialect_profile, + ) + else: + payload = import_planning_input( + source, + package_id=args.package_id, + max_input_bytes=args.max_input_bytes, + target_tokens=args.target_tokens, + ) + require_import_validation_pass(validate_import_result(payload)) + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.import_command == "external": + payload = import_external_dialect( + Path(args.source), + family=args.family, + profile_id=args.profile, + package_id=args.package_id, + max_input_bytes=args.max_input_bytes, + target_tokens=args.target_tokens, + ) + require_external_import_pass(validate_external_import_result(payload)) + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.import_command == "check": + return require_import_validation_pass( + validate_import_result(read_json_object(Path(args.candidate), label="planning import result")) + ) + if args.import_command == "external-check": + return require_external_import_pass( + validate_external_import_result(read_json_object(Path(args.candidate), label="external dialect import result")) + ) + if args.import_command == "proposal-check": + return require_skill_proposal_pass( + validate_skill_improvement_proposal(read_json_object(Path(args.proposal), label="skill proposal")) + ) + raise LifecycleError("command-not-implemented", "import command is not implemented") + + +def _planning_import_profile( + dialect: str | None, + *, + max_input_bytes: int, + target_tokens: int, +) -> dict[str, Any] | None: + if dialect is None: + return None + builders = { + "openspec": openspec_profile, + "spec-kit": spec_kit_profile, + "bmad": bmad_profile, + "spec-kitty": spec_kitty_profile, + } + return builders[dialect](max_input_bytes=max_input_bytes, target_tokens=target_tokens) + + +def _dispatch_quality(args: argparse.Namespace) -> dict[str, Any]: + if args.quality_command == "pack-check": + manifest = read_json_object(Path(args.manifest), label="quality pack") if args.manifest else build_default_quality_pack() + return require_quality_pack_pass(validate_quality_pack(manifest)) + if args.quality_command == "behavior-check": + manifest = read_json_object(Path(args.manifest), label="quality pack") if args.manifest else build_default_quality_pack() + fixtures = [read_json_object(Path(item), label="behavior fixture") for item in args.fixture] + return require_behavior_checks_pass(run_behavior_checks(manifest, fixtures)) + if args.quality_command == "template-list": + payload = build_task_template_library() + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.quality_command == "template-check": + payload = validate_task_template_library(project_root=Path(args.project_root), template_id=args.template_id) + require_task_template_validation_pass(payload) + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.quality_command == "bug-recipe-list": + payload = build_bug_forensics_recipe_library() + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.quality_command == "bug-recipe-check": + payload = validate_bug_forensics_recipe_library(recipe_id=args.recipe_id) + require_bug_forensics_recipe_pass(payload) + if args.out: + write_json_create(Path(args.out), payload) + return payload + raise LifecycleError("command-not-implemented", "quality command is not implemented") + + +def _dispatch_review_mesh(args: argparse.Namespace) -> dict[str, Any]: + if args.review_mesh_command == "template-list": + payload = list_review_mesh_operator_templates() + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.review_mesh_command == "profile": + budget_cap = _review_mesh_budget_cap(args) + payload = build_review_mesh_profile( + profile_id=args.profile_id, + modes=args.mode or None, + default_mode=args.default_mode, + budget_cap=budget_cap, + live_calls_allowed=args.allow_live_calls, + independence_required=args.require_independence, + independence_dimensions=args.independence_dimension or None, + reviewer_model_classes=args.reviewer_model_class or None, + ) + require_review_mesh_profile_pass(validate_review_mesh_profile(payload)) + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.review_mesh_command == "recommend": + if args.text is not None: + payload = recommend_review_mesh_for_text(args.text, sdd_tier=args.sdd_tier, risk_flags=args.risk_flag) + elif args.file: + path = Path(args.file) + payload = recommend_review_mesh_for_text( + path.read_text(encoding="utf-8"), + source_label=path.name, + sdd_tier=args.sdd_tier, + risk_flags=args.risk_flag, + ) + elif args.intake: + payload = recommend_review_mesh_for_intake( + read_json_object(Path(args.intake), label="adapter task intake receipt") + ) + elif args.manifest: + payload = recommend_review_mesh_for_plan_manifest( + read_json_object(Path(args.manifest), label="plan manifest") + ) + else: + raise LifecycleError("review-mesh-recommendation-source-missing", "one recommendation source is required") + require_review_mesh_recommendation_pass(validate_review_mesh_recommendation(payload)) + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.review_mesh_command == "prepare": + source = _review_mesh_source(args, code="review-mesh-prepare-source-missing", label="prepare") + payload = prepare_review_mesh_operator_packets( + source=source, + template_id=args.template, + reviewers=[parse_reviewer_spec(spec) for spec in args.reviewer] if args.reviewer else None, + profile_id=args.profile_id, + phase=args.phase, + blocking=args.blocking, + evidence_ids=args.evidence_id, + out_dir=Path(args.out_dir) if args.out_dir else None, + ) + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.review_mesh_command == "assign": + source = _review_mesh_source(args, code="review-mesh-assignment-source-missing", label="assignment") + profile = ( + read_json_object(Path(args.profile), label="review mesh profile") + if args.profile + else build_review_mesh_profile(default_mode=args.mode, independence_required=False) + ) + payload = build_review_mesh_assignment_packet( + source=source, + mode=args.mode, + phase=args.phase, + assignment_id=args.assignment_id, + reviewer_id=args.reviewer_id, + reviewer_role=args.reviewer_role, + reviewer_model_class=args.reviewer_model_class, + reviewer_host_identity_hash=args.reviewer_host_identity_hash, + reviewer_model_identity_hash=args.reviewer_model_identity_hash, + blocking=args.blocking, + profile=profile, + evidence_ids=args.evidence_id, + ) + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.review_mesh_command == "import-result": + assignment_payload = read_json_object(Path(args.assignment), label="review mesh assignment") + assignment = assignment_payload.get("assignment") if isinstance(assignment_payload.get("assignment"), dict) else assignment_payload + payload = import_review_mesh_result( + profile=read_json_object(Path(args.profile), label="review mesh profile"), + assignment=assignment, + reviewer_output=read_json_object(Path(args.reviewer_output), label="reviewer output"), + allow_local_evidence_refs=args.allow_local_evidence_ref, + ) + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.review_mesh_command == "synthesize": + payload = synthesize_review_mesh_results( + profile=read_json_object(Path(args.profile), label="review mesh profile"), + results=[read_json_object(Path(path), label="review mesh result") for path in args.result], + mode=args.mode, + accepted_finding_ids=args.accepted_finding_id, + rejected_finding_ids=args.rejected_finding_id, + ) + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.review_mesh_command == "quorum": + profile = read_json_object(Path(args.profile), label="review mesh profile") + synthesis = read_json_object(Path(args.synthesis), label="review mesh synthesis") + payload = build_quorum_from_synthesis( + profile=profile, + synthesis=synthesis, + quorum_policy={"minReviewers": args.min_reviewers, "requiredRoles": args.required_role}, + reviewer_roles=args.reviewer_role, + ) + if args.out: + write_json_create(Path(args.out), payload) + return payload + raise LifecycleError("command-not-implemented", "review-mesh command is not implemented") + + +def _review_mesh_source(args: argparse.Namespace, *, code: str, label: str) -> dict[str, Any]: + if args.manifest: + return source_from_manifest(read_json_object(Path(args.manifest), label="plan manifest")) + if args.intake: + return source_from_intake(read_json_object(Path(args.intake), label="adapter task intake receipt")) + if args.handoff: + return source_from_handoff(read_json_object(Path(args.handoff), label="plan handoff")) + raise LifecycleError(code, f"one {label} source is required") + + +def _review_mesh_budget_cap(args: argparse.Namespace) -> dict[str, int] | None: + budget_cap = { + key: value + for key, value in { + "maxInvocations": args.max_invocations, + "maxInputTokens": args.max_input_tokens, + "maxOutputTokens": args.max_output_tokens, + "maxWallSeconds": args.max_wall_seconds, + }.items() + if value is not None + } + return budget_cap or None diff --git a/src/agent_lifecycle/cli/dispatch_lifecycle.py b/src/agent_lifecycle/cli/dispatch_lifecycle.py new file mode 100644 index 00000000..0fb40abd --- /dev/null +++ b/src/agent_lifecycle/cli/dispatch_lifecycle.py @@ -0,0 +1,318 @@ +"""Workflow, audit, and managed-runner CLI dispatch handlers.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Any + +from agent_lifecycle.audit import ( + build_final_implementation_audit, + build_implementation_audit_report, + build_ownership_report, + require_review_verdict_pass, + validate_review_verdict, +) +from agent_lifecycle.audit.ownership import report_has_category +from agent_lifecycle.changesets import changed_files +from agent_lifecycle.cli.progress_hooks import maybe_emit_workflow_progress_hook, validate_workflow_progress_hook_request +from agent_lifecycle.contracts import LifecycleError, read_json_object, write_json_create +from agent_lifecycle.runner import ( + build_runner_snapshot, + initialize_runner_state, + load_runner_policy, + load_runner_state, + request_runner_stop, + resume_runner, + transition_runner, + validate_runner_state, +) +from agent_lifecycle.runner.core import write_runner_state, write_runner_state_create +from agent_lifecycle.workflow import ( + accept_task, + adopt_plan, + apply_budget_decision, + block_run, + commit_task_result, + finalize_run, + next_action, + pause_for_budget_decision, + resolve_blocker, + run_managed_lifecycle_step, + start_execution, + start_task, + status, + validate_budget_exceeded_policy, +) + + +def dispatch_lifecycle(args: argparse.Namespace) -> dict[str, Any]: + """Dispatch workflow, audit, and runner command groups.""" + if args.command == "workflow": + return _dispatch_workflow(args) + if args.command == "audit": + return _dispatch_audit(args) + if args.command == "runner": + return _dispatch_runner(args) + raise LifecycleError("command-not-implemented", "lifecycle command is not implemented") + + +def _dispatch_workflow(args: argparse.Namespace) -> dict[str, Any]: + if args.workflow_command == "budget-policy-check": + return validate_budget_exceeded_policy(read_json_object(Path(args.policy), label="budget policy")) + state_path = Path(args.state) + if args.workflow_command == "status": + return status(state_path, full=args.full) + if args.workflow_command == "next": + return next_action(status(state_path, full=True)["state"]) + if args.workflow_command == "run": + validate_workflow_progress_hook_request(args, command="workflow run") + payload = run_managed_lifecycle_step( + state_path=state_path, + manifest_path=Path(args.manifest), + lock_path=Path(args.lock) if args.lock else None, + operation_id=args.operation_id, + expected_revision=args.expected_revision, + source_revision=args.source_revision, + reason=args.reason, + ) + if args.out: + write_json_create(Path(args.out), payload) + maybe_emit_workflow_progress_hook(args, command="workflow run", state_path=state_path) + return payload + if args.workflow_command == "adopt-plan": + return adopt_plan( + state_path, + manifest_path=Path(args.manifest), + operation_id=args.operation_id, + expected_revision=args.expected_revision, + source_revision=args.source_revision, + reset_tasks=args.reset_tasks, + preserve_accepted_compatible=args.preserve_accepted_compatible, + start_mode=args.start_mode, + authorized_by=args.authorized_by, + ) + if args.workflow_command == "run-start": + return start_execution( + state_path, + operation_id=args.operation_id, + expected_revision=args.expected_revision, + source_revision=args.source_revision, + reason=args.reason, + ) + if args.workflow_command == "finalize": + validate_workflow_progress_hook_request(args, command="workflow finalize") + payload = finalize_run( + state_path, + operation_id=args.operation_id, + expected_revision=args.expected_revision, + source_revision=args.source_revision, + final_audit_path=args.final_audit, + proof_path=args.proof, + proof_integrity_path=args.proof_integrity, + goal_record_path=args.goal_record, + follow_up_register_path=args.follow_up_register, + completion_gate_receipt_path=args.completion_gate_receipt, + final_implementation_audit_path=args.final_implementation_audit, + review_mesh_quorum_paths=args.review_mesh_quorum, + reason=args.reason, + ) + maybe_emit_workflow_progress_hook(args, command="workflow finalize", state_path=state_path) + return payload + return _dispatch_workflow_task(args, state_path) + + +def _dispatch_workflow_task(args: argparse.Namespace, state_path: Path) -> dict[str, Any]: + if args.workflow_command == "block": + return block_run( + state_path, + operation_id=args.operation_id, + expected_revision=args.expected_revision, + blocker_code=args.blocker_code, + reason=args.reason, + ) + if args.workflow_command == "resolve": + return resolve_blocker( + state_path, + operation_id=args.operation_id, + expected_revision=args.expected_revision, + reason=args.reason, + ) + if args.workflow_command == "task-start": + return start_task( + state_path, + task_id=args.task, + operation_id=args.operation_id, + expected_revision=args.expected_revision, + source_revision=args.source_revision, + reason=args.reason, + ) + if args.workflow_command == "task-result": + validate_workflow_progress_hook_request(args, command="workflow task-result") + payload = commit_task_result( + state_path, + task_id=args.task, + operation_id=args.operation_id, + expected_revision=args.expected_revision, + source_revision=args.source_revision, + result_path=args.result, + model_usage_receipt_path=args.model_usage_receipt, + budget_targets_path=args.budget_targets, + reason=args.reason, + ) + maybe_emit_workflow_progress_hook(args, command="workflow task-result", state_path=state_path) + return payload + if args.workflow_command == "budget-decision": + if args.action: + _require_args(args, ["decision_receipt", "receipt"], mode="budget-decision apply") + return apply_budget_decision( + state_path, + task_id=args.task, + operation_id=args.operation_id, + expected_revision=args.expected_revision, + source_revision=args.source_revision, + decision_receipt_path=args.decision_receipt, + action=args.action, + applied_receipt_path=args.receipt, + route_decision_path=args.route_decision, + split_packet_path=args.split_packet, + cap_deltas_path=args.cap_deltas, + operator_identity_hash=args.operator_identity_hash, + reason=args.reason, + ) + _require_args(args, ["model_usage_receipt", "budget_policy", "receipt"], mode="budget-decision pause") + return pause_for_budget_decision( + state_path, + task_id=args.task, + operation_id=args.operation_id, + expected_revision=args.expected_revision, + source_revision=args.source_revision, + usage_receipt_path=args.model_usage_receipt, + budget_policy_path=args.budget_policy, + decision_receipt_path=args.receipt, + reason=args.reason, + ) + if args.workflow_command == "task-accept": + validate_workflow_progress_hook_request(args, command="workflow task-accept") + payload = accept_task( + state_path, + task_id=args.task, + operation_id=args.operation_id, + expected_revision=args.expected_revision, + review_path=args.review, + implementation_audit_path=args.implementation_audit, + reason=args.reason, + ) + maybe_emit_workflow_progress_hook(args, command="workflow task-accept", state_path=state_path) + return payload + raise LifecycleError("command-not-implemented", "workflow command is not implemented") + + +def _require_args(args: argparse.Namespace, names: list[str], *, mode: str) -> None: + missing = [name.replace("_", "-") for name in names if not getattr(args, name, None)] + if missing: + raise LifecycleError("missing-cli-argument", f"{mode} requires arguments", {"missing": missing}) + + +def _dispatch_audit(args: argparse.Namespace) -> dict[str, Any]: + if args.audit_command == "review-check": + review = read_json_object(Path(args.review), label="task review") + verdict = review.get("reviewVerdict", review) + findings = review.get("findings", []) if isinstance(review.get("findings", []), list) else [] + return require_review_verdict_pass(validate_review_verdict(verdict, findings=findings)) + if args.audit_command == "implementation": + payload = build_implementation_audit_report( + manifest_path=Path(args.manifest), + state_path=Path(args.state), + task_id=args.task, + result_path=args.result, + review_path=args.review, + evidence_paths=args.evidence, + sandbox_receipt_paths=args.sandbox_receipt, + review_mesh_quorum_paths=args.review_mesh_quorum, + changed_paths=args.path or None, + expected_revision=args.expected_revision, + base=args.base, + auditor_id=args.auditor_id, + auditor_surface=args.auditor_surface, + ) + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.audit_command == "final-implementation": + payload = build_final_implementation_audit( + manifest_path=Path(args.manifest), + state_path=Path(args.state), + report_paths=args.report, + auditor_id=args.auditor_id, + auditor_surface=args.auditor_surface, + ) + if args.out: + write_json_create(Path(args.out), payload) + return payload + paths = args.path or changed_files(Path.cwd(), base=args.base) + report = build_ownership_report(Path(args.manifest), paths, base=args.base) + if args.fail_on_forbidden and report_has_category(report, {"forbidden"}): + raise LifecycleError( + "forbidden-write-detected", + "ownership report contains forbidden writes", + report["summary"], + ) + if args.fail_on_unowned and report_has_category(report, {"unowned"}): + raise LifecycleError( + "unowned-write-detected", + "ownership report contains unowned writes", + report["summary"], + ) + return report + + +def _dispatch_runner(args: argparse.Namespace) -> dict[str, Any]: + runner_path = Path(args.runner) + if args.runner_command == "start": + workflow_state = read_json_object(Path(args.state), label="workflow state") + policy = load_runner_policy(Path(args.policy) if args.policy else None) + runner_state = initialize_runner_state( + workflow_state, + policy=policy, + operation_id=args.operation_id, + reason=args.reason, + ) + write_runner_state_create(runner_path, runner_state) + return validate_runner_state(runner_state, workflow_state=workflow_state) + runner_state = load_runner_state(runner_path) + if args.runner_command == "status": + workflow_state = read_json_object(Path(args.state), label="workflow state") if args.state else None + if args.profile: + if workflow_state is None: + raise LifecycleError("missing-cli-argument", "runner status with --profile requires --state") + profile = read_json_object(Path(args.profile), label="context profile") + return build_runner_snapshot(runner_state, workflow_state, profile=profile, window=args.target_window) + return validate_runner_state(runner_state, workflow_state=workflow_state) + workflow_state = read_json_object(Path(args.state), label="workflow state") + if args.runner_command == "transition": + request = read_json_object(Path(args.request), label="runner transition request") + payload = transition_runner(runner_state, workflow_state, request) + write_runner_state(runner_path, payload["state"]) + return payload["result"] + if args.runner_command == "stop": + payload = request_runner_stop( + runner_state, + workflow_state, + operation_id=args.operation_id, + expected_runner_revision=args.expected_runner_revision, + reason=args.reason, + ) + write_runner_state(runner_path, payload["state"]) + return payload["result"] + if args.runner_command == "resume": + payload = resume_runner( + runner_state, + workflow_state, + operation_id=args.operation_id, + expected_runner_revision=args.expected_runner_revision, + reason=args.reason, + ) + write_runner_state(runner_path, payload["state"]) + return payload["result"] + raise LifecycleError("command-not-implemented", "runner command is not implemented") diff --git a/src/agent_lifecycle/cli/dispatch_observability.py b/src/agent_lifecycle/cli/dispatch_observability.py new file mode 100644 index 00000000..abfa1f44 --- /dev/null +++ b/src/agent_lifecycle/cli/dispatch_observability.py @@ -0,0 +1,373 @@ +"""Reporting, context, goal, model, and metrics CLI dispatch handlers.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Any + +from agent_lifecycle.context import check_context, load_context_profile, render_context +from agent_lifecycle.context.external_memory import build_episode_retrieval_with_external_context, import_external_memory_context +from agent_lifecycle.contracts import LifecycleError, canonical_digest, read_json_object, write_json_create +from agent_lifecycle.goal import build_goal_progress_view, build_objective_snapshot, update_goal_record, validate_goal_record +from agent_lifecycle.metrics import ( + build_lifecycle_cost_summary, + build_lifecycle_recommendation_summary, + build_quality_cost_signals, + build_task_outcome_index, + build_usage_export, + generate_lifecycle_cost_report, + recommend_from_quality_cost_signals, + recommend_lifecycle_mode, + require_lifecycle_cost_pass, + require_lifecycle_recommendation_pass, + require_usage_export_pass, + validate_lifecycle_cost_report, + validate_usage_export, +) +from agent_lifecycle.model_routing import ( + resolve_model_route, + validate_host_model_profile, + validate_model_routing_profile, + validate_usage_receipt, +) +from agent_lifecycle.reporting import ( + build_change_summary_receipt, + build_lifecycle_progress_view, + build_lifecycle_progress_watch, + build_progress_bridge_receipt, + build_status_view, + build_workflow_event_feed, + render_goal_view_terminal, + render_progress_bridge_terminal, + render_progress_terminal, + render_usage_export_json, + render_usage_export_table, +) + + +def dispatch_observability(args: argparse.Namespace) -> dict[str, Any] | str: + """Dispatch read-only views, context, goal, model, and metrics commands.""" + if args.command == "report": + return _dispatch_report(args) + if args.command == "context": + return _dispatch_context(args) + if args.command == "goal": + return _dispatch_goal(args) + if args.command == "model": + return _dispatch_model(args) + if args.command == "metrics": + return _dispatch_metrics(args) + raise LifecycleError("command-not-implemented", "observability command is not implemented") + + +def _dispatch_report(args: argparse.Namespace) -> dict[str, Any] | str: + if args.report_command == "status-view": + payload = build_status_view( + project_root=Path(args.project_root), + artifact_paths=[Path(item) for item in args.artifact], + max_items=args.max_items, + target_window=args.target_window, + ) + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.report_command == "progress-bridge": + payload = build_progress_bridge_receipt( + adapter_id=args.adapter, + support_level=args.support_level, + hook_point=args.hook_point, + state_path=Path(args.state), + usage_receipt_paths=[Path(item) for item in args.usage_receipt], + change_summary_path=Path(args.change_summary) if args.change_summary else None, + display_mode=args.display_mode, + watch=args.watch, + iterations=args.watch_iterations, + interval_seconds=args.watch_interval, + ) + if args.out: + write_json_create(Path(args.out), payload) + if args.terminal: + return render_progress_bridge_terminal(payload) + return payload + if args.report_command == "event-feed": + payload = build_workflow_event_feed(state_path=Path(args.state)) + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.report_command == "progress": + if args.watch: + payload = build_lifecycle_progress_watch( + state_path=Path(args.state), + usage_receipt_paths=[Path(item) for item in args.usage_receipt], + change_summary_path=Path(args.change_summary) if args.change_summary else None, + iterations=args.watch_iterations, + interval_seconds=args.watch_interval, + ) + else: + payload = build_lifecycle_progress_view( + state_path=Path(args.state), + usage_receipt_paths=[Path(item) for item in args.usage_receipt], + change_summary_path=Path(args.change_summary) if args.change_summary else None, + ) + if args.out: + write_json_create(Path(args.out), payload) + if args.terminal: + return render_progress_terminal(payload) + return payload + if args.report_command == "change-summary": + payload = build_change_summary_receipt( + project_root=Path(args.project_root), + base=args.base, + head=args.head, + staged=args.staged, + paths=args.path, + ) + if args.out: + write_json_create(Path(args.out), payload) + return payload + raise LifecycleError("command-not-implemented", "report command is not implemented") + + +def _dispatch_context(args: argparse.Namespace) -> dict[str, Any]: + if args.context_command == "profile-check": + return load_context_profile(Path(args.profile)) + if args.context_command == "check": + result = check_context( + Path(args.profile), + Path(args.task_packet), + Path(args.summary), + latest_user=args.latest_user, + window=args.target_window, + ) + return _require_context_pass(result) + if args.context_command == "render": + profile = read_json_object(Path(args.profile), label="context profile") + load_context_profile(Path(args.profile)) + result = render_context( + profile, + read_json_object(Path(args.task_packet), label="task packet"), + read_json_object(Path(args.summary), label="state summary"), + latest_user=args.latest_user, + window=args.target_window, + ) + return _require_context_pass(result) + if args.context_command == "external-import": + payload = import_external_memory_context( + Path(args.source), + citation=args.citation, + source_id=args.source_id, + max_input_bytes=args.max_input_bytes, + target_tokens=args.target_tokens, + ) + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.context_command == "episode-retrieve": + payload = build_episode_retrieval_with_external_context( + Path(args.project_root), + list(args.artifact), + external_context_paths=[Path(item) for item in args.external_context], + query=args.query, + max_results=args.max_results, + max_external_context_hints=args.max_external_context_hints, + target_tokens=args.target_tokens, + ) + if args.out: + write_json_create(Path(args.out), payload) + return payload + raise LifecycleError("command-not-implemented", "context command is not implemented") + + +def _dispatch_goal(args: argparse.Namespace) -> dict[str, Any] | str: + record = read_json_object(Path(args.record), label="goal record") + if args.goal_command == "check": + state = read_json_object(Path(args.state), label="workflow state") if args.state else None + return validate_goal_record(record, state=state, require_current=args.current) + if args.goal_command == "summarize": + state = read_json_object(Path(args.state), label="workflow state") + profile = read_json_object(Path(args.profile), label="context profile") if args.profile else None + return build_objective_snapshot(record, state, profile=profile, window=args.target_window) + if args.goal_command == "view": + payload = build_goal_progress_view( + record_path=Path(args.record), + state_path=Path(args.state), + usage_receipt_paths=[Path(item) for item in args.usage_receipt], + change_summary_path=Path(args.change_summary) if args.change_summary else None, + require_current=not args.allow_stale_goal, + ) + if args.out: + write_json_create(Path(args.out), payload) + if args.terminal: + return render_goal_view_terminal(payload) + return payload + if args.goal_command == "update": + state = read_json_object(Path(args.state), label="workflow state") + updated = update_goal_record( + record, + state, + status=args.status, + reason=args.reason, + evidence_ids=args.evidence_id, + ) + if args.out: + write_json_create(Path(args.out), updated) + return updated + raise LifecycleError("command-not-implemented", "goal command is not implemented") + + +def _dispatch_model(args: argparse.Namespace) -> dict[str, Any]: + if args.model_command == "profile-check": + profile = read_json_object(Path(args.profile), label="model profile") + if args.type == "routing": + return validate_model_routing_profile(profile) + if args.type == "host": + return validate_host_model_profile(profile) + if profile.get("schemaVersion") in { + "agent-lifecycle-host-model-profile.v1", + "agent-host-model-selection-profile.v1", + }: + return validate_host_model_profile(profile) + return validate_model_routing_profile(profile) + if args.model_command == "route": + routing_profile = read_json_object(Path(args.profile), label="model routing profile") + host_profile = read_json_object(Path(args.host_profile), label="host model profile") if args.host_profile else None + request = read_json_object(Path(args.request), label="model route request") + return resolve_model_route(request, routing_profile, host_profile=host_profile) + if args.model_command == "usage-check": + receipt = read_json_object(Path(args.receipt), label="model usage receipt") + decision = read_json_object(Path(args.route_decision), label="model route decision") if args.route_decision else None + targets = read_json_object(Path(args.budget_targets), label="budget targets") if args.budget_targets else None + result = validate_usage_receipt(receipt, budget_targets=targets, route_decision=decision) + if result["status"] == "FAIL": + raise LifecycleError( + "model-usage-validation-failed", + "model usage receipt validation failed", + {"validation": result}, + ) + return result + raise LifecycleError("command-not-implemented", "model command is not implemented") + + +def _dispatch_metrics(args: argparse.Namespace) -> dict[str, Any]: + if args.metrics_command == "cost-check": + report = read_json_object(Path(args.receipt), label="lifecycle cost report") + return require_lifecycle_cost_pass(validate_lifecycle_cost_report(report)) + if args.metrics_command == "cost-report": + report = generate_lifecycle_cost_report( + artifact_paths=[Path(item) for item in args.artifact], + mode=args.mode, + root=Path(args.project_root), + ) + validation = require_lifecycle_cost_pass(validate_lifecycle_cost_report(report)) + report_bytes = write_json_create(Path(args.out), report) + summary_path = None + summary_digest = None + if args.summary_out: + summary = build_lifecycle_cost_summary(report) + write_json_create(Path(args.summary_out), summary) + summary_path = args.summary_out + summary_digest = canonical_digest(summary) + return { + "schemaVersion": "agent-lifecycle-cost-generation.v1", + "status": validation["status"], + "reportPath": args.out, + "reportBytes": len(report_bytes), + "reportDigest": canonical_digest(report), + "summaryPath": summary_path, + "summaryDigest": summary_digest, + "validation": validation, + "liveCallsStarted": False, + "productionPromotionClaimed": False, + } + if args.metrics_command == "usage-export": + export = build_usage_export( + artifact_paths=[Path(item) for item in args.artifact], + project_root=Path(args.project_root), + ) + validation = require_usage_export_pass(validate_usage_export(export)) + rendered = render_usage_export_json(export) if args.format == "json" else render_usage_export_table(export) + output_path = Path(args.out) + _write_text_create(output_path, rendered) + return { + "schemaVersion": "agent-usage-export-generation.v1", + "status": validation["status"], + "format": args.format, + "outputPath": args.out, + "outputBytes": len(rendered.encode("utf-8")), + "exportDigest": canonical_digest(export), + "validation": validation, + "liveCallsStarted": False, + "productionPromotionClaimed": False, + } + if args.metrics_command == "outcome-index": + artifacts = [read_json_object(Path(item), label="outcome artifact") for item in args.artifact] + payload = build_task_outcome_index(artifacts, source_paths=list(args.artifact)) + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.metrics_command == "quality-signals": + payload = build_quality_cost_signals(read_json_object(Path(args.index), label="task outcome index")) + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.metrics_command == "recommend": + reports = [read_json_object(Path(item), label="lifecycle cost report") for item in args.report] + baseline_profile = read_json_object(Path(args.baseline_profile), label="lifecycle baseline profile") + recommendation = recommend_lifecycle_mode( + reports=reports, + baseline_profile=baseline_profile, + task_shape=args.task_shape, + current_mode=args.current_mode, + sdd_tier=args.sdd_tier, + risk_flags=args.risk, + ) + require_lifecycle_recommendation_pass(recommendation) + if args.out: + write_json_create(Path(args.out), recommendation) + if args.summary_out: + write_json_create(Path(args.summary_out), build_lifecycle_recommendation_summary(recommendation)) + return recommendation + if args.metrics_command == "learn-recommend": + signals = read_json_object(Path(args.signals), label="quality-cost signals") + baseline_profile = read_json_object(Path(args.baseline_profile), label="lifecycle baseline profile") + recommendation = recommend_from_quality_cost_signals( + signals=signals, + baseline_profile=baseline_profile, + task_shape=args.task_shape, + current_mode=args.current_mode, + sdd_tier=args.sdd_tier, + risk_flags=args.risk, + ) + require_lifecycle_recommendation_pass(recommendation) + if args.out: + write_json_create(Path(args.out), recommendation) + if args.summary_out: + write_json_create(Path(args.summary_out), build_lifecycle_recommendation_summary(recommendation)) + return recommendation + raise LifecycleError("command-not-implemented", "metrics command is not implemented") + + +def _write_text_create(path: Path, text: str) -> bytes: + data = text.encode("utf-8") + path.parent.mkdir(parents=True, exist_ok=True) + try: + with path.open("x", encoding="utf-8") as handle: + handle.write(text) + except FileExistsError as exc: + raise LifecycleError( + "output-already-exists", + "output artifact already exists", + {"path": path.as_posix()}, + ) from exc + return data + + +def _require_context_pass(result: dict[str, Any]) -> dict[str, Any]: + if result.get("status") == "FAIL": + raise LifecycleError( + "context-overflow", + "compact context exceeds target window", + {"receipt": result.get("receipt")}, + ) + return result diff --git a/src/agent_lifecycle/cli/dispatch_planning.py b/src/agent_lifecycle/cli/dispatch_planning.py new file mode 100644 index 00000000..feeaabbf --- /dev/null +++ b/src/agent_lifecycle/cli/dispatch_planning.py @@ -0,0 +1,169 @@ +"""Tiering, specification, planning, and task-packet CLI handlers.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Any + +from agent_lifecycle.compiler import compile_small_model_packets, compile_task_packets +from agent_lifecycle.contracts import LifecycleError, read_json_object, write_json_create +from agent_lifecycle.freeze import verify_plan_lock +from agent_lifecycle.planning import ( + build_plan_snapshot, + load_plan_completeness_profile, + reconcile_plan_snapshot, + render_plan_handoff, + require_reconciliation_pass, + require_repository_references_pass, + resolve_sdd_tier, + validate_acceptance_checklist, + validate_plan_completeness, + validate_plan_manifest, + validate_repository_references, +) +from agent_lifecycle.specification import build_completion_gate_receipt, validate_specification + + +def dispatch_planning(args: argparse.Namespace) -> dict[str, Any]: + """Dispatch plan- and task-oriented command groups.""" + if args.command == "tier": + return _dispatch_tier(args) + if args.command == "specification": + return _dispatch_specification(args) + if args.command == "plan": + return _dispatch_plan(args) + if args.command == "task": + return _dispatch_task(args) + raise LifecycleError("command-not-implemented", "planning command is not implemented") + + +def _dispatch_tier(args: argparse.Namespace) -> dict[str, Any]: + if args.tier_command == "resolve": + return resolve_sdd_tier(read_json_object(Path(args.request), label="tier request")) + raise LifecycleError("command-not-implemented", "tier command is not implemented") + + +def _dispatch_specification(args: argparse.Namespace) -> dict[str, Any]: + if args.specification_command == "check": + return validate_specification(read_json_object(Path(args.specification), label="specification")) + if args.specification_command == "completion-gate": + gate_input = read_json_object(Path(args.input), label="completion gate input") if args.input else {} + payload = build_completion_gate_receipt( + state=read_json_object(Path(args.state), label="workflow state"), + final_audit=read_json_object(Path(args.final_audit), label="final audit") if args.final_audit else None, + follow_up_register=( + read_json_object(Path(args.follow_up_register), label="follow-up register") + if args.follow_up_register + else None + ), + validation_results=gate_input.get("validationResults", []), + required_validation_ids=gate_input.get("requiredValidationIds", []), + follow_up_candidates=gate_input.get("followUpCandidates", []), + regression_signals=gate_input.get("regressionSignals", []), + risk_flags=gate_input.get("riskFlags", []), + split_candidates=gate_input.get("splitCandidates", []), + verifier_id=args.verifier, + ) + if args.out: + write_json_create(Path(args.out), payload) + return payload + raise LifecycleError("command-not-implemented", "specification command is not implemented") + + +def _dispatch_plan(args: argparse.Namespace) -> dict[str, Any]: + if args.plan_command == "check": + manifest = read_json_object(Path(args.manifest), label="plan manifest") + lock = read_json_object(Path(args.lock), label="plan lock") if args.lock else None + completeness_profile = ( + load_plan_completeness_profile(Path(args.completeness_profile)) + if args.completeness_profile + else None + ) + return { + "schemaVersion": "agent-plan-check.v1", + "manifest": validate_plan_manifest( + manifest, + require_completeness=args.require_completeness, + completeness_profile=completeness_profile, + ), + "lock": verify_plan_lock(manifest, lock) if lock else None, + } + if args.plan_command == "completeness-check": + manifest = read_json_object(Path(args.manifest), label="plan manifest") + profile = load_plan_completeness_profile(Path(args.profile)) if args.profile else None + payload = validate_plan_completeness(manifest, profile=profile) + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.plan_command == "acceptance-check": + manifest_path = Path(args.manifest) + acceptance_path = Path(args.acceptance) + return validate_acceptance_checklist( + read_json_object(manifest_path, label="plan manifest"), + acceptance_path.read_text(encoding="utf-8"), + ) + if args.plan_command == "refs-check": + manifest = read_json_object(Path(args.manifest), label="plan manifest") + return require_repository_references_pass(validate_repository_references(manifest)) + if args.plan_command == "snapshot": + manifest = read_json_object(Path(args.manifest), label="plan manifest") + payload = build_plan_snapshot(manifest) + if args.out: + write_json_create(Path(args.out), payload) + return payload + if args.plan_command == "reconcile": + manifest = read_json_object(Path(args.manifest), label="plan manifest") + snapshot = read_json_object(Path(args.snapshot), label="plan snapshot") + return require_reconciliation_pass(reconcile_plan_snapshot(snapshot, manifest)) + if args.plan_command == "handoff": + manifest = read_json_object(Path(args.manifest), label="plan manifest") + snapshot = read_json_object(Path(args.snapshot), label="plan snapshot") if args.snapshot else None + payload = render_plan_handoff( + manifest, + snapshot=snapshot, + max_workstreams=args.max_workstreams, + target_tokens=args.target_tokens, + ) + if payload.get("status") != "PASS": + raise LifecycleError( + "plan-handoff-render-failed", + "plan handoff did not fit the requested limits", + {"handoff": payload}, + ) + if args.out: + write_json_create(Path(args.out), payload) + return payload + raise LifecycleError("command-not-implemented", "plan command is not implemented") + + +def _dispatch_task(args: argparse.Namespace) -> dict[str, Any]: + if args.task_command == "compile": + result = compile_task_packets( + Path(args.manifest), + out_dir=Path(args.out_dir) if args.out_dir else None, + write=args.write, + ) + return {"schemaVersion": "agent-task-packet-compile-result.v1", **result} + if args.task_command == "compile-small": + adaptive_decision = ( + read_json_object(Path(args.adaptive_decision), label="adaptive lifecycle decision") + if args.adaptive_decision + else None + ) + result = compile_small_model_packets( + Path(args.manifest), + context_profile_path=Path(args.context_profile), + out_dir=Path(args.out_dir) if args.out_dir else None, + target_window=args.target_window, + adaptive_decision=adaptive_decision, + write=args.write, + ) + if result["status"] != "PASS": + raise LifecycleError( + "small-model-packet-compile-failed", + "small-model packet compilation failed", + {"result": result}, + ) + return result + raise LifecycleError("command-not-implemented", "task command is not implemented") diff --git a/src/agent_lifecycle/contracts/redaction.py b/src/agent_lifecycle/contracts/redaction.py new file mode 100644 index 00000000..24f87995 --- /dev/null +++ b/src/agent_lifecycle/contracts/redaction.py @@ -0,0 +1,112 @@ +"""Shared redaction primitives for receipts that may carry host output.""" + +from __future__ import annotations + +import re +from typing import Any + +REDACTED_VALUE = "" + +_SENSITIVE_EXACT_KEYS = { + "access_token", + "accesstoken", + "api_key", + "apikey", + "authorization", + "credential", + "credentials", + "id_token", + "idtoken", + "password", + "private_key", + "privatekey", + "refresh_token", + "refreshtoken", + "secret", + "session_token", + "sessiontoken", + "token", +} +_SENSITIVE_SUFFIXES = ( + "_access_token", + "_api_key", + "_credential", + "_key", + "_password", + "_private_key", + "_refresh_token", + "_secret", + "_session_token", + "_token", +) +_PRIVATE_KEY = re.compile(r"-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----", re.DOTALL) +_BEARER = re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]+", re.IGNORECASE) +_KEY_VALUE = re.compile( + r"(?P[\"']?[A-Za-z][A-Za-z0-9_.-]*[\"']?)(?P\s*[:=]\s*)(?PBearer\s+[^\s,;}\]]+|\"(?:\\.|[^\"])*\"|'(?:\\.|[^'])*'|[^\s,;}\]]+)", + re.IGNORECASE, +) +_LOCAL_PATH = re.compile( + r"(?]+|/(?:Users|home)/[^\s\"'<>]+|[A-Za-z]:\\Users\\[^\s\"'<>]+)" +) + + +def redact_text(value: str) -> tuple[str, bool]: + """Redact common secret and local-path forms from text and report a change.""" + + redacted = _PRIVATE_KEY.sub(REDACTED_VALUE, value) + redacted = _BEARER.sub(f"Bearer {REDACTED_VALUE}", redacted) + redacted = _KEY_VALUE.sub(_replace_sensitive_assignment, redacted) + redacted = _LOCAL_PATH.sub(REDACTED_VALUE, redacted) + return redacted, redacted != value + + +def redact_value(value: Any) -> tuple[Any, bool]: + """Redact nested receipt values while preserving JSON-compatible structure.""" + + if isinstance(value, dict): + result: dict[Any, Any] = {} + changed = False + for key, item in value.items(): + if isinstance(key, str) and is_sensitive_key(key): + result[key] = REDACTED_VALUE + changed = True + continue + nested, nested_changed = redact_value(item) + result[key] = nested + changed = changed or nested_changed + return result, changed + if isinstance(value, list): + result = [] + changed = False + for item in value: + nested, nested_changed = redact_value(item) + result.append(nested) + changed = changed or nested_changed + return result, changed + if isinstance(value, str): + return redact_text(value) + return value, False + + +def is_sensitive_key(key: str) -> bool: + """Return whether a JSON key denotes a value that must not be retained.""" + + normalized = key.replace("-", "_").lower() + return normalized in _SENSITIVE_EXACT_KEYS or normalized.endswith(_SENSITIVE_SUFFIXES) + + +def _replace_sensitive_assignment(match: re.Match[str]) -> str: + key = match.group("key") + if not is_sensitive_key(key.strip("\"'")): + return match.group(0) + value = match.group("value") + replacement = _quoted_redaction(value) + return f"{key}{match.group('separator')}{replacement}" + + +def _quoted_redaction(value: str) -> str: + if value.startswith('"'): + return f'"{REDACTED_VALUE}"' + if value.startswith("'"): + return f"'{REDACTED_VALUE}'" + return REDACTED_VALUE diff --git a/src/agent_lifecycle/contracts/review_verdict.py b/src/agent_lifecycle/contracts/review_verdict.py new file mode 100644 index 00000000..3fc52481 --- /dev/null +++ b/src/agent_lifecycle/contracts/review_verdict.py @@ -0,0 +1,116 @@ +"""Provider-neutral structured review-verdict contract validation.""" + +from __future__ import annotations + +from typing import Any + +from agent_lifecycle.contracts import LifecycleError, canonical_digest + +REVIEW_VERDICT_SCHEMA = "agent-review-verdict.v1" +REVIEW_VERDICT_VALIDATION_SCHEMA = "agent-review-verdict-validation.v1" +DIMENSIONS = ("requirementFit", "implementationQuality", "evidenceQuality", "residualRisk") +DIMENSION_STATUSES = {"PASS", "WARN", "FAIL"} +OVERALL_VERDICTS = {"ACCEPTED", "REWORK", "CONTRACT_CHANGE", "BLOCKED"} +NEXT_ACTIONS = {"accept", "fix-implementation", "strengthen-evidence", "reopen-contract", "block-external"} + + +def validate_review_verdict(verdict: dict[str, Any], *, findings: list[dict[str, Any]] | None = None) -> dict[str, Any]: + if not isinstance(verdict, dict): + raise LifecycleError("invalid-review-verdict", "review verdict must be an object") + blockers: list[dict[str, Any]] = [] + if verdict.get("schemaVersion") != REVIEW_VERDICT_SCHEMA: + blockers.append({"code": "invalid-review-verdict-schema", "message": "unsupported review verdict schemaVersion"}) + overall = verdict.get("overall") + if overall not in OVERALL_VERDICTS: + blockers.append({"code": "invalid-review-verdict-overall", "message": "overall verdict is unsupported"}) + dimensions = verdict.get("dimensions") + if not isinstance(dimensions, dict): + blockers.append({"code": "invalid-review-verdict-dimensions", "message": "dimensions must be an object"}) + dimensions = {} + missing = sorted(set(DIMENSIONS).difference(dimensions)) + if missing: + blockers.append({"code": "review-verdict-dimension-missing", "dimensions": missing}) + failing_dimensions: list[str] = [] + warning_dimensions: list[str] = [] + for name in DIMENSIONS: + dimension = dimensions.get(name) + if not isinstance(dimension, dict): + continue + status = dimension.get("status") + if status not in DIMENSION_STATUSES: + blockers.append({"code": "review-verdict-dimension-status", "dimension": name, "status": status}) + continue + reason = dimension.get("reasonCode") + if not isinstance(reason, str) or not reason: + blockers.append({"code": "review-verdict-dimension-reason-missing", "dimension": name}) + summary = dimension.get("summary") + if not isinstance(summary, str) or not summary: + blockers.append({"code": "review-verdict-dimension-summary-missing", "dimension": name}) + if status == "FAIL": + failing_dimensions.append(name) + elif status == "WARN": + warning_dimensions.append(name) + routing = verdict.get("routing") + if not isinstance(routing, dict): + blockers.append({"code": "invalid-review-verdict-routing", "message": "routing must be an object"}) + routing = {} + next_action = routing.get("nextAction") + if next_action not in NEXT_ACTIONS: + blockers.append({"code": "review-verdict-routing-action", "nextAction": next_action}) + target = routing.get("target") + if target is not None and (not isinstance(target, str) or not target): + blockers.append({"code": "review-verdict-routing-target", "message": "routing target must be a non-empty string"}) + open_medium_plus = _open_medium_plus(findings or []) + if open_medium_plus: + blockers.append({"code": "review-verdict-open-findings", "findings": open_medium_plus}) + if overall == "ACCEPTED" and (failing_dimensions or open_medium_plus): + blockers.append({"code": "review-verdict-accepted-with-blockers", "dimensions": failing_dimensions, "findings": open_medium_plus}) + if overall == "ACCEPTED" and next_action != "accept": + blockers.append({"code": "review-verdict-routing-mismatch", "overall": overall, "nextAction": next_action}) + if overall != "ACCEPTED" and next_action == "accept": + blockers.append({"code": "review-verdict-routing-mismatch", "overall": overall, "nextAction": next_action}) + body = { + "schemaVersion": REVIEW_VERDICT_VALIDATION_SCHEMA, + "status": "PASS" if not blockers else "FAIL", + "overall": overall, + "failingDimensions": failing_dimensions, + "warningDimensions": warning_dimensions, + "nextAction": next_action, + "blockers": blockers, + } + return {**body, "verdictDigest": canonical_digest(verdict), "validationDigest": canonical_digest(body)} + + +def require_review_verdict_pass(payload: dict[str, Any]) -> dict[str, Any]: + if payload.get("status") == "FAIL": + raise LifecycleError("review-verdict-validation-failed", "review verdict validation failed", {"validation": payload}) + return payload + + +def compact_review_routing(verdict: dict[str, Any]) -> dict[str, Any]: + validation = validate_review_verdict(verdict) + dimensions = verdict.get("dimensions", {}) + return { + "schemaVersion": "agent-review-routing-summary.v1", + "status": validation["status"], + "overall": validation["overall"], + "nextAction": validation["nextAction"], + "failingDimensions": validation["failingDimensions"], + "warningDimensions": validation["warningDimensions"], + "dimensionStatus": { + key: dimensions.get(key, {}).get("status") + for key in DIMENSIONS + if isinstance(dimensions.get(key), dict) + }, + "verdictDigest": validation["verdictDigest"], + } + + +def _open_medium_plus(findings: list[dict[str, Any]]) -> list[str]: + return [ + str(finding.get("id") or "") + for finding in findings + if isinstance(finding, dict) + and finding.get("status") == "open" + and finding.get("severity") in {"BLOCKER", "HIGH", "MEDIUM"} + ] diff --git a/src/agent_lifecycle/diagnostics/installation_catalog.py b/src/agent_lifecycle/diagnostics/installation_catalog.py new file mode 100644 index 00000000..d19a5dd0 --- /dev/null +++ b/src/agent_lifecycle/diagnostics/installation_catalog.py @@ -0,0 +1,23 @@ +"""Data-only access to installation facts declared by adapter descriptors.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +from agent_lifecycle.contracts import LifecycleError +from agent_lifecycle.host_protocol.validation import validate_installation_facts + + +def load_installation_facts(descriptor: dict[str, Any]) -> dict[str, Any]: + """Return validated descriptor data without interpreting or executing argv.""" + + facts = descriptor.get("installation") + validation = validate_installation_facts(facts) + if validation["status"] != "PASS": + raise LifecycleError( + "adapter-installation-facts-invalid", + "adapter descriptor installation facts are invalid", + {"blockers": validation["blockers"]}, + ) + return deepcopy(facts) diff --git a/src/agent_lifecycle/diagnostics/readiness.py b/src/agent_lifecycle/diagnostics/readiness.py index 5e89ff57..300eb32e 100644 --- a/src/agent_lifecycle/diagnostics/readiness.py +++ b/src/agent_lifecycle/diagnostics/readiness.py @@ -9,6 +9,7 @@ from agent_lifecycle import __version__ from agent_lifecycle.context import load_context_profile from agent_lifecycle.contracts import LifecycleError, canonical_digest, read_json_object +from agent_lifecycle.diagnostics.installation_catalog import load_installation_facts from agent_lifecycle.host_protocol import ( inspect_adapter_descriptor, validate_adapter_descriptor, @@ -114,7 +115,11 @@ def build_adapter_install_plan(*, project_root: Path, descriptor_path: Path) -> host = _required_str(descriptor, "host", path) adapter_id = _required_str(descriptor, "adapterId", path) maturity = descriptor.get("maturity") - files, commands, operator_actions = _install_instructions(host, adapter_id=adapter_id) + installation = ( + load_installation_facts(descriptor) + if "installation" in descriptor + else _undeclared_installation_facts() + ) return { "schemaVersion": INSTALL_PLAN_SCHEMA_VERSION, "status": "DRY_RUN", @@ -127,9 +132,11 @@ def build_adapter_install_plan(*, project_root: Path, descriptor_path: Path) -> "liveCallsStarted": False, "productionPromotionClaimed": False, "maturityChangeClaimed": False, - "files": files, - "commands": commands, - "operatorActions": operator_actions, + "installationStatus": "DECLARED" if "installation" in descriptor else "NOT_DECLARED", + "binaryAliases": installation["binaryAliases"], + "files": installation["files"], + "commands": [*_diagnostic_commands(adapter_id), *installation["commands"]], + "operatorActions": installation["operatorActions"], "nextActions": [ "review this plan", "run adapter validate before any host-local setup", @@ -551,172 +558,52 @@ def _file_check(root: Path, relative: Path, *, name: str) -> dict[str, Any]: } -def _install_instructions(host: str, *, adapter_id: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str]]: +def _diagnostic_commands(adapter_id: str) -> list[dict[str, Any]]: descriptor_arg = f"adapters/{adapter_id}/adapter.descriptor.json" - common_validate = { - "argv": [ - "agent-lifecycle", - "adapter", - "validate", - "--descriptor", - descriptor_arg, - "--baseline", - "conformance/core/adapter-baseline.v1.json", - ], - "purpose": "validate adapter descriptor before setup", - "mutatesHost": False, - "requiresOperator": False, - } - common_inspect = { - "argv": [ - "agent-lifecycle", - "adapter", - "inspect", - "--descriptor", - descriptor_arg, - "--skip-host-commands", - ], - "purpose": "inspect source projection without host commands", - "mutatesHost": False, - "requiresOperator": False, - } - table: dict[str, tuple[list[dict[str, Any]], list[dict[str, Any]], list[str]]] = { - "codex": ( - [ - {"path": ".codex-plugin/plugin.json", "action": "read", "required": True}, - {"path": ".agents/plugins/marketplace.json", "action": "read", "required": True}, - ], - [ - common_validate, - common_inspect, - { - "argv": ["codex", "plugin", "marketplace", "add", "avksp/agent-lifecycle-kit", "--ref", "vX.Y.Z"], - "purpose": "add trusted tagged source marketplace", - "mutatesHost": True, - "requiresOperator": True, - }, - { - "argv": ["codex", "plugin", "add", "agent-lifecycle-kit@agent-lifecycle-kit"], - "purpose": "install plugin from configured marketplace", - "mutatesHost": True, - "requiresOperator": True, - }, - ], - ["choose the release tag", "restart the host session after installation"], - ), - "claude-code": ( - [ - {"path": ".claude-plugin/plugin.json", "action": "read", "required": True}, - {"path": ".claude-plugin/marketplace.json", "action": "read", "required": True}, - ], - [ - common_validate, - common_inspect, - { - "argv": ["claude", "plugin", "marketplace", "add", "avksp/agent-lifecycle-kit"], - "purpose": "add repository marketplace", - "mutatesHost": True, - "requiresOperator": True, - }, - { - "argv": ["claude", "plugin", "install", "agent-lifecycle-kit@agent-lifecycle-kit"], - "purpose": "install plugin from configured marketplace", - "mutatesHost": True, - "requiresOperator": True, - }, - ], - ["run reload command in the host session after installation"], - ), - "cursor": ( - [ - {"path": ".cursor-plugin/plugin.json", "action": "read", "required": True}, - {"path": ".cursor-plugin/marketplace.json", "action": "read", "required": True}, - ], - [ - common_validate, - common_inspect, - { - "argv": ["ln", "-s", "", "~/.cursor/plugins/local/agent-lifecycle-kit"], - "purpose": "link trusted checkout into the local plugin directory", - "mutatesHost": True, - "requiresOperator": True, - }, - ], - ["reload the host window", "do not claim VERIFIED until live receipts exist"], - ), - "opencode": ( - [ - {"path": "skills/*", "action": "copy-preview", "required": True}, - {"path": "adapters/opencode/plugins/agent-lifecycle-kit.js", "action": "copy-preview", "required": True}, - {"path": "opencode.json", "action": "read", "required": True}, - ], - [ - common_validate, - common_inspect, - { - "argv": ["cp", "-R", "/skills/*", ".opencode/skills/"], - "purpose": "copy shared skills into target project", - "mutatesHost": True, - "requiresOperator": True, - }, - ], - ["choose project-level or user-level installation"], - ), - "hermes": ( - [ - {"path": "skills.sh.json", "action": "read", "required": True}, - {"path": "adapters/hermes/hermes.registry.json", "action": "read", "required": True}, - {"path": "adapters/hermes/slash-commands.json", "action": "read", "required": True}, - ], - [ - common_validate, - common_inspect, - { - "argv": ["hermes", "skills", "install", "https://raw.githubusercontent.com/avksp/agent-lifecycle-kit/vX.Y.Z/skills/agent-workflow-orchestrator/SKILL.md"], - "purpose": "install a tagged lifecycle skill", - "mutatesHost": True, - "requiresOperator": True, - }, - ], - ["install every required lifecycle skill from the chosen tag"], - ), - } - if host in table: - return table[host] - files = [ - {"path": f"adapters/{adapter_id}/adapter.descriptor.json", "action": "read", "required": True}, - {"path": f"adapters/{adapter_id}/runner.py", "action": "read", "required": True}, - {"path": f"adapters/{adapter_id}/receipt_normalizer.py", "action": "read", "required": True}, - ] - commands = [ - common_validate, - common_inspect, + return [ { - "argv": ["python", "-m", "pip", "install", "-e", ""], - "purpose": "install the core CLI from a trusted checkout", - "mutatesHost": True, - "requiresOperator": True, + "argv": [ + "agent-lifecycle", + "adapter", + "validate", + "--descriptor", + descriptor_arg, + "--baseline", + "conformance/core/adapter-baseline.v1.json", + ], + "purpose": "validate adapter descriptor before setup", + "mutatesHost": False, + "requiresOperator": False, }, { - "argv": [_host_binary(host), "--version"], - "purpose": "confirm host CLI is available before live proof", + "argv": [ + "agent-lifecycle", + "adapter", + "inspect", + "--descriptor", + descriptor_arg, + "--skip-host-commands", + ], + "purpose": "inspect source projection without host commands", "mutatesHost": False, - "requiresOperator": True, + "requiresOperator": False, }, ] - return files, commands, ["configure host-local model/profile settings outside portable core", "keep adapter EXPERIMENTAL until receipts are accepted"] - -def _resolve(root: Path, path: Path) -> Path: - return path if path.is_absolute() else root / path +def _undeclared_installation_facts() -> dict[str, Any]: + """Keep scaffold diagnostics read-only until a descriptor adds installation facts.""" -def _host_binary(host: str) -> str: return { - "gemini-cli": "gemini", - "qwen-code": "qwen", - "kimi-code": "kimi", - }.get(host, host) + "binaryAliases": [], + "files": [], + "commands": [], + "operatorActions": ["Add validated installation facts to the adapter descriptor before host-local setup."], + } + + +def _resolve(root: Path, path: Path) -> Path: + return path if path.is_absolute() else root / path def _display_path(path: Path, root: Path) -> str: diff --git a/src/agent_lifecycle/host_protocol/receipts.py b/src/agent_lifecycle/host_protocol/receipts.py index c6059bd4..d9b01a6d 100644 --- a/src/agent_lifecycle/host_protocol/receipts.py +++ b/src/agent_lifecycle/host_protocol/receipts.py @@ -4,31 +4,9 @@ from typing import Any +from agent_lifecycle.contracts.redaction import redact_value from agent_lifecycle.host_protocol.contracts import HostOperationReceipt -REDACTED_VALUE = "" -SENSITIVE_EXACT_KEYS = { - "api_key", - "apikey", - "access_token", - "authorization", - "credential", - "id_token", - "password", - "refresh_token", - "secret", - "session_token", - "token", -} -SENSITIVE_KEY_PARTS = ( - "api_key", - "apikey", - "authorization", - "credential", - "password", - "secret", -) - def normalize_host_operation_receipt(payload: dict[str, Any], *, redact_sensitive: bool = True) -> dict[str, Any]: """Return a closed host-operation receipt with optional sensitive redaction.""" @@ -36,23 +14,8 @@ def normalize_host_operation_receipt(payload: dict[str, Any], *, redact_sensitiv receipt = HostOperationReceipt.from_json(payload).to_json() if not redact_sensitive: return receipt - return _redact(receipt) - - -def _redact(value: Any) -> Any: - if isinstance(value, dict): - redacted: dict[str, Any] = {} - for key, item in value.items(): - if _is_sensitive_key(key): - redacted[key] = REDACTED_VALUE - else: - redacted[key] = _redact(item) - return redacted - if isinstance(value, list): - return [_redact(item) for item in value] - return value - - -def _is_sensitive_key(key: str) -> bool: - normalized = key.replace("-", "_").lower() - return normalized in SENSITIVE_EXACT_KEYS or any(part in normalized for part in SENSITIVE_KEY_PARTS) + redacted, applied = redact_value(receipt) + usage = dict(redacted["usage"]) + usage["receiptRedaction"] = {"applied": applied, "secretValuesStored": False} + redacted["usage"] = usage + return redacted diff --git a/src/agent_lifecycle/host_protocol/validation.py b/src/agent_lifecycle/host_protocol/validation.py index 9ad5d394..80654dd6 100644 --- a/src/agent_lifecycle/host_protocol/validation.py +++ b/src/agent_lifecycle/host_protocol/validation.py @@ -36,6 +36,9 @@ "task-audit", "final-audit", } +INSTALLATION_FACTS_SCHEMA_VERSION = "agent-adapter-installation-facts.v1" +INSTALLATION_FILE_ACTIONS = {"read", "copy-preview"} +SHELL_EXECUTABLES = {"bash", "cmd", "powershell", "pwsh", "sh", "zsh"} def validate_adapter_descriptor( @@ -90,6 +93,8 @@ def _validate_descriptor_shape(descriptor: dict[str, Any], blockers: list[dict[s if descriptor.get("coreSemantics") != "delegated-to-agent-lifecycle-core": blockers.append({"code": "adapter-core-semantics-overclaim", "message": "adapter must delegate lifecycle semantics to core"}) _validate_managed_launch_profile(descriptor.get("managedLaunch"), blockers) + if "installation" in descriptor: + _validate_installation_facts(descriptor.get("installation"), blockers) operations = descriptor.get("operations") if not isinstance(operations, list): blockers.append({"code": "invalid-adapter-operations", "message": "operations must be an array"}) @@ -126,6 +131,19 @@ def validate_managed_launch_profile(profile: dict[str, Any]) -> dict[str, Any]: } +def validate_installation_facts(facts: Any) -> dict[str, Any]: + """Validate declarative adapter installation guidance without executing it.""" + + blockers: list[dict[str, Any]] = [] + _validate_installation_facts(facts, blockers) + return { + "schemaVersion": "agent-adapter-installation-facts-validation.v1", + "status": "PASS" if not blockers else "FAIL", + "blockers": blockers, + "productionPromotionClaimed": False, + } + + def _validate_managed_launch_profile(profile: Any, blockers: list[dict[str, Any]]) -> None: if not isinstance(profile, dict): blockers.append({"code": "adapter-managed-launch-missing", "message": "managedLaunch must be an object"}) @@ -165,6 +183,66 @@ def _validate_managed_launch_profile(profile: Any, blockers: list[dict[str, Any] blockers.append({"code": "adapter-managed-launch-reason", "message": "non-supported managedLaunch profiles need a reason"}) +def _validate_installation_facts(facts: Any, blockers: list[dict[str, Any]]) -> None: + if not isinstance(facts, dict): + blockers.append({"code": "adapter-installation-facts-missing", "message": "installation must be an object"}) + return + if facts.get("schemaVersion") != INSTALLATION_FACTS_SCHEMA_VERSION: + blockers.append( + { + "code": "adapter-installation-facts-schema", + "message": "installation schemaVersion is invalid", + "actual": facts.get("schemaVersion"), + } + ) + aliases = facts.get("binaryAliases") + if not isinstance(aliases, list) or not aliases or not all(isinstance(item, str) and item.strip() == item for item in aliases): + blockers.append({"code": "adapter-installation-binary-aliases", "message": "binaryAliases must be a non-empty string array"}) + elif len(set(aliases)) != len(aliases): + blockers.append({"code": "adapter-installation-binary-aliases-duplicate", "message": "binaryAliases must be unique"}) + + files = facts.get("files") + if not isinstance(files, list) or not files: + blockers.append({"code": "adapter-installation-files", "message": "installation files must be a non-empty array"}) + else: + for index, item in enumerate(files): + if not isinstance(item, dict): + blockers.append({"code": "adapter-installation-file", "index": index, "message": "file record must be an object"}) + continue + path = item.get("path") + if not isinstance(path, str) or not path or path.startswith(("/", "\\")) or "\x00" in path: + blockers.append({"code": "adapter-installation-file-path", "index": index}) + if item.get("action") not in INSTALLATION_FILE_ACTIONS: + blockers.append({"code": "adapter-installation-file-action", "index": index}) + if not isinstance(item.get("required"), bool): + blockers.append({"code": "adapter-installation-file-required", "index": index}) + + commands = facts.get("commands") + if not isinstance(commands, list) or not commands: + blockers.append({"code": "adapter-installation-commands", "message": "installation commands must be a non-empty array"}) + else: + for index, item in enumerate(commands): + if not isinstance(item, dict): + blockers.append({"code": "adapter-installation-command", "index": index, "message": "command record must be an object"}) + continue + if "command" in item or "shell" in item: + blockers.append({"code": "adapter-installation-command-shell", "index": index, "message": "commands must use argv arrays without shell fields"}) + argv = item.get("argv") + if not isinstance(argv, list) or not argv or not all(isinstance(value, str) and value and "\n" not in value for value in argv): + blockers.append({"code": "adapter-installation-command-argv", "index": index}) + elif argv[0].lower() in SHELL_EXECUTABLES: + blockers.append({"code": "adapter-installation-command-shell", "index": index, "message": "shell executables are not installation guidance"}) + if not isinstance(item.get("purpose"), str) or not item["purpose"]: + blockers.append({"code": "adapter-installation-command-purpose", "index": index}) + for field in ("mutatesHost", "requiresOperator"): + if not isinstance(item.get(field), bool): + blockers.append({"code": "adapter-installation-command-flag", "index": index, "field": field}) + + actions = facts.get("operatorActions") + if not isinstance(actions, list) or not actions or not all(isinstance(item, str) and item for item in actions): + blockers.append({"code": "adapter-installation-operator-actions", "message": "operatorActions must be a non-empty string array"}) + + def _validate_baseline(descriptor: dict[str, Any], baseline: dict[str, Any], blockers: list[dict[str, Any]]) -> None: rules = baseline.get("maturityRules", {}) expected_maturity = rules.get("requiredReleaseMaturity") diff --git a/src/agent_lifecycle/neutrality/cli.py b/src/agent_lifecycle/neutrality/cli.py index 5c6f71c2..467e802e 100644 --- a/src/agent_lifecycle/neutrality/cli.py +++ b/src/agent_lifecycle/neutrality/cli.py @@ -17,7 +17,12 @@ from agent_lifecycle.neutrality.errors import NeutralityError, write_neutrality_error from agent_lifecycle.neutrality.paths import resolve_repository_relative_root from agent_lifecycle.neutrality.policy import load_policy -from agent_lifecycle.neutrality.receipt import build_claims, build_receipt, verify_existing_receipt +from agent_lifecycle.neutrality.receipt import ( + build_claims, + build_receipt, + require_zero_completeness_counters, + verify_existing_receipt, +) from agent_lifecycle.neutrality.scanner import scan_repository @@ -127,8 +132,7 @@ def _bootstrap(args: argparse.Namespace) -> int: output_paths=[primary_path.relative_to(workspace_root), receipt_path.relative_to(workspace_root)], ).to_json(operation) - if args.require_zero_findings and report["counters"]["findings"] != 0: - raise NeutralityError("neutrality findings are non-zero") + require_zero_completeness_counters(report) claims = build_claims( operation=operation, diff --git a/src/agent_lifecycle/neutrality/receipt.py b/src/agent_lifecycle/neutrality/receipt.py index 2f670cb6..3f70eb3a 100644 --- a/src/agent_lifecycle/neutrality/receipt.py +++ b/src/agent_lifecycle/neutrality/receipt.py @@ -11,6 +11,18 @@ from .errors import NeutralityError from .paths import stable_read_bytes +REQUIRED_COMPLETENESS_COUNTERS = ( + "findings", + "skippedInputs", + "opaqueInputs", + "readRaces", + "incompleteScans", + "unsupportedArchives", + "archiveLimitBreaches", + "occupiedOutputConflicts", + "pathAliasConflicts", +) + def build_claims( *, @@ -22,20 +34,7 @@ def build_claims( policy: dict[str, Any], profile: dict[str, Any], ) -> dict[str, Any]: - zero_counters = { - key: int(report["counters"].get(key, 0)) - for key in [ - "findings", - "skippedInputs", - "opaqueInputs", - "readRaces", - "incompleteScans", - "unsupportedArchives", - "archiveLimitBreaches", - "occupiedOutputConflicts", - "pathAliasConflicts", - ] - } + zero_counters = require_zero_completeness_counters(report) output_manifest = [ { "publicationOrder": 1, @@ -100,8 +99,12 @@ def verify_existing_receipt( trust_root_path: Path, expected_signer_fingerprint: str, ) -> bool: - receipt = load_json(stable_read_bytes(receipt_path)) - primary = stable_read_bytes(primary_path) + try: + receipt = load_json(stable_read_bytes(receipt_path)) + primary = stable_read_bytes(primary_path) + report = load_json(primary) + except (OSError, NeutralityError): + return False if receipt.get("schemaVersion") != "agent-neutrality-detached-receipt.v3": return False if expected_operation is not None and receipt.get("operation") != expected_operation: @@ -119,9 +122,30 @@ def verify_existing_receipt( signature = receipt.get("signature") if not isinstance(claims_digest, str) or not isinstance(signature, str): return False + if report.get("claimsDigest") != claims_digest: + return False + try: + require_zero_completeness_counters(report) + except NeutralityError: + return False return verify(public_key, RECEIPT_DOMAIN + claims_digest.encode("ascii"), bytes.fromhex(signature)) +def require_zero_completeness_counters(report: dict[str, Any]) -> dict[str, int]: + """Return required counters only when every declared completeness value is zero.""" + + counters = report.get("counters") + if not isinstance(counters, dict): + raise NeutralityError("neutrality scan counters are non-zero") + zero_counters: dict[str, int] = {} + for key in REQUIRED_COMPLETENESS_COUNTERS: + value = counters.get(key) + if isinstance(value, bool) or not isinstance(value, int) or value != 0: + raise NeutralityError("neutrality scan counters are non-zero") + zero_counters[key] = value + return zero_counters + + def _public_key_for_fingerprint(trust_root: dict[str, Any], expected_fingerprint: str) -> bytes: if trust_root.get("schemaVersion") != "agent-neutrality-trust-root.v1": raise NeutralityError("unsupported trust root schemaVersion") diff --git a/src/agent_lifecycle/workflow/reviews.py b/src/agent_lifecycle/workflow/reviews.py index 9b88fb36..1b058fcd 100644 --- a/src/agent_lifecycle/workflow/reviews.py +++ b/src/agent_lifecycle/workflow/reviews.py @@ -4,8 +4,8 @@ from typing import Any -from agent_lifecycle.audit import validate_review_verdict from agent_lifecycle.contracts import LifecycleError +from agent_lifecycle.contracts.review_verdict import validate_review_verdict def validate_task_result( diff --git a/tests/adapter_sessions/test_env_policy.py b/tests/adapter_sessions/test_env_policy.py index 325f8c26..452a00cc 100644 --- a/tests/adapter_sessions/test_env_policy.py +++ b/tests/adapter_sessions/test_env_policy.py @@ -9,11 +9,11 @@ class AdapterSessionEnvPolicyTests(unittest.TestCase): - def test_env_resolution_passes_only_allowlisted_names_and_patterns(self) -> None: + def test_env_resolution_passes_only_exact_allowlisted_names(self) -> None: profile = { "env": { "allow": ["ALK_ALLOWED"], - "allowPatterns": ["HOST_*"], + "allowPatterns": [], "projectPolicyAllowed": False, } } @@ -22,10 +22,18 @@ def test_env_resolution_passes_only_allowlisted_names_and_patterns(self) -> None process_env={"ALK_ALLOWED": "1", "HOST_TOKEN": "secret", "OTHER": "no"}, ) - self.assertEqual(set(env), {"ALK_ALLOWED", "HOST_TOKEN"}) + self.assertEqual(set(env), {"ALK_ALLOWED"}) self.assertTrue(receipt["valuesRedacted"]) self.assertFalse(receipt["secretValuesStored"]) + def test_env_resolution_rejects_descriptor_wildcard(self) -> None: + profile = {"env": {"allow": [], "allowPatterns": ["HOST_*"], "projectPolicyAllowed": False}} + + with self.assertRaisesRegex(Exception, "exact environment variable names") as caught: + resolve_launch_env(profile, process_env={"HOST_TOKEN": "secret"}) + + self.assertEqual(caught.exception.code, "adapter-env-wildcard-disallowed") + def test_project_policy_extends_names_without_secret_values(self) -> None: with tempfile.TemporaryDirectory() as tmp: policy = Path(tmp) / "env-policy.json" @@ -38,6 +46,17 @@ def test_project_policy_extends_names_without_secret_values(self) -> None: self.assertEqual(receipt["includedNames"], ["PROJECT_KEY"]) self.assertFalse(receipt["secretValuesStored"]) + def test_project_policy_rejects_wildcard(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + policy = Path(tmp) / "env-policy.json" + policy.write_text(json.dumps({"allow": [], "allowPatterns": ["HOST_*"]}), encoding="utf-8") + profile = {"env": {"allow": [], "allowPatterns": [], "projectPolicyAllowed": True}} + + with self.assertRaisesRegex(Exception, "exact environment variable names") as caught: + resolve_launch_env(profile, policy_path=policy, process_env={"HOST_TOKEN": "secret"}) + + self.assertEqual(caught.exception.code, "adapter-env-wildcard-disallowed") + if __name__ == "__main__": unittest.main() diff --git a/tests/adapter_sessions/test_secure_launcher.py b/tests/adapter_sessions/test_secure_launcher.py index 61b683b8..ad0f53a6 100644 --- a/tests/adapter_sessions/test_secure_launcher.py +++ b/tests/adapter_sessions/test_secure_launcher.py @@ -1,28 +1,33 @@ from __future__ import annotations import sys +import tempfile import unittest +from pathlib import Path from agent_lifecycle.adapter_sessions.launcher import launch_from_descriptor class SecureAdapterLauncherTests(unittest.TestCase): - def test_supported_profile_launches_with_argv_and_redacted_env(self) -> None: - descriptor = _descriptor(argv=[sys.executable, "-c", "print('ok')"]) + def test_supported_profile_is_blocked_before_process_creation(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + marker = Path(tmp) / "launched.txt" + descriptor = _descriptor(argv=[sys.executable, "-c", f"from pathlib import Path; Path({str(marker)!r}).write_text('launched')"]) - receipt = launch_from_descriptor( - descriptor=descriptor, - session_id="session-1", - launch_mode="interactive", - process_env={"SAFE_TOKEN": "secret", "OTHER": "no"}, - ) + receipt = launch_from_descriptor( + descriptor=descriptor, + session_id="session-1", + launch_mode="interactive", + process_env={"SAFE_TOKEN": "secret", "OTHER": "no"}, + ) - self.assertEqual(receipt["status"], "PASS") + self.assertFalse(marker.exists()) + + self.assertEqual(receipt["status"], "BLOCKED") self.assertFalse(receipt["shell"]) - self.assertTrue(receipt["hostLaunchStarted"]) - self.assertEqual(receipt["env"]["includedNames"], ["SAFE_TOKEN"]) - self.assertTrue(receipt["env"]["valuesRedacted"]) - self.assertIn("ok", receipt["stdout"]["tail"]) + self.assertFalse(receipt["hostLaunchStarted"]) + self.assertEqual(receipt["argv"], []) + self.assertIn("adapter-generic-launch-disabled", {item["code"] for item in receipt["blockers"]}) def test_wrapper_only_profile_blocks_native_launch(self) -> None: descriptor = _descriptor(status="WRAPPER_ONLY", reason="wrapper required") @@ -31,7 +36,19 @@ def test_wrapper_only_profile_blocks_native_launch(self) -> None: self.assertEqual(receipt["status"], "BLOCKED") self.assertFalse(receipt["hostLaunchStarted"]) - self.assertIn("adapter-managed-launch-unsupported", {item["code"] for item in receipt["blockers"]}) + self.assertIn("adapter-generic-launch-disabled", {item["code"] for item in receipt["blockers"]}) + + def test_invalid_profile_returns_structured_blocker(self) -> None: + receipt = launch_from_descriptor( + descriptor={"adapterId": "codex", "managedLaunch": {"status": "SUPPORTED"}}, + session_id="session-1", + launch_mode="interactive", + ) + + self.assertEqual(receipt["status"], "BLOCKED") + codes = {item["code"] for item in receipt["blockers"]} + self.assertIn("adapter-generic-launch-invalid-descriptor", codes) + self.assertIn("adapter-generic-launch-disabled", codes) def _descriptor(*, argv: list[str] | None = None, status: str = "SUPPORTED", reason: str | None = None) -> dict: diff --git a/tests/adapter_sessions/test_session_contracts.py b/tests/adapter_sessions/test_session_contracts.py index cdfcd640..dd27660c 100644 --- a/tests/adapter_sessions/test_session_contracts.py +++ b/tests/adapter_sessions/test_session_contracts.py @@ -44,6 +44,27 @@ def test_launch_receipt_is_redacted_and_shell_false(self) -> None: self.assertTrue(receipt["env"]["valuesRedacted"]) self.assertFalse(receipt["modelCallsStarted"]) + def test_launch_receipt_records_actual_output_redaction(self) -> None: + local_path = "/" + "Users/operator/private.log" + receipt = build_launch_receipt( + status="PASS", + adapter_id="codex", + session_id="session-1", + launch_mode="interactive", + argv=["codex"], + timeout_seconds=1.0, + env={"includedNames": [], "valuesRedacted": True, "secretValuesStored": False}, + exit_code=0, + timed_out=False, + stdout_tail="safe output", + stderr_tail=f"Authorization: Bearer secret-value {local_path}", + ) + + self.assertFalse(receipt["stdout"]["redacted"]) + self.assertTrue(receipt["stderr"]["redacted"]) + self.assertNotIn("secret-value", receipt["stderr"]["tail"]) + self.assertNotIn(local_path, receipt["stderr"]["tail"]) + def test_resume_receipt_blocks_lineage_mismatch(self) -> None: receipt = build_resume_receipt( session_id="session-1", diff --git a/tests/adapters/test_host_protocol_validation.py b/tests/adapters/test_host_protocol_validation.py index e82152bb..bd910967 100644 --- a/tests/adapters/test_host_protocol_validation.py +++ b/tests/adapters/test_host_protocol_validation.py @@ -33,6 +33,15 @@ def test_host_operation_examples_validate_through_contract_path(self) -> None: self.assertEqual(result["requestCount"], 13) self.assertEqual(result["receiptCount"], 13) + def test_installation_facts_reject_shell_command_fields(self) -> None: + descriptor = _load_json(ROOT / "adapters/codex/adapter.descriptor.json") + descriptor["installation"]["commands"][0]["shell"] = True + + result = validate_adapter_descriptor(descriptor) + + self.assertEqual(result["status"], "FAIL") + self.assertIn("adapter-installation-command-shell", {item["code"] for item in result["blockers"]}) + def test_verified_adapter_without_live_evidence_fails(self) -> None: # NEG-R03-07 Runtime Adapter Overclaim descriptor = _load_json(ROOT / "adapters/codex/adapter.descriptor.json") diff --git a/tests/adapters/test_publication_manifests.py b/tests/adapters/test_publication_manifests.py index ac543ec1..fbd6152e 100644 --- a/tests/adapters/test_publication_manifests.py +++ b/tests/adapters/test_publication_manifests.py @@ -3,13 +3,14 @@ import json import runpy import sys +import tomllib import unittest from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[2] -VERSION = "1.50.1" +VERSION = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]["version"] PLUGIN_NAME = "agent-lifecycle-kit" TOOLS_RELEASE = ROOT / "tools" / "release" if str(TOOLS_RELEASE) not in sys.path: @@ -168,6 +169,8 @@ def test_publication_contract_covers_current_projection_paths(self) -> None: "adapters/claude/.claude-plugin/plugin.json", "adapters/codex/.codex-plugin/plugin.json", "adapters/cursor/.cursor-plugin/plugin.json", + "docs/guides/quickstart.md", + "docs/ru/quickstart.md", }.issubset(paths) ) diff --git a/tests/audit/test_review_verdict.py b/tests/audit/test_review_verdict.py index 50706881..eeaca1e4 100644 --- a/tests/audit/test_review_verdict.py +++ b/tests/audit/test_review_verdict.py @@ -12,12 +12,17 @@ sys.path.insert(0, str(ROOT / "src")) from agent_lifecycle.audit import compact_review_routing, validate_review_verdict # noqa: E402 +from agent_lifecycle.audit.review_verdict import validate_review_verdict as facade_validate_review_verdict # noqa: E402 from agent_lifecycle.cli import main # noqa: E402 from agent_lifecycle.contracts import LifecycleError # noqa: E402 +from agent_lifecycle.contracts.review_verdict import validate_review_verdict as contract_validate_review_verdict # noqa: E402 from agent_lifecycle.workflow.reviews import validate_task_review # noqa: E402 class ReviewVerdictTests(unittest.TestCase): + def test_audit_module_remains_a_contract_facade(self) -> None: + self.assertIs(facade_validate_review_verdict, contract_validate_review_verdict) + def test_review_verdict_accepts_split_quality_dimensions(self) -> None: verdict = _accepted_verdict() diff --git a/tests/cli/test_adapter_session_commands.py b/tests/cli/test_adapter_session_commands.py index 6dcd26a3..feb7764c 100644 --- a/tests/cli/test_adapter_session_commands.py +++ b/tests/cli/test_adapter_session_commands.py @@ -33,6 +33,50 @@ def test_session_start_status_and_launch_blocked_by_wrapper_only_profile(self) - self.assertEqual(status["status"], "BLOCKED") self.assertFalse(status["lifecycleCoverageClaimed"]) + def test_session_start_blocks_custom_supported_descriptor_before_launch(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sessions = root / "sessions" + descriptor = root / "adapter.descriptor.json" + descriptor.write_text( + json.dumps( + { + "adapterId": "codex", + "managedLaunch": { + "status": "SUPPORTED", + "shell": False, + "timeoutSeconds": 5.0, + "env": {"allow": [], "allowPatterns": [], "projectPolicyAllowed": False}, + "writesNativeConfig": False, + "promptInjectionDefault": False, + "argvTemplates": {"interactive": ["codex"], "managedTask": ["codex"], "resume": ["codex"]}, + }, + } + ), + encoding="utf-8", + ) + + code, payload, stderr = _run_cli( + [ + "adapter", + "session", + "start", + "--adapter", + "codex", + "--descriptor", + str(descriptor), + "--session-root", + str(sessions), + "--launch", + ] + ) + + self.assertEqual(code, 0) + self.assertEqual(stderr, "") + self.assertEqual(payload["status"], "BLOCKED") + self.assertFalse(payload["hostLaunchStarted"]) + self.assertIn("adapter-generic-launch-disabled", {item["code"] for item in payload["launchReceipt"]["blockers"]}) + def test_adapter_run_preserves_stdout_json_and_prints_progress_to_stderr(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) diff --git a/tests/cli/test_dispatch_routing.py b/tests/cli/test_dispatch_routing.py new file mode 100644 index 00000000..7b58f645 --- /dev/null +++ b/tests/cli/test_dispatch_routing.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import argparse +import unittest +from unittest.mock import patch + +from agent_lifecycle.cli.dispatch import dispatch +from agent_lifecycle.contracts import LifecycleError + + +class DispatchRoutingTests(unittest.TestCase): + def test_root_dispatch_routes_each_domain_group_to_its_delegate(self) -> None: + cases = { + "diagnose": "dispatch_adapters", + "adapter": "dispatch_adapters", + "version": "dispatch_contracts", + "review-mesh": "dispatch_contracts", + "workflow": "dispatch_lifecycle", + "runner": "dispatch_lifecycle", + "report": "dispatch_observability", + "metrics": "dispatch_observability", + "plan": "dispatch_planning", + "task": "dispatch_planning", + } + for command, delegate in cases.items(): + with self.subTest(command=command), patch( + f"agent_lifecycle.cli.dispatch.{delegate}", + return_value={"delegate": delegate}, + ) as handler: + payload = dispatch(argparse.Namespace(command=command), []) + + self.assertEqual(payload, {"delegate": delegate}) + handler.assert_called_once() + + def test_unknown_group_keeps_stable_error(self) -> None: + with self.assertRaisesRegex(LifecycleError, "reserved but not implemented") as caught: + dispatch(argparse.Namespace(command="future"), []) + + self.assertEqual(caught.exception.code, "command-not-implemented") diff --git a/tests/cli/test_foundation.py b/tests/cli/test_foundation.py index 313549e1..58fd5005 100644 --- a/tests/cli/test_foundation.py +++ b/tests/cli/test_foundation.py @@ -4,6 +4,7 @@ import sys import tempfile import unittest +from importlib import import_module from pathlib import Path try: @@ -12,6 +13,17 @@ from helpers import * # noqa: F401,F403,E402 class CliFoundationTests(unittest.TestCase): + def test_root_dispatch_delegates_are_importable(self) -> None: + for module_name in ( + "agent_lifecycle.cli.dispatch_adapters", + "agent_lifecycle.cli.dispatch_contracts", + "agent_lifecycle.cli.dispatch_lifecycle", + "agent_lifecycle.cli.dispatch_observability", + "agent_lifecycle.cli.dispatch_planning", + ): + with self.subTest(module=module_name): + self.assertIsNotNone(import_module(module_name)) + def test_version_outputs_compact_json(self) -> None: code, payload = _run_cli(["version"]) self.assertEqual(code, 0) diff --git a/tests/contracts/test_redaction.py b/tests/contracts/test_redaction.py new file mode 100644 index 00000000..9cef524a --- /dev/null +++ b/tests/contracts/test_redaction.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import unittest + +from agent_lifecycle.contracts.redaction import REDACTED_VALUE, redact_text, redact_value + + +class ReceiptRedactionTests(unittest.TestCase): + def test_redact_text_covers_assignment_bearer_private_key_and_local_path(self) -> None: + local_path = "/" + "Users/operator/private.log" + value = ( + "API_KEY=top-secret Authorization: Bearer token-value " + "{\"session-token\": \"session-secret\"} " + "-----BEGIN PRIVATE KEY-----\nprivate-material\n-----END PRIVATE KEY----- " + + local_path + ) + + redacted, applied = redact_text(value) + + self.assertTrue(applied) + self.assertNotIn("top-secret", redacted) + self.assertNotIn("token-value", redacted) + self.assertNotIn("session-secret", redacted) + self.assertNotIn("private-material", redacted) + self.assertNotIn(local_path, redacted) + self.assertIn(REDACTED_VALUE, redacted) + + def test_redact_value_preserves_safe_values_and_tracks_no_change(self) -> None: + redacted, applied = redact_value({"inputTokens": 12, "message": "safe", "items": ["ok"]}) + + self.assertFalse(applied) + self.assertEqual(redacted, {"inputTokens": 12, "message": "safe", "items": ["ok"]}) + + def test_redact_value_redacts_nested_sensitive_keys(self) -> None: + redacted, applied = redact_value({"nested": {"refresh_token": "secret"}, "path": "C:\\Users\\operator\\secret.txt"}) + + self.assertTrue(applied) + self.assertEqual(redacted["nested"]["refresh_token"], REDACTED_VALUE) + self.assertEqual(redacted["path"], REDACTED_VALUE) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/contracts/test_review_verdict.py b/tests/contracts/test_review_verdict.py new file mode 100644 index 00000000..f5aad2b6 --- /dev/null +++ b/tests/contracts/test_review_verdict.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import unittest + +from agent_lifecycle.contracts.review_verdict import compact_review_routing, validate_review_verdict + + +class ReviewVerdictContractTests(unittest.TestCase): + def test_contract_accepts_complete_accepted_verdict(self) -> None: + verdict = _accepted_verdict() + + validation = validate_review_verdict(verdict) + summary = compact_review_routing(verdict) + + self.assertEqual(validation["status"], "PASS") + self.assertEqual(summary["nextAction"], "accept") + + def test_contract_rejects_open_medium_findings_for_accepted_verdict(self) -> None: + validation = validate_review_verdict( + _accepted_verdict(), + findings=[{"id": "M1", "status": "open", "severity": "MEDIUM"}], + ) + + self.assertEqual(validation["status"], "FAIL") + self.assertIn("review-verdict-open-findings", {item["code"] for item in validation["blockers"]}) + + +def _accepted_verdict() -> dict[str, object]: + return { + "schemaVersion": "agent-review-verdict.v1", + "overall": "ACCEPTED", + "dimensions": { + "requirementFit": {"status": "PASS", "reasonCode": "requirements-met", "summary": "Requirements are covered."}, + "implementationQuality": {"status": "PASS", "reasonCode": "quality-met", "summary": "Implementation is maintainable."}, + "evidenceQuality": {"status": "PASS", "reasonCode": "evidence-current", "summary": "Evidence is current."}, + "residualRisk": {"status": "PASS", "reasonCode": "risk-low", "summary": "No blocking residual risk remains."}, + }, + "routing": {"nextAction": "accept", "target": "task"}, + } diff --git a/tests/diagnostics/test_readiness.py b/tests/diagnostics/test_readiness.py index 015f9bd3..7ae65a20 100644 --- a/tests/diagnostics/test_readiness.py +++ b/tests/diagnostics/test_readiness.py @@ -33,6 +33,8 @@ def test_readiness_report_is_redacted_read_only_and_promotion_safe(self) -> None self.assertFalse(report["adapters"][0]["productionPromotionClaimed"]) self.assertFalse(report["adapters"][0]["maturityChangeClaimed"]) self.assertEqual(report["installPlans"][0]["status"], "DRY_RUN") + self.assertEqual(report["installPlans"][0]["binaryAliases"], ["codex"]) + self.assertTrue(all(isinstance(item["argv"], list) for item in report["installPlans"][0]["commands"])) self.assertNotIn(str(ROOT), rendered) def test_tracked_evidence_summary_and_local_raw_receipts_are_classified_separately(self) -> None: diff --git a/tests/host_protocol/test_adapter_capabilities.py b/tests/host_protocol/test_adapter_capabilities.py index 391cc21e..cd232996 100644 --- a/tests/host_protocol/test_adapter_capabilities.py +++ b/tests/host_protocol/test_adapter_capabilities.py @@ -47,12 +47,13 @@ def test_manifest_validation_fails_on_descriptor_drift(self) -> None: self.assertIn("capability-manifest-operation-drift", codes) def test_receipt_normalizer_redacts_sensitive_nested_values(self) -> None: + local_path = "/" + "Users/operator/work/out.json" payload = { "schemaVersion": "agent-host-operation-receipt.v1", "operationId": "op-1", "capability": "launch", "status": "PASS", - "outputs": [{"path": "work/out.json", "apiKey": "secret-value"}], + "outputs": [{"path": local_path, "apiKey": "secret-value", "message": "Bearer abc.def"}], "usage": {"inputTokens": 10, "provider": {"session-token": "abc"}}, } @@ -61,6 +62,10 @@ def test_receipt_normalizer_redacts_sensitive_nested_values(self) -> None: self.assertEqual(normalized["outputs"][0]["apiKey"], "") self.assertEqual(normalized["usage"]["provider"]["session-token"], "") self.assertEqual(normalized["usage"]["inputTokens"], 10) + self.assertEqual(normalized["outputs"][0]["path"], "") + self.assertEqual(normalized["outputs"][0]["message"], "Bearer ") + self.assertTrue(normalized["usage"]["receiptRedaction"]["applied"]) + self.assertFalse(normalized["usage"]["receiptRedaction"]["secretValuesStored"]) def test_adapter_package_discovery_is_advisory_only(self) -> None: with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/host_protocol/test_adapter_launch_profiles.py b/tests/host_protocol/test_adapter_launch_profiles.py index c1256637..3eeb42b8 100644 --- a/tests/host_protocol/test_adapter_launch_profiles.py +++ b/tests/host_protocol/test_adapter_launch_profiles.py @@ -5,7 +5,7 @@ from pathlib import Path from agent_lifecycle.host_protocol import validate_adapter_descriptor -from agent_lifecycle.host_protocol.validation import validate_managed_launch_profile +from agent_lifecycle.host_protocol.validation import validate_installation_facts, validate_managed_launch_profile ROOT = Path(__file__).resolve().parents[2] @@ -23,6 +23,16 @@ def test_current_adapters_declare_truthful_managed_launch_profiles(self) -> None self.assertFalse(descriptor["managedLaunch"]["writesNativeConfig"]) self.assertFalse(descriptor["managedLaunch"]["promptInjectionDefault"]) + def test_installation_facts_are_data_only(self) -> None: + descriptor = json.loads((ROOT / "adapters/codex/adapter.descriptor.json").read_text(encoding="utf-8")) + facts = descriptor["installation"] + + validation = validate_installation_facts(facts) + + self.assertEqual(validation["status"], "PASS", validation["blockers"]) + self.assertEqual(facts["binaryAliases"], ["codex"]) + self.assertTrue(all("command" not in item and "shell" not in item for item in facts["commands"])) + def test_supported_launch_profile_requires_argv_templates(self) -> None: validation = validate_managed_launch_profile( { diff --git a/tests/neutrality/test_neutrality.py b/tests/neutrality/test_neutrality.py index f7d2e022..556e95da 100644 --- a/tests/neutrality/test_neutrality.py +++ b/tests/neutrality/test_neutrality.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import hashlib import os import sys import tempfile @@ -24,6 +25,12 @@ from agent_lifecycle.neutrality.errors import NeutralityError # noqa: E402 from agent_lifecycle.neutrality.paths import validate_output_paths # noqa: E402 from agent_lifecycle.neutrality.policy import load_policy # noqa: E402 +from agent_lifecycle.neutrality.receipt import ( # noqa: E402 + REQUIRED_COMPLETENESS_COUNTERS, + build_claims, + require_zero_completeness_counters, + verify_existing_receipt, +) from agent_lifecycle.neutrality.scanner import scan_repository # noqa: E402 @@ -67,6 +74,56 @@ def test_clean_scan_has_zero_counters(self) -> None: ) self.assertEqual(report.to_json({"operationId": "op"})["counters"]["findings"], 0) + def test_signed_claims_reject_each_nonzero_completeness_counter(self) -> None: + report = _clean_report() + for counter in REQUIRED_COMPLETENESS_COUNTERS: + with self.subTest(counter=counter): + candidate = json.loads(json.dumps(report)) + candidate["counters"][counter] = 1 + with self.assertRaises(NeutralityError) as raised: + build_claims( + operation={"operationId": "op"}, + report=candidate, + authority_digest="0" * 64, + primary_path="out/report.json", + receipt_path="out/receipt.json", + policy={"archives": {}}, + profile={}, + ) + self.assertEqual(raised.exception.code, "neutrality-counters-nonzero") + + def test_existing_receipt_rejects_mutated_nonzero_counter(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + outside = Path(tempfile.mkdtemp()) + seed = b"n" * 32 + paths = _write_authority(root, seed, datetime.now(UTC).replace(microsecond=0), authority_root=outside) + self.assertEqual(_run_cli(root, seed, env_paths=paths), 0) + original_report = json.loads((root / "out/report.json").read_text(encoding="utf-8")) + original_receipt = json.loads((root / "out/receipt.json").read_text(encoding="utf-8")) + for counter in REQUIRED_COMPLETENESS_COUNTERS: + with self.subTest(counter=counter): + report = json.loads(json.dumps(original_report)) + receipt = json.loads(json.dumps(original_receipt)) + report["counters"][counter] = 1 + report_bytes = json.dumps(report, sort_keys=True).encode("utf-8") + (root / "out/report.json").write_bytes(report_bytes) + receipt["primaryArtifact"] = {"sha256": hashlib.sha256(report_bytes).hexdigest(), "bytes": len(report_bytes)} + (root / "out/receipt.json").write_text(json.dumps(receipt), encoding="utf-8") + self.assertFalse( + verify_existing_receipt( + receipt_path=root / "out/receipt.json", + primary_path=root / "out/report.json", + expected_operation=None, + trust_root_path=paths["trust"], + expected_signer_fingerprint=fingerprint(publickey_from_seed(seed)), + ) + ) + + def test_completeness_counter_helper_rejects_missing_counter(self) -> None: + with self.assertRaises(NeutralityError): + require_zero_completeness_counters({"counters": {"findings": 0}}) + def test_output_alias_conflict_fails_closed(self) -> None: with self.assertRaises(NeutralityError) as caught: validate_output_paths([Path("Out/report.json"), Path("out/report.json")]) @@ -383,6 +440,18 @@ def _policy( return load_policy(path) +def _clean_report() -> dict: + return { + "scope": "current-tree-complete", + "counters": {name: 0 for name in REQUIRED_COMPLETENESS_COUNTERS}, + "digests": { + "workingTreeDigest": "0" * 64, + "gitObjectSetDigest": "1" * 64, + "subjectDigest": "2" * 64, + }, + } + + def _scan_root(root: Path, policy: object) -> object: return scan_repository( workspace_root=root, diff --git a/tests/package/run_packaging_smoke.py b/tests/package/run_packaging_smoke.py index 06152b29..c1bffcd8 100644 --- a/tests/package/run_packaging_smoke.py +++ b/tests/package/run_packaging_smoke.py @@ -1,13 +1,14 @@ from __future__ import annotations import argparse +import hashlib import json import shutil import subprocess import sys import tempfile import venv -from pathlib import Path +from pathlib import Path, PurePosixPath, PureWindowsPath from typing import Any ROOT = Path(__file__).resolve().parents[2] @@ -40,7 +41,7 @@ def main() -> int: _run_command([str(_venv_script(venv_dir, "agent-lifecycle")), "version"], commands, blockers) _run_command([str(_venv_script(venv_dir, "agent-lifecycle-neutrality")), "--help"], commands, blockers) marker = _probe_type_marker(python) - commands.append({"argv": [str(python), "-c", "py.typed probe"], "returncode": 0 if marker else 1}) + commands.append({"argv": _portable_argv([str(python), "-c", "py.typed probe"]), "returncode": 0 if marker else 1}) if not marker: blockers.append({"code": "py-typed-missing", "message": "installed wheel does not include agent_lifecycle/py.typed"}) @@ -48,7 +49,7 @@ def main() -> int: evidence = { "schemaVersion": "agent-packaging-smoke-evidence.v1", "status": status, - "distDir": dist_dir.as_posix(), + "distDir": _portable_path(dist_dir), "commands": commands, "blockers": blockers, "productionPromotionClaimed": False, @@ -60,18 +61,47 @@ def main() -> int: def _run_command(argv: list[str], commands: list[dict[str, Any]], blockers: list[dict[str, Any]]) -> None: result = subprocess.run(argv, cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) - commands.append({"argv": argv, "returncode": result.returncode}) + portable_argv = _portable_argv(argv) + commands.append({"argv": portable_argv, "returncode": result.returncode}) if result.returncode != 0: blockers.append( { "code": "packaging-command-failed", - "argv": argv, - "stdoutTail": result.stdout[-2000:], - "stderrTail": result.stderr[-2000:], + "argv": portable_argv, + "stdoutIdentity": _stream_identity(result.stdout), + "stderrIdentity": _stream_identity(result.stderr), } ) +def _portable_argv(argv: list[str]) -> list[str]: + return [_portable_path(item, executable=index == 0) for index, item in enumerate(argv)] + + +def _portable_path(value: str | Path, *, executable: bool = False) -> str: + raw_value = str(value) + path = Path(raw_value) + if path.is_absolute(): + try: + return path.resolve().relative_to(ROOT.resolve()).as_posix() + except ValueError: + name = path.name + elif PurePosixPath(raw_value).is_absolute(): + name = PurePosixPath(raw_value).name + elif PureWindowsPath(raw_value).is_absolute(): + name = PureWindowsPath(raw_value).name + else: + return path.as_posix() + if executable and name.lower().startswith("python"): + return "python" + return name or "external-path" + + +def _stream_identity(value: str) -> dict[str, Any]: + encoded = value.encode("utf-8") + return {"bytes": len(encoded), "sha256": hashlib.sha256(encoded).hexdigest()} + + def _probe_type_marker(python: Path) -> bool: code = ( "import importlib.resources as r; " diff --git a/tests/package/test_foundation.py b/tests/package/test_foundation.py index e2d154c8..5caf5408 100644 --- a/tests/package/test_foundation.py +++ b/tests/package/test_foundation.py @@ -18,7 +18,7 @@ def test_package_metadata_is_stable(self) -> None: pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) project = pyproject["project"] self.assertEqual(project["name"], "agent-lifecycle-kit") - self.assertEqual(project["version"], "1.50.1") + self.assertEqual(project["version"], "1.50.2") self.assertEqual(project["requires-python"], ">=3.11,<3.14") self.assertEqual(project["license"]["text"], "Apache-2.0") self.assertEqual(project["dependencies"], []) @@ -62,7 +62,7 @@ def test_uv_lock_declares_runtime_graph(self) -> None: self.assertEqual(lock["requires-python"], ">=3.11,<3.14") packages = {package["name"]: package for package in lock["package"]} self.assertIn("agent-lifecycle-kit", packages) - self.assertEqual(packages["agent-lifecycle-kit"]["version"], "1.50.1") + self.assertEqual(packages["agent-lifecycle-kit"]["version"], "1.50.2") def test_foundation_ci_uses_stdlib_unittest(self) -> None: pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) diff --git a/tests/package/test_packaging_smoke.py b/tests/package/test_packaging_smoke.py new file mode 100644 index 00000000..6cf74dcb --- /dev/null +++ b/tests/package/test_packaging_smoke.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import unittest + +from .run_packaging_smoke import ROOT, _portable_argv, _portable_path, _stream_identity + + +class PackagingSmokePortabilityTests(unittest.TestCase): + def test_external_executable_paths_are_reduced_to_stable_labels(self) -> None: + external_python = "/" + "Users/example/venv/bin/python3.12" + self.assertEqual( + _portable_argv([external_python, "-m", "build"]), + ["python", "-m", "build"], + ) + self.assertEqual( + _portable_argv(["/private/tmp/venv/bin/agent-lifecycle", "version"]), + ["agent-lifecycle", "version"], + ) + self.assertEqual( + _portable_argv([r"C:\Users\example\venv\Scripts\python.exe", "-m", "build"]), + ["python", "-m", "build"], + ) + + def test_repository_paths_remain_relative(self) -> None: + self.assertEqual(_portable_path(ROOT / "dist"), "dist") + + def test_process_output_is_represented_only_by_identity(self) -> None: + identity = _stream_identity("sensitive process output") + + self.assertEqual(identity["bytes"], 24) + self.assertEqual(len(identity["sha256"]), 64) + self.assertNotIn("output", identity) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/release/test_adapter_installation_metadata_validator.py b/tests/release/test_adapter_installation_metadata_validator.py new file mode 100644 index 00000000..6a46ce13 --- /dev/null +++ b/tests/release/test_adapter_installation_metadata_validator.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +class AdapterInstallationMetadataValidatorTests(unittest.TestCase): + def test_current_descriptors_pass_installation_metadata_validator(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + evidence = Path(tmp) / "installation-metadata.json" + subprocess.run( + [ + sys.executable, + str(ROOT / "tools/release/validate_adapter_installation_metadata.py"), + "--descriptor-root", + "adapters", + "--evidence", + str(evidence), + ], + cwd=ROOT, + check=True, + ) + payload = json.loads(evidence.read_text(encoding="utf-8")) + + self.assertEqual(payload["schemaVersion"], "agent-adapter-installation-metadata-validation.v1") + self.assertEqual(payload["status"], "PASS") + self.assertEqual(payload["adapterCount"], 12) + self.assertFalse(payload["requiredInvariants"]["diagnosticsHostExecution"]) + self.assertFalse(Path(payload["catalog"]["path"]).is_absolute()) + + def test_validator_rejects_shell_command_field(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + descriptor_root = root / "adapters" / "codex" + descriptor_root.mkdir(parents=True) + source = ROOT / "adapters/codex/adapter.descriptor.json" + target = descriptor_root / "adapter.descriptor.json" + shutil.copyfile(source, target) + descriptor = json.loads(target.read_text(encoding="utf-8")) + descriptor["installation"]["commands"][0]["command"] = "codex plugin add" + target.write_text(json.dumps(descriptor), encoding="utf-8") + evidence = root / "evidence.json" + + result = subprocess.run( + [ + sys.executable, + str(ROOT / "tools/release/validate_adapter_installation_metadata.py"), + "--descriptor-root", + str(root / "adapters"), + "--evidence", + str(evidence), + ], + cwd=ROOT, + text=True, + ) + payload = json.loads(evidence.read_text(encoding="utf-8")) + + self.assertNotEqual(result.returncode, 0) + self.assertEqual(payload["status"], "FAIL") + self.assertIn("adapter-installation-facts-invalid", {item["code"] for item in payload["blockers"]}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/release/test_adapter_launcher_security.py b/tests/release/test_adapter_launcher_security.py index 8999aa55..6844a132 100644 --- a/tests/release/test_adapter_launcher_security.py +++ b/tests/release/test_adapter_launcher_security.py @@ -61,6 +61,30 @@ def test_validator_rejects_shell_true(self) -> None: self.assertEqual(payload["status"], "FAIL") self.assertIn("adapter-launcher-forbidden-snippet", {item["code"] for item in payload["blockers"]}) + def test_validator_rejects_generic_launcher_process_route(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + bad = root / "launcher.py" + evidence = root / "evidence.json" + bad.write_text("def launch_from_descriptor():\n return run_process([])\n", encoding="utf-8") + + result = subprocess.run( + [ + sys.executable, + str(ROOT / "tools/release/validate_adapter_launcher_security.py"), + "--paths", + str(bad), + "--evidence", + str(evidence), + ], + cwd=ROOT, + text=True, + ) + payload = json.loads(evidence.read_text(encoding="utf-8")) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("adapter-generic-launch-process-route", {item["code"] for item in payload["blockers"]}) + if __name__ == "__main__": unittest.main() diff --git a/tests/release/test_cli_dispatch_boundary_validator.py b/tests/release/test_cli_dispatch_boundary_validator.py new file mode 100644 index 00000000..29b1c3ec --- /dev/null +++ b/tests/release/test_cli_dispatch_boundary_validator.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +class CliDispatchBoundaryValidatorTests(unittest.TestCase): + def test_current_root_dispatcher_passes_boundary(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + evidence = Path(tmp) / "dispatch-boundary.json" + subprocess.run( + [ + sys.executable, + str(ROOT / "tools/release/validate_cli_dispatch_boundary.py"), + "--path", + "src/agent_lifecycle/cli/dispatch.py", + "--max-lines", + "800", + "--evidence", + str(evidence), + ], + cwd=ROOT, + check=True, + ) + payload = json.loads(evidence.read_text(encoding="utf-8")) + + self.assertEqual(payload["status"], "PASS") + self.assertLessEqual(payload["actualLines"], 800) + self.assertEqual(len(payload["routedDelegates"]), 5) + + def test_validator_rejects_oversized_dispatcher_without_delegates(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + path = root / "dispatch.py" + path.write_text("def _dispatch_domain():\n pass\n" + ("# filler\n" * 5), encoding="utf-8") + evidence = root / "dispatch-boundary.json" + result = subprocess.run( + [ + sys.executable, + str(ROOT / "tools/release/validate_cli_dispatch_boundary.py"), + "--path", + str(path), + "--max-lines", + "2", + "--evidence", + str(evidence), + ], + cwd=ROOT, + text=True, + ) + payload = json.loads(evidence.read_text(encoding="utf-8")) + + self.assertNotEqual(result.returncode, 0) + codes = {item["code"] for item in payload["blockers"]} + self.assertIn("cli-dispatch-line-limit-exceeded", codes) + self.assertIn("cli-dispatch-delegate-import-missing", codes) + self.assertIn("cli-dispatch-domain-handler-retained", codes) diff --git a/tests/release/test_docs_gates.py b/tests/release/test_docs_gates.py index d3cbf70f..1750800b 100644 --- a/tests/release/test_docs_gates.py +++ b/tests/release/test_docs_gates.py @@ -139,6 +139,30 @@ def test_quickstart_and_adapter_docs_cover_bounded_commands(self) -> None: ): self.assertIn(command, install) + def test_security_docs_describe_blocked_launch_and_fail_closed_receipts(self) -> None: + managed_sessions = [ + ROOT / "docs/reference/managed-adapter-sessions.md", + ROOT / "docs/ru/reference/managed-adapter-sessions.md", + ] + for path in managed_sessions: + with self.subTest(path=path): + text = path.read_text(encoding="utf-8") + self.assertIn("adapter-generic-launch-disabled", text) + self.assertIn("WRAPPER_ONLY", text) + self.assertIn("шаблоны" if "/docs/ru/" in path.as_posix() else "wildcard", text) + + for path in ( + ROOT / "docs/security/neutrality-contract.md", + ROOT / "docs/ru/security/neutrality-contract.md", + ): + with self.subTest(path=path): + text = path.read_text(encoding="utf-8") + self.assertIn("readRaces", text) + self.assertIn("pathAliasConflicts", text) + + readiness = (ROOT / "docs/reference/readiness-diagnostics.md").read_text(encoding="utf-8") + self.assertIn("schema-validated installation facts", readiness) + def test_russian_docs_link_to_russian_docs(self) -> None: for path in sorted((ROOT / "docs/ru").rglob("*.md")): text = path.read_text(encoding="utf-8") @@ -737,6 +761,8 @@ def _write_min_docs(root: Path, *, unsupported_verified_row: bool) -> None: "adapter run.\n" "`WRAPPER_ONLY`.\n" "shell: false.\n" + "adapter-generic-launch-disabled.\n" + "wildcard.\n" "plugin installation alone.\n", ) _write_text( @@ -749,8 +775,37 @@ def _write_min_docs(root: Path, *, unsupported_verified_row: bool) -> None: "adapter run.\n" "`WRAPPER_ONLY`.\n" "shell: false.\n" + "adapter-generic-launch-disabled.\n" + "шаблоны.\n" "установка плагина.\n", ) + _write_text( + root / "docs/reference/readiness-diagnostics.md", + "`agent-adapter-install-plan.v1`.\n" + "schema-validated installation facts.\n" + "argv arrays.\n" + "Diagnostics never interpret the argv arrays as a shell command.\n", + ) + _write_text( + root / "docs/ru/reference/readiness-diagnostics.md", + "`agent-adapter-install-plan.v1`.\n" + "argv-массивы.\n" + "Диагностика не трактует argv-массивы как строку shell.\n", + ) + _write_text( + root / "docs/security/neutrality-contract.md", + "Completeness counters.\n" + "`readRaces`.\n" + "`pathAliasConflicts`.\n" + "fail closed.\n", + ) + _write_text( + root / "docs/ru/security/neutrality-contract.md", + "Счётчики полноты.\n" + "`readRaces`.\n" + "`pathAliasConflicts`.\n" + "ненулевое значение приводит к отказу.\n", + ) _write_text( root / "docs/adapters/progress-bridge-matrix.md", "Progress support is documented separately from adapter maturity.\n" diff --git a/tests/release/test_module_dependency_validator.py b/tests/release/test_module_dependency_validator.py new file mode 100644 index 00000000..d12279bf --- /dev/null +++ b/tests/release/test_module_dependency_validator.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +class ModuleDependencyValidatorTests(unittest.TestCase): + def test_current_audit_workflow_cycle_is_absent(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + evidence = Path(tmp) / "dependencies.json" + subprocess.run( + [ + sys.executable, + str(ROOT / "tools/release/validate_module_dependencies.py"), + "--package-root", + "src/agent_lifecycle", + "--forbid-cycle", + "agent_lifecycle.audit,agent_lifecycle.audit.implementation,agent_lifecycle.workflow.reviews", + "--evidence", + str(evidence), + ], + cwd=ROOT, + check=True, + ) + payload = json.loads(evidence.read_text(encoding="utf-8")) + + self.assertEqual(payload["status"], "PASS") + self.assertEqual(payload["forbiddenCycles"][0][0], "agent_lifecycle.audit") + + def test_validator_rejects_declared_cycle(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "agent_lifecycle" + root.mkdir() + (root / "__init__.py").write_text("", encoding="utf-8") + (root / "first.py").write_text("from agent_lifecycle import second\n", encoding="utf-8") + (root / "second.py").write_text("from agent_lifecycle import third\n", encoding="utf-8") + (root / "third.py").write_text("from agent_lifecycle import first\n", encoding="utf-8") + evidence = Path(tmp) / "evidence.json" + result = subprocess.run( + [ + sys.executable, + str(ROOT / "tools/release/validate_module_dependencies.py"), + "--package-root", + str(root), + "--forbid-cycle", + "agent_lifecycle.first,agent_lifecycle.second,agent_lifecycle.third", + "--evidence", + str(evidence), + ], + cwd=ROOT, + text=True, + ) + payload = json.loads(evidence.read_text(encoding="utf-8")) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("module-import-cycle", {item["code"] for item in payload["blockers"]}) diff --git a/tests/release/test_neutrality_receipt_validator.py b/tests/release/test_neutrality_receipt_validator.py new file mode 100644 index 00000000..b36e9f65 --- /dev/null +++ b/tests/release/test_neutrality_receipt_validator.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +class NeutralityReceiptValidatorTests(unittest.TestCase): + def test_current_neutrality_sources_pass_validator(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + evidence = Path(tmp) / "neutrality-receipt.json" + subprocess.run( + [ + sys.executable, + str(ROOT / "tools/release/validate_neutrality_receipt.py"), + "--path", + "src/agent_lifecycle/neutrality", + "--evidence", + str(evidence), + ], + cwd=ROOT, + check=True, + ) + payload = json.loads(evidence.read_text(encoding="utf-8")) + + self.assertEqual(payload["schemaVersion"], "agent-neutrality-receipt-validation.v1") + self.assertEqual(payload["status"], "PASS") + self.assertEqual(len(payload["requiredCounters"]), 9) + + def test_validator_rejects_missing_existing_receipt_gate(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "receipt.py").write_text( + "REQUIRED_COMPLETENESS_COUNTERS = ('findings',)\n" + "def build_claims():\n require_zero_completeness_counters()\n" + "def verify_existing_receipt():\n return True\n", + encoding="utf-8", + ) + (root / "cli.py").write_text("def _bootstrap():\n require_zero_completeness_counters()\n", encoding="utf-8") + evidence = root / "evidence.json" + result = subprocess.run( + [ + sys.executable, + str(ROOT / "tools/release/validate_neutrality_receipt.py"), + "--path", + str(root), + "--evidence", + str(evidence), + ], + cwd=ROOT, + text=True, + ) + payload = json.loads(evidence.read_text(encoding="utf-8")) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("neutrality-completeness-gate-missing", {item["code"] for item in payload["blockers"]}) diff --git a/tests/release/test_publication_adoption.py b/tests/release/test_publication_adoption.py index 84ebcc93..0b50d8f0 100644 --- a/tests/release/test_publication_adoption.py +++ b/tests/release/test_publication_adoption.py @@ -4,6 +4,7 @@ import subprocess import sys import tempfile +import tomllib import unittest from pathlib import Path @@ -14,7 +15,7 @@ from validate_publication_adoption import validate_publication_adoption # noqa: E402 -TARGET_VERSION = "1.50.1" +TARGET_VERSION = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]["version"] class PublicationAdoptionTests(unittest.TestCase): diff --git a/tests/release/test_publication_versions.py b/tests/release/test_publication_versions.py index 5f623e22..82044e08 100644 --- a/tests/release/test_publication_versions.py +++ b/tests/release/test_publication_versions.py @@ -4,6 +4,7 @@ import subprocess import sys import tempfile +import tomllib import unittest from pathlib import Path @@ -14,8 +15,8 @@ from publication_contract import build_publication_manifest, validate_publication_tree # noqa: E402 -TARGET_VERSION = "1.50.1" -TARGET_REF = "v1.50.1" +TARGET_VERSION = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]["version"] +TARGET_REF = f"v{TARGET_VERSION}" class PublicationVersionTests(unittest.TestCase): @@ -24,7 +25,7 @@ def test_publication_manifest_records_field_shapes_and_last_policy(self) -> None self.assertEqual(manifest["schemaVersion"], "agent-publication-manifest.v1") self.assertFalse(manifest["productionPromotionClaimed"]) field_forms = {entry["fieldForm"] for entry in manifest["entries"]} - self.assertEqual(field_forms, {"version", "source.ref"}) + self.assertEqual(field_forms, {"version", "source.ref", "package.pin"}) self.assertFalse(manifest["lastChannelPolicy"]["pluginVersionMayBeFloating"]) self.assertEqual(manifest["lastChannelPolicy"]["allowedFloatingRef"], "source-ref-only") @@ -67,6 +68,18 @@ def test_stale_marketplace_ref_fails(self) -> None: self.assertEqual(result["status"], "FAIL") self.assertIn("codex-marketplace-source-ref", {item["entryId"] for item in result["blockers"]}) + def test_stale_quickstart_package_pin_fails(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _write_publication_fixture(root, version=TARGET_VERSION, ref=TARGET_REF) + (root / "docs/guides/quickstart.md").write_text( + "python -m pip install agent-lifecycle-kit==1.29.1\n", + encoding="utf-8", + ) + result = validate_publication_tree(root=root, target_version=TARGET_VERSION, target_ref=TARGET_REF) + self.assertEqual(result["status"], "FAIL") + self.assertIn("quickstart-package-pin", {item["entryId"] for item in result["blockers"]}) + def test_cli_writes_fail_evidence_and_nonzero_exit(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -153,6 +166,10 @@ def _write_publication_fixture(root: Path, *, version: str, ref: str) -> None: "plugins": [{"name": "agent-lifecycle-kit", "source": ".", "version": version}], }, ) + for path in ("docs/guides/quickstart.md", "docs/ru/quickstart.md"): + target = root / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(f"python -m pip install agent-lifecycle-kit=={version}\n", encoding="utf-8") def _write_json(path: Path, payload: dict[str, object]) -> None: diff --git a/tests/release/test_release_evidence_portability_validator.py b/tests/release/test_release_evidence_portability_validator.py new file mode 100644 index 00000000..0d2e5c58 --- /dev/null +++ b/tests/release/test_release_evidence_portability_validator.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +VALIDATOR = ROOT / "tools/release/validate_release_evidence_portability.py" + + +class ReleaseEvidencePortabilityValidatorTests(unittest.TestCase): + def test_clean_evidence_directory_passes(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.json" + source.write_text( + json.dumps({"status": "PASS", "path": "src/module.py", "outputIdentity": {"bytes": 0, "sha256": "0" * 64}}), + encoding="utf-8", + ) + evidence = root / "portability.json" + + subprocess.run( + [sys.executable, str(VALIDATOR), "--evidence-dir", str(root), "--evidence", str(evidence)], + cwd=ROOT, + check=True, + ) + payload = json.loads(evidence.read_text(encoding="utf-8")) + + self.assertEqual(payload["status"], "PASS") + self.assertEqual(payload["artifactCount"], 1) + + def test_private_paths_credentials_and_raw_output_fail_closed(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + private_path = "/" + "Users/example/secret.txt" + source = root / "source.json" + source.write_text( + json.dumps( + { + "path": private_path, + "authorization": "Bearer " + "x" * 24, + "token": "opaque-value", + "stderrTail": "raw process failure", + } + ), + encoding="utf-8", + ) + evidence = root / "portability.json" + + result = subprocess.run( + [sys.executable, str(VALIDATOR), "--evidence-dir", str(root), "--evidence", str(evidence)], + cwd=ROOT, + text=True, + ) + payload = json.loads(evidence.read_text(encoding="utf-8")) + + self.assertNotEqual(result.returncode, 0) + self.assertEqual(payload["status"], "FAIL") + self.assertEqual( + {item["code"] for item in payload["blockers"]}, + { + "release-evidence-private-absolute-path", + "release-evidence-credential-like-value", + "release-evidence-raw-process-output", + }, + ) + self.assertEqual( + sum(item["code"] == "release-evidence-credential-like-value" for item in payload["blockers"]), + 2, + ) + + def test_nested_platform_paths_and_compound_credentials_fail_closed(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source.json" + source.write_text( + json.dumps( + { + "nested": [ + {"windowsPath": r"C:\Users\example\secret.txt"}, + {"linuxPath": "/root/.ssh/id_rsa"}, + {"accessToken": "short"}, + ] + } + ), + encoding="utf-8", + ) + evidence = root / "portability.json" + + result = subprocess.run( + [sys.executable, str(VALIDATOR), "--evidence-dir", str(root), "--evidence", str(evidence)], + cwd=ROOT, + text=True, + ) + payload = json.loads(evidence.read_text(encoding="utf-8")) + + self.assertNotEqual(result.returncode, 0) + self.assertEqual(payload["status"], "FAIL") + self.assertEqual( + sum(item["code"] == "release-evidence-private-absolute-path" for item in payload["blockers"]), + 2, + ) + self.assertIn( + "$.nested[2].accessToken", + {item.get("jsonPath") for item in payload["blockers"]}, + ) + + def test_malformed_json_and_missing_directory_fail_closed(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + malformed = root / "malformed.json" + malformed.write_text("{", encoding="utf-8") + malformed_evidence = root / "malformed-portability.json" + + malformed_result = subprocess.run( + [ + sys.executable, + str(VALIDATOR), + "--evidence-dir", + str(root), + "--evidence", + str(malformed_evidence), + ], + cwd=ROOT, + text=True, + ) + malformed_payload = json.loads(malformed_evidence.read_text(encoding="utf-8")) + + missing_evidence = root / "missing-portability.json" + missing_result = subprocess.run( + [ + sys.executable, + str(VALIDATOR), + "--evidence-dir", + str(root / "absent"), + "--evidence", + str(missing_evidence), + ], + cwd=ROOT, + text=True, + ) + missing_payload = json.loads(missing_evidence.read_text(encoding="utf-8")) + + self.assertNotEqual(malformed_result.returncode, 0) + self.assertIn("release-evidence-json-invalid", {item["code"] for item in malformed_payload["blockers"]}) + self.assertNotEqual(missing_result.returncode, 0) + self.assertEqual(missing_payload["blockers"][0]["code"], "release-evidence-directory-missing") + + def test_private_key_marker_without_pem_dashes_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "source.json").write_text( + json.dumps({"message": "BEGIN PRIVATE KEY sensitive-material"}), + encoding="utf-8", + ) + evidence = root / "portability.json" + + result = subprocess.run( + [sys.executable, str(VALIDATOR), "--evidence-dir", str(root), "--evidence", str(evidence)], + cwd=ROOT, + text=True, + ) + payload = json.loads(evidence.read_text(encoding="utf-8")) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("release-evidence-credential-like-value", {item["code"] for item in payload["blockers"]}) + + def test_existing_output_receipt_is_the_only_excluded_artifact(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "source.json").write_text(json.dumps({"status": "PASS"}), encoding="utf-8") + evidence = root / "portability.json" + evidence.write_text(json.dumps({"path": str(Path("/").joinpath("private", "old"))}), encoding="utf-8") + + subprocess.run( + [sys.executable, str(VALIDATOR), "--evidence-dir", str(root), "--evidence", str(evidence)], + cwd=ROOT, + check=True, + ) + payload = json.loads(evidence.read_text(encoding="utf-8")) + + self.assertEqual(payload["status"], "PASS") + self.assertEqual(payload["artifactCount"], 1) + + def test_external_artifact_paths_preserve_relative_identity(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "one").mkdir() + (root / "two").mkdir() + (root / "one" / "summary.json").write_text(json.dumps({"status": "PASS"}), encoding="utf-8") + (root / "two" / "summary.json").write_text(json.dumps({"status": "PASS"}), encoding="utf-8") + evidence = root / "portability.json" + + subprocess.run( + [sys.executable, str(VALIDATOR), "--evidence-dir", str(root), "--evidence", str(evidence)], + cwd=ROOT, + check=True, + ) + payload = json.loads(evidence.read_text(encoding="utf-8")) + + self.assertEqual( + [item["path"] for item in payload["artifacts"]], + ["external-evidence/one/summary.json", "external-evidence/two/summary.json"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/release/publication_contract.py b/tools/release/publication_contract.py index c50260bc..673bc4fb 100644 --- a/tools/release/publication_contract.py +++ b/tools/release/publication_contract.py @@ -106,6 +106,18 @@ "jsonPath": ["plugins", 0, "version"], "fieldForm": "version", }, + { + "id": "quickstart-package-pin", + "path": "docs/guides/quickstart.md", + "kind": "text-package-pin", + "fieldForm": "package.pin", + }, + { + "id": "quickstart-ru-package-pin", + "path": "docs/ru/quickstart.md", + "kind": "text-package-pin", + "fieldForm": "package.pin", + }, ) @@ -121,7 +133,7 @@ def build_publication_manifest(*, target_version: str, target_ref: str) -> dict[str, Any]: entries = [] for entry in PUBLICATION_ENTRIES: - expected = target_ref if entry["fieldForm"] == "source.ref" else target_version + expected = _expected_value(entry=entry, target_version=target_version, target_ref=target_ref) entries.append( { "id": entry["id"], @@ -211,9 +223,23 @@ def _read_entry_value(*, path: Path, entry: dict[str, Any]) -> str | None: if kind == "python-version-assignment": match = re.search(r'^__version__\s*=\s*"([^"]+)"', path.read_text(encoding="utf-8"), re.MULTILINE) return match.group(1) if match else None + if kind == "text-package-pin": + match = re.search( + rf"{re.escape(PLUGIN_NAME)}==([0-9]+(?:\.[0-9]+){{2}})", + path.read_text(encoding="utf-8"), + ) + return match.group(0) if match else None raise ValueError(f"unsupported publication entry kind: {kind}") +def _expected_value(*, entry: dict[str, Any], target_version: str, target_ref: str) -> str: + if entry["fieldForm"] == "source.ref": + return target_ref + if entry["fieldForm"] == "package.pin": + return f"{PLUGIN_NAME}=={target_version}" + return target_version + + def _json_path(payload: Any, path: list[Any]) -> str | None: current = payload for part in path: diff --git a/tools/release/validate_adapter_installation_metadata.py b/tools/release/validate_adapter_installation_metadata.py new file mode 100644 index 00000000..3314f0b8 --- /dev/null +++ b/tools/release/validate_adapter_installation_metadata.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import argparse +import ast +from pathlib import Path +from typing import Any + +from release_common import ROOT, digest_value, file_identity, load_json, write_json +from agent_lifecycle.host_protocol import validate_adapter_descriptor +from agent_lifecycle.host_protocol.validation import validate_installation_facts + +FORBIDDEN_CATALOG_IMPORTS = { + "os", + "subprocess", + "agent_lifecycle.adapter_sessions.process", +} +FORBIDDEN_CATALOG_CALLS = {"Popen", "run", "run_process", "spawn", "system"} + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--descriptor-root", default="adapters") + parser.add_argument("--evidence", required=True) + args = parser.parse_args() + + descriptor_root = Path(args.descriptor_root) + descriptors = sorted(descriptor_root.glob("*/adapter.descriptor.json")) + blockers: list[dict[str, Any]] = [] + rows: list[dict[str, Any]] = [] + for path in descriptors: + descriptor = load_json(path) + descriptor_validation = validate_adapter_descriptor(descriptor) + installation_validation = validate_installation_facts(descriptor.get("installation")) + if descriptor_validation["status"] != "PASS": + blockers.append({"code": "adapter-descriptor-invalid", "path": path.as_posix(), "blockers": descriptor_validation["blockers"]}) + if installation_validation["status"] != "PASS": + blockers.append( + { + "code": "adapter-installation-facts-invalid", + "path": path.as_posix(), + "blockers": installation_validation["blockers"], + } + ) + installation = descriptor.get("installation") if isinstance(descriptor.get("installation"), dict) else {} + rows.append( + { + "adapterId": descriptor.get("adapterId"), + "host": descriptor.get("host"), + "binaryAliases": installation.get("binaryAliases"), + "commandCount": len(installation.get("commands", [])) if isinstance(installation.get("commands"), list) else 0, + "identity": file_identity(path), + } + ) + if not descriptors: + blockers.append({"code": "adapter-descriptors-missing", "message": "no adapter descriptors found"}) + + catalog_path = ROOT / "src/agent_lifecycle/diagnostics/installation_catalog.py" + blockers.extend(_catalog_boundary_blockers(catalog_path)) + catalog_identity = file_identity(catalog_path) + catalog_identity["path"] = catalog_path.relative_to(ROOT).as_posix() + body = { + "schemaVersion": "agent-adapter-installation-metadata-validation.v1", + "status": "PASS" if not blockers else "FAIL", + "adapterCount": len(descriptors), + "adapters": rows, + "catalog": catalog_identity, + "requiredInvariants": { + "descriptorOwned": True, + "argvArraysOnly": True, + "diagnosticsHostExecution": False, + }, + "blockers": blockers, + "productionPromotionClaimed": False, + } + write_json(Path(args.evidence), {**body, "validationDigest": digest_value(body)}) + return 0 if not blockers else 1 + + +def _catalog_boundary_blockers(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + return [{"code": "installation-catalog-missing", "path": path.as_posix()}] + tree = ast.parse(path.read_text(encoding="utf-8"), filename=path.as_posix()) + blockers: list[dict[str, Any]] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name in FORBIDDEN_CATALOG_IMPORTS: + blockers.append({"code": "installation-catalog-process-import", "path": path.as_posix(), "module": alias.name}) + elif isinstance(node, ast.ImportFrom): + module = node.module or "" + if module in FORBIDDEN_CATALOG_IMPORTS: + blockers.append({"code": "installation-catalog-process-import", "path": path.as_posix(), "module": module}) + elif isinstance(node, ast.Call): + name = _call_name(node.func) + if name in FORBIDDEN_CATALOG_CALLS: + blockers.append({"code": "installation-catalog-process-call", "path": path.as_posix(), "call": name}) + return blockers + + +def _call_name(node: ast.expr) -> str | None: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return node.attr + return None + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/release/validate_adapter_launcher_security.py b/tools/release/validate_adapter_launcher_security.py index 5251d492..9f368daa 100644 --- a/tools/release/validate_adapter_launcher_security.py +++ b/tools/release/validate_adapter_launcher_security.py @@ -34,6 +34,7 @@ def main() -> int: for snippet in FORBIDDEN_SNIPPETS: if snippet in text and not _allowed_write(file_path, snippet): blockers.append({"code": "adapter-launcher-forbidden-snippet", "path": file_path.as_posix(), "snippet": snippet}) + blockers.extend(_boundary_blockers(file_path, text)) body = { "schemaVersion": "agent-adapter-launcher-security-validation.v1", "status": "PASS" if not blockers else "FAIL", @@ -46,6 +47,8 @@ def main() -> int: "redactedReceipts": True, "nativeConfigWrites": False, "secretStorage": False, + "genericDescriptorLaunch": False, + "exactEnvironmentNames": True, }, "productionPromotionClaimed": False, } @@ -59,5 +62,20 @@ def _allowed_write(path: Path, snippet: str) -> bool: return False +def _boundary_blockers(path: Path, text: str) -> list[dict[str, Any]]: + blockers: list[dict[str, Any]] = [] + if path.name == "launcher.py": + if "adapter-generic-launch-disabled" not in text: + blockers.append({"code": "adapter-generic-launch-blocker-missing", "path": path.as_posix()}) + if "run_process(" in text or "subprocess.run(" in text: + blockers.append({"code": "adapter-generic-launch-process-route", "path": path.as_posix()}) + if path.name == "env.py": + if "import fnmatch" in text or "fnmatch." in text: + blockers.append({"code": "adapter-env-wildcard-route", "path": path.as_posix()}) + if "adapter-env-wildcard-disallowed" not in text: + blockers.append({"code": "adapter-env-wildcard-blocker-missing", "path": path.as_posix()}) + return blockers + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/tools/release/validate_cli_dispatch_boundary.py b/tools/release/validate_cli_dispatch_boundary.py new file mode 100644 index 00000000..51836ccd --- /dev/null +++ b/tools/release/validate_cli_dispatch_boundary.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import argparse +import ast +from pathlib import Path +from typing import Any + +from release_common import digest_value, file_identity, write_json + + +REQUIRED_DELEGATES = { + "dispatch_adapters": "agent_lifecycle.cli.dispatch_adapters", + "dispatch_contracts": "agent_lifecycle.cli.dispatch_contracts", + "dispatch_lifecycle": "agent_lifecycle.cli.dispatch_lifecycle", + "dispatch_observability": "agent_lifecycle.cli.dispatch_observability", + "dispatch_planning": "agent_lifecycle.cli.dispatch_planning", +} + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--path", required=True) + parser.add_argument("--max-lines", required=True, type=int) + parser.add_argument("--evidence", required=True) + args = parser.parse_args() + + path = Path(args.path) + blockers: list[dict[str, Any]] = [] + if not path.is_file(): + blockers.append({"code": "cli-dispatch-path-missing", "path": path.as_posix()}) + body = _body(path, args.max_lines, None, [], [], blockers) + write_json(Path(args.evidence), {**body, "validationDigest": digest_value(body)}) + return 1 + + source = path.read_text(encoding="utf-8") + line_count = len(source.splitlines()) + if args.max_lines < 1: + blockers.append({"code": "cli-dispatch-invalid-line-limit", "maxLines": args.max_lines}) + elif line_count > args.max_lines: + blockers.append( + { + "code": "cli-dispatch-line-limit-exceeded", + "path": path.as_posix(), + "actualLines": line_count, + "maxLines": args.max_lines, + } + ) + + tree = ast.parse(source, filename=path.as_posix()) + imported_delegates = _imported_delegates(tree) + routed_delegates = _routed_delegates(tree) + missing_imports = sorted(set(REQUIRED_DELEGATES).difference(imported_delegates)) + missing_routes = sorted(set(REQUIRED_DELEGATES).difference(routed_delegates)) + if missing_imports: + blockers.append({"code": "cli-dispatch-delegate-import-missing", "delegates": missing_imports}) + if missing_routes: + blockers.append({"code": "cli-dispatch-delegate-route-missing", "delegates": missing_routes}) + if any( + isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("_dispatch_") + for node in tree.body + ): + blockers.append({"code": "cli-dispatch-domain-handler-retained", "path": path.as_posix()}) + + body = _body(path, args.max_lines, line_count, sorted(imported_delegates), sorted(routed_delegates), blockers) + write_json(Path(args.evidence), {**body, "validationDigest": digest_value(body)}) + return 0 if not blockers else 1 + + +def _imported_delegates(tree: ast.AST) -> set[str]: + imported: set[str] = set() + for node in tree.body: + if not isinstance(node, ast.ImportFrom): + continue + for name, module in REQUIRED_DELEGATES.items(): + if node.module == module and any(alias.name == name for alias in node.names): + imported.add(name) + return imported + + +def _routed_delegates(tree: ast.AST) -> set[str]: + routed: set[str] = set() + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name): + continue + if node.func.id in REQUIRED_DELEGATES: + routed.add(node.func.id) + return routed + + +def _body( + path: Path, + max_lines: int, + line_count: int | None, + imported_delegates: list[str], + routed_delegates: list[str], + blockers: list[dict[str, Any]], +) -> dict[str, Any]: + return { + "schemaVersion": "agent-cli-dispatch-boundary-validation.v1", + "status": "PASS" if not blockers else "FAIL", + "path": path.as_posix(), + "file": file_identity(path) if path.is_file() else None, + "maxLines": max_lines, + "actualLines": line_count, + "requiredDelegates": REQUIRED_DELEGATES, + "importedDelegates": imported_delegates, + "routedDelegates": routed_delegates, + "blockers": blockers, + "productionPromotionClaimed": False, + } + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/release/validate_docs_compat.py b/tools/release/validate_docs_compat.py index eeb5a845..c04a9667 100644 --- a/tools/release/validate_docs_compat.py +++ b/tools/release/validate_docs_compat.py @@ -681,9 +681,30 @@ "adapter run", "`WRAPPER_ONLY`", "shell: false", + "adapter-generic-launch-disabled", + "wildcard", "plugin installation alone", ), ), + ( + "docs/reference/readiness-diagnostics.md", + ( + "`agent-adapter-install-plan.v1`", + "schema-validated installation facts", + "argv arrays", + "Diagnostics never interpret the argv", + "arrays as a shell command", + ), + ), + ( + "docs/security/neutrality-contract.md", + ( + "Completeness counters", + "`readRaces`", + "`pathAliasConflicts`", + "fail closed", + ), + ), ( "docs/reference/project-comparison.md", ( @@ -712,9 +733,28 @@ "adapter run", "`WRAPPER_ONLY`", "shell: false", + "adapter-generic-launch-disabled", + "шаблоны", "установка плагина", ), ), + ( + "docs/ru/reference/readiness-diagnostics.md", + ( + "`agent-adapter-install-plan.v1`", + "argv-массивы", + "не трактует argv-массивы как строку shell", + ), + ), + ( + "docs/ru/security/neutrality-contract.md", + ( + "Счётчики полноты", + "`readRaces`", + "`pathAliasConflicts`", + "приводит к отказу", + ), + ), ( "docs/reference/review-mesh.md", ( diff --git a/tools/release/validate_module_dependencies.py b/tools/release/validate_module_dependencies.py new file mode 100644 index 00000000..ee9b26c4 --- /dev/null +++ b/tools/release/validate_module_dependencies.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import argparse +import ast +from pathlib import Path +from typing import Any + +from release_common import digest_value, file_identity, write_json + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--package-root", required=True) + parser.add_argument("--forbid-cycle", action="append", default=[]) + parser.add_argument("--max-lines", action="append", default=[]) + parser.add_argument("--evidence", required=True) + args = parser.parse_args() + + package_root = Path(args.package_root) + graph, identities = _build_graph(package_root) + blockers: list[dict[str, Any]] = [] + forbidden_cycles = [_parse_cycle(value) for value in args.forbid_cycle] + for cycle in forbidden_cycles: + if _nodes_share_cycle(graph, cycle): + blockers.append({"code": "module-import-cycle", "modules": cycle}) + line_checks = [] + for value in args.max_lines: + path, maximum = _parse_line_check(value) + count = len(path.read_text(encoding="utf-8").splitlines()) if path.exists() else None + line_checks.append({"path": path.as_posix(), "maxLines": maximum, "actualLines": count}) + if count is None: + blockers.append({"code": "module-line-check-path-missing", "path": path.as_posix()}) + elif count > maximum: + blockers.append({"code": "module-line-limit-exceeded", "path": path.as_posix(), "actualLines": count, "maxLines": maximum}) + body = { + "schemaVersion": "agent-module-dependency-validation.v1", + "status": "PASS" if not blockers else "FAIL", + "moduleCount": len(graph), + "sourceFiles": identities, + "forbiddenCycles": forbidden_cycles, + "lineChecks": line_checks, + "blockers": blockers, + "productionPromotionClaimed": False, + } + write_json(Path(args.evidence), {**body, "validationDigest": digest_value(body)}) + return 0 if not blockers else 1 + + +def _build_graph(package_root: Path) -> tuple[dict[str, set[str]], list[dict[str, Any]]]: + if not package_root.is_dir(): + return {}, [] + module_root = _module_root(package_root) + graph: dict[str, set[str]] = {} + identities: list[dict[str, Any]] = [] + for path in sorted(package_root.rglob("*.py")): + module = _module_name(package_root, module_root, path) + graph.setdefault(module, set()) + identities.append(file_identity(path)) + tree = ast.parse(path.read_text(encoding="utf-8"), filename=path.as_posix()) + graph[module].update(_imports(tree, module)) + return graph, identities + + +def _module_root(package_root: Path) -> str: + names = [package_root.name] + parent = package_root.parent + while (parent / "__init__.py").exists(): + names.insert(0, parent.name) + parent = parent.parent + return ".".join(names) + + +def _module_name(package_root: Path, module_root: str, path: Path) -> str: + relative = path.relative_to(package_root).with_suffix("") + parts = list(relative.parts) + if parts[-1] == "__init__": + parts.pop() + return ".".join([module_root, *parts]) if parts else module_root + + +def _imports(tree: ast.AST, module: str) -> set[str]: + imports: set[str] = set() + package = module.rsplit(".", 1)[0] if "." in module else module + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imports.update(alias.name for alias in node.names if alias.name.startswith("agent_lifecycle")) + elif isinstance(node, ast.ImportFrom): + target = _resolve_from_import(module, package, node) + if target and target.startswith("agent_lifecycle"): + imports.add(target) + imports.update(f"{target}.{alias.name}" for alias in node.names if alias.name != "*") + return imports + + +def _resolve_from_import(module: str, package: str, node: ast.ImportFrom) -> str | None: + if node.level == 0: + return node.module + base_parts = package.split(".") + if node.level > len(base_parts): + return None + parent = base_parts[: len(base_parts) - node.level + 1] + if node.module: + parent.extend(node.module.split(".")) + return ".".join(parent) + + +def _parse_cycle(value: str) -> list[str]: + modules = [item.strip() for item in value.split(",") if item.strip()] + if len(modules) < 2: + raise SystemExit("--forbid-cycle must contain at least two comma-separated modules") + return modules + + +def _parse_line_check(value: str) -> tuple[Path, int]: + path_text, separator, limit_text = value.rpartition("=") + if not separator or not path_text or not limit_text.isdigit() or int(limit_text) < 1: + raise SystemExit("--max-lines must use PATH=POSITIVE_INTEGER") + return Path(path_text), int(limit_text) + + +def _nodes_share_cycle(graph: dict[str, set[str]], nodes: list[str]) -> bool: + return all(_reachable(graph, source, target) for source in nodes for target in nodes if source != target) + + +def _reachable(graph: dict[str, set[str]], source: str, target: str) -> bool: + pending = [source] + visited: set[str] = set() + while pending: + current = pending.pop() + if current == target: + return True + if current in visited: + continue + visited.add(current) + pending.extend(graph.get(current, set()).difference(visited)) + return False + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/release/validate_neutrality_receipt.py b/tools/release/validate_neutrality_receipt.py new file mode 100644 index 00000000..932fea70 --- /dev/null +++ b/tools/release/validate_neutrality_receipt.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import argparse +import ast +from pathlib import Path +from typing import Any + +from release_common import digest_value, file_identity, write_json + +REQUIRED_COUNTERS = { + "findings", + "skippedInputs", + "opaqueInputs", + "readRaces", + "incompleteScans", + "unsupportedArchives", + "archiveLimitBreaches", + "occupiedOutputConflicts", + "pathAliasConflicts", +} + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--path", required=True) + parser.add_argument("--evidence", required=True) + args = parser.parse_args() + + root = Path(args.path) + receipt_path = root / "receipt.py" if root.is_dir() else root + cli_path = root / "cli.py" if root.is_dir() else root.with_name("cli.py") + blockers = _validate_sources(receipt_path, cli_path) + body = { + "schemaVersion": "agent-neutrality-receipt-validation.v1", + "status": "PASS" if not blockers else "FAIL", + "receipt": file_identity(receipt_path) if receipt_path.exists() else None, + "cli": file_identity(cli_path) if cli_path.exists() else None, + "requiredCounters": sorted(REQUIRED_COUNTERS), + "blockers": blockers, + "productionPromotionClaimed": False, + } + write_json(Path(args.evidence), {**body, "validationDigest": digest_value(body)}) + return 0 if not blockers else 1 + + +def _validate_sources(receipt_path: Path, cli_path: Path) -> list[dict[str, Any]]: + blockers: list[dict[str, Any]] = [] + if not receipt_path.exists(): + return [{"code": "neutrality-receipt-source-missing", "path": receipt_path.as_posix()}] + if not cli_path.exists(): + return [{"code": "neutrality-cli-source-missing", "path": cli_path.as_posix()}] + receipt_tree = ast.parse(receipt_path.read_text(encoding="utf-8"), filename=receipt_path.as_posix()) + cli_tree = ast.parse(cli_path.read_text(encoding="utf-8"), filename=cli_path.as_posix()) + counters = _declared_counter_names(receipt_tree) + if counters != REQUIRED_COUNTERS: + blockers.append({"code": "neutrality-completeness-counter-set", "actual": sorted(counters), "expected": sorted(REQUIRED_COUNTERS)}) + for function_name in ("build_claims", "verify_existing_receipt"): + if not _function_calls(receipt_tree, function_name, "require_zero_completeness_counters"): + blockers.append({"code": "neutrality-completeness-gate-missing", "function": function_name}) + if not _function_calls(cli_tree, "_bootstrap", "require_zero_completeness_counters"): + blockers.append({"code": "neutrality-bootstrap-completeness-gate-missing"}) + return blockers + + +def _declared_counter_names(tree: ast.AST) -> set[str]: + for node in tree.body: + if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == "REQUIRED_COMPLETENESS_COUNTERS" for target in node.targets): + if isinstance(node.value, (ast.Tuple, ast.List)): + return {item.value for item in node.value.elts if isinstance(item, ast.Constant) and isinstance(item.value, str)} + return set() + + +def _function_calls(tree: ast.AST, function_name: str, call_name: str) -> bool: + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == function_name: + for child in ast.walk(node): + if isinstance(child, ast.Call) and isinstance(child.func, ast.Name) and child.func.id == call_name: + return True + return False + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/release/validate_release_evidence_portability.py b/tools/release/validate_release_evidence_portability.py new file mode 100644 index 00000000..cde39dbb --- /dev/null +++ b/tools/release/validate_release_evidence_portability.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + +from release_common import ROOT, digest_value, file_identity, write_json + +PRIVATE_PATH_PATTERNS = ( + re.compile(r"(?:^|\s)/(?:Users|Volumes|private|home|tmp|root|etc|opt|snap)/[^\s\"'`]+"), + re.compile(r"(?:^|\s)/usr/local/[^\s\"'`]+"), + re.compile(r"(?:^|\s)/var/folders/[^\s\"'`]+"), + re.compile(r"[A-Za-z]:\\Users\\[^\s\"'`]+"), + re.compile(r"file://[^\s\"'`]+", re.IGNORECASE), +) +CREDENTIAL_PATTERNS = ( + re.compile(r"\b[A-Z][A-Z0-9_]{2,}_(?:API_KEY|TOKEN|SECRET|PASSWORD)\s*=\s*\S+"), + re.compile(r"\bBearer\s+[A-Za-z0-9._-]{12,}\b", re.IGNORECASE), + re.compile(r"\bsk-[A-Za-z0-9]{20,}\b"), + re.compile(r"BEGIN [A-Z ]*PRIVATE KEY"), +) +RAW_OUTPUT_FIELDS = {"stdout", "stderr", "stdouttail", "stderrtail", "rawoutput", "processoutput"} +CREDENTIAL_FIELD_SUFFIXES = ( + "apikey", + "authorization", + "credential", + "password", + "privatekey", + "secret", + "token", +) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--evidence-dir", required=True) + parser.add_argument("--evidence", required=True) + args = parser.parse_args() + + evidence_dir = Path(args.evidence_dir) + output_path = Path(args.evidence) + blockers: list[dict[str, Any]] = [] + artifacts: list[dict[str, Any]] = [] + if not evidence_dir.is_dir(): + blockers.append({"code": "release-evidence-directory-missing", "path": _portable_path(evidence_dir)}) + candidates: list[Path] = [] + else: + output_resolved = output_path.resolve() + candidates = sorted( + (path for path in evidence_dir.rglob("*.json") if path.resolve() != output_resolved), + key=lambda path: path.as_posix(), + ) + + for path in candidates: + portable_artifact = _portable_artifact_path(path, evidence_dir=evidence_dir) + identity = file_identity(path) + identity["path"] = portable_artifact + artifacts.append(identity) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + blockers.append( + { + "code": "release-evidence-json-invalid", + "path": portable_artifact, + "error": type(exc).__name__, + } + ) + continue + _scan_value(payload, artifact=portable_artifact, json_path="$", blockers=blockers) + + body = { + "schemaVersion": "agent-release-evidence-portability-validation.v1", + "status": "PASS" if not blockers else "FAIL", + "evidenceDir": _portable_path(evidence_dir), + "artifactCount": len(artifacts), + "artifacts": artifacts, + "requiredInvariants": { + "privateAbsolutePaths": False, + "credentialLikeValues": False, + "rawProcessOutput": False, + "selfReceiptExcluded": True, + }, + "blockers": blockers, + "productionPromotionClaimed": False, + } + write_json(output_path, {**body, "validationDigest": digest_value(body)}) + return 0 if not blockers else 1 + + +def _scan_value(value: Any, *, artifact: str, json_path: str, blockers: list[dict[str, Any]]) -> None: + if isinstance(value, dict): + for key, item in value.items(): + child_path = f"{json_path}.{key}" + normalized_key = re.sub(r"[^a-z]", "", str(key).lower()) + if normalized_key in RAW_OUTPUT_FIELDS and item not in (None, "", [], {}): + blockers.append( + { + "code": "release-evidence-raw-process-output", + "artifact": artifact, + "jsonPath": child_path, + } + ) + if ( + _is_credential_field(normalized_key) + and isinstance(item, str) + and item.strip() + and not any(pattern.search(item) for pattern in CREDENTIAL_PATTERNS) + ): + blockers.append( + { + "code": "release-evidence-credential-like-value", + "artifact": artifact, + "jsonPath": child_path, + } + ) + _scan_value(item, artifact=artifact, json_path=child_path, blockers=blockers) + return + if isinstance(value, list): + for index, item in enumerate(value): + _scan_value(item, artifact=artifact, json_path=f"{json_path}[{index}]", blockers=blockers) + return + if not isinstance(value, str): + return + if any(pattern.search(value) for pattern in PRIVATE_PATH_PATTERNS): + blockers.append( + { + "code": "release-evidence-private-absolute-path", + "artifact": artifact, + "jsonPath": json_path, + } + ) + if any(pattern.search(value) for pattern in CREDENTIAL_PATTERNS): + blockers.append( + { + "code": "release-evidence-credential-like-value", + "artifact": artifact, + "jsonPath": json_path, + } + ) + + +def _portable_path(path: Path) -> str: + if not path.is_absolute(): + return path.as_posix() + try: + return path.resolve().relative_to(ROOT.resolve()).as_posix() + except ValueError: + return "external-evidence" + + +def _portable_artifact_path(path: Path, *, evidence_dir: Path) -> str: + if path.is_absolute(): + try: + return path.resolve().relative_to(ROOT.resolve()).as_posix() + except ValueError: + pass + try: + relative = path.resolve().relative_to(evidence_dir.resolve()).as_posix() + except ValueError: + relative = path.name or "artifact.json" + return f"external-evidence/{relative}" + + +def _is_credential_field(normalized_key: str) -> bool: + return any(normalized_key.endswith(suffix) for suffix in CREDENTIAL_FIELD_SUFFIXES) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index a8d1342d..bdbfcf65 100644 --- a/uv.lock +++ b/uv.lock @@ -4,5 +4,5 @@ requires-python = ">=3.11,<3.14" [[package]] name = "agent-lifecycle-kit" -version = "1.50.1" +version = "1.50.2" source = { editable = "." }