diff --git a/docs/releases/2.2.1/patch-execution.md b/docs/releases/2.2.1/patch-execution.md index f0faf4332..b8fdfbe5e 100644 --- a/docs/releases/2.2.1/patch-execution.md +++ b/docs/releases/2.2.1/patch-execution.md @@ -106,3 +106,29 @@ after the guided-setup integration repair. Signing remains unavailable locally. Pending final hosted validation, signed tag, publication, registry integrity readback, and clean lifecycle canaries. This document does not claim that 2.2.1 has shipped. + +## Resumed verification, September 7 + +The secure GitHub gateway authenticated as an authorized repository maintainer. +All GitHub API requests in this continuation use that gateway. No local +credential inspection or signing-key discovery is part of this continuation. +The v2.2.1 tag and release are absent; npm returns E404 for 2.2.1 and still +reports latest 2.2.0. + +The ba3a64a2 hosted run passed coverage, lint, CodeQL, and Linux tests, but nine +Windows test jobs failed. Gateway downloads for both job logs and test artifacts +returned HTTP 401 from redirected storage. Check metadata confirms failures +occur during tests after successful dependency installation. Failed-suite +annotations now expose bounded diagnostic context through the checks API. +The runner also counts subprocess failure when a suite prints `Failed: 0`. +Seven isolated runner regressions pass. + +Follow-up review reproduced additional release defects. Ordered JSON merges +to one Kimi destination were collapsed by destination-only preview indexing; +operation-specific previews preserve the supported merge sequence (24 focused +tests pass). Array-form Claude commands now receive the same plugin-root +materialization as strings, including rejection of unresolved reads (seven new +and 36 existing settings tests pass). Static PowerShell alias and stdin values +are resolved conservatively, with independent review covering mixed named and +positional alias arguments. Hosted verification on the final patch remains +required before merge or release. diff --git a/docs/releases/2.2.1/release-notes.md b/docs/releases/2.2.1/release-notes.md index 5bd695d1c..4d05b3c3d 100644 --- a/docs/releases/2.2.1/release-notes.md +++ b/docs/releases/2.2.1/release-notes.md @@ -93,8 +93,9 @@ and signing evidence are tracked separately in the release checklist. ## Upgrade -Install or update the published package, then run the same ECC command path you -already use: +After the release workflow publishes 2.2.1 and verifies registry integrity, +install or update the package, then run the same ECC command path you already +use. Until publication completes, the exact-version command below returns E404. ```bash npm install -g ecc-universal@2.2.1 diff --git a/scripts/lib/install/claude-settings.js b/scripts/lib/install/claude-settings.js index 3c18668a6..7075c5bd9 100644 --- a/scripts/lib/install/claude-settings.js +++ b/scripts/lib/install/claude-settings.js @@ -255,6 +255,10 @@ function resolveManagedHookCommands(managedHooks, targetRoot) { const encodedRoot = Buffer.from(targetRoot, 'utf8').toString('base64'); const rootExpression = `Buffer.from('${encodedRoot}','base64').toString('utf8')`; const resolveCommand = command => { + // Leave invalid entries intact so managed-hook validation reports them. + if (typeof command !== 'string') { + return command; + } const resolved = command .split(PLUGIN_ROOT_ENV_PROLOGUE) .join(`var e=${rootExpression};`); @@ -273,9 +277,11 @@ function resolveManagedHookCommands(managedHooks, targetRoot) { ...entry, hooks: entry.hooks.map(hook => ({ ...hook, - ...(typeof hook.command === 'string' + ...(typeof hook.command === 'string' || Array.isArray(hook.command) ? { - command: resolveCommand(hook.command), + command: Array.isArray(hook.command) + ? hook.command.map(resolveCommand) + : resolveCommand(hook.command), } : {}), })), diff --git a/scripts/lib/multi-harness-setup.js b/scripts/lib/multi-harness-setup.js index 3b9b9eee0..141eb4206 100644 --- a/scripts/lib/multi-harness-setup.js +++ b/scripts/lib/multi-harness-setup.js @@ -373,9 +373,12 @@ async function applyPreflightedManagedPlan(entry) { : preflightManagedPlan(entry.preview.plan); const ownedDestinations = new Set(preview.ownershipSnapshot.destinations); let expectedStateFingerprint = preview.ownershipSnapshot.stateFingerprint; - const expectedOperations = new Map(preview.operations.map(operation => [ - canonicalPath(operation.destinationPath), operation, + // Several ordered JSON merges may share one destination. Preserve each + // operation's preview instead of collapsing that sequence to one path entry. + const expectedOperations = new Map(preview.plan.operations.map((operation, index) => [ + operation, preview.operations[index], ])); + const writtenDestinations = new Set(); const assertStateUnchanged = () => ( assertInstallStateUnchanged(preview.plan, expectedStateFingerprint) ); @@ -385,12 +388,17 @@ async function applyPreflightedManagedPlan(entry) { }; const assertOperationUnchanged = operation => { const destination = canonicalPath(operation.destinationPath); - const expected = expectedOperations.get(destination); + const expected = expectedOperations.get(operation); const currentClassification = classifyManagedOperation(operation, ownedDestinations); + const expectedClassification = operation.kind === 'merge-json' + && writtenDestinations.has(destination) + ? 'managed-json-update' + : expected && expected.classification; if ( !expected || expected.kind !== operation.kind - || expected.classification !== currentClassification + || canonicalPath(expected.destinationPath) !== destination + || expectedClassification !== currentClassification ) { throw new Error( `Refusing to write ${operation.destinationPath}: destination changed after Kimi preflight.` @@ -412,6 +420,7 @@ async function applyPreflightedManagedPlan(entry) { assertStateUnchanged(); const destination = assertOperationUnchanged(operation); ownedDestinations.add(destination); + writtenDestinations.add(destination); }, beforeInstallStateWrite: prepareInstallStateWrite, }); diff --git a/scripts/lib/powershell-destructive-command.js b/scripts/lib/powershell-destructive-command.js index 77f3ac095..6ec294453 100644 --- a/scripts/lib/powershell-destructive-command.js +++ b/scripts/lib/powershell-destructive-command.js @@ -58,6 +58,21 @@ const START_PROCESS_SWITCH_PARAMETERS = new Set([ 'usenewenvironment', 'wait', ]); +const ALIAS_VALUE_PARAMETERS = new Set([ + 'name', 'value', 'description', 'option', 'scope', + 'erroraction', 'warningaction', 'informationaction', 'progressaction', + 'errorvariable', 'warningvariable', 'informationvariable', + 'outvariable', 'outbuffer', 'pipelinevariable', +]); +const ALIAS_SWITCH_PARAMETERS = new Set([ + 'force', 'passthru', 'whatif', 'confirm', 'verbose', 'debug', +]); +const ALIAS_PARAMETER_ABBREVIATIONS = Object.freeze({ + ea: 'erroraction', wa: 'warningaction', infa: 'informationaction', proga: 'progressaction', + ev: 'errorvariable', wv: 'warningvariable', iv: 'informationvariable', + ov: 'outvariable', ob: 'outbuffer', pv: 'pipelinevariable', + wi: 'whatif', cf: 'confirm', vb: 'verbose', db: 'debug', +}); const MAX_SCAN_DEPTH = 4; const MAX_CONTEXT_LENGTH = 4096; const DYNAMIC_EXECUTION_MARKER = '__ecc_dynamic_execution__'; @@ -1225,16 +1240,22 @@ function addNestedScan(payload, depth, findings, analysis, options = {}, scanSta scanPowerShell(payload, depth + 1, findings, analysis, options, scanState); } -function staticPipelineInput(tokens) { +function staticTokenValue(tokens, index, state, findings, inline = false) { + const value = inline ? parameterValue(tokens[index]) : tokens[index]; + const quoteKind = inline ? tokens.inlineValueQuoteKinds?.[index] : tokens.quoteKinds?.[index]; + if (quoteKind === "'") return value; + const source = inline + ? parameterValue(tokens.tokenSources?.[index] || tokens[index]) + : tokens.tokenSources?.[index] ?? value; + return expandStaticDoubleQuotedString(source, state, findings); +} + +function staticPipelineInput(tokens, state, findings) { if (!tokens || tokens.length === 0) return null; - if (tokens.length === 1) { - const value = String(tokens[0] || ''); - return value || null; - } + if (tokens.length === 1) return staticTokenValue(tokens, 0, state, findings); const command = commandBasename(tokens[0]); if ((command === 'write-output' || command === 'echo') && tokens.length === 2) { - const value = String(tokens[1] || ''); - return tokens.quotedTokens?.[1] === true || /\s/.test(value) ? value : null; + return staticTokenValue(tokens, 1, state, findings); } return null; } @@ -1272,7 +1293,7 @@ function scanNestedPowerShell(tokens, depth, findings, analysis, scanState, upst let payload = inlinePayload ? [inlinePayload, ...tokens.slice(index + 1)].join(' ') : tokens.slice(index + 1).join(' '); - const pipelinePayload = payload === '-' ? staticPipelineInput(upstreamTokens) : null; + const pipelinePayload = payload === '-' ? staticPipelineInput(upstreamTokens, scanState, findings) : null; const payloadIndex = index + 1; const inlineQuoteKind = tokens.inlineValueQuoteKinds?.[index]; if (inlinePayload && inlineQuoteKind !== "'") { @@ -1754,26 +1775,70 @@ function scanScriptBlockConsumer(tokens, quotedTokens, findings, state) { } } -function staticAliasDefinition(tokens, quotedTokens = []) { - let name = null; - let value = null; +function aliasParameterName(token) { + const name = String(token).replace(/^-+/, '').split(':')[0].toLowerCase(); + if (Object.hasOwn(ALIAS_PARAMETER_ABBREVIATIONS, name)) return ALIAS_PARAMETER_ABBREVIATIONS[name]; + const parameters = [...ALIAS_VALUE_PARAMETERS, ...ALIAS_SWITCH_PARAMETERS]; + if (parameters.includes(name)) return name; + const matches = parameters.filter(parameter => name && parameter.startsWith(name)); + return matches.length === 1 ? matches[0] : null; +} + +function aliasArguments(tokens, quotedTokens) { + const named = new Map(); const positional = []; + let ambiguous = false; for (let index = 1; index < tokens.length; index += 1) { - const token = tokens[index]; - if (!quotedTokens[index] && isParameterPrefix(token, 'name')) { - name = parameterValue(token) || tokens[++index] || null; - } else if (!quotedTokens[index] && isParameterPrefix(token, 'value')) { - value = parameterValue(token) || tokens[++index] || null; - } else if (!String(token).startsWith('-')) { - positional.push(token); + const token = String(tokens[index]); + if (quotedTokens[index] || !token.startsWith('-')) { + positional.push({ index, inline: false }); + if (!quotedTokens[index] && token.startsWith('@')) ambiguous = true; + continue; + } + const parameter = aliasParameterName(token); + if (!parameter) { + ambiguous = true; + continue; + } + if (named.has(parameter)) ambiguous = true; + const inline = token.includes(':'); + const argument = { index: ALIAS_VALUE_PARAMETERS.has(parameter) && !inline ? ++index : index, inline }; + if (ALIAS_VALUE_PARAMETERS.has(parameter) && tokens[argument.index] === undefined) ambiguous = true; + named.set(parameter, argument); + // Option accepts a comma-separated array; its continuation belongs to the + // named parameter rather than the remaining positional name/value slots. + if (parameter === 'option') { + while (index + 1 < tokens.length && !quotedTokens[index] && + (String(tokens[index]).endsWith(',') || String(tokens[index + 1]).startsWith(','))) index += 1; } } - name ||= positional[0] || null; - value ||= positional[1] || null; - if (!/^[A-Za-z_][\w-]*$/.test(name || '') || !/^[A-Za-z_][\w./\\-]*$/.test(value || '')) { - return null; - } - return { name: name.toLowerCase(), value }; + return { named, positional, ambiguous }; +} + +function staticAliasDefinitions(tokens, quotedTokens, state) { + const args = aliasArguments(tokens, quotedTokens); + // Definitions stay inert. Uncertain binding is gated only when a candidate + // alias is invoked, without allowing auxiliary values to hide its target. + const unresolved = new Set(); + const resolve = argument => argument + ? staticTokenValue(tokens, argument.index, state, unresolved, argument.inline) + : null; + let positionalIndex = 0; + const nameArgument = args.named.get('name') || args.positional[positionalIndex++]; + const valueArgument = args.named.get('value') || args.positional[positionalIndex++]; + const name = resolve(nameArgument); + const value = resolve(valueArgument); + const ambiguous = args.ambiguous || positionalIndex < args.positional.length; + const possibleNames = args.named.has('value') ? args.positional : args.positional.slice(0, -1); + const names = ambiguous && !args.named.has('name') + ? [name, ...possibleNames.map(resolve)] + : [name]; + const target = ambiguous || value === null || /^@/.test(value) + ? DYNAMIC_EXECUTION_MARKER + : value; + if (!/^[A-Za-z_][\w./\\-]*$/.test(target || '')) return []; + return [...new Set(names.filter(candidate => /^[A-Za-z_][\w-]*$/.test(candidate || '')))] + .map(candidate => ({ name: candidate.toLowerCase(), value: target })); } function scanInvokeScriptCalls(source, unquoted, depth, findings, analysis, state) { @@ -1862,9 +1927,10 @@ function scanPowerShell(command, depth, findings, analysis = null, options = {}, state ); } - if (commandName === 'set-alias' || commandName === 'new-alias') { - const definition = staticAliasDefinition(tokens, executable.quotedTokens); - if (definition) state.aliases.set(definition.name, definition.value); + if (['set-alias', 'new-alias', 'sal', 'nal'].includes(commandName)) { + for (const definition of staticAliasDefinitions(tokens, executable.quotedTokens, state)) { + state.aliases.set(definition.name, definition.value); + } } const classInvocation = commandName.match(/^\[([a-z_][\w-]*)\]::/i); if (classInvocation) recordInvocation(state, `__class__:${classInvocation[1].toLowerCase()}`); diff --git a/tests/ci/run-all.test.js b/tests/ci/run-all.test.js new file mode 100644 index 000000000..21c760c86 --- /dev/null +++ b/tests/ci/run-all.test.js @@ -0,0 +1,112 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const source = fs.readFileSync(path.join(__dirname, '..', 'run-all.js'), 'utf8'); + +function run(result, filename = 'sample.test.js', actions = true) { + const logs = []; + const exit = {}; + let status; + let spawns = 0; + const fakeProcess = { + env: actions ? { GITHUB_ACTIONS: 'true' } : {}, + exit(code) { status = code; throw exit; }, + }; + const fakeFs = { + readdirSync: () => [{ + name: filename, + isDirectory: () => false, + isFile: () => true, + }], + existsSync: () => true, + }; + try { + vm.runInNewContext(source, { + __dirname: path.resolve('/virtual/tests'), + process: fakeProcess, + console: { log: (...args) => logs.push(args.join(' ')) }, + require(name) { + if (name === 'fs') return fakeFs; + if (name === 'path') return path; + if (name === 'child_process') return { + spawnSync() { spawns += 1; return result; }, + }; + throw new Error(`Unexpected dependency: ${name}`); + }, + }); + } catch (error) { + if (error !== exit) throw error; + } + assert.strictEqual(spawns, 1); + return { status, logs, annotations: logs.filter(line => line.startsWith('::error ')) }; +} + +const tests = [ + ['nonzero exit overrides a zero-failure summary', () => { + const result = run({ status: 1, stdout: 'Passed: 2, Failed: 0', stderr: 'Error: late crash' }); + assert.strictEqual(result.status, 1); + assert.strictEqual(result.annotations.length, 1); + assert.match(result.annotations[0], /file=tests\/sample.test.js/); + assert.match(result.annotations[0], /status 1.*Error: late crash/); + assert.ok(result.logs.includes('Error: late crash')); + }], + ['startup errors always count as failures and annotate their cause', () => { + const result = run({ status: null, stdout: 'Failed: 0', error: new Error('spawn node ENOENT') }); + assert.strictEqual(result.status, 1); + assert.strictEqual(result.annotations.length, 1); + assert.match(result.annotations[0], /failed to start.*spawn node ENOENT/); + }], + ['annotation properties and messages escape workflow command characters', () => { + const result = run({ status: null, error: new Error('100% broken\r\nnext line') }, 'sample%,:.test.js'); + assert.strictEqual(result.annotations.length, 1); + assert.ok(result.annotations[0].includes('file=tests/sample%25%2C%3A.test.js')); + assert.ok(result.annotations[0].includes('100%25 broken%0D%0Anext line')); + assert.ok(!result.annotations[0].includes('\n')); + assert.ok(!result.annotations[0].includes('\r')); + }], + ['failure summaries annotate concise context even with a successful exit', () => { + const output = `${'routine log\n'.repeat(100)}FAIL regression example\nPassed: 2, Failed: 1`; + const result = run({ status: 0, stdout: output }); + assert.strictEqual(result.status, 1); + assert.strictEqual(result.annotations.length, 1); + assert.match(result.annotations[0], /FAIL regression example/); + assert.ok(result.annotations[0].length < 1500); + assert.ok(!result.annotations[0].includes('routine log')); + assert.ok(result.logs.includes(output)); + assert.ok(result.logs.some(line => /Failed:\s+1\s/.test(line))); + }], + ['signals fail even when no summary was printed', () => { + const result = run({ status: null, signal: 'SIGTERM' }); + assert.strictEqual(result.status, 1); + assert.match(result.annotations[0], /SIGTERM/); + }], + ['healthy suites preserve successful totals and emit no annotation', () => { + const result = run({ status: 0, stdout: 'Passed: 3, Failed: 0' }); + assert.strictEqual(result.status, 0); + assert.deepStrictEqual(result.annotations, []); + assert.ok(result.logs.some(line => /Passed:\s+3\s/.test(line))); + }], + ['local failures retain console diagnostics without workflow annotations', () => { + const result = run({ status: 1, stderr: 'Error: local failure' }, 'sample.test.js', false); + assert.strictEqual(result.status, 1); + assert.deepStrictEqual(result.annotations, []); + assert.ok(result.logs.includes('Error: local failure')); + }], +]; + +let failed = 0; +for (const [name, test] of tests) { + try { + test(); + console.log(`PASS ${name}`); + } catch (error) { + failed += 1; + console.error(`FAIL ${name}\n${error.stack || error.message}`); + } +} +console.log(`Passed: ${tests.length - failed}, Failed: ${failed}`); +process.exitCode = failed ? 1 : 0; diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index abf328201..495928691 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -3020,6 +3020,20 @@ function runTests() { "$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command \"$payload\"", "$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command \"Write-Output ready; $payload\"", "$payload='Remove-Item'; pwsh -Command $payload -Force C:/tmp/demo", + "$cmd='Remove-Item'; Set-Alias zap $cmd; zap -Force C:/tmp/demo", + "$cmd='Remove-Item'; Set-Alias -Name zap $cmd; zap -Force C:/tmp/demo", + "$cmd='Remove-Item'; Set-Alias -Scope Global -Name zap $cmd; zap -Force C:/tmp/demo", + "$cmd='Remove-Item'; New-Alias -Description demo -Name zap $cmd; zap -Force C:/tmp/demo", + "$cmd='Remove-Item'; sal -Option AllScope -Name zap $cmd; zap -Force C:/tmp/demo", + 'Set-Alias -Unknown demo -Name zap Write-Output; zap ok', + + 'Set-Alias -Name zap Remove-Item; zap -Force C:/tmp/demo', + 'Set-Alias -Name zap $cmd; zap -Force C:/tmp/demo', + "$cmd='Remove-Item'; Set-Alias -Value $cmd zap; zap -Force C:/tmp/demo", + "$payload='Remove-Item -Force C:/tmp/demo'; $payload | pwsh -Command -", + "$payload='Remove-Item -Force C:/tmp/demo'; Write-Output $payload | pwsh -Command -", + "Set-Alias zap $cmd; zap -Force C:/tmp/demo; $cmd='Write-Output'", + "$payload | pwsh -Command -; $payload='Write-Output ok'", 'pwsh -Command "Write-Output ready; $runtimePayload"', 'pwsh -Command $runtimePayload -Force C:/tmp/demo', 'Write-Output "$(Remove-Item -Force C:/tmp/demo)"', diff --git a/tests/hooks/governance-capture.test.js b/tests/hooks/governance-capture.test.js index c7cc46c52..7d40ebe78 100644 --- a/tests/hooks/governance-capture.test.js +++ b/tests/hooks/governance-capture.test.js @@ -247,6 +247,50 @@ async function runTests() { command: "$payload='Remove-Item -Force C:/private/expanded-command-sentinel'; pwsh -Command \"Write-Output ready; $payload\"", expectedRules: ['powershell.remove-item.force'], }, + { + command: "$cmd='Remove-Item'; Set-Alias zap $cmd; zap -Force C:/private/alias-command-sentinel", + expectedRules: ['powershell.remove-item.force'], + }, + { + command: "$cmd='Remove-Item'; Set-Alias -Name zap $cmd; zap -Force C:/private/alias-command-sentinel", + expectedRules: ['powershell.remove-item.force'], + }, + { + command: 'Set-Alias -Name zap Remove-Item; zap -Force C:/private/alias-command-sentinel', + expectedRules: ['powershell.remove-item.force'], + }, + { + command: 'Set-Alias -Name zap $cmd; zap -Force C:/private/alias-command-sentinel', + expectedRules: ['powershell.dynamic-execution'], + }, + { + command: "$cmd='Remove-Item'; Set-Alias -Scope Global -Name zap $cmd; zap -Force C:/private/alias-command-sentinel", + expectedRules: ['powershell.remove-item.force'], + }, + { + command: "$cmd='Remove-Item'; New-Alias -Description demo -Name zap $cmd; zap -Force C:/private/alias-command-sentinel", + expectedRules: ['powershell.remove-item.force'], + }, + { + command: "$cmd='Remove-Item'; sal -Option AllScope -Name zap $cmd; zap -Force C:/private/alias-command-sentinel", + expectedRules: ['powershell.remove-item.force'], + }, + { + command: 'Set-Alias -Unknown demo -Name zap Write-Output; zap ok', + expectedRules: ['powershell.dynamic-execution'], + }, + { + command: "$payload='Remove-Item -Force C:/private/stdin-command-sentinel'; $payload | pwsh -Command -", + expectedRules: ['powershell.remove-item.force'], + }, + { + command: "Set-Alias zap $cmd; zap -Force C:/private/alias-command-sentinel; $cmd='Write-Output'", + expectedRules: ['powershell.dynamic-execution'], + }, + { + command: "$payload | pwsh -Command -; $payload='Write-Output ok'", + expectedRules: ['powershell.dynamic-execution'], + }, { command: 'pwsh -Command "Write-Output ready; $runtimePayload"', expectedRules: ['powershell.dynamic-execution'], diff --git a/tests/lib/claude-settings-array.test.js b/tests/lib/claude-settings-array.test.js new file mode 100644 index 000000000..bc1a9e093 --- /dev/null +++ b/tests/lib/claude-settings-array.test.js @@ -0,0 +1,88 @@ +'use strict'; + +const assert = require('assert'); +const { spawnSync } = require('child_process'); +const { materializeManagedHooks } = require('../../scripts/lib/install/claude-settings'); + +function config(command) { + return { + hooks: { + Stop: [{ id: 'ecc:array', hooks: [{ type: 'command', command }] }], + }, + }; +} + +function materialize(command, root = '/opt/ecc') { + return materializeManagedHooks(config(command), root).Stop[0].hooks[0].command; +} + +const tests = [ + ['materializes every array command without changing the source', () => { + const source = config([ + 'node -e "var e=process.env.CLAUDE_PLUGIN_ROOT;console.log(e)"', + 'node -e "var e=process.env.CLAUDE_PLUGIN_ROOT;console.log(e)"', + '${CLAUDE_PLUGIN_ROOT}/scripts/start.js', + '--unchanged', + ]); + const before = JSON.parse(JSON.stringify(source)); + const command = materializeManagedHooks(source, '/opt/ecc').Stop[0].hooks[0].command; + assert.deepStrictEqual(source, before); + assert.notStrictEqual(command, source.hooks.Stop[0].hooks[0].command); + assert.ok(command.slice(0, 2).every(value => !value.includes('process.env.CLAUDE_PLUGIN_ROOT'))); + assert.deepStrictEqual(command.slice(2), ['/opt/ecc/scripts/start.js', '--unchanged']); + }], + ['array argv commands execute with the exact root in a clean process', () => { + const root = '/tmp/ECC space/\'"$`\\路径'; + const command = materialize([ + process.execPath, + '-e', + 'var e=process.env.CLAUDE_PLUGIN_ROOT;process.stdout.write(e);', + ], root); + const result = spawnSync(command[0], command.slice(1), { + env: {}, + encoding: 'utf8', + timeout: 5000, + }); + assert.ifError(result.error); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, root); + }], + ...[0, 1].map(index => [ + `rejects an unresolved root read in array element ${index}`, + () => { + const command = ['echo first', 'echo second']; + command[index] = 'node -e "const root=process.env.CLAUDE_PLUGIN_ROOT"'; + assert.throws(() => materialize(command), /Unable to resolve CLAUDE_PLUGIN_ROOT/); + }, + ]), + ['rejects a remaining root read after resolving an array prologue', () => { + assert.throws(() => materialize([ + 'var e=process.env.CLAUDE_PLUGIN_ROOT;console.log(process.env.CLAUDE_PLUGIN_ROOT);', + ]), /Unable to resolve CLAUDE_PLUGIN_ROOT/); + }], + ['retains supported root assignments in array commands', () => { + const command = materialize([ + 'var e=process.env.CLAUDE_PLUGIN_ROOT;process.env.CLAUDE_PLUGIN_ROOT=e;', + ]); + assert.ok(!command[0].includes('var e=process.env.CLAUDE_PLUGIN_ROOT;')); + assert.ok(command[0].includes('process.env.CLAUDE_PLUGIN_ROOT=e;')); + }], + ['invalid arrays still fail command validation', () => { + for (const command of [[], ['node', null], ['node', 3], ['node', ' ']]) { + assert.throws(() => materialize(command), /invalid command/); + } + }], +]; + +let failed = 0; +for (const [name, run] of tests) { + try { + run(); + console.log(` PASS ${name}`); + } catch (error) { + failed += 1; + console.error(` FAIL ${name}\n ${error.stack || error.message}`); + } +} +console.log(`\nResults: Passed: ${tests.length - failed}, Failed: ${failed}`); +process.exitCode = failed > 0 ? 1 : 0; diff --git a/tests/lib/multi-harness-setup.test.js b/tests/lib/multi-harness-setup.test.js index 2858a7ab4..24b5a5f45 100644 --- a/tests/lib/multi-harness-setup.test.js +++ b/tests/lib/multi-harness-setup.test.js @@ -601,6 +601,44 @@ function writeManagedState(plan, overrides = {}) { } }); + for (const existing of [false, true]) { + await test(`applies ordered JSON merges to the same ${existing ? 'existing' : 'new'} destination`, async () => { + const root = tempDir('ecc-guided-repeated-json-'); + const projection = require('../../scripts/lib/install-state-store-sync'); + const originalProjection = projection.projectCanonicalInstallState; + projection.projectCanonicalInstallState = async () => ({ status: 'projected' }); + try { + const destination = path.join(root, '.kimi-code', 'mcp.json'); + if (existing) writeFile(destination, JSON.stringify({ userSetting: true })); + const plan = managedPlan(root, [ + stateOperation(destination, { + kind: 'merge-json', + mergePayload: { servers: { first: { command: 'first' } }, sequence: 'first' }, + strategy: 'merge-json', + }), + stateOperation(destination, { + kind: 'merge-json', + mergePayload: { servers: { second: { command: 'second' } }, sequence: 'second' }, + strategy: 'merge-json', + }), + ]); + const result = await applyMultiHarnessPlan({ + harnesses: [{ id: 'kimi', preview: preflightManagedPlan(plan) }], + request: { harnesses: ['kimi'] }, + }); + assert.strictEqual(result.status, 'complete', JSON.stringify(result.failure)); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(destination, 'utf8')), { + ...(existing ? { userSetting: true } : {}), + servers: { first: { command: 'first' }, second: { command: 'second' } }, + sequence: 'second', + }); + } finally { + projection.projectCanonicalInstallState = originalProjection; + fs.rmSync(root, { recursive: true, force: true }); + } + }); + } + await test('refuses conflicting JSON created after preview but before apply', async () => { const root = tempDir('ecc-guided-late-json-collision-'); try { diff --git a/tests/lib/powershell-destructive-command.test.js b/tests/lib/powershell-destructive-command.test.js index 7a4ae31cf..43f01994a 100644 --- a/tests/lib/powershell-destructive-command.test.js +++ b/tests/lib/powershell-destructive-command.test.js @@ -206,6 +206,109 @@ test('does not resolve earlier invocations from later scalar assignments', () => expectSafe('$payload = "Write-Output ok"; pwsh -Command "$payload"'); }); +test('resolves scalar values supplied to aliases and shell stdin', () => { + for (const command of [ + "$cmd='Remove-Item'; Set-Alias zap $cmd; zap -Force C:/tmp/demo", + "$cmd='Remove-Item'; New-Alias -Name zap -Value $cmd; zap -Force C:/tmp/demo", + "$cmd='Remove-Item'; Set-Alias -Name zap -Value:$cmd; zap -Force C:/tmp/demo", + '$cmd=\'Remove-Item\'; Set-Alias zap "$cmd"; zap -Force C:/tmp/demo', + "$payload='Remove-Item -Force C:/tmp/demo'; $payload | pwsh -Command -", + "$payload='Remove-Item -Force C:/tmp/demo'; Write-Output $payload | pwsh -Command -", + '$payload=\'Remove-Item -Force C:/tmp/demo\'; "$payload" | pwsh -Command -', + '$payload=\'Remove-Item -Force C:/tmp/demo\'; Write-Output "$payload" | pwsh -Command -', + ]) { + expectRules(command, [RULES.REMOVE_FORCE]); + } + expectSafe('Set-Alias zap $runtimeCommand'); + expectSafe("$cmd='Write-Output'; Set-Alias zap $cmd; zap ok"); + expectSafe("$payload='Write-Output ok'; $payload | pwsh -Command -"); + expectSafe("$payload='Remove-Item -Force C:/tmp/demo'; '$payload' | pwsh -Command -"); + expectSafe("$cmd='Remove-Item'; Set-Alias zap '$cmd'; zap -Force C:/tmp/demo"); +}); + +test('binds remaining alias positional arguments after named parameters', () => { + for (const definition of [ + 'Set-Alias -Name zap $cmd', + 'New-Alias -Name:zap $cmd', + 'Set-Alias -Value $cmd zap', + 'New-Alias zap -Value:$cmd', + ]) { + expectRules(`$cmd='Remove-Item'; ${definition}; zap -Force C:/tmp/demo`, [ + RULES.REMOVE_FORCE, + ]); + expectRules(`${definition}; zap -Force C:/tmp/demo`, [RULES.DYNAMIC_EXECUTION]); + expectRules(`${definition}; zap -Force C:/tmp/demo; $cmd='Write-Output'`, [ + RULES.DYNAMIC_EXECUTION, + ]); + expectSafe(`$cmd='Write-Output'; ${definition}; zap ok`); + expectSafe(definition); + } + expectRules('Set-Alias -Name zap Remove-Item; zap -Force C:/tmp/demo', [RULES.REMOVE_FORCE]); + expectRules('Set-Alias -Value Remove-Item zap; zap -Force C:/tmp/demo', [RULES.REMOVE_FORCE]); + expectSafe("$cmd='Remove-Item'; Set-Alias -Name zap '$cmd'; zap -Force C:/tmp/demo"); +}); + +test('binds alias auxiliary parameters independently of their layout', () => { + const parameters = [ + '-Scope Global', '-Sc:Global', '-Description demo', '-Desc:demo', + '-Option AllScope', '-Opt:AllScope', '-Option ReadOnly, Private', + '-Force', '-Fo:$false', '-PassThru', '-Pass:$false', + '-Verbose', '-vb:$false', '-Debug', '-db:$false', + '-Confirm:$false', '-cf:$false', '-WhatIf:$false', '-wi:$false', + '-ErrorAction Stop', '-ea:Stop', '-WarningAction Continue', '-wa:Continue', + '-InformationAction Continue', '-infa:Continue', '-ProgressAction Continue', + '-proga:Continue', '-ErrorVariable errors', '-ev:errors', + '-WarningVariable warnings', '-wv:warnings', '-InformationVariable info', '-iv:info', + '-OutVariable output', '-ov:output', '-OutBuffer 1', '-ob:1', + '-PipelineVariable item', '-pv:item', + ]; + for (const command of ['Set-Alias', 'New-Alias', 'sal', 'nal']) { + for (const parameter of parameters) { + for (const args of [ + `${parameter} -Name zap $cmd`, + `-Name zap ${parameter} $cmd`, + `-Name zap $cmd ${parameter}`, + `${parameter} zap -Value $cmd`, + `${parameter} zap $cmd`, + ]) { + const definition = `${command} ${args}`; + expectRules(`$cmd='Remove-Item'; ${definition}; zap -Force C:/tmp/demo`, [RULES.REMOVE_FORCE]); + expectRules(`${definition}; zap -Force C:/tmp/demo`, [RULES.DYNAMIC_EXECUTION]); + expectSafe(`$cmd='Write-Output'; ${definition}; zap ok`); + expectSafe(definition); + } + } + } + expectRules('Set-Alias -Scope Global -Name zap Remove-Item; zap -Force C:/tmp/demo', [RULES.REMOVE_FORCE]); +}); + +test('gates invoked aliases with unsupported or ambiguous parameter binding', () => { + for (const definition of [ + 'Set-Alias -Unknown demo -Name zap Write-Output', + 'Set-Alias -Unknown demo zap Write-Output', + 'Set-Alias -Name zap -V Write-Output', + 'Set-Alias -Name zap -Option AllScope extra Write-Output', + 'Set-Alias -Name zap @parameters', + ]) { + expectRules(`${definition}; zap ok`, [RULES.DYNAMIC_EXECUTION]); + expectSafe(`${definition}; Write-Output ok`); + } +}); + +test('gates unresolved aliases and stdin without using later or reassigned scalars', () => { + for (const command of [ + 'Set-Alias zap $cmd; zap -Force C:/tmp/demo', + "Set-Alias zap $cmd; zap -Force C:/tmp/demo; $cmd='Write-Output'", + "$cmd='Remove-Item'; Set-Alias zap $cmd; $cmd='Write-Output'; zap -Force C:/tmp/demo", + '$payload | pwsh -Command -', + 'Write-Output $payload | pwsh -Command -', + "$payload | pwsh -Command -; $payload='Write-Output ok'", + "$payload='Remove-Item -Force C:/tmp/demo'; $payload | pwsh -Command -; $payload='Write-Output ok'", + ]) { + expectRules(command, [RULES.DYNAMIC_EXECUTION]); + } +}); + test('classifies powershell and pwsh command payloads recursively', () => { expectRules( 'powershell -Command "Remove-Item -Recurse C:/tmp/demo"', diff --git a/tests/run-all.js b/tests/run-all.js index fd79cb4af..09bc0ccef 100644 --- a/tests/run-all.js +++ b/tests/run-all.js @@ -43,6 +43,21 @@ function discoverTestFiles() { .sort(); } +function escapeAnnotation(value, property = false) { + const escaped = value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); + return property ? escaped.replace(/:/g, '%3A').replace(/,/g, '%2C') : escaped; +} + +function annotateFailure(displayPath, reason, output) { + if (process.env.GITHUB_ACTIONS !== 'true') return; + const context = output.split(/\r?\n/) + .filter(line => /\b(?:FAIL|[A-Za-z]*Error)\b|[✗❌]/i.test(line)) + .slice(0, 3) + .join('\n'); + const message = [reason, context].filter(Boolean).join(': ').slice(0, 1000); + console.log(`::error file=${escapeAnnotation(`tests/${displayPath}`, true)}::${escapeAnnotation(message)}`); +} + const testFiles = discoverTestFiles(); const BOX_W = 58; // inner width between ║ delimiters @@ -96,22 +111,29 @@ for (const testFile of testFiles) { if (stderr) console.log(stderr); // Parse results from combined output - const combined = stdout + stderr; + const combined = `${stdout}\n${stderr}`; const passedMatch = combined.match(/Passed:\s*(\d+)/); const failedMatch = combined.match(/Failed:\s*(\d+)/); if (passedMatch) totalPassed += parseInt(passedMatch[1], 10); - if (failedMatch) totalFailed += parseInt(failedMatch[1], 10); + const reportedFailures = failedMatch ? parseInt(failedMatch[1], 10) : 0; + const processFailed = Boolean(result.error) || result.status !== 0; + totalFailed += processFailed ? Math.max(reportedFailures, 1) : reportedFailures; + let failureReason; if (result.error) { - console.log(`✗ ${displayPath} failed to start: ${result.error.message}`); - totalFailed += failedMatch ? 0 : 1; - continue; + failureReason = `failed to start: ${result.error.message}`; + } else if (result.status !== 0) { + failureReason = result.signal + ? `terminated by signal ${result.signal}` + : `exited with status ${result.status}`; + } else if (reportedFailures > 0) { + failureReason = `reported ${reportedFailures} failed tests`; } - if (result.status !== 0) { - console.log(`✗ ${displayPath} exited with status ${result.status}`); - totalFailed += failedMatch ? 0 : 1; + if (failureReason) { + console.log(`✗ ${displayPath} ${failureReason}`); + annotateFailure(displayPath, failureReason, combined); } }