diff --git a/README.md b/README.md index 0a0f4af..857f173 100644 --- a/README.md +++ b/README.md @@ -56,3 +56,12 @@ evidence broadens to the full configured command or stops; it never silently becomes a passing verification result. Apache-2.0. + +## Opsle Tasks capability package + +The independently versioned [Tasks capability package](packages/tasks-capability/README.md) +ships the executable planning/capture adapter, AV runtime, and explicit schemas. +Build an installable artifact with `npm run pack:tasks-capability`. Generic trusted +discovery and operator project grants activate it; installation defaults to disabled. +AV remains independent and owns verification planning, while Tasks owns command +execution and release. diff --git a/docs/tasks-capability-compatibility.json b/docs/tasks-capability-compatibility.json new file mode 100644 index 0000000..02f14e5 --- /dev/null +++ b/docs/tasks-capability-compatibility.json @@ -0,0 +1,39 @@ +{ + "kind": "development-compatibility-check", + "package": "@opsle/affected-verification-tasks-capability", + "version": "0.1.0", + "artifact_sha256": "06a5184cccffb0081bedd80a2aab12f2ee574f6ee11079bd54460e5b0c7268bd", + "npm_integrity": "sha512-DdM6bJn/+TnwoA25VXHlUxAh6kw28ta0HpCWDc7tdQbv2+zcHb4cBjnTD4nx50kS2EjV5thTkY+Rhtetkg281Q==", + "tasks_revision": "e1207c5264c59e14efe9838bba3a33ba504665d2", + "tasks_capabilities_source_sha256": "d8568872a1d76e5ac78e134154683b43c7ed46c2b583eaef0e9aac3569a09f30", + "central_tasks_source_unchanged": true, + "node_version": "24.20.0", + "command": "OPSLE_TASKS_RUNTIME_ROOT= node --test tests/tasks-capability.test.js", + "tests": { "passed": 13, "failed": 0, "skipped": 0 }, + "synthetic_compatible_upgrade": { + "version": "0.1.1", + "artifact_sha256": "9a3c0425fec8b0154086213bf8821bb3f6a082b49a881e952c5dd9869b0c69f5", + "published": false + }, + "lifecycle": [ + "isolated npm pack and install after source export removal", + "discovery without activation", + "operator grant activation", + "planning and capture through real generic runtime", + "repository authority selection rejected", + "revoke and disable for new runtimes", + "duplicate identities rejected", + "mutated installation rejected", + "removal blocks required authority and preserves evidence", + "reinstall and explicit regrant", + "separately packed compatible upgrade and fresh-process restart", + "empty catalog forces full verification or stop through real Tasks selection" + ], + "final_pipeline_checks_pending": [ + "npm run verify", + "OPSLE_TASKS_RUNTIME_ROOT= npm run verify:tasks-capability (includes existing Tasks regressions)", + "existing CI pinned gitleaks secret scan" + ], + "release_authorized": false, + "historical_benchmark_artifacts_modified": false +} diff --git a/package.json b/package.json index c29f9b1..db50d60 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,10 @@ "benchmark:av-exp-001": "./benchmark/av-exp-001/reproduce.sh", "benchmark:av-exp-002": "./benchmark/av-exp-002/reproduce.sh", "benchmark:av-exp-003": "./benchmark/av-exp-003/reproduce.sh", - "verify:av-exp-003": "node ./benchmark/av-exp-003/verify-results.mjs" + "verify:av-exp-003": "node ./benchmark/av-exp-003/verify-results.mjs", + "build:tasks-capability": "npm --prefix packages/tasks-capability run build", + "pack:tasks-capability": "npm pack ./packages/tasks-capability", + "verify:tasks-capability": "node tools/verify-tasks-capability.js" }, "engines": { "node": ">=20" diff --git a/packages/tasks-capability/.gitignore b/packages/tasks-capability/.gitignore new file mode 100644 index 0000000..d36b5b7 --- /dev/null +++ b/packages/tasks-capability/.gitignore @@ -0,0 +1,4 @@ +/adapter.js +/runtime/ +/LICENSE +/*.tgz diff --git a/packages/tasks-capability/README.md b/packages/tasks-capability/README.md new file mode 100644 index 0000000..f378496 --- /dev/null +++ b/packages/tasks-capability/README.md @@ -0,0 +1,172 @@ +# Affected Verification capability for Opsle Tasks + +`@opsle/affected-verification-tasks-capability` **0.1.0** is an independently +versioned, dependency-free capability owned and released by the public Affected +Verification repository. AV remains the verification planning authority. Tasks +owns operator grants, command execution, observed results, repair, and release. +The package neither runs catalog commands nor authorizes deployment. + +The executable ESM entry point implements the generic +`opsle.capability-manifest.v1` / `opsle.capability-result.v1` contract. Both +`verification.plan` and `verification.capture` are **required deterministic +authorities**. `default_enabled` is **false**. No AV identity branches, new central +Tasks dependencies, private Tasks imports, sibling checkouts, Graphify dependency, +or structural-evidence contract are needed. + +## Build and install + +Build from an AV checkout with Node 20+ and npm: + +```sh +npm run pack:tasks-capability +sha256sum opsle-affected-verification-tasks-capability-0.1.0.tgz +``` + +`npm pack` builds the adapter, includes the current AV core, runtime helpers, +JSON schemas, and licenses. Only the build reads AV source outside this package. +The installed artifact contains everything it needs. No install scripts or +network dependencies are required. Review the artifact hash and provenance +before an operator installs it outside repositories and agent-writable paths: + +```sh +npm install --prefix /srv/opsle-capabilities/av-0.1.0 \ + --ignore-scripts --no-audit --no-fund \ + ./opsle-affected-verification-tasks-capability-0.1.0.tgz +``` + +Set the operator-owned `OPSLE_CAPABILITY_PATH` to the installed directory: +`/srv/opsle-capabilities/av-0.1.0/node_modules/@opsle/affected-verification-tasks-capability`. +Tasks also accepts the generic `capabilityRoots` configuration. Multiple roots +use the platform path delimiter. Include the individually installed roots for +other required authorities and observers. **Replace** the bundled AV discovery +root; do not also discover Tasks' entire bundled `capabilities/` directory, which +would discover the same AV identity twice and correctly fail. + +This is operator configuration, not a central runtime code change. Discovery +checks the manifest and executable path; it does not activate this package. +Grant the project capability using Task 15's operator project-grant mechanism: + +```json +{"schema":"opsle.capability-grants.v1","allow":["opsle.affected-verification"]} +``` + +Persist this in the project's `capability_grants` / `capability_grants_json` +through the existing operator interface. Repository `.opsle/capabilities.json` +cannot enable or disable an authority, change its executable, or grant it trust. +An absent required authority blocks verification, including after a revoke. + +## Compatibility and schemas + +The real generic runtime compatibility target is Opsle Tasks revision +`e1207c5264c59e14efe9838bba3a33ba504665d2` (Node 24+ for its complete regression +suite). Its `src/capabilities.js` SHA-256 is +`d8568872a1d76e5ac78e134154683b43c7ed46c2b583eaef0e9aac3569a09f30`. +Tests import that unmodified runtime only as a compatibility test dependency; +the installed capability never imports Tasks source. Execution metadata comes +from generic `services.executionConfig`; project/task/attempt/execution bindings +come from the generic invocation services. All executable configuration remains +operator-owned. + +| Hook | Request | Response | +| --- | --- | --- | +| `verification.plan` | `opsle.execution.verification-request.v1` | `opsle.execution.verification-analysis.v1` | +| `verification.capture` | `opsle.execution.change-capture-request.v1` | `opsle.execution.change-set.v1` | + +All JSON Schemas are in `schemas/`. They include the immutable task manifest, +task request, task plan, plan v2, and retained evidence v1. Their existing schema +identities are preserved. `opsle.affected-verification.tasks-config.v2` explicitly +removes v1's configurable `repository` executable path: the resolved configuration +is empty. The generic repository configuration envelope may include only its +v2 schema identifier. Unsupported schemas, hooks, and extra request fields fail. +Structural validation supplements AV's native cross-field semantic validation; +it cannot establish completeness or provenance by itself. + +The compatibility analysis envelope retains `change`, `decision`, `error`, +`evidencePath`, `inputPath`, `receiptPath`, and `record`. An `ok` capability envelope +means analysis completed, **not that verification passed**. A failed analysis has +`decision: null`, an explicit error, and `ANALYSIS_FAILED` evidence. The Tasks +consumer must use full configured verification or stop. Missing manifests use +only the operator's configured full command. Empty catalogs return an explicit +analysis error, so the compatible Tasks runtime cannot enter its legacy +`NO_AUTOMATED_VERIFICATION` completion path: it must run the full configured +command or stop. Capture/transport failures throw and block the required +hook. Unsupported/unsafe requests throw before staging or writing evidence. + +Planning stages the retained worktree and binds binary diff, exact base commit, +and staged Git tree identities. The manifest is read from the immutable base, +not agent-modified content. Every action must match the exact immutable catalog +partition and command. The adapter validates canonical decision identity and +recomputes the AV decision to check provenance/completeness. Unknown, incomplete, +or opaque evidence cannot justify an unexplained skip. Tasks must compare the +post-verification capture tree with the planned tree before accepting changes; +the package never decides that command execution passed. + +SSH target validation, argv quoting, strict host-key checking, connection and +remote process deadlines, bounded Git output, and private evidence are retained +from the compatibility adapter. Local project execution exists only under +`NODE_ENV=test`; production requires the configured SSH target. Runtime and schema +hashes are embedded in the entry point and checked before loading and before each +invocation, extending the generic runtime's manifest/entry-point byte checks to +all packaged files that affect planning. Operator-protected installation roots +remain the trust boundary; hashes do not make writable installations trustworthy. + +OBSERVE/SHADOW observations and historical benchmarks retain their existing +limits. No observation is promoted to execution authority or production trust. +External structural evidence is not accepted by this interface and cannot narrow +verification; only AV's own provenance and completeness decision can justify +selection. The core remains separately usable without Tasks. + +## Revoke, remove, reinstall, and upgrade + +Remove this identity from the operator project grant to disable it for subsequent +execution runtimes. Quiesce/drain active executions and restart Tasks when changing +grants or installations: a runtime holds an execution-scoped grant snapshot. +Repository selection cannot revoke authority. Keep retained logs and capability +events outside the installation; never delete historical evidence on uninstall. + +After revocation and draining, remove this package's discovery root and uninstall +its npm package. Missing required authority blocks new verification. To reinstall, +install a reviewed artifact in a fresh version directory, restore its discovery +root, restart, and explicitly regrant the project. Installation alone does not +restore a revoked grant. + +For a compatible upgrade, build/review the new independently versioned artifact, +install into a new version directory, drain executions, atomically replace the +operator discovery-root configuration, and restart. Never discover both versions +with the same identity. Never mutate an active installation in place. Grants for +the same identity persist until explicitly revoked. Review compatibility before +retaining them. Rollback uses the previous reviewed artifact and the same restart +procedure. An incompatible contract requires a new explicit schema version. + +## Verification and release + +Public AV CI runs `npm run verify`, including standalone package/schema conformance +and negative tests, and the existing pinned gitleaks secret-scan job. Core sources +and historical benchmark artifacts are not rewritten by packaging. + +The operator release pipeline must provision the trusted Tasks checkout at the +recorded revision, then run: + +```sh +npm run verify +OPSLE_TASKS_RUNTIME_ROOT=/path/to/pinned/opsle-tasks npm run verify:tasks-capability +npm run pack:tasks-capability +sha256sum opsle-affected-verification-tasks-capability-0.1.0.tgz +``` + +The second command is mandatory for release: it refuses to skip if Tasks is +unavailable or at an unreviewed revision. It exercises isolated npm installation, +discovery without activation, grant, both hooks, repository authority protection, +revoke/disable, duplicate identities, byte mutation, removal with retained history, +reinstall/regrant, and compatible patch replacement/restart. It also runs Tasks' +existing capability, adapter, history, and AV regressions. The standalone suite +reports an explicit skip for the external lifecycle check when no Tasks checkout +is provisioned; that is not release evidence. + +Keep pipeline output recording package version, artifact SHA-256/npm integrity, +Tasks revision/runtime hash, unchanged central source, conformance results, and +secret-scan results with the release. Increment this package's `package.json` and +`opsle-capability.json` together; AV core and capability versions have independent +release schedules. Update the pinned compatibility revision only after contract +review and passing tests. Publish only the reviewed npm tarball after these gates; +this task does not publish or deploy it. diff --git a/packages/tasks-capability/THIRD_PARTY_NOTICES b/packages/tasks-capability/THIRD_PARTY_NOTICES new file mode 100644 index 0000000..d35b234 --- /dev/null +++ b/packages/tasks-capability/THIRD_PARTY_NOTICES @@ -0,0 +1,24 @@ +The execution and verification compatibility helpers are adapted from Opsle Tasks +revision e1207c5264c59e14efe9838bba3a33ba504665d2. + +MIT License + +Copyright (c) 2026 Opsle + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/tasks-capability/build.mjs b/packages/tasks-capability/build.mjs new file mode 100644 index 0000000..f22b728 --- /dev/null +++ b/packages/tasks-capability/build.mjs @@ -0,0 +1,53 @@ +import { cpSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +const root = dirname(fileURLToPath(import.meta.url)); +const runtime = resolve(root, 'runtime'); +const metadata = JSON.parse(readFileSync(resolve(root, 'package.json'))); +const manifest = JSON.parse(readFileSync(resolve(root, 'opsle-capability.json'))); +if (metadata.version !== manifest.version || manifest.default_enabled !== false) { + throw new Error('Package/manifest versions must agree and installation must default to disabled.'); +} +rmSync(runtime, { recursive: true, force: true }); +mkdirSync(runtime, { recursive: true }); +cpSync(resolve(root, 'src'), runtime, { recursive: true }); +cpSync(resolve(root, '../../src'), resolve(runtime, 'core'), { recursive: true }); +cpSync(resolve(root, '../../schemas/plan-v2.schema.json'), resolve(root, 'schemas/plan-v2.schema.json')); +cpSync(resolve(root, '../../LICENSE'), resolve(root, 'LICENSE')); +const hashes = {}; +function collect(directory) { + for (const entry of readdirSync(resolve(root, directory), { withFileTypes: true }).sort((a,b) => a.name.localeCompare(b.name))) { + const path = `${directory}/${entry.name}`; + if (entry.isDirectory()) collect(path); + else hashes[path] = createHash('sha256').update(readFileSync(resolve(root, path))).digest('hex'); + } +} +collect('runtime'); collect('schemas'); +const identity = `sha256:${createHash('sha256').update(JSON.stringify(hashes)).digest('hex')}`; +writeFileSync(resolve(root, 'adapter.js'), `// Generated by build.mjs. Runtime bytes are pinned to this trusted entry point. +import { readFileSync, realpathSync, lstatSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +const hashes = ${JSON.stringify(hashes, null, 2)}; +const packageIdentity = ${JSON.stringify(identity)}; +function integrity() { + const root = realpathSync(fileURLToPath(new URL('.', import.meta.url))); + for (const [path, expected] of Object.entries(hashes)) { + const file = fileURLToPath(new URL(path, import.meta.url)); + if (lstatSync(file).isSymbolicLink() || !realpathSync(file).startsWith(root + '/') + || createHash('sha256').update(readFileSync(file)).digest('hex') !== expected) { + throw new Error('Capability installation changed: ' + path); + } + } +} +export async function createCapability(context) { + integrity(); + const { createAdapter } = await import('./runtime/adapter.js'); + const adapter = createAdapter(context, packageIdentity); + return { + health() { integrity(); return { available: true, detail: null }; }, + invoke(hook, payload) { integrity(); return adapter.invoke(hook, payload); }, + }; +} +`); diff --git a/packages/tasks-capability/opsle-capability.json b/packages/tasks-capability/opsle-capability.json new file mode 100644 index 0000000..3330d72 --- /dev/null +++ b/packages/tasks-capability/opsle-capability.json @@ -0,0 +1,28 @@ +{ + "schema": "opsle.capability-manifest.v1", + "id": "opsle.affected-verification", + "name": "Affected Verification", + "version": "0.1.0", + "adapter": "adapter.js", + "default_enabled": false, + "configuration_schema": "opsle.affected-verification.tasks-config.v2", + "configuration": {}, + "hooks": [ + { + "name": "verification.plan", + "input_schema": "opsle.execution.verification-request.v1", + "output_schema": "opsle.execution.verification-analysis.v1", + "role": "authority", + "execution": "deterministic", + "failure": "required" + }, + { + "name": "verification.capture", + "input_schema": "opsle.execution.change-capture-request.v1", + "output_schema": "opsle.execution.change-set.v1", + "role": "authority", + "execution": "deterministic", + "failure": "required" + } + ] +} diff --git a/packages/tasks-capability/package.json b/packages/tasks-capability/package.json new file mode 100644 index 0000000..5b8a85f --- /dev/null +++ b/packages/tasks-capability/package.json @@ -0,0 +1,24 @@ +{ + "name": "@opsle/affected-verification-tasks-capability", + "version": "0.1.0", + "description": "Independent Affected Verification authority for the generic Opsle Tasks capability contract", + "type": "module", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "exports": "./adapter.js", + "files": [ + "adapter.js", + "runtime/", + "schemas/", + "opsle-capability.json", + "LICENSE", + "README.md", + "THIRD_PARTY_NOTICES" + ], + "scripts": { + "build": "node build.mjs", + "prepack": "npm run build" + } +} diff --git a/packages/tasks-capability/schemas/capture-request-v1.schema.json b/packages/tasks-capability/schemas/capture-request-v1.schema.json new file mode 100644 index 0000000..5d66f03 --- /dev/null +++ b/packages/tasks-capability/schemas/capture-request-v1.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/opsle/affected-verification/packages/tasks-capability/schemas/capture-request-v1.schema.json", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "task" + ], + "properties": { + "schema": { + "const": "opsle.execution.change-capture-request.v1" + }, + "task": { + "type": "object", + "additionalProperties": true, + "required": [ + "id", + "repo_name", + "repo_path", + "worktree_path", + "base_commit" + ], + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "repo_id": { + "type": "integer", + "minimum": 1 + }, + "repo_name": { + "type": "string", + "minLength": 1 + }, + "repo_path": { + "type": "string", + "pattern": "^/[^\\u0000-\\u001f]+$" + }, + "worktree_path": { + "type": "string", + "pattern": "^/[^\\u0000-\\u001f]+$" + }, + "base_commit": { + "type": "string", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" + }, + "ssh_host": { + "type": "string" + }, + "ssh_user": { + "type": "string" + }, + "test_command": { + "type": "string" + } + } + } + } +} diff --git a/packages/tasks-capability/schemas/change-set-v1.schema.json b/packages/tasks-capability/schemas/change-set-v1.schema.json new file mode 100644 index 0000000..0ac4b6b --- /dev/null +++ b/packages/tasks-capability/schemas/change-set-v1.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/opsle/affected-verification/packages/tasks-capability/schemas/change-set-v1.schema.json", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "repository_identity", + "base_revision", + "target_revision", + "identity", + "diff_sha256", + "paths" + ], + "properties": { + "schema": { + "const": "opsle.execution.change-set.v1" + }, + "repository_identity": { + "type": "string", + "minLength": 1 + }, + "base_revision": { + "type": "string", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" + }, + "target_revision": { + "type": "string", + "pattern": "^git-tree:(?:[0-9a-f]{40}|[0-9a-f]{64})$" + }, + "identity": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "diff_sha256": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "paths": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "regions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "risk_tags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } +} diff --git a/packages/tasks-capability/schemas/evidence-v1.schema.json b/packages/tasks-capability/schemas/evidence-v1.schema.json new file mode 100644 index 0000000..a7747f7 --- /dev/null +++ b/packages/tasks-capability/schemas/evidence-v1.schema.json @@ -0,0 +1,407 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/opsle/affected-verification/packages/tasks-capability/schemas/evidence-v1.schema.json", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "task_id", + "attempt_id", + "execution_id", + "generation", + "execution_target", + "status", + "mechanism", + "repository", + "change", + "manifest", + "input_path", + "value_receipt_path", + "decision", + "analysis_error", + "verification_results", + "fallback", + "limitations" + ], + "properties": { + "schema": { + "const": "opsle.tasks.affected-verification-evidence.v1" + }, + "task_id": { + "type": "integer", + "minimum": 1 + }, + "attempt_id": { + "type": "integer", + "minimum": 1 + }, + "execution_id": { + "type": "string", + "minLength": 1 + }, + "generation": { + "type": "integer", + "minimum": 1 + }, + "execution_target": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "path" + ], + "properties": { + "kind": { + "enum": [ + "ssh", + "test-local" + ] + }, + "path": { + "type": "string", + "minLength": 1 + }, + "host": { + "type": "string", + "minLength": 1 + }, + "user": { + "type": "string", + "minLength": 1 + } + } + }, + "status": { + "enum": [ + "ANALYSIS_FAILED", + "ANALYZED", + "PASSED", + "FAILED", + "BLOCKED", + "VERIFICATION_RUNNING", + "VERIFICATION_FAILED", + "NO_AUTOMATED_VERIFICATION", + "VERIFICATION_PENDING" + ] + }, + "mechanism": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "revision", + "interface" + ], + "properties": { + "id": { + "const": "opsle.affected-verification" + }, + "revision": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "interface": { + "const": "opsle.affected-verification.task-plan.v1" + } + } + }, + "repository": { + "type": "object", + "additionalProperties": false, + "required": [ + "identity", + "base_revision", + "target_revision" + ], + "properties": { + "identity": { + "type": "string", + "minLength": 1 + }, + "base_revision": { + "type": "string", + "minLength": 1 + }, + "target_revision": { + "type": "string", + "minLength": 1 + } + } + }, + "change": { + "type": "object", + "additionalProperties": false, + "required": [ + "identity", + "diff_sha256", + "paths" + ], + "properties": { + "identity": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "diff_sha256": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "paths": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "regions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "risk_tags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + }, + "manifest": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [], + "properties": { + "source_path": { + "type": "string", + "minLength": 1 + }, + "source_identity": { + "type": "string", + "minLength": 1 + }, + "evidence_complete": { + "type": "boolean" + }, + "catalog_complete": { + "type": "boolean" + } + } + }, + { + "type": "null" + } + ] + }, + "input_path": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "value_receipt_path": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "decision": { + "anyOf": [ + { + "$ref": "task-plan-v1.schema.json" + }, + { + "type": "null" + } + ] + }, + "analysis_error": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "verification_results": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "type", + "command", + "status", + "exit_code", + "signal", + "duration_ms", + "stdout_path", + "stderr_path", + "evidence_path" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "minLength": 1 + }, + "command": { + "type": "string", + "minLength": 1 + }, + "status": { + "enum": [ + "RUNNING", + "PASSED", + "FAILED" + ] + }, + "exit_code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "signal": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "duration_ms": { + "anyOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "stdout_path": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "stderr_path": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "evidence_path": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + } + } + } + }, + "fallback": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "required", + "reason" + ], + "properties": { + "required": { + "type": "boolean" + }, + "command_source": { + "anyOf": [ + { + "const": "tasks-project-test-command" + }, + { + "type": "null" + } + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + } + } + }, + { + "type": "null" + } + ] + }, + "limitations": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + } +} diff --git a/packages/tasks-capability/schemas/manifest-v1.schema.json b/packages/tasks-capability/schemas/manifest-v1.schema.json new file mode 100644 index 0000000..dda65de --- /dev/null +++ b/packages/tasks-capability/schemas/manifest-v1.schema.json @@ -0,0 +1,369 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/opsle/affected-verification/packages/tasks-capability/schemas/manifest-v1.schema.json", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "evidence_complete", + "catalog_complete", + "components", + "checks", + "policy" + ], + "properties": { + "schema": { + "const": "opsle.affected-verification.manifest.v1" + }, + "source_path": { + "type": "string", + "minLength": 1 + }, + "source_identity": { + "type": "string", + "minLength": 1 + }, + "evidence_complete": { + "type": "boolean" + }, + "catalog_complete": { + "type": "boolean" + }, + "components": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "dependencies", + "path_globs" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "dependencies": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "path_globs": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "risk_tags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + } + } + }, + "checks": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "type", + "command", + "scope", + "tags", + "test_executions" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "type": { + "enum": [ + "unit-test", + "integration-test", + "end-to-end-test", + "lint", + "typecheck", + "build", + "compiler", + "schema", + "migration", + "api-contract", + "security", + "snapshot", + "visual", + "smoke", + "release", + "documentation", + "test-infrastructure", + "other" + ] + }, + "command": { + "type": "string", + "minLength": 1 + }, + "scope": { + "type": "object", + "additionalProperties": false, + "required": [ + "components" + ], + "properties": { + "components": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + } + } + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "test_executions": { + "type": "integer", + "minimum": 0 + }, + "cost": { + "type": "object", + "additionalProperties": false, + "required": [ + "value", + "unit" + ], + "properties": { + "value": { + "type": "number", + "minimum": 0 + }, + "unit": { + "type": "string", + "minLength": 1 + } + } + }, + "dependency": { + "type": "object", + "additionalProperties": false, + "required": [ + "completeness", + "mechanisms", + "boundaries", + "explanation" + ], + "properties": { + "completeness": { + "enum": [ + "COMPLETE_FOR_CHECK", + "COMPLETE_WITH_DECLARED_BOUNDARIES", + "INCOMPLETE", + "OPAQUE_BOUNDARY", + "UNKNOWN" + ] + }, + "mechanisms": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "positive" + ], + "properties": { + "kind": { + "enum": [ + "DECLARED_SCOPE", + "STATIC_IMPORT", + "NATIVE_SELECTOR", + "RUNTIME_TRACE", + "COVERAGE", + "SUBPROCESS", + "CHILD_INTERPRETER", + "DYNAMIC_IMPORT", + "PLUGIN_OR_ENTRY_POINT_DISCOVERY", + "EXEC_EVAL_OR_CODE_GENERATION", + "RUNTIME_LOADED_MODULE", + "REFLECTION_OR_REGISTRATION" + ] + }, + "positive": { + "type": "boolean" + } + } + } + }, + "boundaries": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "kind", + "status", + "relevant", + "explanation", + "source" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "kind": { + "enum": [ + "SUBPROCESS", + "CHILD_INTERPRETER", + "DYNAMIC_IMPORT", + "PLUGIN_OR_ENTRY_POINT_DISCOVERY", + "EXEC_EVAL_OR_CODE_GENERATION", + "RUNTIME_LOADED_MODULE", + "REFLECTION_OR_REGISTRATION" + ] + }, + "status": { + "enum": [ + "OPEN", + "CLOSED", + "IRRELEVANT" + ] + }, + "relevant": { + "type": "boolean" + }, + "explanation": { + "type": "string", + "minLength": 1 + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "line", + "construct" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "line": { + "type": "integer", + "minimum": 1 + }, + "construct": { + "type": "string", + "minLength": 1 + } + } + } + } + } + }, + "explanation": { + "type": "string", + "minLength": 1 + } + } + } + } + } + }, + "policy": { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "rules" + ], + "properties": { + "version": { + "type": "string", + "minLength": 1 + }, + "rules": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "match", + "escalation", + "required_check_tags" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "match": { + "type": "object", + "additionalProperties": false, + "required": [], + "properties": { + "path_globs": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "risk_tags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "component_ids": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + } + }, + "escalation": { + "enum": [ + "NONE", + "BROADEN", + "FULL", + "INVALIDATE" + ] + }, + "required_check_tags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + } + } +} diff --git a/packages/tasks-capability/schemas/task-plan-v1.schema.json b/packages/tasks-capability/schemas/task-plan-v1.schema.json new file mode 100644 index 0000000..476ce1f --- /dev/null +++ b/packages/tasks-capability/schemas/task-plan-v1.schema.json @@ -0,0 +1,126 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/opsle/affected-verification/packages/tasks-capability/schemas/task-plan-v1.schema.json", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "decision_identity", + "task", + "repository", + "manifest", + "impacts", + "plan" + ], + "properties": { + "schema": { + "const": "opsle.affected-verification.task-plan.v1" + }, + "decision_identity": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "task": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "execution_id" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "execution_id": { + "type": "string", + "minLength": 1 + } + } + }, + "repository": { + "type": "object", + "additionalProperties": false, + "required": [ + "identity", + "base_revision", + "target_revision" + ], + "properties": { + "identity": { + "type": "string", + "minLength": 1 + }, + "base_revision": { + "type": "string", + "minLength": 1 + }, + "target_revision": { + "type": "string", + "minLength": 1 + } + } + }, + "manifest": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "source_path", + "source_identity" + ], + "properties": { + "schema": { + "const": "opsle.affected-verification.manifest.v1" + }, + "source_path": { + "type": "string", + "minLength": 1 + }, + "source_identity": { + "type": "string", + "minLength": 1 + } + } + }, + "impacts": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "components", + "confidence", + "reason" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "components": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "confidence": { + "enum": [ + "KNOWN", + "UNKNOWN" + ] + }, + "reason": { + "type": "string", + "minLength": 1 + } + } + } + }, + "plan": { + "$ref": "plan-v2.schema.json" + } + } +} diff --git a/packages/tasks-capability/schemas/task-request-v1.schema.json b/packages/tasks-capability/schemas/task-request-v1.schema.json new file mode 100644 index 0000000..16498bd --- /dev/null +++ b/packages/tasks-capability/schemas/task-request-v1.schema.json @@ -0,0 +1,106 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/opsle/affected-verification/packages/tasks-capability/schemas/task-request-v1.schema.json", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "task", + "repository", + "change", + "manifest" + ], + "properties": { + "schema": { + "const": "opsle.affected-verification.task-request.v1" + }, + "task": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "execution_id" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "execution_id": { + "type": "string", + "minLength": 1 + } + } + }, + "repository": { + "type": "object", + "additionalProperties": false, + "required": [ + "identity", + "base_revision", + "target_revision" + ], + "properties": { + "identity": { + "type": "string", + "minLength": 1 + }, + "base_revision": { + "type": "string", + "minLength": 1 + }, + "target_revision": { + "type": "string", + "minLength": 1 + } + } + }, + "change": { + "type": "object", + "additionalProperties": false, + "required": [ + "identity", + "paths" + ], + "properties": { + "identity": { + "type": "string", + "minLength": 1 + }, + "paths": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "regions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "risk_tags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + }, + "manifest": { + "$ref": "manifest-v1.schema.json" + } + } +} diff --git a/packages/tasks-capability/schemas/tasks-config-v2.schema.json b/packages/tasks-capability/schemas/tasks-config-v2.schema.json new file mode 100644 index 0000000..be1f270 --- /dev/null +++ b/packages/tasks-capability/schemas/tasks-config-v2.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/opsle/affected-verification/packages/tasks-capability/schemas/tasks-config-v2.schema.json", + "type": "object", + "additionalProperties": false, + "required": [], + "properties": { + "schema": { + "const": "opsle.affected-verification.tasks-config.v2" + } + } +} diff --git a/packages/tasks-capability/schemas/verification-analysis-v1.schema.json b/packages/tasks-capability/schemas/verification-analysis-v1.schema.json new file mode 100644 index 0000000..21e3c5d --- /dev/null +++ b/packages/tasks-capability/schemas/verification-analysis-v1.schema.json @@ -0,0 +1,121 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/opsle/affected-verification/packages/tasks-capability/schemas/verification-analysis-v1.schema.json", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "change", + "decision", + "error", + "evidencePath", + "inputPath", + "receiptPath", + "record" + ], + "properties": { + "schema": { + "const": "opsle.execution.verification-analysis.v1" + }, + "change": { + "type": "object", + "additionalProperties": false, + "required": [ + "repository_identity", + "base_revision", + "target_revision", + "identity", + "diff_sha256", + "paths" + ], + "properties": { + "repository_identity": { + "type": "string", + "minLength": 1 + }, + "base_revision": { + "type": "string", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" + }, + "target_revision": { + "type": "string", + "pattern": "^git-tree:(?:[0-9a-f]{40}|[0-9a-f]{64})$" + }, + "identity": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "diff_sha256": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "paths": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "regions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "risk_tags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + }, + "decision": { + "anyOf": [ + { + "$ref": "task-plan-v1.schema.json" + }, + { + "type": "null" + } + ] + }, + "error": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "evidencePath": { + "type": "string", + "minLength": 1 + }, + "inputPath": { + "type": "string", + "minLength": 1 + }, + "receiptPath": { + "type": "string", + "minLength": 1 + }, + "record": { + "$ref": "evidence-v1.schema.json" + } + } +} diff --git a/packages/tasks-capability/schemas/verification-request-v1.schema.json b/packages/tasks-capability/schemas/verification-request-v1.schema.json new file mode 100644 index 0000000..4afa825 --- /dev/null +++ b/packages/tasks-capability/schemas/verification-request-v1.schema.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/opsle/affected-verification/packages/tasks-capability/schemas/verification-request-v1.schema.json", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "task", + "attemptId", + "executionId", + "generation" + ], + "properties": { + "schema": { + "const": "opsle.execution.verification-request.v1" + }, + "task": { + "type": "object", + "additionalProperties": true, + "required": [ + "id", + "repo_name", + "repo_path", + "worktree_path", + "base_commit" + ], + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "repo_id": { + "type": "integer", + "minimum": 1 + }, + "repo_name": { + "type": "string", + "minLength": 1 + }, + "repo_path": { + "type": "string", + "pattern": "^/[^\\u0000-\\u001f]+$" + }, + "worktree_path": { + "type": "string", + "pattern": "^/[^\\u0000-\\u001f]+$" + }, + "base_commit": { + "type": "string", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" + }, + "ssh_host": { + "type": "string" + }, + "ssh_user": { + "type": "string" + }, + "test_command": { + "type": "string" + } + } + }, + "attemptId": { + "type": "integer", + "minimum": 1 + }, + "executionId": { + "type": "string", + "minLength": 1 + }, + "generation": { + "type": "integer", + "minimum": 1 + } + } +} diff --git a/packages/tasks-capability/src/adapter.js b/packages/tasks-capability/src/adapter.js new file mode 100644 index 0000000..cacdefd --- /dev/null +++ b/packages/tasks-capability/src/adapter.js @@ -0,0 +1,41 @@ +import { analyzeAffectedVerification, captureBuildChange } from './verification.js'; +import { validateSchema } from './schema.js'; + +const hooks = { + 'verification.plan': ['verification-request-v1', 'verification-analysis-v1'], + 'verification.capture': ['capture-request-v1', 'change-set-v1'], +}; +export function createAdapter({ manifest, configuration, services }, packageIdentity) { + validateSchema('tasks-config-v2', configuration); + const binding = structuredClone(services.task); + const config = { ...services.executionConfig, packageIdentity }; + return { + invoke(hook, payload) { + if (!Object.hasOwn(hooks, hook)) throw new Error(`Unsupported capability hook: ${hook}`); + const [input, output] = hooks[hook]; + validateSchema(input, payload); + for (const key of ['id', 'repo_id', 'repo_name', 'repo_path', 'base_commit', 'worktree_path', 'ssh_host', 'ssh_user', 'sshHost', 'sshUser', 'test_command']) { + if (payload.task[key] !== binding[key]) { + throw new Error(`Verification request has a different task binding: ${key}`); + } + } + if (hook === 'verification.plan' && (payload.attemptId !== services.attemptId + || payload.executionId !== services.executionId)) throw new Error('Different attempt or execution binding'); + const native = hook === 'verification.capture' + ? captureBuildChange(payload.task, config) + : analyzeAffectedVerification({ ...payload, config }); + const contract = manifest.hooks.find(item => item.name === hook); + const outputSchema = contract.outputSchema ?? contract.output_schema; + const value = { schema: outputSchema, ...native }; + validateSchema(output, value); + return { + schema: 'opsle.capability-result.v1', capability: manifest.id, hook, + output_schema: outputSchema, status: 'ok', value, receipts: [], events: [], + artifacts: hook === 'verification.plan' ? [{ + kind: 'verification-analysis', path: native.evidencePath, + schema: 'opsle.tasks.affected-verification-evidence.v1', + }] : [], + }; + }, + }; +} diff --git a/packages/tasks-capability/src/execution.js b/packages/tasks-capability/src/execution.js new file mode 100644 index 0000000..f3b7416 --- /dev/null +++ b/packages/tasks-capability/src/execution.js @@ -0,0 +1,84 @@ +import { randomUUID } from 'node:crypto'; +import { resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; + +export const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`; +const argv = args => args.map(quote).join(' '); +export function executionTarget(project = {}) { + const host = project.ssh_host ?? project.sshHost ?? ''; + const user = project.ssh_user ?? project.sshUser ?? ''; + const path = project.repo_path ?? project.path; + if (!host && !user && process.env.NODE_ENV === 'test') return { kind: 'test-local', path }; + if (!/^[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}$/.test(host)) throw new Error('Project execution host is required: configure its private Incus hostname. Local execution is disabled.'); + if (!/^[a-z_][a-z0-9_-]{0,63}$/.test(user)) throw new Error('Project SSH user is required or invalid.'); + if (typeof path !== 'string' || !path.startsWith('/') || /[\x00-\x1f]/.test(path) || path === '/') throw new Error('Project repository path must be an absolute path inside its container.'); + return { kind: 'ssh', host, user, path }; +} + +export function sshArguments(config, target, script, seconds = 30) { + if (target.kind !== 'ssh') throw new Error('SSH requires an explicit project execution target.'); + if (!config.sshKeyPath) throw new Error('Tasks SSH key is not configured (OPSLE_SSH_KEY_PATH).'); + // GNU timeout owns a remote process group, including provider grandchildren. + // It still enforces the deadline if the control plane or SSH connection disappears. + return ['-T', '-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes', + '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=10', + '-o', 'ServerAliveInterval=5', '-o', 'ServerAliveCountMax=2', + '-i', config.sshKeyPath, + ...(config.sshKnownHostsPath ? ['-o', `UserKnownHostsFile=${config.sshKnownHostsPath}`] : []), + '--', `${target.user}@${target.host}`, + `exec timeout --signal=TERM --kill-after=5s ${Math.max(1, seconds)}s /bin/sh -c ${quote(script)}`]; +} + +export function executionError(result, target, label = 'Remote command') { + const detail = String(result.stderr || result.error?.message || '').slice(-2000); + const where = target.kind === 'ssh' ? `${target.user}@${target.host}` : 'test-local'; + let reason; + if (result.error?.code === 'ETIMEDOUT' || [124, 137].includes(result.status ?? result.code)) reason = 'command timeout'; + else if ((result.status ?? result.code) === 255 && /Permission denied|Authentication failed/i.test(detail)) reason = 'SSH authentication failure'; + else if (/could not read Username|Authentication failed|Permission denied.*publickey/i.test(detail)) reason = 'repository origin authentication failure'; + else if (/Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(detail)) reason = 'SSH host key verification failure'; + else if (/Could not resolve hostname|Connection refused|No route to host|Connection timed out|Network is unreachable|Connection closed|Connection reset/i.test(detail) || (result.status ?? result.code) === 255) reason = 'container/host unreachable or SSH connection lost'; + else if (/OPSLE_REPOSITORY_MISSING|not a git repository/.test(detail)) reason = 'repository missing'; + else if (/OPSLE_PROVIDER_MISSING/.test(detail)) reason = 'provider CLI missing'; + else if (/OPSLE_PROVIDER_AUTH|not logged in|authentication required|unauthorized|invalid.*token|please.*log.?in/i.test(detail)) reason = 'provider authentication unavailable'; + else reason = `exit ${result.status ?? result.code ?? result.error?.code ?? 'unknown'}`; + const evidence = result.stderrPath ? ` Raw evidence: ${result.stderrPath}` : ''; + const error = new Error(`${label}: ${reason} at ${where}.${reason === 'repository missing' ? ` Repository: ${target.path}.` : ''}${evidence}`); + error.code = reason; + return error; +} + +function invocation(config, target, script, seconds, cwd) { + if (target.kind === 'test-local' && process.env.NODE_ENV === 'test') return { command: '/bin/sh', args: ['-c', script], cwd }; + return { command: config.sshBin || 'ssh', args: sshArguments(config, target, script, seconds) }; +} + +export function projectScript(cwd, script, environment = {}) { + const exports = Object.entries(environment).map(([key, value]) => { + if (!/^[A-Z_][A-Z0-9_]*$/.test(key)) throw new Error('Invalid command environment name.'); + return `export ${key}=${quote(value)};`; + }).join('\n'); + return `export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:/usr/local/bin:/usr/bin:/bin";\n${exports}\ncd ${quote(cwd)} 2>/dev/null || { echo OPSLE_REPOSITORY_MISSING >&2; exit 72; };\n${script}`; +} + +export function projectExec(config, target, args, options = {}) { + const seconds = options.timeoutSeconds || 20; + const command = invocation(config, target, projectScript(options.cwd || target.path, `exec ${argv(args)}`), seconds, options.cwd || target.path); + const result = spawnSync(command.command, command.args, { + cwd: command.cwd, input: options.input, encoding: options.encoding ?? 'utf8', + timeout: (seconds + 15) * 1000, maxBuffer: options.maxBuffer || 10_000_000, + }); + if (result.error || result.status !== 0) { + if (options.allowFailure && !result.error && ![72, 124, 137, 255].includes(result.status)) return result; + if (config.logsDir) { + result.stderrPath = resolve(config.logsDir, `execution-error-${randomUUID()}.stderr`); + writeFileSync(result.stderrPath, String(result.stderr || result.error?.message || '').slice(-20_000_000), { mode: 0o600 }); + } + throw executionError(result, target, args[0]); + } + return options.allowFailure ? result : result.stdout; +} + +export const projectGit = (config, target, args, cwd = target.path, options = {}) => projectExec(config, target, ['git', ...args], { ...options, cwd }); + diff --git a/packages/tasks-capability/src/planner-cli.js b/packages/tasks-capability/src/planner-cli.js new file mode 100644 index 0000000..fdbafd5 --- /dev/null +++ b/packages/tasks-capability/src/planner-cli.js @@ -0,0 +1,14 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { planTaskVerification } from './core/task.js'; +import { canonicalJson } from './core/canonical.js'; +import { buildValueReceipt } from './core/value-receipt.js'; +try { + const [command, input, option, receipt] = process.argv.slice(2); + if (command !== 'task-plan' || option !== '--receipt' || !receipt) throw new Error('Unsupported planner invocation'); + const decision = planTaskVerification(JSON.parse(readFileSync(input, 'utf8'))); + writeFileSync(receipt, canonicalJson(buildValueReceipt(decision.plan)) + '\n', { mode: 0o600, flag: 'wx' }); + process.stdout.write(canonicalJson(decision) + '\n'); +} catch (error) { + process.stderr.write(String(error.message).slice(0, 3000) + '\n'); + process.exitCode = 2; +} diff --git a/packages/tasks-capability/src/schema.js b/packages/tasks-capability/src/schema.js new file mode 100644 index 0000000..0928f38 --- /dev/null +++ b/packages/tasks-capability/src/schema.js @@ -0,0 +1,55 @@ +import { readFileSync } from 'node:fs'; +const cache = new Map(); +function load(name) { + if (!/^[a-z0-9-]+$/.test(name)) throw new Error('Unsupported schema name'); + if (!cache.has(name)) cache.set(name, JSON.parse(readFileSync(new URL(`../schemas/${name}.schema.json`, import.meta.url), 'utf8'))); + return cache.get(name); +} +// Deliberately bounded validator for the JSON Schema vocabulary used by this package. +// Cross-field completeness/provenance checks remain in AV's native validator. +export function validateSchema(name, value) { + const root = load(name); + function check(schema, item, path, document = root, depth = 0) { + if (depth > 80) throw new Error('Schema nesting exceeds limit'); + const fail = () => { throw new Error(`Invalid ${name} schema at ${path}`); }; + if (schema.$ref) { + const [file, fragment] = schema.$ref.split('#'); + const doc = file ? load(file.replace('.schema.json', '')) : document; + const target = fragment ? fragment.slice(1).split('/').reduce((v,k) => v[k], doc) : doc; + return check(target, item, path, doc, depth + 1); + } + if (schema.anyOf) { + for (const branch of schema.anyOf) { + try { check(branch, item, path, document, depth + 1); return; } catch { /* try remaining union members */ } + } + fail(); + } + if (Object.hasOwn(schema, 'const') && item !== schema.const) fail(); + if (schema.enum && !schema.enum.includes(item)) fail(); + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + const matches = type => type === undefined || (type === 'null' ? item === null + : type === 'array' ? Array.isArray(item) + : type === 'object' ? item !== null && typeof item === 'object' && !Array.isArray(item) + : type === 'integer' ? Number.isSafeInteger(item) + : type === 'number' ? typeof item === 'number' && Number.isFinite(item) + : typeof item === type); + if (!types.some(matches)) fail(); + if (typeof item === 'string' && ((schema.minLength && item.length < schema.minLength) + || (schema.pattern && !new RegExp(schema.pattern).test(item)))) fail(); + if (typeof item === 'number' && schema.minimum !== undefined && item < schema.minimum) fail(); + if (Array.isArray(item)) { + if (schema.minItems && item.length < schema.minItems) fail(); + if (schema.uniqueItems && new Set(item.map(v => JSON.stringify(v))).size !== item.length) fail(); + if (schema.items) item.forEach((v,i) => check(schema.items, v, `${path}[${i}]`, document, depth + 1)); + } else if (item !== null && typeof item === 'object') { + for (const key of schema.required || []) if (!Object.hasOwn(item, key)) fail(); + for (const [key, v] of Object.entries(item)) { + if (Object.hasOwn(schema.properties || {}, key)) check(schema.properties[key], v, `${path}.${key}`, document, depth + 1); + else if (schema.additionalProperties === false) fail(); + else if (typeof schema.additionalProperties === 'object') check(schema.additionalProperties, v, `${path}.${key}`, document, depth + 1); + } + } + } + check(root, value, '$'); + return value; +} diff --git a/packages/tasks-capability/src/verification.js b/packages/tasks-capability/src/verification.js new file mode 100644 index 0000000..6d0783a --- /dev/null +++ b/packages/tasks-capability/src/verification.js @@ -0,0 +1,256 @@ +import { executionTarget, projectGit } from './execution.js'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync, renameSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { contentIdentity } from './core/canonical.js'; +import { planTaskVerification } from './core/task.js'; +import { validateSchema } from './schema.js'; + +export const AV_MANIFEST_PATH = '.opsle/affected-verification.json'; +const AV_TASK_PLAN_SCHEMA = 'opsle.affected-verification.task-plan.v1'; +const AV_PLAN_SCHEMA = 'opsle.affected-verification.plan.v2'; +const AV_REQUEST_SCHEMA = 'opsle.affected-verification.task-request.v1'; +const AV_MANIFEST_SCHEMA = 'opsle.affected-verification.manifest.v1'; +const AV_EVIDENCE_SCHEMA = 'opsle.tasks.affected-verification-evidence.v1'; +const MAX_MANIFEST_BYTES = 1_000_000; +const MAX_ARTIFACT_BYTES = 5_000_000; +const packageRoot = fileURLToPath(new URL('..', import.meta.url)); + +const hash = value => `sha256:${createHash('sha256').update(value).digest('hex')}`; + +function git(args, task, config, options = {}) { + return projectGit(config, executionTarget(task), args, task.worktree_path, options); +} + +function atomicJson(path, value) { + const text = `${JSON.stringify(value)}\n`; + if (Buffer.byteLength(text) > MAX_ARTIFACT_BYTES) { + throw new Error('Affected Verification evidence exceeds the bounded artifact limit.'); + } + const temporary = `${path}.tmp-${process.pid}`; + writeFileSync(temporary, text, { mode: 0o600, flag: 'wx' }); + renameSync(temporary, path); +} + +function fallbackManifest(testCommand) { + const checks = testCommand.trim() ? [{ + id: 'tasks.full-verification', + type: 'integration-test', + command: testCommand, + scope: { components: ['repository'] }, + tags: ['tasks-full-fallback'], + test_executions: 1, + dependency: { + completeness: 'UNKNOWN', + mechanisms: [{ kind: 'DECLARED_SCOPE', positive: false }], + boundaries: [], + explanation: 'No base-revision AV manifest exists; Tasks can defend only its full configured command.', + }, + }] : []; + return { + schema: AV_MANIFEST_SCHEMA, + source_path: '', + source_identity: hash(testCommand), + evidence_complete: false, + catalog_complete: true, + components: [{ id: 'repository', dependencies: [], path_globs: ['**'], risk_tags: [] }], + checks, + policy: { version: 'tasks-fallback-v1', rules: [] }, + }; +} + +function baseManifest(task, config) { + const object = `${task.base_commit}:${AV_MANIFEST_PATH}`; + const exists = git(['cat-file', '-e', object], task, config, { allowFailure: true }); + if (exists.status !== 0) return fallbackManifest(task.test_command || ''); + const raw = git(['show', object], task, config); + if (Buffer.byteLength(raw) > MAX_MANIFEST_BYTES) { + throw new Error(`Affected Verification manifest exceeds ${MAX_MANIFEST_BYTES} bytes.`); + } + let manifest; + try { manifest = JSON.parse(raw); } + catch { throw new Error(`Base-revision ${AV_MANIFEST_PATH} is not valid JSON.`); } + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { + throw new Error(`Base-revision ${AV_MANIFEST_PATH} must contain a JSON object.`); + } + validateSchema('manifest-v1', manifest); + const policy = manifest.policy && typeof manifest.policy === 'object' + ? manifest.policy + : { version: '1', rules: [] }; + const rules = Array.isArray(policy.rules) ? [...policy.rules] : []; + rules.push({ + id: 'affected-verification-manifest-change', + match: { path_globs: [AV_MANIFEST_PATH], risk_tags: [], component_ids: [] }, + escalation: 'FULL', + required_check_tags: [], + }); + return { + ...manifest, + schema: manifest.schema, + source_path: AV_MANIFEST_PATH, + source_identity: hash(raw), + policy: { ...policy, rules }, + }; +} + +export function captureBuildChange(task, config = {}) { + if (!task.worktree_path || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(task.base_commit || '')) { + throw new Error('Affected Verification requires a retained worktree and exact base revision.'); + } + git(['cat-file', '-e', `${task.base_commit}^{commit}`], task, config); + git(['add', '--all'], task, config); + const targetTree = git(['write-tree'], task, config).trim(); + const patch = git([ + 'diff', '--cached', '--binary', '--no-ext-diff', task.base_commit, '--', + ], task, config, { encoding: 'buffer' }); + const paths = git([ + 'diff', '--cached', '--name-only', '-z', task.base_commit, '--', + ], task, config).split('\0').filter(Boolean).sort(); + const remote = git(['config', '--get', 'remote.origin.url'], task, config, { allowFailure: true }); + return { + repository_identity: remote.status === 0 && remote.stdout.trim() + ? remote.stdout.trim() + : `local:${task.repo_name}`, + base_revision: task.base_commit, + target_revision: `git-tree:${targetTree}`, + identity: hash(patch), + diff_sha256: hash(patch), + paths: paths.map(path => ({ path, regions: [], risk_tags: [] })), + }; +} + +export function validateDecision(decision, request) { + if (decision?.schema !== AV_TASK_PLAN_SCHEMA || decision.plan?.schema !== AV_PLAN_SCHEMA) { + throw new Error('Affected Verification returned an unsupported or malformed plan.'); + } + if (decision.task?.id !== request.task.id + || decision.task?.execution_id !== request.task.execution_id + || decision.repository?.identity !== request.repository.identity + || decision.repository?.base_revision !== request.repository.base_revision + || decision.repository?.target_revision !== request.repository.target_revision + || decision.plan.change?.identity !== request.change.identity) { + throw new Error('Affected Verification returned a plan for a different task execution or change set.'); + } + const expected = new Map(request.manifest.checks.map(check => [check.id, check.command])); + const actions = [...(decision.plan.selected_checks || []), ...(decision.plan.skipped_checks || [])]; + if (actions.length !== expected.size || new Set(actions.map(item => item.id)).size !== actions.length) { + throw new Error('Affected Verification did not return an exact catalog partition.'); + } + for (const action of actions) { + if (!expected.has(action.id) || expected.get(action.id) !== action.command) { + throw new Error('Affected Verification returned an action outside the immutable command catalog.'); + } + } + const allowed = new Set([ + 'SUFFICIENT_TARGETED', 'SUFFICIENT_BROADENED', + 'FULL_VERIFICATION_REQUIRED', 'INSUFFICIENT_EVIDENCE', + ]); + if (!allowed.has(decision.plan.sufficiency) + || decision.plan.argument?.unknown_is_safe_to_skip !== false + || decision.plan.argument?.every_skip_explained !== true + || decision.plan.argument?.every_skip_dependency_complete !== true) { + throw new Error('Affected Verification returned incomplete sufficiency evidence.'); + } + if (decision.decision_identity !== contentIdentity({ ...decision, decision_identity: undefined }) + || contentIdentity(decision) !== contentIdentity(planTaskVerification(request))) { + throw new Error('Affected Verification decision differs from canonical provenance or completeness.'); + } + return decision; +} + +export function analyzeAffectedVerification({ config, task, attemptId, executionId, generation }) { + const change = captureBuildChange(task, config); + const inputPath = resolve(config.logsDir, `task-${task.id}-attempt-${attemptId}-av-${generation}-input.json`); + const receiptPath = resolve(config.logsDir, `task-${task.id}-attempt-${attemptId}-av-${generation}-value-receipt.json`); + const evidencePath = resolve(config.logsDir, `task-${task.id}-attempt-${attemptId}-av-${generation}.json`); + let manifest; + let decision = null; + let error = null; + try { + manifest = baseManifest(task, config); + if (change.paths.length === 0) manifest = { ...manifest, evidence_complete: false }; + const request = { + schema: AV_REQUEST_SCHEMA, + task: { id: `task-${task.id}`, execution_id: executionId }, + repository: { + identity: change.repository_identity, + base_revision: change.base_revision, + target_revision: change.target_revision, + }, + change: { identity: change.identity, paths: change.paths }, + manifest, + }; + validateSchema('task-request-v1', request); + atomicJson(inputPath, request); + // Empty catalogs must enter Tasks' error/full-fallback path, not its legacy + // NO_AUTOMATED_VERIFICATION branch, which allows completion without checks. + if (manifest.checks.length === 0) { + throw new Error('Affected Verification has no catalogued checks; full configured verification is required or execution must stop.'); + } + const binary = resolve(packageRoot, 'runtime', 'planner-cli.js'); + if (!existsSync(binary)) throw new Error('Affected Verification CLI is unavailable.'); + const result = spawnSync(process.execPath, [ + binary, 'task-plan', inputPath, '--receipt', receiptPath, + ], { encoding: 'utf8', timeout: 10_000, maxBuffer: MAX_ARTIFACT_BYTES }); + if (result.error) throw new Error(`Affected Verification failed internally: ${result.error.message}`); + if (result.status !== 0) { + throw new Error(`Affected Verification rejected the task change: ${(result.stdout || result.stderr || 'unknown error').trim().slice(0, 2000)}`); + } + try { decision = validateDecision(JSON.parse(result.stdout), request); } + catch (cause) { + if (cause instanceof SyntaxError) throw new Error('Affected Verification returned invalid JSON.'); + throw cause; + } + } catch (cause) { + error = String(cause.message || cause).slice(0, 3000); + } + const record = { + schema: AV_EVIDENCE_SCHEMA, + task_id: task.id, + attempt_id: attemptId, + execution_id: executionId, + generation, + execution_target: executionTarget(task), + status: error ? 'ANALYSIS_FAILED' : 'ANALYZED', + mechanism: { + id: 'opsle.affected-verification', + revision: config.packageIdentity, + interface: AV_TASK_PLAN_SCHEMA, + }, + repository: { + identity: change.repository_identity, + base_revision: change.base_revision, + target_revision: change.target_revision, + }, + change: { + identity: change.identity, + diff_sha256: change.diff_sha256, + paths: change.paths, + }, + manifest: manifest ? { + source_path: manifest.source_path, + source_identity: manifest.source_identity, + evidence_complete: manifest.evidence_complete, + catalog_complete: manifest.catalog_complete, + } : null, + input_path: existsSync(inputPath) ? inputPath : null, + value_receipt_path: existsSync(receiptPath) ? receiptPath : null, + decision, + analysis_error: error, + verification_results: [], + fallback: null, + limitations: [ + 'Changed regions are not inferred; path ownership and check completeness come from the immutable base-revision manifest.', + 'Passing selected commands proves only their observed process results, not global correctness.', + ], + }; + validateSchema('evidence-v1', record); + atomicJson(evidencePath, record); + return { change, decision, error, evidencePath, inputPath, receiptPath, record }; +} + +export function saveAffectedVerificationRecord(path, record) { + atomicJson(path, record); +} diff --git a/tests/tasks-capability.test.js b/tests/tasks-capability.test.js new file mode 100644 index 0000000..c8f1a14 --- /dev/null +++ b/tests/tasks-capability.test.js @@ -0,0 +1,280 @@ +import test, { before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { pathToFileURL, fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const compatRevision = 'e1207c5264c59e14efe9838bba3a33ba504665d2'; +const id = 'opsle.affected-verification'; +const schema = 'opsle.execution.verification-request.v1'; +const emptySelection = { schema: 'opsle.capability-selection.v1', enable: [], disable: [], configuration: {} }; +const grant = { schema: 'opsle.capability-grants.v1', allow: [id] }; +const hash = value => createHash('sha256').update(value).digest('hex'); +const run = (cmd, args, cwd) => execFileSync(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }); +const git = (cwd, args) => run('git', args, cwd).trim(); +let temp, tarball, packed, upgradeTarball; +before(() => { + process.env.NODE_ENV = 'test'; + temp = mkdtempSync(resolve(tmpdir(), 'av-capability-')); + // Build from a minimal export: neither Tasks source nor sibling AV checkout is available. + const source = resolve(temp, 'source'); + mkdirSync(resolve(source, 'packages'), { recursive: true }); + for (const path of ['src', 'schemas', 'LICENSE']) cpSync(resolve(root, path), resolve(source, path), { recursive: true }); + const packagePath = resolve(source, 'packages/tasks-capability'); + cpSync(resolve(root, 'packages/tasks-capability'), packagePath, { + recursive: true, filter: path => !['runtime', 'adapter.js'].includes(path.split('/').at(-1)) || path.includes('/src/'), + }); + packed = JSON.parse(run('npm', ['pack', '--json', '--pack-destination', temp], packagePath))[0]; + tarball = resolve(temp, packed.filename); + // Build a second, synthetic compatible patch tarball for the restart contract. + for (const name of ['package.json', 'opsle-capability.json']) { + const path = resolve(packagePath, name); + const value = JSON.parse(readFileSync(path)); value.version = '0.1.1'; + writeFileSync(path, JSON.stringify(value)); + } + upgradeTarball = resolve(temp, JSON.parse(run('npm', ['pack', '--json', '--pack-destination', temp], packagePath))[0].filename); + rmSync(source, { recursive: true }); +}); +after(() => rmSync(temp, { recursive: true, force: true })); +function install(name, artifact = tarball) { + const prefix = resolve(temp, name); + mkdirSync(prefix); + run('npm', ['install', '--prefix', prefix, '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', artifact], temp); + return resolve(prefix, 'node_modules/@opsle/affected-verification-tasks-capability'); +} +function fixture(t, name, modify = x => x) { + const path = resolve(temp, name); + mkdirSync(resolve(path, '.opsle'), { recursive: true }); + mkdirSync(resolve(path, 'logs')); + mkdirSync(resolve(path, 'repo')); + const project = resolve(path, 'repo'); + mkdirSync(resolve(project, '.opsle')); + const manifest = modify({ + schema: 'opsle.affected-verification.manifest.v1', evidence_complete: true, catalog_complete: true, + components: ['a', 'b'].map(id => ({ id, dependencies: [], path_globs: [`${id}.js`], risk_tags: [] })), + checks: ['a', 'b'].map(id => ({ id, type: 'unit-test', command: `node --test ${id}.test.js`, + scope: { components: [id] }, tags: [], test_executions: 1, + dependency: { completeness: 'COMPLETE_FOR_CHECK', mechanisms: [{ kind: 'DECLARED_SCOPE', positive: false }], boundaries: [], explanation: 'Immutable scope' } })), + policy: { version: '1', rules: [] }, + }); + if (manifest !== null) writeFileSync(resolve(project, '.opsle/affected-verification.json'), JSON.stringify(manifest)); + for (const id of ['a', 'b']) writeFileSync(resolve(project, `${id}.js`), 'before\n'); + git(project, ['init', '-q']); git(project, ['add', '.']); + git(project, ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-qm', 'base']); + const task = { id: 1, repo_id: 1, repo_name: name, repo_path: project, worktree_path: project, + base_commit: git(project, ['rev-parse', 'HEAD']), test_command: 'full-suite', capability_grants: grant }; + writeFileSync(resolve(project, 'a.js'), 'after\n'); + return { task, logsDir: resolve(path, 'logs'), manifest }; +} +async function direct(directory, f) { + const { createCapability } = await import(pathToFileURL(resolve(directory, 'adapter.js'))); + const manifest = JSON.parse(readFileSync(resolve(directory, 'opsle-capability.json'))); + return createCapability({ manifest, configuration: {}, services: { task: f.task, attemptId: 1, + executionId: 'exec-1', executionConfig: { logsDir: f.logsDir } } }); +} +const request = f => ({ schema, task: f.task, attemptId: 1, executionId: 'exec-1', generation: 1 }); + +test('installable artifact contains planner and schemas, plans deterministically, captures drift and retains private evidence', async t => { + const directory = install('standalone'); + const f = fixture(t, 'standalone-project'); + const adapter = await direct(directory, f); + assert.equal(adapter.health().available, true); + const result = adapter.invoke('verification.plan', request(f)); + assert.equal(result.status, 'ok'); + assert.equal(result.value.error, null); + assert.deepEqual(result.value.decision.plan.selected_checks.map(x => x.id), ['a']); + assert.deepEqual(result.value.decision.plan.skipped_checks.map(x => x.id), ['b']); + for (const path of [result.value.inputPath, result.value.receiptPath, result.value.evidencePath]) assert.equal(statSync(path).mode & 0o777, 0o600); + const repeat = adapter.invoke('verification.plan', { ...request(f), generation: 2 }); + assert.deepEqual(repeat.value.decision, result.value.decision); + const capture = () => adapter.invoke('verification.capture', { schema: 'opsle.execution.change-capture-request.v1', task: f.task }).value; + assert.equal(capture().target_revision, result.value.change.target_revision); + writeFileSync(resolve(f.task.repo_path, 'a.js'), 'drift\n'); + assert.notEqual(capture().target_revision, result.value.change.target_revision); + assert.equal(existsSync(resolve(directory, 'runtime/core/task.js')), true); + const pkg = JSON.parse(readFileSync(resolve(directory, 'package.json'))); + assert.equal(pkg.dependencies, undefined); + assert.equal(JSON.parse(readFileSync(resolve(directory, 'opsle-capability.json'))).default_enabled, false); + t.diagnostic(JSON.stringify({ version: pkg.version, artifact_sha256: hash(readFileSync(tarball)), npm_integrity: packed.integrity })); +}); + +test('unsupported hooks, schemas, bindings and external structural evidence cannot enter planning', async t => { + const bound = fixture(t, 'invalid-project'); + const adapter = await direct(install('invalid'), bound); + const f = fixture(t, 'wrong-project'); + for (const update of [{ attemptId: 2 }, { executionId: 'other-execution' }]) { + assert.throws(() => adapter.invoke('verification.plan', { ...request(bound), ...update }), /binding/); + } + const { validateSchema } = await import(pathToFileURL(resolve(temp, 'invalid/node_modules/@opsle/affected-verification-tasks-capability/runtime/schema.js'))); + assert.throws(() => validateSchema('tasks-config-v2', { repository: '/untrusted-executable' }), /schema/); + assert.throws(() => validateSchema('tasks-config-v2', { schema: 'opsle.affected-verification.tasks-config.v1' }), /schema/); + assert.throws(() => adapter.invoke('structural.evidence', request(f)), /Unsupported/); + assert.throws(() => adapter.invoke('verification.plan', { ...request(f), schema: 'future' }), /schema/); + assert.throws(() => adapter.invoke('verification.plan', request(f)), /binding/); + assert.throws(() => adapter.invoke('verification.plan', { ...request(f), structural_evidence: { complete: true } }), /schema/); + for (const generation of ['../../escape', 0, 1.5]) assert.throws(() => adapter.invoke('verification.plan', { ...request(f), generation }), /schema/); +}); + +for (const [name, mutate, expected] of [ + ['incomplete', m => ({ ...m, evidence_complete: false }), 'FULL_VERIFICATION_REQUIRED'], + ['opaque', m => { m.checks[1].dependency.completeness = 'OPAQUE_BOUNDARY'; m.checks[1].dependency.boundaries = [{ id: 'dynamic', kind: 'DYNAMIC_IMPORT', status: 'OPEN', relevant: true, explanation: 'Unresolved runtime import', source: { path: 'b.js', line: 1, construct: 'import(name)' } }]; return m; }, 'SUFFICIENT_BROADENED'], + ['missing', () => null, 'FULL_VERIFICATION_REQUIRED'], + ['empty-catalog', m => ({ ...m, checks: [] }), null], + ['malformed', m => ({ ...m, schema: 'unsupported' }), null], + ['external-evidence', m => ({ ...m, structural_evidence: { complete: true } }), null], +]) test(`${name} evidence broadens or fails closed`, async t => { + const f = fixture(t, name, mutate); + const adapter = await direct(install(`install-${name}`), f); + const result = adapter.invoke('verification.plan', request(f)).value; + if (expected) { assert.equal(result.error, null, result.error); assert.equal(result.decision.plan.sufficiency, expected); } + else { assert.ok(result.error); assert.equal(result.decision, null); assert.equal(result.record.status, 'ANALYSIS_FAILED'); } + assert.deepEqual(result.record.verification_results, []); +}); + +test('immutable base manifest, empty change and missing fallback never manufacture passing verification', async t => { + const f = fixture(t, 'immutable'); + const adapter = await direct(install('install-immutable'), f); + writeFileSync(resolve(f.task.repo_path, '.opsle/affected-verification.json'), '{"checks":[]}'); + const result = adapter.invoke('verification.plan', request(f)).value; + assert.equal(result.error, null); + assert.equal(result.decision.plan.sufficiency, 'FULL_VERIFICATION_REQUIRED'); + assert.equal(result.decision.plan.selected_checks.length, 2); + git(f.task.repo_path, ['reset', '--hard', f.task.base_commit]); + const empty = adapter.invoke('verification.plan', { ...request(f), generation: 2 }).value; + assert.equal(empty.decision.plan.sufficiency, 'FULL_VERIFICATION_REQUIRED'); + const missing = fixture(t, 'no-fallback', () => null); + missing.task.test_command = ''; + const noFallback = (await direct(install('install-no-fallback'), missing)).invoke('verification.plan', request(missing)).value; + assert.equal(noFallback.decision, null); + assert.match(noFallback.error, /no catalogued checks/); +}); + +test('mutated transitive installation bytes block invocation before execution', async t => { + const directory = install('mutated'); + const f = fixture(t, 'mutated-project'); + const adapter = await direct(directory, f); + const path = resolve(directory, 'runtime/core/planner.js'); + writeFileSync(path, readFileSync(path, 'utf8') + '\n// mutation\n'); + assert.throws(() => adapter.invoke('verification.plan', request(f)), /installation changed/); +}); + +test('canonical decisions reject wrong task/change, catalog injection, and forged completeness', async t => { + const directory = install('decisions'); + const f = fixture(t, 'decision-project'); + const adapter = await direct(directory, f); + const result = adapter.invoke('verification.plan', request(f)).value; + const input = JSON.parse(readFileSync(result.inputPath)); + const { validateDecision } = await import(pathToFileURL(resolve(directory, 'runtime/verification.js'))); + for (const mutate of [ + x => { x.task.id = 'task-2'; }, x => { x.plan.change.identity = 'other'; }, + x => { x.plan.selected_checks[0].command = 'touch /tmp/injected'; }, + x => { x.plan.skipped_checks = []; }, x => { x.plan.argument.unknown_is_safe_to_skip = true; }, + x => { x.plan.provenance = {}; }, + ]) { const decision = structuredClone(result.decision); mutate(decision); assert.throws(() => validateDecision(decision, input)); } +}); + +test('SSH failures, invalid targets and quoting remain bounded and fail closed', async t => { + const directory = install('ssh'); + const { executionTarget, projectGit, sshArguments } = await import(pathToFileURL(resolve(directory, 'runtime/execution.js'))); + for (const ssh_host of ['-oProxyCommand=bad', 'host; touch /tmp/injected']) assert.throws(() => executionTarget({ ssh_host, ssh_user: 'deploy', repo_path: '/repo' })); + assert.throws(() => executionTarget({ ssh_host: 'host', ssh_user: 'bad;user', repo_path: '/repo' })); + const target = executionTarget({ ssh_host: 'host', ssh_user: 'deploy', repo_path: '/repo' }); + assert.throws(() => sshArguments({}, target, 'true'), /key/); + const fake = resolve(temp, 'ssh-failure'); + writeFileSync(fake, '#!/bin/sh\necho "Host key verification failed" >&2\nexit 255\n', { mode: 0o700 }); + assert.throws(() => projectGit({ sshBin: fake, sshKeyPath: '/fixture-not-a-key' }, target, ['status'], '/repo', { allowFailure: true }), /host key verification failure/); + for (const [status, message, expected] of [ + [255, 'Permission denied', /SSH authentication failure/], + [255, 'Connection refused', /unreachable/], + [124, '', /command timeout/], [72, 'OPSLE_REPOSITORY_MISSING', /repository missing/], + ]) { + writeFileSync(fake, `#!/bin/sh\necho '${message}' >&2\nexit ${status}\n`, { mode: 0o700 }); + assert.throws(() => projectGit({ sshBin: fake, sshKeyPath: '/fixture-not-a-key' }, target, ['status'], '/repo', { allowFailure: true }), expected); + } + const args = sshArguments({ sshKeyPath: '/fixture-not-a-key' }, target, "echo '$(false)'", 1); + assert.ok(args.includes('StrictHostKeyChecking=yes')); + assert.match(args.at(-1), /timeout --signal=TERM --kill-after=5s 1s/); + const f = fixture(t, 'quoted-project'); + const filename = "odd ' $(touch SHOULD_NOT_EXIST).js"; + writeFileSync(resolve(f.task.repo_path, filename), 'change'); + const adapter = await direct(directory, f); + const captured = adapter.invoke('verification.capture', { schema: 'opsle.execution.change-capture-request.v1', task: f.task }).value; + assert.ok(captured.paths.some(x => x.path === filename)); + assert.equal(existsSync(resolve(f.task.repo_path, 'SHOULD_NOT_EXIST')), false); +}); + +const tasksRoot = process.env.OPSLE_TASKS_RUNTIME_ROOT; +test('real generic Tasks lifecycle: discover, grant, dispatch, revoke, remove, reinstall and compatible upgrade/restart', { + skip: !tasksRoot && 'Set OPSLE_TASKS_RUNTIME_ROOT to the pinned Tasks checkout; required by verify:tasks-capability.', +}, async t => { + assert.equal(git(tasksRoot, ['rev-parse', 'HEAD']), compatRevision, 'Compatibility revision must be explicitly updated after review'); + const before = git(tasksRoot, ['diff', 'HEAD', '--', 'src']); + assert.equal(before, '', 'Tasks runtime must be unchanged'); + const sourceHash = hash(readFileSync(resolve(tasksRoot, 'src/capabilities.js'))); + const { discoverCapabilities, createCapabilityRuntime } = await import(pathToFileURL(resolve(tasksRoot, 'src/capabilities.js'))); + const { verificationSelection } = await import(pathToFileURL(resolve(tasksRoot, 'src/runner.js'))); + const f = fixture(t, 'lifecycle-project'); + let directory = install('lifecycle'); + const events = []; + const config = { capabilityRoots: [directory], logsDir: f.logsDir }; + const runtime = (grants = grant, selection = emptySelection) => createCapabilityRuntime({ config, + task: { ...f.task, capability_grants: grants }, attemptId: 1, executionId: 'exec-1', selection, + emitEvent: (kind, message) => events.push({ kind, message }), + }); + assert.equal(discoverCapabilities(config)[0].defaultEnabled, false); + const inactive = await runtime({ schema: grant.schema, allow: [] }); + assert.equal(inactive.status.length, 0); + await assert.rejects(inactive.authority('verification.plan', request(f)), /exactly one enabled authority/); + for (const key of ['enable', 'disable']) await assert.rejects(runtime(grant, { ...emptySelection, [key]: [id] }), /operator-controlled/); + const enabled = await runtime(); + const analysis = await enabled.authority('verification.plan', request(f)); + assert.equal(analysis.error, null); + const capture = await enabled.authority('verification.capture', { schema: 'opsle.execution.change-capture-request.v1', task: f.task }); + assert.equal(capture.identity, analysis.change.identity); + assert.ok(events.some(x => x.kind === 'CAPABILITY_ARTIFACT')); + const history = readFileSync(analysis.evidencePath); + const empty = fixture(t, 'lifecycle-empty', () => null); + empty.task.test_command = ''; + const emptyRuntime = await createCapabilityRuntime({ config: { ...config, logsDir: empty.logsDir }, + task: empty.task, attemptId: 1, executionId: 'exec-1', selection: emptySelection }); + const emptyAnalysis = await emptyRuntime.authority('verification.plan', request(empty)); + assert.throws(() => verificationSelection(emptyAnalysis, empty.task), /no full verification command/); + assert.equal(verificationSelection(emptyAnalysis, { ...empty.task, test_command: 'full-suite' }).mode, 'FULL_FALLBACK'); + const revoked = await runtime({ schema: grant.schema, allow: [] }); + await assert.rejects(revoked.authority('verification.capture', { schema: 'opsle.execution.change-capture-request.v1', task: f.task }), /exactly one enabled authority/); + config.capabilityRoots = [directory, install('duplicate')]; + assert.throws(() => discoverCapabilities(config), /Duplicate/); + config.capabilityRoots = [directory]; + const entry = resolve(directory, 'adapter.js'); + writeFileSync(entry, readFileSync(entry, 'utf8') + '\n// changed\n'); + await assert.rejects(enabled.authority('verification.plan', request(f)), /installation changed/); + rmSync(directory, { recursive: true }); + assert.deepEqual(discoverCapabilities(config), []); + await assert.rejects((await runtime()).authority('verification.plan', request(f)), /exactly one enabled authority/); + assert.deepEqual(readFileSync(analysis.evidencePath), history); + directory = install('reinstall'); config.capabilityRoots = [directory]; + assert.equal((await runtime({ schema: grant.schema, allow: [] })).status.length, 0); + const restored = await runtime(); + assert.equal((await restored.authority('verification.plan', { ...request(f), generation: 2 })).error, null); + // Install the separately packed compatible patch artifact, then start a fresh runtime. + const upgraded = install('upgrade', upgradeTarball); + const restart = resolve(temp, 'restart.mjs'); + writeFileSync(restart, `import { createCapabilityRuntime } from ${JSON.stringify(pathToFileURL(resolve(tasksRoot, 'src/capabilities.js')).href)}; +const context = JSON.parse(process.argv[2]); +const runtime = await createCapabilityRuntime(context); +const value = await runtime.authority('verification.capture', {schema:'opsle.execution.change-capture-request.v1',task:context.task}); +process.stdout.write(JSON.stringify({version:runtime.status[0].version,value}));`); + const restarted = JSON.parse(run(process.execPath, [restart, JSON.stringify({ config: { ...config, capabilityRoots: [upgraded] }, task: f.task, attemptId: 1, executionId: 'exec-1', selection: emptySelection })], temp)); + assert.equal(restarted.version, '0.1.1'); + assert.equal(restarted.value.identity, capture.identity); + assert.deepEqual(readFileSync(analysis.evidencePath), history); + assert.equal(git(tasksRoot, ['diff', 'HEAD', '--', 'src']), before); + assert.equal(hash(readFileSync(resolve(tasksRoot, 'src/capabilities.js'))), sourceHash); + t.diagnostic(JSON.stringify({ tasks_revision: compatRevision, runtime_sha256: sourceHash, + artifact_version: packed.version, artifact_sha256: hash(readFileSync(tarball)), + upgrade_artifact_sha256: hash(readFileSync(upgradeTarball)), central_source_unchanged: true })); +}); diff --git a/tools/verify-tasks-capability.js b/tools/verify-tasks-capability.js new file mode 100644 index 0000000..db2eeec --- /dev/null +++ b/tools/verify-tasks-capability.js @@ -0,0 +1,14 @@ +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +const root = fileURLToPath(new URL('..', import.meta.url)); +const runtime = process.env.OPSLE_TASKS_RUNTIME_ROOT; +if (!runtime) throw new Error('OPSLE_TASKS_RUNTIME_ROOT must name the trusted Tasks compatibility checkout; lifecycle verification cannot be skipped for release.'); +const revision = execFileSync('git', ['-C', runtime, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); +if (revision !== 'e1207c5264c59e14efe9838bba3a33ba504665d2') throw new Error('Unreviewed Tasks compatibility revision'); +const env = { ...process.env, NODE_ENV: 'test', OPSLE_AFFECTED_VERIFICATION_REPO: root }; +execFileSync(process.execPath, ['--test', 'tests/tasks-capability.test.js'], { cwd: root, env, stdio: 'inherit' }); +// These are the existing generic contract and AV regressions, not a replacement runtime. +execFileSync(process.execPath, ['--test', ...[ + 'capabilities.test.js', 'capability-history.test.js', 'adapters.test.js', 'affected-verification.test.js', +].map(name => resolve(runtime, 'test', name))], { cwd: root, env, stdio: 'inherit' });