mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-13 13:17:55 +02:00
fix: close release review gaps and expose failing CI suites
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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()}`);
|
||||
|
||||
@@ -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;
|
||||
@@ -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)"',
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"',
|
||||
|
||||
+30
-8
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user