diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index bc1e936a21..9b365a17c9 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -1,5 +1,18 @@ # Compatibility Contract +## Web Console 1.6.204 / bridge 0.2.6 candidate + +Audit admission preserves known 401/403 and verified Key-revision failures with +safe corrective guidance. It still requires a durable start ACK before any +business request. Infrastructure failures and invalid ACKs fail closed; an ACK +failure after a possible write remains UNKNOWN_RESULT without a second write. +Browser and stdio use the same command engine and public error contract. + +Original full/enabled CI, CodeQL, artifact qualification, publication and matching +Server/native acceptance remain pending. This does not complete Audit acceptance +or the 243-group inventory. The current published component is 203 / 0.2.5 below. +See the [candidate note](docs/releases/web-console-1.6.204.md). + ## Web Console 1.6.203 / bridge 0.2.5 published Adds restricted-Key discovery against the existing Engine361 typed contract. diff --git a/README.md b/README.md index 5802fa0107..5bcf9e1dea 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,11 @@ history, authorship, licenses, and dependency notices. is published with the original qualified CI artifacts. All 13 public release assets passed actual anonymous GET, byte-count and SHA256 verification. +Web Console **1.6.204** / bridge **0.2.6** is a +[candidate](docs/releases/web-console-1.6.204.md) for clear audit-admission +authentication and Key-policy errors. Original CI/CodeQL, publication and +matching Server runtime acceptance remain pending. + Twelve shared typed tools provide resource discovery and human-reviewed changes for supported workloads, External Services, DNS Aliases, audit metadata, and Certificate names/descriptions. Certificate PEM material, keys, chains, renewal diff --git a/app/utils/ai-command-contract.js b/app/utils/ai-command-contract.js index 4e235999df..5f583da5c2 100644 --- a/app/utils/ai-command-contract.js +++ b/app/utils/ai-command-contract.js @@ -135,6 +135,9 @@ export function publicError(error) { const entry = ERRORS[code] || ERRORS.UNKNOWN_RESULT; const result = {status: entry[1], code, nextStep: entry[0], contractVersion: COMMAND_VERSION}; const serverCode = error && error.body && error.body.code; + if ( ['FORBIDDEN', 'CONTEXT_CHANGED'].includes(code) && serverCode === 'ApiKeyPolicyChanged' ) { + result.nextStep = 'The API Key policy changed. Stop this request and ask the user to review the Key and reconnect the bridge with its current policy. Do not retry automatically, broaden permissions, or try another ID or environment.'; + } if ( code === 'UNAVAILABLE' && serverCode === 'ResourceChangeFeatureDisabled' ) { result.nextStep = 'Resource changes are disabled on this server. Ask an operator to enable the feature, or use the normal console.'; } diff --git a/app/utils/ai-command-engine.js b/app/utils/ai-command-engine.js index 306988ff74..63d409b8fe 100644 --- a/app/utils/ai-command-engine.js +++ b/app/utils/ai-command-engine.js @@ -137,6 +137,11 @@ export class CommandEngine { let acknowledgement; try { acknowledgement = await this.audit(event, captured); } catch (error) { this.context.assertCurrent(captured); + // Admission still fails closed. Preserve verified authentication/policy + // failures so their repair does not suggest changing environments. + const status = Number(error && (error.status || error.statusCode || (error.response && error.response.status))); + if ( ['SESSION_REQUIRED', 'FORBIDDEN', 'CONTEXT_CHANGED', 'CONFLICT'].includes(publicError(error).code) && + (error instanceof CommandError || [401, 403].includes(status)) ) { throw error; } throw new CommandError('UNAVAILABLE'); } this.context.assertCurrent(captured); diff --git a/bridge/README.md b/bridge/README.md index 78afd5cdce..5bacde7e58 100644 --- a/bridge/README.md +++ b/bridge/README.md @@ -5,7 +5,12 @@ command layer. It needs a compatible Engine with `api.ai.bridge.enabled=true`, a dedicated **AI collaboration bridge** Key and the parent repository's supported Node.js toolchain. It cannot approve its own change plans. -The [0.2.4 release archive](https://github.com/PastureStack/web-console/releases/tag/1.6.202) +Bridge **0.2.6** is a source candidate for clear audit-admission authentication +and Key-policy errors. It preserves start/result ACK, context and revision +protections. Required CI, publication and matching Server/native acceptance +remain pending; the download instructions below use the published 0.2.5. + +The [0.2.5 release archive](https://github.com/PastureStack/web-console/releases/tag/1.6.203) contains twelve typed tools for resource-family discovery, safe metadata reads and stack/service/container lifecycle plans. Unsupported-operation replies explain that Global services follow eligible @@ -48,14 +53,14 @@ native browser support. The operator must separately opt in to `api.ai.bridge.enabled` and, for upgrade plans, `api.ai.service.upgrade.enabled`; both Engine flags default to false. -Download the numeric release's `pasturestack-webmcp-bridge-0.2.4.zip` and its +Download the numeric release's `pasturestack-webmcp-bridge-0.2.5.zip` and its `.zip.sha256` checksum, then verify before extraction: ```sh -curl --fail --location --remote-name https://github.com/PastureStack/web-console/releases/download/1.6.202/pasturestack-webmcp-bridge-0.2.4.zip -curl --fail --location --remote-name https://github.com/PastureStack/web-console/releases/download/1.6.202/pasturestack-webmcp-bridge-0.2.4.zip.sha256 -sha256sum --check pasturestack-webmcp-bridge-0.2.4.zip.sha256 -unzip pasturestack-webmcp-bridge-0.2.4.zip +curl --fail --location --remote-name https://github.com/PastureStack/web-console/releases/download/1.6.203/pasturestack-webmcp-bridge-0.2.5.zip +curl --fail --location --remote-name https://github.com/PastureStack/web-console/releases/download/1.6.203/pasturestack-webmcp-bridge-0.2.5.zip.sha256 +sha256sum --check pasturestack-webmcp-bridge-0.2.5.zip.sha256 +unzip pasturestack-webmcp-bridge-0.2.5.zip ``` Extract the whole archive, keeping both `bridge/` and @@ -63,16 +68,16 @@ Extract the whole archive, keeping both `bridge/` and 24.22.1 available, install from the extracted directory: ```sh -cd pasturestack-webmcp-bridge-0.2.4/bridge +cd pasturestack-webmcp-bridge-0.2.5/bridge npm ci --ignore-scripts --no-audit --no-fund ``` Its MCP entry point is the extracted `bridge/server.mjs`. `source-manifest.json` records the included source hashes; the root also includes LICENSE and the -bridge CycloneDX SBOM. To reproduce the source release from a checkout: +bridge CycloneDX SBOM. To package this candidate from its source checkout (not a published release): ```sh -python scripts/package-webmcp-bridge.py --version 0.2.4 --sbom /path/to/webmcp-bridge.cdx.json --output /path/to/pasturestack-webmcp-bridge-0.2.4.zip +python scripts/package-webmcp-bridge.py --version 0.2.6 --sbom /path/to/webmcp-bridge.cdx.json --output /path/to/pasturestack-webmcp-bridge-0.2.6.zip ``` Store the following configuration outside the extracted source tree in a private diff --git a/bridge/core.mjs b/bridge/core.mjs index 77f919abed..e1dfc971d4 100644 --- a/bridge/core.mjs +++ b/bridge/core.mjs @@ -133,7 +133,7 @@ export function createTransport(config, fetcher = fetch) { } if (verifiedRevision !== null && revision !== verifiedRevision) { await response.body?.cancel(); - throw new CommandError('CONTEXT_CHANGED'); + throw Object.assign(new CommandError('CONTEXT_CHANGED'), {body: {code: 'ApiKeyPolicyChanged'}}); } verifiedRevision = revision; } diff --git a/bridge/package-lock.json b/bridge/package-lock.json index fcfc4c0af6..c859615ada 100644 --- a/bridge/package-lock.json +++ b/bridge/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/webmcp-bridge", - "version": "0.2.5", + "version": "0.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/webmcp-bridge", - "version": "0.2.5", + "version": "0.2.6", "dependencies": { "@modelcontextprotocol/sdk": "1.32.2" }, diff --git a/bridge/package.json b/bridge/package.json index cc9ee041b9..33d4d2cdf0 100644 --- a/bridge/package.json +++ b/bridge/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/webmcp-bridge", - "version": "0.2.5", + "version": "0.2.6", "private": true, "type": "module", "description": "Isolated stdio adapter for the shared PastureStack command contract", diff --git a/bridge/server.mjs b/bridge/server.mjs index 9ea05b0469..b72eea47ec 100644 --- a/bridge/server.mjs +++ b/bridge/server.mjs @@ -7,7 +7,7 @@ try { const path = process.env.PASTURESTACK_BRIDGE_CONFIG; if (!path) throw new SetupError('CONFIG_REQUIRED'); const bridge = await createBridge(await loadConfig(path)); - const server = new Server({name: 'pasturestack-webmcp-bridge', version: '0.2.5'}, { + const server = new Server({name: 'pasturestack-webmcp-bridge', version: '0.2.6'}, { capabilities: {tools: {}}, instructions: 'Use names and returned opaque references. Never guess IDs. A human must approve change plans in the console. Rejected calls must be repaired only as directed; never evade permissions. Accepted/running is not success. Read the existing operation until verifiedResult=true.', }); diff --git a/bridge/test/core.test.mjs b/bridge/test/core.test.mjs index 98725d00b3..12fbbed232 100644 --- a/bridge/test/core.test.mjs +++ b/bridge/test/core.test.mjs @@ -103,6 +103,28 @@ test('bootstrap pins a revision and every later request sends it before a mutati assert.equal(writes,0); }); +test('audit admission reports real transport auth and revision failures without business dispatch', async () => { + for (const [status,code,expected] of [[401,'AuthenticationRequired','SESSION_REQUIRED'], + [403,'PermissionDenied','FORBIDDEN'],[403,'ApiKeyPolicyChanged','FORBIDDEN'], + [200,'ApiKeyPolicyChanged','CONTEXT_CHANGED']]) { + let admitted=false;const calls=[]; + const bridge=await createBridge(config,{fetcher:async(url,options)=>{ + calls.push({path:url.pathname,method:options.method,revision:options.headers['X-PastureStack-Key-Expected-Revision']}); + if(!admitted)return response({data:[{id:'p1',name:'Default',state:'active'}]}); + return status===200?response({accepted:true,requestId:'ack'},'2'): + new Response(JSON.stringify({code,message:secret}),{status}); + }}); + admitted=true;const initial=calls.length; + const result=await bridge.engine.execute('find_resources',{family:'audit',resourceType:'auditLog',auditFilters:{lastMinutes:10,limit:1}}); + assert.equal(result.status,'rejected');assert.equal(result.code,expected); + assert.deepEqual(calls.slice(initial),[{path:'/v2-beta/projects/p1/aiToolCallEvents',method:'POST',revision:'1'}]); + assert.equal(JSON.stringify(result).includes(secret),false); + assert.doesNotMatch(result.nextStep,/select an environment|enable this integration/); + if(code==='ApiKeyPolicyChanged')assert.match(result.nextStep,/Key policy changed.*reconnect/); + bridge.close(); + } +}); + test('shared business engine lists scoped refs and reports authorization failure without cookie fallback', async () => { const calls=[]; const bridge=await createBridge(config,{fetcher:async(url,opts)=>{ diff --git a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json index 41fde9f70a..eb422cf6ca 100644 --- a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json +++ b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.203", + "version": "1.6.204", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.203", + "version": "1.6.204", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/docs/releases/web-console-1.6.204.md b/docs/releases/web-console-1.6.204.md new file mode 100644 index 0000000000..7b75996b9a --- /dev/null +++ b/docs/releases/web-console-1.6.204.md @@ -0,0 +1,41 @@ +# Web Console 1.6.204 / bridge 0.2.6 candidate + +This candidate is not published. Original full/enabled CI, required CodeQL, +artifact/SBOM qualification and matching Server/native acceptance remain pending. +Published Web Console 1.6.203 / bridge 0.2.5 remains the current release. + +## Clear audit-admission errors + +A known authentication or Key-policy denial from audit admission previously +became a generic unavailable-integration error. The shared command engine now +preserves verified 401/403 or internal authentication/context failures so the +reply gives the correct sign-in, permission or Key-reconnection guidance. +Verified stdio revision drift retains CONTEXT_CHANGED and uses a fixed safe +ApiKeyPolicyChanged marker; it never adopts the new revision automatically. + +Admission remains fail-closed. No business request runs without a verified start +ACK. Generic 409, infrastructure/network failures and invalid ACKs remain +unavailable. Context replacement takes precedence over a stale denial. A result +ACK failure after a possible write stays UNKNOWN_RESULT with its authorized +recovery reference; it does not claim rejection or submit another change. +Arbitrary error messages, detail, payloads, credentials and headers are not +projected into tool results. Browser and stdio use the same command sources. + +Five new regression cases and twelve directly affected source-focused cases +passed in the isolated correction, with the original causal failures retained. +Those are source results, not new original-CI or native acceptance. The existing +CI entry point now includes the audit-focused command cases and actual bridge +core transport tests without removing any previous gate. Fresh CI totals and +artifact digests must come from that producer's actual reports. + +The package/lock updates are version metadata only; dependencies and security +decisions are unchanged. Existing restricted-Key discovery, helper contracts, +pending-review layout, typed tools, Full/legacy behavior, role/Key ceilings, +current-context checks and human confirmation are retained. WebMCP remains +independently build-gated and starts off on every page. + +This client correction does not complete Audit acceptance, native plan +read/apply or Member cleanup. Matching Engine/Server fixes, role/Key/cross-ID +checks and designated workflows still need their own fresh evidence. The +243-group inventory remains in progress. Consume a qualified published +component through its matching Server release; do not replace runtime files. diff --git a/package-lock.json b/package-lock.json index 41fde9f70a..eb422cf6ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.203", + "version": "1.6.204", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.203", + "version": "1.6.204", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/package.json b/package.json index ce7e9dd342..ea0b6b939d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/web-console", - "version": "1.6.203", + "version": "1.6.204", "private": true, "description": "PastureStack browser console for the compatible control platform.", "repository": { diff --git a/scripts/check-modernization-blockers b/scripts/check-modernization-blockers index 913ce5237f..aa3826ef7a 100755 --- a/scripts/check-modernization-blockers +++ b/scripts/check-modernization-blockers @@ -41,8 +41,8 @@ with open('package.json', encoding='utf-8') as f: print(json.load(f).get('version', '')) PY ) -if [[ "$version" != "1.6.203" ]]; then - echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.203" +if [[ "$version" != "1.6.204" ]]; then + echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.204" failures=$((failures + 1)) fi diff --git a/scripts/ci b/scripts/ci index 7904f2af55..85b1a44ced 100755 --- a/scripts/ci +++ b/scripts/ci @@ -16,6 +16,8 @@ node --test scripts/test-webmcp-external-service.mjs node --test scripts/test-webmcp-alias-service.mjs node --test scripts/test-webmcp-certificate-metadata.mjs node --test scripts/test-webmcp-audit.mjs scripts/test-webmcp-audit-extension.mjs +node scripts/test-webmcp-commands.js --filter=audit +node --test bridge/test/core.test.mjs node scripts/test-ai-collaboration-ui.js node scripts/test-ai-resource-review-labels.mjs node --test scripts/test-ai-resource-confirm-errors.mjs diff --git a/scripts/package-webmcp-bridge.py b/scripts/package-webmcp-bridge.py index faf309fd89..3a910891fb 100644 --- a/scripts/package-webmcp-bridge.py +++ b/scripts/package-webmcp-bridge.py @@ -11,7 +11,7 @@ import zipfile -VERSION = "0.2.5" +VERSION = "0.2.6" SOURCE_FILES = ( "LICENSE", "app/utils/ai-command-context.js", diff --git a/scripts/test-webmcp-commands.js b/scripts/test-webmcp-commands.js index 1f38763ce6..93c50f27d6 100644 --- a/scripts/test-webmcp-commands.js +++ b/scripts/test-webmcp-commands.js @@ -25,7 +25,7 @@ function deferred() { async function main() { const {CommandContext} = await import(moduleUrl('ai-command-context')); const {CommandEngine, waitForOperationPoll} = await import(moduleUrl('ai-command-engine')); - const {COMMANDS, publicError} = await import(moduleUrl('ai-command-contract')); + const {COMMANDS, CommandError, publicError} = await import(moduleUrl('ai-command-contract')); const legacyNames = ['find_stacks', 'find_services', 'get_service', 'update_service_description', 'preview_service_upgrade', 'apply_service_upgrade', 'get_operation']; const {registerCommandTools} = await import(moduleUrl('webmcp-tools')); let count = 0; @@ -126,6 +126,73 @@ async function main() { assert.equal(f.requests.length, 0); assert.equal(events.length, 1); }); + await test('audit admission preserves authentication denials and safe Key policy repair', async () => { + for ( const [error, code] of [ + [{status: 401, body: {message: 'SECRET_SENTINEL'}}, 'SESSION_REQUIRED'], + [{status: 403, body: {code: 'PermissionDenied', message: 'SECRET_SENTINEL'}}, 'FORBIDDEN'], + [{status: 403, body: {code: 'ApiKeyPolicyChanged', message: 'SECRET_SENTINEL'}}, 'FORBIDDEN'], + [Object.assign(new CommandError('CONTEXT_CHANGED'), {body: {code: 'ApiKeyPolicyChanged'}}), 'CONTEXT_CHANGED'], + [new CommandError('FORBIDDEN'), 'FORBIDDEN'], + ] ) { + const f = fixture(); let attempts = 0; + f.engine.audit = async () => { attempts++; throw error; }; + const result = await f.engine.execute('find_stacks', {}); + assert.equal(result.status, 'rejected'); assert.equal(result.code, code); + assert.equal(attempts, 1); assert.equal(f.requests.length, 0); assert.equal(f.ensureCalls(), 0); + assert.equal(JSON.stringify(result).includes('SECRET_SENTINEL'), false); + assert.doesNotMatch(result.nextStep, /select an environment|enable this integration/); + if ( error.body?.code === 'ApiKeyPolicyChanged' ) { + assert.match(result.nextStep, /Key policy changed/); assert.match(result.nextStep, /reconnect/); + assert.match(result.nextStep, /Do not retry automatically/); + } + } + }); + + await test('audit admission keeps infrastructure and unverified acknowledgements unavailable', async () => { + for ( const error of [{status: 500, body: {code: 'ApiKeyPolicyChanged'}}, {status: 503}, + {status: 409, body: {code: 'UnrecognizedAuditFailure'}}, new Error('SECRET_SENTINEL')] ) { + const f = fixture(); f.engine.audit = async () => { throw error; }; + assert.equal((await f.engine.execute('find_stacks', {})).code, 'UNAVAILABLE'); + assert.equal(f.requests.length, 0); assert.equal(f.ensureCalls(), 0); + } + for ( const acknowledgement of [null, {}, {accepted: false, requestId: 'not-durable'}, + {accepted: true}, {accepted: true, requestId: ''}] ) { + const f = fixture(); f.engine.audit = async () => acknowledgement; + assert.equal((await f.engine.execute('find_stacks', {})).code, 'UNAVAILABLE'); + assert.equal(f.requests.length, 0); assert.equal(f.ensureCalls(), 0); + } + }); + + await test('audit admission auth errors on result ACK never reject an already submitted apply', async () => { + for ( const status of [401, 403] ) { + const f = fixture(), plan = await preview(f); await f.engine.confirmPlan(plan.planRef); + const audit = f.engine.audit; + f.engine.audit = async (event, captured) => { + if ( event.command === 'apply_service_upgrade' && event.phase === 'result' ) { + throw {status, body: {code: 'ApiKeyPolicyChanged', message: 'SECRET_SENTINEL'}}; + } + return audit(event, captured); + }; + const result = await f.engine.execute('apply_service_upgrade', {planRef: plan.planRef}); + assert.equal(result.status, 'unknown'); assert.equal(result.code, 'UNKNOWN_RESULT'); + assert.ok(result.operationRef); assert.match(result.nextStep, /Do not submit another change/); + assert.equal(f.requests.filter((item) => item.url.endsWith('?action=apply')).length, 1); + assert.equal(JSON.stringify(result).includes('SECRET_SENTINEL'), false); + } + }); + + await test('audit admission context replacement takes precedence over an old session denial', async () => { + const f = fixture(), barrier = deferred(); let attempts = 0; + f.engine.audit = async () => { attempts++; return barrier.promise; }; + const pending = f.engine.execute('find_stacks', {}); + await Promise.resolve(); f.snapshot.generation = 'replacement-session'; + barrier.reject({status: 403, body: {code: 'ApiKeyPolicyChanged'}}); + const result = await pending; + assert.equal(result.code, 'CONTEXT_CHANGED'); assert.equal(result.items, undefined); + assert.doesNotMatch(result.nextStep, /Key policy changed/); + assert.equal(attempts, 1); assert.equal(f.requests.length, 0); assert.equal(f.ensureCalls(), 0); + }); + await test('result audit failure after apply preserves recovery reference without a second mutation', async () => { const f = fixture(), plan = await preview(f); await f.engine.confirmPlan(plan.planRef);