Merge upstream/main into feat/bengali-language-support

Keep both the new Polski locale and Bengali in every language switcher,
locale list and alias map; cross-link docs/pl and docs/bn. Resync catalog
counts (69 agents, 294 skills) and the skill-trigger registry digest.

Co-Authored-By: Kishor Morol <kishormorol@gmail.com>
This commit is contained in:
Md. Kishor Morol
2026-10-03 19:54:18 -04:00
co-authored by Kishor Morol
580 changed files with 71608 additions and 2518 deletions
+1
View File
@@ -133,6 +133,7 @@ function determineInstallCwd(record, repoRoot) {
// install-apply.js if its package.json identifies it as ECC — otherwise a
// cloned project that ships a nested `evil/{package.json,scripts/install-apply.js}`
// could drive auto-update into executing attacker code (GHSA-hfpv-w6mp-5g95).
// 'everything-claude-code' is the legacy 1.x package name, kept for existing checkouts.
const ECC_PACKAGE_NAMES = new Set(['ecc-universal', 'everything-claude-code']);
function validateRepoRoot(repoRoot) {
+390
View File
@@ -0,0 +1,390 @@
#!/usr/bin/env node
/**
* Regenerate pi/core/ deterministically from manifests/pi-core.json.
*
* The profile is a curated, Pi-native, skills+prompts-only package:
* pi/core/package.json - { name: "ecc-pi-core", version: <root VERSION>, license: MIT,
* keywords: ["pi-package", "skills"],
* pi: { skills: ["./skills"], prompts: ["./commands"] } }
* pi/core/LICENSE - copy of the root LICENSE
* pi/core/README.md - short generated overview
* pi/core/CURATION.md - every excluded skill/command with its reason
* pi/core/skills/ - curated skill directories (council renamed to ecc-council)
* pi/core/commands/ - curated prompt command files
*
* Safety checks (build fails if violated; semantics documented in
* manifests/pi-core.json safety.semantics):
* - no callable http(s) endpoints (documentation links allowed via the host
* allowlist in manifests/pi-core.json, plus localhost/example placeholders
* and non-FQDN internal hostnames)
* - no runtime download-and-run forms: pipe-to-shell (curl|sh, wget|sh) or
* fetch-and-run npx (-y/--yes, pkg@version, create-*, degit, skills add);
* skills whose own operation downloads tooling are excluded in the manifest
* - no secrets or tokens
* - no absolute per-user home paths (/Users/..., /home/..., C:\Users\...)
* - no symlinks
* - every SKILL.md has frontmatter with name == directory name and a description
* - no duplicate skill names
*
* Scoped exceptions for incidental mentions (anti-pattern warnings, detection
* examples) live in manifests/pi-core.json safety.scanAllowlist with a reason.
*
* Usage: node scripts/build-pi-core.js [--check]
* --check rebuild and verify pi/core is already up to date (exit 1 on drift)
*/
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const MANIFEST_PATH = path.join(ROOT, 'manifests', 'pi-core.json');
const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
const PROFILE_DIR = path.join(ROOT, manifest.profile.dir);
const SKILLS_SRC = path.join(ROOT, 'skills');
const COMMANDS_SRC = path.join(ROOT, 'commands');
const VERSION = fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf8').trim();
const CHECK_MODE = process.argv.includes('--check');
const violations = [];
function fail(msg) {
violations.push(msg);
}
// ---------- partition completeness -----------------------------------------
// Every on-disk skill dir and command file must be classified exactly once in
// the manifest, so new content cannot silently bypass curation.
{
const onDiskSkills = fs.readdirSync(SKILLS_SRC, { withFileTypes: true })
.filter(e => e.isDirectory()).map(e => e.name);
const known = new Set([...manifest.skills.include, ...Object.keys(manifest.skills.exclude)]);
for (const dir of onDiskSkills) {
if (!known.has(dir)) fail(`skills/${dir} is not classified in manifests/pi-core.json`);
}
for (const name of known) {
if (!onDiskSkills.includes(name)) fail(`manifests/pi-core.json references missing skills/${name}`);
}
const onDiskCommands = fs.readdirSync(COMMANDS_SRC).filter(f => f.endsWith('.md'));
const knownCmd = new Set([...manifest.commands.include, ...Object.keys(manifest.commands.exclude)]);
for (const f of onDiskCommands) {
if (!knownCmd.has(f)) fail(`commands/${f} is not classified in manifests/pi-core.json`);
}
for (const f of knownCmd) {
if (!onDiskCommands.includes(f)) fail(`manifests/pi-core.json references missing commands/${f}`);
}
}
// ---------- deterministic copy ----------------------------------------------
/** Recursively list files under dir, sorted; reject symlinks. */
function listFiles(dir, base) {
const out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
const full = path.join(dir, entry.name);
const rel = base ? `${base}/${entry.name}` : entry.name;
if (entry.isSymbolicLink()) {
fail(`symlink found in profile source: ${rel}`);
} else if (entry.isDirectory()) {
out.push(...listFiles(full, rel));
} else if (entry.isFile()) {
out.push({ full, rel });
}
}
return out;
}
function copyFile(src, dest) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
}
/** Parse YAML frontmatter minimally: returns { name, description } or null. */
function parseFrontmatter(text) {
const m = text.replace(/^\uFEFF/, '').match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
if (!m) return null;
const name = (m[1].match(/^name:\s*["']?(.+?)["']?\s*$/m) || [])[1];
const description = (m[1].match(/^description:\s*["']?([\s\S]+?)["']?\s*$/m) || [])[1];
return { name: name || '', description: description || '', raw: m[1] };
}
// ---------- rebuild pi/core --------------------------------------------------
const tmpDir = path.join(ROOT, 'pi', '.core-build-tmp');
fs.rmSync(tmpDir, { recursive: true, force: true });
fs.mkdirSync(tmpDir, { recursive: true });
const rename = manifest.skills.rename || {};
const skillDescriptions = [];
const seenNames = new Map();
for (const dirName of manifest.skills.include) {
const srcDir = path.join(SKILLS_SRC, dirName);
const outName = rename[dirName] || dirName;
const destDir = path.join(tmpDir, 'skills', outName);
const skillMd = path.join(srcDir, 'SKILL.md');
if (!fs.existsSync(skillMd)) {
fail(`skills/${dirName}/SKILL.md is missing`);
continue;
}
const text = fs.readFileSync(skillMd, 'utf8');
const fm = parseFrontmatter(text);
if (!fm) {
fail(`skills/${dirName}/SKILL.md has no parseable frontmatter`);
} else {
if (fm.name !== dirName) fail(`skills/${dirName}/SKILL.md frontmatter name "${fm.name}" != directory name`);
if (!fm.description) fail(`skills/${dirName}/SKILL.md frontmatter has no description`);
skillDescriptions.push(fm.description);
if (seenNames.has(outName)) fail(`duplicate skill name in profile: ${outName} (skills/${dirName} and skills/${seenNames.get(outName)})`);
seenNames.set(outName, dirName);
}
for (const f of listFiles(srcDir, '')) {
let content = null;
if (rename[dirName] && f.rel === 'SKILL.md') {
// Rename the skill inside pi/core only; the root skill keeps its name.
content = fs.readFileSync(f.full, 'utf8')
.replace(/^name:\s*["']?[^\n"']+["']?\s*$/m, `name: ${outName}`);
fs.mkdirSync(path.dirname(path.join(destDir, f.rel)), { recursive: true });
fs.writeFileSync(path.join(destDir, f.rel), content);
} else {
copyFile(f.full, path.join(destDir, f.rel));
}
}
}
for (const file of manifest.commands.include) {
copyFile(path.join(COMMANDS_SRC, file), path.join(tmpDir, 'commands', file));
}
copyFile(path.join(ROOT, 'LICENSE'), path.join(tmpDir, 'LICENSE'));
const profilePackage = {
name: manifest.profile.packageName,
version: VERSION,
license: manifest.profile.license,
keywords: manifest.profile.keywords,
pi: { skills: ['./skills'], prompts: ['./commands'] },
};
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(profilePackage, null, 2) + '\n');
const skillCount = manifest.skills.include.length;
const commandCount = manifest.commands.include.length;
const descChars = skillDescriptions.reduce((n, d) => n + d.length, 0);
fs.writeFileSync(path.join(tmpDir, 'README.md'), `# ecc-pi-core
A curated, Pi-native profile of ECC: ${skillCount} portable
engineering skills and ${commandCount} pure prompt-workflow commands, with no extensions,
no hooks, no runtime downloads, and no network or SaaS dependencies.
## Contents
- \`skills/\` - language, framework, testing/TDD, code review, security review,
planning, refactoring, docs, and git/PR workflow skills.
- \`commands/\` - prompt commands that are pure prompt workflows.
- \`CURATION.md\` - every excluded skill and command with its reason.
## Use
Copy this directory into your project (or pin a release tarball) and load it with the
Pi coding agent:
\`\`\`sh
pi --no-extensions --extension pi/core
\`\`\`
Offline load test (as run in CI):
\`\`\`sh
PI_OFFLINE=1 pi --offline --mode rpc --no-session --no-context-files --no-extensions \\
--extension pi/core </dev/null >/dev/null
\`\`\`
## Regenerate
\`pi/core\` is generated from \`manifests/pi-core.json\` and committed so release
tarballs contain it verbatim. After changing the manifest or any included source
content, run:
\`\`\`sh
node scripts/build-pi-core.js
\`\`\`
and commit the result. CI verifies the committed profile is up to date.
`);
{
const lines = [];
lines.push('# Curation');
lines.push('');
lines.push(`pi/core includes ${skillCount} of ${Object.keys(manifest.skills.exclude).length + skillCount} skills ` +
`and ${commandCount} of ${Object.keys(manifest.commands.exclude).length + commandCount} commands from the root of ECC.`);
lines.push('Everything excluded is listed here with its reason.');
lines.push('');
lines.push('## Rules');
lines.push('');
lines.push('Include: ' + manifest.curationRules.include.join('; ') + '.');
lines.push('');
lines.push('Exclude anything that:');
for (const rule of manifest.curationRules.exclude) lines.push(`- ${rule}`);
lines.push('');
lines.push('## Excluded skills');
lines.push('');
lines.push('| Skill | Reason |');
lines.push('|---|---|');
for (const [name, reason] of Object.entries(manifest.skills.exclude)) {
lines.push(`| \`${name}\` | ${reason.replace(/\|/g, '\\|')} |`);
}
lines.push('');
lines.push('## Excluded commands');
lines.push('');
lines.push('| Command | Reason |');
lines.push('|---|---|');
for (const [file, reason] of Object.entries(manifest.commands.exclude)) {
lines.push(`| \`${file.replace(/\.md$/, '')}\` | ${reason.replace(/\|/g, '\\|')} |`);
}
lines.push('');
lines.push('## Renames');
lines.push('');
for (const [from, to] of Object.entries(rename)) {
lines.push(`- \`${from}\` is shipped as \`${to}\` inside pi/core (the root skill keeps its original name).`);
}
lines.push('');
fs.writeFileSync(path.join(tmpDir, 'CURATION.md'), lines.join('\n'));
}
// ---------- safety scans -----------------------------------------------------
//
// Scanner semantics (mirrors the curation rules in manifests/pi-core.json):
// - URLs: documentation links are allowed via safety.urlAllowlistHosts.
// Placeholder hosts (example.com/org/net and subdomains) and non-FQDN
// internal hostnames (localhost, docker service names like "api" or "db")
// are always allowed; they are not callable endpoints.
// - Runtime downloads: pipe-to-shell (curl|sh, wget|sh) and fetch-and-run
// npx forms (-y/--yes, pkg@version, create-*, degit, "skills add") fail.
// Local-first `npx <tool>` (jest, tsc, playwright, prisma, ...) and
// standard project dependency installation (pip install <dep>, npm i) are
// the reader's own project workflow, not the profile downloading code to
// run itself, so they are allowed; skills whose own operation downloads
// tooling are excluded in the manifest instead (see CURATION.md).
// - Home paths: absolute per-user paths (/Users/..., /home/..., C:\Users\...)
// fail. Portable tilde references (~/.cache/...) are user-agnostic and OK.
const defaultHosts = new Set(manifest.safety.defaultAllowedHosts || []);
const allowHosts = new Set([...(manifest.safety.urlAllowlistHosts || []), ...defaultHosts]);
const scanAllowlist = manifest.safety.scanAllowlist || [];
const PLACEHOLDER_SUFFIXES = ['.example.com', '.example.org', '.example.net'];
function isAllowlisted(relPath, line) {
return scanAllowlist.some(e => relPath === e.path && line.includes(e.contains));
}
function hostAllowed(hostname) {
if (!hostname) return false;
if (allowHosts.has(hostname)) return true;
if (!hostname.includes('.')) return true; // localhost, docker service names, placeholders
return PLACEHOLDER_SUFFIXES.some(s => hostname.endsWith(s));
}
const URL_RE = /https?:\/\/([A-Za-z0-9._-]+)(?::\d+)?[^\s)\]"'`<>}]*/g;
const SECRET_RES = [
{ re: /AKIA[0-9A-Z]{16}/, label: 'AWS access key' },
{ re: /ghp_[A-Za-z0-9]{20,}/, label: 'GitHub PAT' },
{ re: /github_pat_[A-Za-z0-9_]{22,}/, label: 'GitHub fine-grained PAT' },
{ re: /sk-[A-Za-z0-9_-]{20,}/, label: 'OpenAI-style key' },
{ re: /xox[baprs]-[A-Za-z0-9-]{10,}/, label: 'Slack token' },
{ re: /AIza[0-9A-Za-z_-]{35}/, label: 'Google API key' },
{ re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/, label: 'private key block' },
{ re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, label: 'JWT' },
];
const INSTALL_RES = [
{ re: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:ba|z|fi)?sh\b/, label: 'pipe-to-shell (curl|sh / wget|sh)' },
{ re: /\bnpx\s+(?:-y\b|--yes\b)/, label: 'npx -y/--yes (fetch-and-run)' },
{ re: /\bnpx\s+(?:--\S+\s+)*[A-Za-z@][^\s]*@\d/, label: 'npx pkg@version (fetch-and-run)' },
{ re: /\bnpx\s+(?:--\S+\s+)*(?:create-[a-z-]+|degit\b|skills\s+add\b)/, label: 'npx scaffold/fetch form' },
{ re: /\bpipx\s+install\b/, label: 'pipx install' },
];
const HOME_RES = [
{ re: /\/Users\/[^\s)"'`\]<>|]+/, label: 'macOS home path' },
{ re: /\/home\/[^\s)"'`\]<>|]+/, label: 'Linux home path' },
{ re: /[A-Za-z]:\\Users\\[^\s)"'`\]<>|]+/, label: 'Windows home path' },
];
const GENERATED_FILES = new Set(['package.json', 'README.md', 'CURATION.md']);
function scanTextFile(relPath, text) {
if (GENERATED_FILES.has(relPath)) return; // generated from the manifest itself
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const at = `${relPath}:${i + 1}`;
const allowlisted = isAllowlisted(relPath, line);
for (const m of line.matchAll(URL_RE)) {
if (!hostAllowed(m[1]) && !allowlisted) {
fail(`${at}: non-allowlisted URL host ${m[1]} (${m[0].slice(0, 100)})`);
}
}
if (allowlisted) continue;
for (const { re, label } of SECRET_RES) {
if (re.test(line)) fail(`${at}: possible secret (${label})`);
}
for (const { re, label } of INSTALL_RES) {
if (re.test(line)) fail(`${at}: runtime download pattern (${label})`);
}
for (const { re, label } of HOME_RES) {
if (re.test(line)) fail(`${at}: absolute home path (${label})`);
}
}
}
for (const f of listFiles(tmpDir, '')) {
const rel = f.rel;
const buf = fs.readFileSync(f.full);
if (buf.includes(0)) continue; // binary file: nothing textual to scan
scanTextFile(rel, buf.toString('utf8'));
}
// ---------- emit report -------------------------------------------------------
const report = [
`pi/core: ${skillCount} skills, ${commandCount} commands`,
`skill description text: ${descChars} characters (target: under ~40000)`,
];
if (descChars > 42000) fail(`skill description text too large: ${descChars} characters`);
if (violations.length) {
fs.rmSync(tmpDir, { recursive: true, force: true });
console.error('pi/core build FAILED:');
for (const v of violations) console.error(' - ' + v);
console.error(report.join('\n'));
process.exit(1);
}
if (CHECK_MODE) {
// Compare tmpDir against the committed profile without touching it.
const committed = listFiles(PROFILE_DIR, '').map(f => f.rel).sort();
const built = listFiles(tmpDir, '').map(f => f.rel).sort();
let drift = false;
if (committed.join('\n') !== built.join('\n')) {
console.error('pi/core file list drift:');
const cSet = new Set(committed), bSet = new Set(built);
for (const f of committed) if (!bSet.has(f)) console.error(' only committed: ' + f);
for (const f of built) if (!cSet.has(f)) console.error(' only built: ' + f);
drift = true;
} else {
for (const rel of committed) {
const a = fs.readFileSync(path.join(PROFILE_DIR, rel));
const b = fs.readFileSync(path.join(tmpDir, rel));
if (!a.equals(b)) { console.error('pi/core content drift: ' + rel); drift = true; }
}
}
fs.rmSync(tmpDir, { recursive: true, force: true });
if (drift) {
console.error('Run: node scripts/build-pi-core.js');
process.exit(1);
}
console.log('pi/core is up to date.');
console.log(report.join('\n'));
process.exit(0);
}
fs.rmSync(PROFILE_DIR, { recursive: true, force: true });
fs.renameSync(tmpDir, PROFILE_DIR);
console.log(report.join('\n'));
console.log(`wrote ${manifest.profile.dir}/`);
+42
View File
@@ -18,6 +18,8 @@ const path = require('path');
const ROOT = path.join(__dirname, '../..');
const README_PATH = path.join(ROOT, 'README.md');
const AGENTS_PATH = path.join(ROOT, 'AGENTS.md');
const SOUL_PATH = path.join(ROOT, 'SOUL.md');
const GEMINI_PATH = path.join(ROOT, '.gemini', 'GEMINI.md');
const README_ZH_CN_PATH = path.join(ROOT, 'README.zh-CN.md');
const DOCS_ZH_CN_README_PATH = path.join(ROOT, 'docs', 'zh-CN', 'README.md');
const DOCS_ZH_CN_AGENTS_PATH = path.join(ROOT, 'docs', 'zh-CN', 'AGENTS.md');
@@ -273,6 +275,30 @@ function parseAgentsDocExpectations(agentsContent) {
return expectations;
}
function parseCrossHarnessIdentityExpectations(content, source) {
const match = content.match(/with\s+(\d+)\s+specialized agents,\s+(\d+)\s+skills,\s+(?:and\s+)?(\d+)\s+commands/i);
if (!match) {
throw new Error(`${source} is missing the catalog summary line`);
}
return [
{ category: 'agents', mode: 'exact', expected: Number(match[1]), source },
{ category: 'skills', mode: 'exact', expected: Number(match[2]), source },
{ category: 'commands', mode: 'exact', expected: Number(match[3]), source },
];
}
function syncCrossHarnessIdentity(content, catalog, source) {
return replaceOrThrow(
content,
/(with\s+)(\d+)(\s+specialized agents,\s+)(\d+)(\s+skills,\s+(?:and\s+)?)(\d+)(\s+commands)/i,
(_, prefix, __, agentsSuffix, ___, skillsSuffix, ____, commandsSuffix) => (
`${prefix}${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsSuffix}${catalog.commands.count}${commandsSuffix}`
),
source
);
}
function parseZhAgentsDocExpectations(agentsContent) {
const summaryMatch = agentsContent.match(/提供\s+(\d+)\s+个专业代理、\s*(\d+)(\+)?\s*项技能、\s*(\d+)\s+条命令/i);
if (!summaryMatch) {
@@ -563,6 +589,8 @@ function createDocumentSpecs(paths = {}) {
const {
readmePath = README_PATH,
agentsPath = AGENTS_PATH,
soulPath = SOUL_PATH,
geminiPath = GEMINI_PATH,
zhRootReadmePath = README_ZH_CN_PATH,
zhDocsReadmePath = DOCS_ZH_CN_README_PATH,
zhDocsAgentsPath = DOCS_ZH_CN_AGENTS_PATH,
@@ -581,6 +609,16 @@ function createDocumentSpecs(paths = {}) {
parseExpectations: parseAgentsDocExpectations,
syncContent: syncEnglishAgents,
},
{
filePath: soulPath,
parseExpectations: content => parseCrossHarnessIdentityExpectations(content, 'SOUL.md'),
syncContent: (content, catalog) => syncCrossHarnessIdentity(content, catalog, 'SOUL.md'),
},
{
filePath: geminiPath,
parseExpectations: content => parseCrossHarnessIdentityExpectations(content, '.gemini/GEMINI.md'),
syncContent: (content, catalog) => syncCrossHarnessIdentity(content, catalog, '.gemini/GEMINI.md'),
},
{
filePath: zhRootReadmePath,
parseExpectations: parseZhRootReadmeExpectations,
@@ -633,6 +671,8 @@ function createDocumentSpecsForRoot(root) {
return createDocumentSpecs({
readmePath: path.join(root, 'README.md'),
agentsPath: path.join(root, 'AGENTS.md'),
soulPath: path.join(root, 'SOUL.md'),
geminiPath: path.join(root, '.gemini', 'GEMINI.md'),
zhRootReadmePath: path.join(root, 'README.zh-CN.md'),
zhDocsReadmePath: path.join(root, 'docs', 'zh-CN', 'README.md'),
zhDocsAgentsPath: path.join(root, 'docs', 'zh-CN', 'AGENTS.md'),
@@ -742,6 +782,7 @@ module.exports = {
formatExpectation,
main,
parseAgentsDocExpectations,
parseCrossHarnessIdentityExpectations,
parseCatalogDescriptionExpectations,
parseReadmeExpectations,
parseZhAgentsDocExpectations,
@@ -751,6 +792,7 @@ module.exports = {
syncCatalogDescription,
syncEnglishAgents,
syncEnglishReadme,
syncCrossHarnessIdentity,
syncZhAgents,
syncZhDocsReadme,
syncZhRootReadme,
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env node
/**
* Offline load test for the pi/core profile.
*
* Requires the Pi coding agent CLI on PATH (`pi`). Spawns:
*
* PI_OFFLINE=1 pi --offline --mode rpc --no-session --no-context-files \
* --no-extensions --skill pi/core/skills --prompt-template pi/core/commands
*
* and asserts:
* 1. the process exits 0 (the profile loads cleanly, fully offline);
* 2. the RPC `get_commands` response succeeds and reports exactly the
* commands listed in manifests/pi-core.json (proof the prompt tree was
* actually parsed, not just accepted as a path).
*
* Flag note: the natural `--extension pi/core` only loads a package's
* `pi.extensions` entries; pi/core is a skills+prompts-only package, so the
* equivalent resource flags `--skill` and `--prompt-template` are used.
*
* Usage: node scripts/ci/pi-core-load-test.js
*/
'use strict';
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const ROOT = path.join(__dirname, '..', '..');
const PROFILE = path.join(ROOT, 'pi', 'core');
const manifest = JSON.parse(
fs.readFileSync(path.join(ROOT, 'manifests', 'pi-core.json'), 'utf8')
);
const expected = manifest.commands.include
.map(f => f.replace(/\.md$/, ''))
.sort();
const child = spawn(
'pi',
[
'--offline',
'--mode', 'rpc',
'--no-session',
'--no-context-files',
'--no-extensions',
'--skill', path.join(PROFILE, 'skills'),
'--prompt-template', path.join(PROFILE, 'commands'),
],
{ env: { ...process.env, PI_OFFLINE: '1' } }
);
let buffer = '';
let response = null;
let stderr = '';
const watchdog = setTimeout(() => {
console.error('timed out waiting for pi RPC response');
child.kill('SIGKILL');
process.exit(1);
}, 120000);
child.stderr.on('data', d => { stderr += d; });
child.stdout.on('data', d => {
buffer += d;
let idx;
while ((idx = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, idx);
buffer = buffer.slice(idx + 1);
try {
const msg = JSON.parse(line);
if (msg.id === '1' && msg.type === 'response' && msg.command === 'get_commands') {
response = msg;
child.stdin.end();
}
} catch { /* ignore non-JSON chatter */ }
}
});
child.on('error', err => {
clearTimeout(watchdog);
console.error('failed to spawn pi:', err.message);
process.exit(1);
});
child.stdin.write('{"id":"1","type":"get_commands"}\n');
child.on('close', status => {
clearTimeout(watchdog);
if (status !== 0) {
console.error(`pi exited ${status}`);
if (stderr) console.error(stderr);
process.exit(1);
}
if (!response || response.success !== true) {
console.error('get_commands RPC did not succeed');
process.exit(1);
}
const loaded = (response.data.commands || [])
.filter(c => c.source === 'prompt')
.map(c => c.name)
.sort();
const missing = expected.filter(c => !loaded.includes(c));
const extra = loaded.filter(c => !expected.includes(c));
if (missing.length || extra.length) {
console.error('pi/core command load mismatch');
if (missing.length) console.error(' missing: ' + missing.join(', '));
if (extra.length) console.error(' unexpected: ' + extra.join(', '));
process.exit(1);
}
console.log(`pi offline load OK: ${loaded.length}/${expected.length} commands loaded from pi/core`);
});
+365
View File
@@ -0,0 +1,365 @@
'use strict';
const fs = require('node:fs');
const { performance } = require('node:perf_hooks');
const API_VERSION = '2022-11-28';
const SHA = /^[0-9a-f]{40}$/;
const MAX_PAGES = 10;
const MAX_ITEMS = 1000;
const DEFAULT_ATTEMPTS = 20;
const DEFAULT_DELAY_MS = 30_000;
const TOTAL_TIMEOUT_MS = 600_000;
const REQUEST_TIMEOUT_MS = 15_000;
const CI_PATH = '.github/workflows/ci.yml';
const CODEQL_PATH = 'dynamic/github-code-scanning/codeql';
// Repository policy: default CodeQL must complete all three categories in ONE
// attempt. A new category requires an explicit policy update, not silent approval.
const REQUIRED_CODEQL = ['Analyze (actions)', 'Analyze (javascript-typescript)', 'Analyze (python)'];
const ACTIONS_APP = { id: 15368, slug: 'github-actions' };
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
const id = value => Number.isSafeInteger(value) && value > 0;
const sha = value => typeof value === 'string' && SHA.test(value);
const text = value => typeof value === 'string' && value.length > 0;
const resultShape = value => record(value) && text(value.status)
&& (value.conclusion === null || text(value.conclusion));
const repoShape = value => record(value) && id(value.id) && text(value.full_name);
const objectShape = value => record(value) && text(value.type) && sha(value.sha);
const referenceShape = value => record(value) && text(value.ref) && objectShape(value.object);
const tagShape = value => record(value) && sha(value.sha) && text(value.tag)
&& objectShape(value.object) && record(value.verification)
&& typeof value.verification.verified === 'boolean' && text(value.verification.reason);
const workflowShape = value => record(value) && id(value.id) && text(value.path) && text(value.state);
const runShape = value => resultShape(value) && id(value.id) && id(value.workflow_id)
&& text(value.path) && sha(value.head_sha) && text(value.head_branch) && text(value.event)
&& id(value.run_attempt) && id(value.check_suite_id)
&& repoShape(value.repository) && repoShape(value.head_repository);
const checkShape = value => resultShape(value) && id(value.id) && text(value.name)
&& sha(value.head_sha) && record(value.check_suite) && id(value.check_suite.id)
&& record(value.app) && id(value.app.id) && text(value.app.slug);
const jobShape = value => resultShape(value) && id(value.id) && text(value.name)
&& id(value.run_id) && id(value.run_attempt) && sha(value.head_sha)
&& text(value.head_branch) && text(value.check_run_url);
function requiredEnvironment(env = process.env) {
const inputs = {
repository: env.GITHUB_REPOSITORY,
releaseSha: env.RELEASE_SHA,
releaseTag: env.RELEASE_TAG,
token: env.GITHUB_TOKEN,
tagObjectSha: env.RELEASE_TAG_OBJECT_SHA,
};
for (const name of ['repository', 'releaseSha', 'releaseTag', 'token']) {
if (!text(inputs[name])) throw new Error(`Missing required release gate input: ${name}`);
}
validateInputs(inputs);
return inputs;
}
function validateInputs(inputs) {
if (!/^[A-Za-z0-9_-][A-Za-z0-9_.-]*\/[A-Za-z0-9_-][A-Za-z0-9_.-]*$/.test(inputs.repository || '')) {
throw new Error('Invalid release repository');
}
if (!sha(inputs.releaseSha)) throw new Error('RELEASE_SHA must be a full lowercase commit SHA');
if (!/^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/.test(inputs.releaseTag || '')) {
throw new Error('RELEASE_TAG is not a supported version tag');
}
if (!text(inputs.token)) throw new Error('Missing release gate token');
if (inputs.tagObjectSha !== undefined && !sha(inputs.tagObjectSha)) {
throw new Error('Invalid expected tag object SHA');
}
}
function setting(value, fallback, maximum) {
const parsed = value === undefined ? fallback : Number(value);
if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > maximum) {
throw new Error('Release gate settings must be positive integers within their finite limits');
}
return parsed;
}
class ReleaseGateDeadlineError extends Error {}
function createGithubClient(inputs, fetchImpl = fetch, options = {}) {
validateInputs(inputs);
const now = options.now || (() => performance.now());
const deadline = now() + setting(options.timeoutMs, TOTAL_TIMEOUT_MS, TOTAL_TIMEOUT_MS);
const requestMs = setting(options.requestTimeoutMs, REQUEST_TIMEOUT_MS, REQUEST_TIMEOUT_MS);
const base = `https://api.github.com/repos/${inputs.repository}`;
function remaining() {
const left = deadline - now();
if (left <= 0) throw new ReleaseGateDeadlineError('Release gate global deadline exceeded');
return left;
}
async function bounded(operation, limit) {
const controller = new AbortController();
let timer;
try {
return await Promise.race([
Promise.resolve().then(() => operation(controller.signal)),
new Promise((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new ReleaseGateDeadlineError('Release gate request or global deadline exceeded'));
}, Math.min(limit, remaining()));
}),
]);
} finally {
clearTimeout(timer);
}
}
function urlFor(pathOrUrl) {
const url = new URL(pathOrUrl === '' || pathOrUrl.startsWith('/') ? base + pathOrUrl : pathOrUrl);
if (url.origin !== 'https://api.github.com' || url.username || url.password || url.hash
|| (url.pathname !== `/repos/${inputs.repository}` && !url.pathname.startsWith(`/repos/${inputs.repository}/`))) {
throw new Error('GitHub API URL escaped the release repository');
}
return url;
}
async function page(url, validator) {
remaining();
return bounded(async signal => {
const response = await fetchImpl(url.toString(), {
redirect: 'error', signal,
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${inputs.token}`,
'X-GitHub-Api-Version': API_VERSION,
},
});
if (!response.ok) throw new Error(`GitHub API failed with status ${response.status}`);
let payload;
try { payload = await response.json(); } catch { throw new Error('Invalid GitHub API JSON response'); }
if (!validator(payload)) throw new Error('GitHub API response validation failed');
remaining();
return { payload, link: response.headers?.get?.('link') };
}, requestMs);
}
async function get(path, validator) {
return (await page(urlFor(path), validator)).payload;
}
async function pages(path, key, itemValidator) {
const first = urlFor(path);
const seen = new Set();
const identities = new Set();
let next = first;
let total;
const items = [];
while (next) {
const identity = paginationIdentity(next, first);
if (seen.has(identity)) throw new Error('GitHub API pagination cycle');
if (seen.size >= MAX_PAGES) throw new Error('GitHub API page limit exceeded');
seen.add(identity);
const { payload, link } = await page(next, value => record(value)
&& Number.isSafeInteger(value.total_count) && value.total_count >= 0
&& Array.isArray(value[key]));
if (payload.total_count > MAX_ITEMS || payload[key].length > 100) {
throw new Error('GitHub API item limit exceeded');
}
if (total !== undefined && total !== payload.total_count) throw new Error('GitHub API collection total changed');
total = payload.total_count;
for (const item of payload[key]) {
if (!itemValidator(item)) throw new Error('GitHub API response validation failed');
if (identities.has(item.id)) throw new Error('Ambiguous duplicate GitHub API item');
identities.add(item.id);
items.push(item);
}
if (items.length > MAX_ITEMS || items.length > total) throw new Error('GitHub API item limit or total exceeded');
const linkUrl = nextPageUrl(link);
next = linkUrl ? urlFor(linkUrl) : null;
}
if (items.length !== total) throw new Error('Incomplete GitHub API collection total');
return items;
}
return { get, pages, pause: sleep => bounded(signal => sleep(signal), remaining()), remaining };
}
function paginationIdentity(url, first) {
const query = candidate => {
const keys = [...candidate.searchParams.keys()];
if (new Set(keys).size !== keys.length) throw new Error('Ambiguous pagination query');
return [...candidate.searchParams].filter(([key]) => key !== 'page').sort().map(pair => JSON.stringify(pair)).join(',');
};
const page = url.searchParams.get('page');
if (url.pathname !== first.pathname || query(url) !== query(first)
|| (page !== null && !/^[1-9][0-9]*$/.test(page))) {
throw new Error('GitHub API pagination escaped the endpoint collection');
}
return `${url.pathname}?${query(url)}&page=${page || '1'}`;
}
function nextPageUrl(header) {
if (!header) return null;
let next = null;
for (const entry of header.split(',')) {
const match = entry.trim().match(/^<([^>]+)>;\s*rel="(next|prev|first|last)"$/);
if (!match) throw new Error('Malformed GitHub API pagination Link');
if (match[2] === 'next') {
if (next) throw new Error('Ambiguous GitHub API next page');
next = match[1];
}
}
return next;
}
async function verifySignedAnnotatedTag(inputs, fetchImpl = fetch, options = {}) {
validateInputs(inputs);
const client = options.client || createGithubClient(inputs, fetchImpl, options);
const reference = await client.get(`/git/ref/tags/${encodeURIComponent(inputs.releaseTag)}`, referenceShape);
if (reference.ref !== `refs/tags/${inputs.releaseTag}` || reference.object.type !== 'tag') {
throw new Error('Release ref must match the requested annotated tag; lightweight tags are rejected');
}
if (inputs.tagObjectSha && reference.object.sha !== inputs.tagObjectSha) {
throw new Error('Release tag object changed after initial verification');
}
const tag = await client.get(`/git/tags/${reference.object.sha}`, tagShape);
if (tag.sha !== reference.object.sha || tag.tag !== inputs.releaseTag) {
throw new Error('Signed tag object identity or name does not match the release ref');
}
// GitHub signature validity is not a project-specific authorized-signer list.
if (tag.verification.verified !== true || tag.verification.reason !== 'valid') {
throw new Error('Release tag signature is not verified');
}
if (tag.object.type !== 'commit' || tag.object.sha !== inputs.releaseSha) {
throw new Error('Verified release tag does not point at the checked-out commit');
}
return tag.sha;
}
async function trustedProducers(client, inputs) {
const repository = await client.get('', value => repoShape(value) && value.default_branch === 'main');
if (repository.full_name !== inputs.repository) throw new Error('Repository identity mismatch');
const workflows = await client.pages('/actions/workflows?per_page=100', 'workflows', workflowShape);
const select = path => {
const matches = workflows.filter(workflow => workflow.path === path);
if (matches.length !== 1 || matches[0].state !== 'active') throw new Error('Missing or ambiguous active trusted workflow');
return matches[0];
};
return { repository, ci: select(CI_PATH), codeql: select(CODEQL_PATH) };
}
function selectRuns(runs, inputs, trusted) {
const sameRepo = repo => repo.id === trusted.repository.id && repo.full_name === inputs.repository;
const select = (workflow, event) => runs.filter(run => run.workflow_id === workflow.id
&& run.path === workflow.path && run.head_sha === inputs.releaseSha && run.head_branch === 'main'
&& run.event === event && sameRepo(run.repository) && sameRepo(run.head_repository))
.sort((a, b) => b.id - a.id || b.run_attempt - a.run_attempt)[0];
return { ci: select(trusted.ci, 'push'), codeql: select(trusted.codeql, 'dynamic') };
}
function statusOf(result, label, pendingLabel = label) {
if (!result) return { state: 'pending', reason: `${pendingLabel} run not found for release SHA` };
if (result.status !== 'completed') return { state: 'pending', reason: `${pendingLabel} is ${result.status}` };
return result.conclusion === 'success' ? { state: 'passed' }
: { state: 'failed', reason: `${label} concluded ${result.conclusion}` };
}
function assessExactShaGates(selected, checks, jobs, inputs) {
for (const [name, run] of Object.entries(selected)) {
const assessment = statusOf(run, name);
if (assessment.state !== 'passed') return assessment;
}
const run = selected.codeql;
if (jobs.some(job => !REQUIRED_CODEQL.includes(job.name))) {
throw new Error('Unexpected CodeQL category; review the explicit required-category policy');
}
for (const name of REQUIRED_CODEQL) {
const matches = jobs.filter(job => job.name === name);
if (matches.length > 1) throw new Error('Ambiguous required CodeQL job');
const job = matches[0];
if (!job) return { state: 'pending', reason: `CodeQL job "${name}" missing from selected attempt` };
if (job.run_id !== run.id || job.run_attempt !== run.run_attempt
|| job.head_sha !== inputs.releaseSha || job.head_branch !== 'main') {
throw new Error('CodeQL job does not belong to the selected run attempt');
}
const check = checks.find(candidate => job.check_run_url
=== `https://api.github.com/repos/${inputs.repository}/check-runs/${candidate.id}`);
if (!check || check.name !== name || check.head_sha !== inputs.releaseSha
|| check.check_suite.id !== run.check_suite_id || check.app.id !== ACTIONS_APP.id
|| check.app.slug !== ACTIONS_APP.slug) {
return { state: 'pending', reason: `CodeQL check "${name}" missing or not bound to trusted job` };
}
for (const [kind, result] of [['job', job], ['check', check]]) {
const assessment = statusOf(result, name, `CodeQL ${kind} "${name}"`);
if (assessment.state !== 'passed') return assessment;
}
}
return { state: 'passed' };
}
function defaultSleep(delay, signal) {
return new Promise(resolve => {
const timer = setTimeout(resolve, delay);
signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
});
}
async function waitForExactShaGates(inputs, fetchImpl = fetch, sleep = defaultSleep, options = {}) {
const client = options.client || createGithubClient(inputs, fetchImpl, options);
const attempts = setting(options.attempts ?? process.env.RELEASE_GATE_ATTEMPTS, DEFAULT_ATTEMPTS, DEFAULT_ATTEMPTS);
const delay = setting(options.delayMs ?? process.env.RELEASE_GATE_DELAY_MS, DEFAULT_DELAY_MS, DEFAULT_DELAY_MS);
let lastReason = 'no gate assessment completed';
try {
const trusted = await trustedProducers(client, inputs);
const readRuns = async () => selectRuns(await client.pages(
`/actions/runs?head_sha=${inputs.releaseSha}&branch=main&per_page=100`, 'workflow_runs', runShape
), inputs, trusted);
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const selected = await readRuns();
let assessment = statusOf(selected.ci, 'CI');
if (assessment.state === 'passed') assessment = statusOf(selected.codeql, 'CodeQL');
if (assessment.state === 'passed') {
const run = selected.codeql;
const jobs = await client.pages(`/actions/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=100`, 'jobs', jobShape);
const checks = await client.pages(`/check-suites/${run.check_suite_id}/check-runs?filter=all&per_page=100`, 'check_runs', checkShape);
assessment = assessExactShaGates(selected, checks, jobs, inputs);
if (assessment.state === 'passed') {
// Do not approve an attempt superseded while its jobs/checks were read.
const finalRuns = await readRuns();
if (JSON.stringify(finalRuns) === JSON.stringify(selected)) return;
assessment = { state: 'pending', reason: 'Trusted CI or CodeQL run changed during verification' };
}
}
if (assessment.state === 'failed') throw new Error(assessment.reason);
lastReason = assessment.reason;
if (attempt < attempts) await client.pause(signal => sleep(delay, signal));
}
} catch (error) {
if (error instanceof ReleaseGateDeadlineError) {
throw new Error(`${error.message}; last pending gate: ${lastReason}`, { cause: error });
}
throw error;
}
throw new Error(`Timed out waiting for successful exact-SHA CI and CodeQL checks; last pending gate: ${lastReason}`);
}
async function main() {
const inputs = requiredEnvironment();
const tagOnly = process.argv.includes('--tag-only');
if (tagOnly && !inputs.tagObjectSha) throw new Error('Tag-only recheck requires the original tag object SHA');
const client = createGithubClient(inputs);
const tagObjectSha = await verifySignedAnnotatedTag(inputs, fetch, { client });
if (!tagOnly) await waitForExactShaGates(inputs, fetch, defaultSleep, { client });
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `release_sha=${inputs.releaseSha}\ntag_object_sha=${tagObjectSha}\n`);
}
console.log(tagOnly ? 'Verified unchanged release tag snapshot.'
: 'Verified signed annotated tag and successful exact-SHA CI/CodeQL gates.');
}
if (require.main === module) {
main().catch(error => {
console.error(`Release gate verification failed: ${error.message}`);
process.exitCode = 1;
});
}
module.exports = { assessExactShaGates, createGithubClient, requiredEnvironment, verifySignedAnnotatedTag, waitForExactShaGates };
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* NanoClaw v2 — Barebones Agent REPL for Everything Claude Code
* NanoClaw v2 — Barebones Agent REPL for ECC
*
* Zero external dependencies. Session-aware REPL around `claude -p`.
*/
+1 -1
View File
@@ -2,7 +2,7 @@
/**
* scripts/codemaps/generate.ts
*
* Codemap Generator for everything-claude-code (ECC)
* Codemap Generator for ECC
*
* Scans the current working directory and generates architectural
* codemap documentation under docs/CODEMAPS/ as specified by the
+3 -1
View File
@@ -81,7 +81,9 @@ require_file "$CONFIG_FILE" "Global config.toml"
require_file "$AGENTS_FILE" "Global AGENTS.md"
if [[ -f "$AGENTS_FILE" ]]; then
if search_file '^# Everything Claude Code \(ECC\)' "$AGENTS_FILE"; then
# Accept the current "# ECC" heading and the legacy expanded-name heading
# that older syncs wrote into existing ~/.codex/AGENTS.md files.
if search_file '^# (ECC|Everything Claude Code \(ECC\))( |$)' "$AGENTS_FILE"; then
ok "AGENTS contains ECC root instructions"
else
fail "AGENTS missing ECC root instructions"
+1 -1
View File
@@ -94,7 +94,7 @@ const DEFAULT_MCP_STARTUP_TIMEOUT_TOML = `startup_timeout_sec = ${DEFAULT_MCP_ST
// mcp-configs/mcp-servers.json. Existing user-managed entries are never
// touched by the merge (add-only), except the known-invalid repair below.
const ECC_SERVERS = {
'chrome-devtools': dlxServer('chrome-devtools', 'chrome-devtools-mcp@latest', { startup_timeout_sec: DEFAULT_MCP_STARTUP_TIMEOUT_SEC }, DEFAULT_MCP_STARTUP_TIMEOUT_TOML)
'chrome-devtools': dlxServer('chrome-devtools', 'chrome-devtools-mcp@1.10.1', { startup_timeout_sec: DEFAULT_MCP_STARTUP_TIMEOUT_SEC }, DEFAULT_MCP_STARTUP_TIMEOUT_TOML)
};
// ECC <= 2.0.0 emitted [mcp_servers.exa] with a `url` key. Codex rejects
+9 -4
View File
@@ -3,7 +3,7 @@
// Slash commands: /ecc /help /skill /docs /release
//
// Env: DISCORD_BOT_TOKEN (required), DISCORD_APP_ID (required),
// ECC_REPO (path to local clone, default ~/GitHub/ECC/everything-claude-code),
// ECC_REPO (path to local clone, default ~/GitHub/ECC),
// DISCORD_INVITE (optional, shown in /ecc)
//
// Crash-only design: any gateway close, error, or missed heartbeat ack exits
@@ -21,7 +21,12 @@ if (!TOKEN || !APP_ID) {
console.error('missing DISCORD_BOT_TOKEN / DISCORD_APP_ID');
process.exit(1);
}
const REPO = process.env.ECC_REPO || join(homedir(), 'GitHub/ECC/everything-claude-code');
// Prefer ~/GitHub/ECC. The nested legacy checkout path is still accepted so
// existing hosts keep working without setting ECC_REPO.
const REPO = process.env.ECC_REPO || [
join(homedir(), 'GitHub/ECC'),
join(homedir(), 'GitHub/ECC/everything-claude-code'),
].find(dir => existsSync(join(dir, 'package.json'))) || join(homedir(), 'GitHub/ECC');
const REPO_URL = 'https://github.com/affaan-m/ECC';
const INVITE = process.env.DISCORD_INVITE || '';
const API = 'https://discord.com/api/v10';
@@ -133,12 +138,12 @@ const HELP = [
const handlers = {
ecc: () => [
'**Everything Claude Code (ECC)** — the agent harness performance system.',
'**ECC** — the agent harness performance system.',
'Skills, agents, rules, hooks, MCP conventions, and operator workflows that move across Claude Code, Codex, OpenCode, Cursor, Gemini, and Zed.',
'',
`- repo: ${REPO_URL}`,
'- site: https://ecc.tools',
`- install: \`/plugin marketplace add affaan-m/everything-claude-code\` then \`/plugin install ecc\``,
`- install: \`/plugin marketplace add affaan-m/ECC\` then \`/plugin install ecc\``,
INVITE ? `- invite a friend: ${INVITE}` : '',
].filter(Boolean).join('\n'),
+64 -1
View File
@@ -19,6 +19,7 @@
# GAN_PROJECT_DIR — Working directory (default: current dir)
# GAN_SKIP_PLANNER — Set to "true" to skip planner phase
# GAN_EVAL_MODE — playwright, screenshot, or code-only (default: playwright)
# playwright requires a connected MCP server named "playwright"
set -euo pipefail
@@ -96,6 +97,38 @@ score_passes() {
awk -v s="$score" -v t="$threshold" 'BEGIN { exit !(s >= t) }'
}
playwright_mcp_is_connected() {
local status
local status_value
local check_mark
local heavy_check_mark
status=$(NO_COLOR=1 claude mcp get playwright 2>/dev/null) || return 1
status_value=$(printf '%s\n' "$status" | awk '
/^[[:space:]]*Status:[[:space:]]*/ {
sub(/^[[:space:]]*Status:[[:space:]]*/, "")
sub(/[[:space:]]*$/, "")
print
exit
}
')
check_mark=$(printf '\342\234\223')
heavy_check_mark=$(printf '\342\234\224')
[ "$status_value" = "$check_mark Connected" ] || \
[ "$status_value" = "$heavy_check_mark Connected" ]
}
evaluator_tools_for_mode() {
local base_tools="Read,Write,Bash,Grep,Glob"
local playwright_tools="mcp__playwright__browser_navigate,mcp__playwright__browser_click,mcp__playwright__browser_take_screenshot,mcp__playwright__browser_snapshot,mcp__playwright__browser_type,mcp__playwright__browser_fill_form,mcp__playwright__browser_resize,mcp__playwright__browser_press_key"
if [ "$1" = "playwright" ]; then
printf '%s,%s\n' "$base_tools" "$playwright_tools"
else
printf '%s\n' "$base_tools"
fi
}
elapsed() {
local now=$(date +%s)
local diff=$((now - START_TIME))
@@ -104,6 +137,30 @@ elapsed() {
# ─── Setup ───────────────────────────────────────────────────────────────────
case "$EVAL_MODE" in
playwright)
if ! playwright_mcp_is_connected; then
fail "GAN_EVAL_MODE=playwright requires a connected MCP server named 'playwright'."
fail "Run 'claude mcp get playwright' to inspect its status, or choose GAN_EVAL_MODE=screenshot or code-only."
exit 1
fi
;;
screenshot|code-only)
;;
*)
fail "Unsupported GAN_EVAL_MODE. Expected playwright, screenshot, or code-only."
exit 1
;;
esac
EVALUATOR_TOOLS=$(evaluator_tools_for_mode "$EVAL_MODE")
EVALUATOR_OPTIONS=(--allowedTools "$EVALUATOR_TOOLS")
if [ "$EVAL_MODE" != "playwright" ]; then
# Allow rules only pre-approve calls. Deny this server's tools explicitly
# in modes that inspect existing screenshots or source instead.
EVALUATOR_OPTIONS+=(--disallowedTools 'mcp__playwright__*')
fi
phase "GAN-STYLE HARNESS — Setup"
log "Brief: ${CYAN}${BRIEF}${NC}"
@@ -205,8 +262,14 @@ Update gan-harness/generator-state.md." \
# ── EVALUATE ──
echo -e "${RED}>> EVALUATOR (iteration $i)${NC}"
if [ "$EVAL_MODE" = "playwright" ] && ! playwright_mcp_is_connected; then
fail "The Playwright MCP server disconnected before evaluator iteration $i."
fail "Run 'claude mcp get playwright' to inspect its status, then retry the harness."
exit 1
fi
claude -p --model "$EVALUATOR_MODEL" \
--allowedTools "Read,Write,Bash,Grep,Glob" \
"${EVALUATOR_OPTIONS[@]}" \
"You are the Evaluator in a GAN-style harness. Read agents/gan-evaluator.md for full instructions.
Iteration: $i
+5 -2
View File
@@ -216,7 +216,8 @@ function hasFileWithExtension(rootDir, relativeDir, extensions) {
function detectTargetMode(rootDir) {
const packageJson = safeParseJson(safeRead(rootDir, 'package.json'));
if (packageJson?.name === 'everything-claude-code') {
// 'everything-claude-code' is the legacy package name, kept for older checkouts.
if (['ecc-universal', 'everything-claude-code'].includes(packageJson?.name)) {
return 'repo';
}
@@ -232,6 +233,8 @@ function detectTargetMode(rootDir) {
return 'consumer';
}
// The everything-claude-code entries below are legacy install ids, kept so
// existing 1.x installs are still detected.
const ECC_PLUGIN_KEY_PATTERNS = [
/^ecc@/i,
/^everything-claude-code@/i,
@@ -831,7 +834,7 @@ function getConsumerChecks(rootDir) {
points: 4,
scopes: ['repo'],
path: '~/.claude/plugins/ecc/ (legacy everything-claude-code paths also supported)',
description: 'Everything Claude Code is installed for the active user or project',
description: 'ECC is installed for the active user or project',
pass: Boolean(pluginInstall),
fix: 'Install the ECC plugin for this user or project before auditing project-specific harness quality.',
},
File diff suppressed because it is too large Load Diff
+69 -3
View File
@@ -43,9 +43,12 @@ const PROTECTED_FILES = new Set([
'prettier.config.js',
'prettier.config.cjs',
'prettier.config.mjs',
// Biome
// Biome's discovered filenames. Custom --config-path/extends targets need
// reference context; an arbitrary biome.* basename is not sufficient.
'biome.json',
'biome.jsonc',
'.biome.json',
'.biome.jsonc',
// Ruff (Python)
'.ruff.toml',
'ruff.toml',
@@ -57,11 +60,74 @@ const PROTECTED_FILES = new Set([
'.stylelintrc',
'.stylelintrc.json',
'.stylelintrc.yml',
'.stylelintrc.yaml',
'.stylelintrc.js',
'.stylelintrc.cjs',
'.stylelintrc.mjs',
// Stylelint's current spelling; only the legacy `.stylelintrc*` forms were
// listed, so a project using the documented `stylelint.config.js` had no
// protection at all.
'stylelint.config.js',
'stylelint.config.cjs',
'stylelint.config.mjs',
'stylelint.config.ts',
'stylelint.config.mts',
'stylelint.config.cts',
'.markdownlint.json',
'.markdownlint.jsonc',
'.markdownlint.yaml',
'.markdownlintrc'
'.markdownlint.yml',
'.markdownlint.cjs',
'.markdownlint.mjs',
'.markdownlintrc',
// markdownlint-cli2 reads its own config names, not `.markdownlint.*`.
'.markdownlint-cli2.jsonc',
'.markdownlint-cli2.yaml',
'.markdownlint-cli2.cjs',
'.markdownlint-cli2.mjs',
// Ignore files are the cheapest way to make a check pass without touching
// the code OR the config: adding one path to .eslintignore silences the
// failing file outright. Blocking the config while leaving its ignore list
// open left the hook's whole purpose one line away from being defeated.
// First-time creation stays allowed by the same existence check below.
'.eslintignore',
'.prettierignore',
'.stylelintignore',
'.markdownlintignore'
]);
/**
* Exact basenames only catch a tool's canonical entry point. Real repos split
* flat config across files: a shared `eslint.config.base.mjs` holding the
* ignore list and rule severities, imported by per-workspace
* `eslint.config.mjs` files. That is the common monorepo shape, and matching
* basenames alone protected the leaves while leaving the trunk -- the file that
* actually carries the rules -- freely editable.
*
* These patterns cover `<tool>.config.<qualifier>.<ext>` and
* `.<tool>rc.<qualifier>.<ext>` for the linters and formatters listed above.
* They are case-insensitive for the same reason the Set lookup above is.
*
* Deliberately NOT matched: build and test tooling -- `vite.config.ts`,
* `vitest.config.ts`, `jest.config.js`, `playwright.config.ts`,
* `tsconfig.json`. This hook exists to stop a LINTER config being weakened in
* place of fixing the code; editing a bundler or test-runner config is
* ordinary work, and sweeping those in would make the hook obstructive.
*/
const PROTECTED_PATTERNS = [
// eslint.config.base.mjs, prettier.config.shared.cjs, stylelint.config.local.js ...
/^(eslint|prettier|stylelint|commitlint|oxlint)\.config(\.[A-Za-z0-9_-]+)*\.(js|mjs|cjs|ts|mts|cts)$/i,
// .eslintrc.base.json, .prettierrc.shared.yml ...
/^\.(eslintrc|prettierrc|stylelintrc|markdownlintrc)(\.[A-Za-z0-9_-]+)*\.(js|cjs|mjs|json|jsonc|yml|yaml|toml)$/i,
];
function isProtectedName(basename) {
const lower = basename.toLowerCase();
return PROTECTED_FILES.has(basename)
|| PROTECTED_FILES.has(lower)
|| PROTECTED_PATTERNS.some((re) => re.test(basename));
}
function parseInput(inputOrRaw) {
if (typeof inputOrRaw === 'string') {
try {
@@ -101,7 +167,7 @@ function run(inputOrRaw, options = {}) {
// silently overwrite the real config while the guard returned exit 0.
// On genuinely case-sensitive filesystems this only costs a false positive
// on a distinct file that differs from a protected name by case alone.
if (PROTECTED_FILES.has(basename) || PROTECTED_FILES.has(basename.toLowerCase())) {
if (isProtectedName(basename)) {
// Allow first-time creation — there's no existing config to weaken.
// The hook's purpose is blocking modifications; writing a brand-new
// config file in a project that has none is a legitimate bootstrap
+419 -127
View File
@@ -53,11 +53,15 @@ const ROUTINE_POWERSHELL_NARROW_RECOVERY_HINT =
const ECC_DISABLE_VALUES = new Set(['0', 'false', 'off', 'disabled', 'disable']);
const ECC_ENABLE_VALUES = new Set(['1', 'true', 'on', 'enabled', 'enable', 'yes']);
// SQL-keyword + dd patterns stay as a single regex — they are stable
// phrases without shell-flag ordering concerns. Quoted strings are
// stripped before this regex runs so a commit message mentioning
// "drop table" no longer triggers a false positive.
const DESTRUCTIVE_SQL_DD = /\b(drop\s+table|delete\s+from|truncate|dd\s+if=)\b/i;
// SQL keywords remain a phrase check. Quoted strings are stripped before
// this regex runs so a commit message mentioning "drop table" stays passive.
// `dd if=` used to be a fourth arm here. Matching it as text could not work:
// the arm ended in `=`, so the shared trailing \b required the NEXT character
// to be a word character and `dd if=/dev/zero` slipped through while
// `echo dd if=x` — which runs no dd at all — was gated. The boundary decided
// the verdict instead of the command position, so dd moved to isDestructiveDd()
// alongside the other token-based detectors (#2642).
const DESTRUCTIVE_SQL = /\b(drop\s+table|delete\s+from|truncate)\b/i;
// Operator-supplied additional destructive patterns. Lazily compiled from
// `GATEGUARD_BASH_EXTRA_DESTRUCTIVE` (regex source) on first use, then
@@ -287,6 +291,9 @@ function tokenizeAllowlistedShellWords(input) {
}
const SHELL_SEGMENT_SEPARATORS = new Set([';', '|', '&', '\n', '\r']);
// Keep only the lexical information needed for Bash's reserved word `time`.
// Quoted/escaped `time` is an external command, not a shell pipeline prefix.
const SHELL_TIME_TOKENS = new WeakMap();
/**
* Quote-aware split of a command line into segments, with quotes removed from
@@ -303,30 +310,55 @@ const SHELL_SEGMENT_SEPARATORS = new Set([';', '|', '&', '\n', '\r']);
function quoteAwareSegments(input) {
const segments = [];
let words = [];
let timeTokens = new Set();
let current = '';
let hasWord = false;
let literalWord = true;
let quote = null;
let escaped = false;
const flushWord = () => {
if (hasWord) words.push(current);
if (hasWord) {
if (literalWord && ['time', '-p', '--'].includes(current)) timeTokens.add(words.length);
words.push(current);
}
current = '';
hasWord = false;
literalWord = true;
};
const flushSegment = () => {
flushWord();
if (words.length) segments.push(words);
if (words.length) {
if (timeTokens.size) SHELL_TIME_TOKENS.set(words, timeTokens);
segments.push(words);
}
words = [];
timeTokens = new Set();
};
for (const ch of String(input || '')) {
const source = String(input || '');
for (let i = 0; i < source.length; i += 1) {
const ch = source[i];
if (escaped) {
current += ch;
hasWord = true;
escaped = false;
continue;
}
if (ch === '\\') {
if (ch === '\\' && quote !== "'") {
const next = source[i + 1];
// Single quotes preserve every backslash; double quotes only escape
// shell-special characters. env -S must receive those literal bytes.
if (quote === '"' && next && !['$', '`', '"', '\\', '\n'].includes(next)) {
current += ch;
hasWord = true;
continue;
}
if (next === '\n') {
i += 1;
continue;
}
literalWord = false;
escaped = true;
hasWord = true;
continue;
@@ -339,6 +371,7 @@ function quoteAwareSegments(input) {
}
if (ch === '"' || ch === "'") {
quote = ch;
literalWord = false;
hasWord = true; // entering a quote starts a word, even if its content is empty
continue;
}
@@ -421,65 +454,307 @@ const SUDO_VALUE_FLAGS = new Set([
'--command-timeout',
]);
const DOAS_VALUE_FLAGS = new Set(['-u', '-C']);
const EXEC_VALUE_FLAGS = new Set(['-a']);
const ENV_VALUE_FLAGS = new Set(['-u', '--unset', '-C', '--chdir', '-a', '--argv0', '-S', '--split-string']);
const SHELL_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
/**
* Advance past `sudo`/`doas`/`env` wrappers including their flags and
* `VAR=value` assignments, so `sudo -u postgres psql ...` and
* `env PGUSER=postgres psql ...` still resolve to the real command.
* Split one literal env -S argument into argv, never into shell programs.
* Operators, substitutions and variable spellings stay literal text; no host
* environment is read. A dynamic executable name therefore remains opaque.
* Unterminated quotes, unknown escapes and invalid quoted \c return null.
* This is bounded literal parsing, not GNU env variable interpolation.
*
* @param {string} source
* @returns {string[] | null}
*/
function splitEnvWords(source) {
const words = [];
let word = '';
let hasWord = false;
let quote = null;
const flush = () => {
if (hasWord) words.push(word);
word = '';
hasWord = false;
};
const escapes = { f: '\f', n: '\n', r: '\r', t: '\t', v: '\v' };
for (let i = 0; i < source.length; i += 1) {
const ch = source[i];
if (ch === '\\' && quote !== "'") {
const next = source[++i];
if (next === undefined) return null;
if (next === 'c') {
if (quote) return null;
flush();
return words;
}
if (next === '_') {
if (quote) {
word += ' ';
hasWord = true;
} else flush();
continue;
}
if (Object.prototype.hasOwnProperty.call(escapes, next)) word += escapes[next];
else if (['#', '$', '"', "'", '\\'].includes(next)) word += next;
else return null;
hasWord = true;
continue;
}
if (quote) {
if (ch === quote) quote = null;
else word += ch;
hasWord = true;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
hasWord = true;
} else if (ch === '#' && !hasWord) {
break;
} else if (/\s/.test(ch)) {
flush();
} else {
word += ch;
hasWord = true;
}
}
if (quote) return null;
flush();
return words;
}
/** Locate a value-taking flag, including the tail of a short-option cluster. */
function wrapperValueOption(arg, valueFlags) {
if (arg.startsWith('--')) {
const separator = arg.indexOf('=');
const name = separator === -1 ? arg : arg.slice(0, separator);
return valueFlags.has(name)
? { name, value: separator === -1 ? undefined : arg.slice(separator + 1) }
: null;
}
if (!arg.startsWith('-')) return null;
for (let i = 1; i < arg.length; i += 1) {
const name = `-${arg[i]}`;
if (valueFlags.has(name)) {
return { name, value: i + 1 < arg.length ? arg.slice(i + 1) : undefined };
}
}
return null;
}
// Explicit external-launcher argv grammars for dd, SQL clients and shell-wrapper discovery.
// Unknown flags do not justify guessing which later argument executes.
// This literal allowlist cannot prove arbitrary custom-wrapper semantics or
// resolve dynamically selected executables; quoted operand text stays data.
const DD_LAUNCHER_OPTIONS = {
xargs: {
values: new Set(['-a', '--arg-file', '-d', '--delimiter', '-E', '-I', '-J', '-L', '-n', '--max-args', '-P', '--max-procs', '-s', '--max-chars', '--process-slot-var']),
optional: new Set(['-e', '--eof', '-i', '--replace', '-l', '--max-lines']),
flags: new Set(['-0', '--null', '-r', '--no-run-if-empty', '-t', '--verbose', '-p', '--interactive', '-x', '--exit', '-o', '--open-tty', '--show-limits'])
},
timeout: {
values: new Set(['-k', '--kill-after', '-s', '--signal']),
optional: new Set(),
flags: new Set(['-v', '--verbose', '--foreground', '--preserve-status'])
},
nice: { values: new Set(['-n', '--adjustment']), optional: new Set(), flags: new Set() },
nohup: { values: new Set(), optional: new Set(), flags: new Set() },
time: {
values: new Set(['-f', '--format', '-o', '--output-file']),
optional: new Set(),
flags: new Set(['-p', '--portability', '-a', '--append', '-q', '--quiet', '-v', '--verbose']),
nonCommand: new Set(['--help', '-V', '--version'])
},
stdbuf: {
values: new Set(['-i', '--input', '-o', '--output', '-e', '--error']),
optional: new Set(), flags: new Set()
},
ionice: {
values: new Set(['-c', '--class', '-n', '--classdata']),
optional: new Set(), flags: new Set(['-t', '--ignore']),
// These modes query/change existing processes rather than launch argv.
nonCommand: new Set(['-p', '--pid', '-P', '--pgid', '-u', '--uid'])
},
setsid: {
values: new Set(), optional: new Set(),
flags: new Set(['-c', '--ctty', '-f', '--fork', '-w', '--wait'])
}
};
/** Return the command position after one explicitly supported launcher's options. */
function ddLauncherCommandIndex(argv, index, name) {
const { values, optional, flags, nonCommand } = DD_LAUNCHER_OPTIONS[name];
// GNU time uses getopt_long: unique prefixes resolve against its complete
// eight-option table, including terminating help/version. Other launchers
// retain their explicit spellings; this does not affect shell-keyword time.
const timeLongOptions = name === 'time'
? [...values, ...flags, ...nonCommand].filter(flag => flag.startsWith('--'))
: null;
index += 1;
while (index < argv.length) {
const arg = argv[index];
if (arg === '--') {
index += 1;
break;
}
if (!arg.startsWith('-') || arg === '-') break;
// nice retains the historical -N / --N priority spellings.
if (name === 'nice' && /^--?\d+$/.test(arg)) {
index += 1;
continue;
}
if (arg.startsWith('--')) {
const separator = arg.indexOf('=');
let flag = separator === -1 ? arg : arg.slice(0, separator);
if (timeLongOptions && !timeLongOptions.includes(flag)) {
const matches = timeLongOptions.filter(option => option.startsWith(flag));
if (matches.length !== 1) return argv.length;
[flag] = matches;
}
if (nonCommand && nonCommand.has(flag)) return argv.length;
if (values.has(flag)) index += separator === -1 ? 2 : 1;
else if (optional.has(flag) || (separator === -1 && flags.has(flag))) index += 1;
else return argv.length;
continue;
}
let consumesNext = false;
for (let offset = 1; offset < arg.length; offset += 1) {
const flag = `-${arg[offset]}`;
if (nonCommand && nonCommand.has(flag)) return argv.length;
if (values.has(flag)) {
consumesNext = offset + 1 === arg.length;
break;
}
if (optional.has(flag)) break;
if (!flags.has(flag)) return argv.length;
}
index += consumesNext ? 2 : 1;
}
// timeout's duration is data, followed by exactly one executable position.
return name === 'timeout' ? index + 1 : index;
}
/**
* Resolve leading assignments, shell prefixes and sudo/doas/env into command
* argv. Wrapper-specific option values never become executable names. Every
* wrapper/split consumes source bytes, so the input-size budget bounds nested
* expansion without rejecting a valid long chain at an arbitrary depth.
*
* @param {string[]} tokens dequoted tokens for one segment
* @returns {number} index of the real command token
* @param {boolean} [allowShellBuiltins] false for external argv (e.g. find -exec)
* @param {boolean} [allowDdLaunchers] opt-in; other shared callers retain their grammar
* @returns {string[]} normalized argv, or [] when no literal command resolves
*/
function unwrapLeadWrappers(tokens) {
function unwrapLeadWrappers(tokens, allowShellBuiltins = true, allowDdLaunchers = false) {
let argv = tokens.slice();
let index = 0;
for (let guard = 0; guard < 4; guard += 1) {
if (index >= tokens.length) return index;
const base = commandBasename(tokens[index]);
if (base === 'sudo' || base === 'doas') {
let allowAssignments = true;
let allowShellTime = allowShellBuiltins;
const timeTokens = SHELL_TIME_TOKENS.get(tokens);
let budget = tokens.reduce((size, token) => size + token.length + 1, 1);
while (index < argv.length && budget-- > 0) {
while (allowAssignments && index < argv.length && SHELL_ASSIGNMENT.test(argv[index])) {
index += 1;
while (index < tokens.length) {
const flag = tokens[index];
allowShellTime = false;
}
if (index >= argv.length) return [];
const base = commandBasename(argv[index]);
if (allowDdLaunchers && allowShellTime && argv[index] === 'time' && timeTokens && timeTokens.has(index)) {
// Current Bash accepts only raw -p and -- as reserved-time options.
// Quotes/escapes make them executable words, unlike external time argv.
// The next command/exec builtin or assignment keeps shell semantics.
index += 1;
if (argv[index] === '-p' && timeTokens.has(index)) index += 1;
if (argv[index] === '--' && timeTokens.has(index)) index += 1;
continue;
}
if (allowShellBuiltins && base === 'command') {
allowShellTime = false;
index += 1;
while (index < argv.length && argv[index].startsWith('-') && argv[index] !== '-') {
const flag = argv[index++];
if (flag === '--') break;
// -v/-V (including -pv) only describe names; no command executes.
if (!/^-[pVv]+$/.test(flag) || /[vV]/.test(flag)) return [];
}
allowAssignments = false;
continue;
}
if (allowShellBuiltins && base === 'exec') {
allowShellTime = false;
index += 1;
while (index < argv.length && argv[index].startsWith('-') && argv[index] !== '-') {
const flag = argv[index];
if (flag === '--') {
index += 1;
break;
}
const option = wrapperValueOption(flag, EXEC_VALUE_FLAGS);
const flagLetters = option ? flag.slice(1, flag.indexOf('a')) : flag.slice(1);
if (!/^[cl]*$/.test(flagLetters)) return [];
index += option && option.value === undefined ? 2 : 1;
}
// exec replaces the shell with an external executable; its argument
// 'command' is not the shell's builtin, and A=1 is not an assignment.
allowShellBuiltins = false;
allowAssignments = false;
continue;
}
if (allowDdLaunchers && Object.prototype.hasOwnProperty.call(DD_LAUNCHER_OPTIONS, base)) {
index = ddLauncherCommandIndex(argv, index, base);
allowShellTime = false;
allowShellBuiltins = false;
allowAssignments = false;
continue;
}
if (base === 'sudo' || base === 'doas') {
allowShellTime = false;
allowShellBuiltins = false;
allowAssignments = true;
const valueFlags = base === 'sudo' ? SUDO_VALUE_FLAGS : DOAS_VALUE_FLAGS;
index += 1;
while (index < argv.length) {
const flag = argv[index];
if (flag === '--') {
index += 1;
break;
}
if (flag === '-' || !flag.startsWith('-')) break;
if (SUDO_VALUE_FLAGS.has(flag)) {
index += 2;
continue;
}
if (/^--[^=]+=.*$/.test(flag)) {
index += 1;
continue;
}
index += 1;
const option = wrapperValueOption(flag, valueFlags);
index += option && option.value === undefined ? 2 : 1;
}
continue;
}
if (base === 'env') {
allowShellTime = false;
allowShellBuiltins = false;
allowAssignments = true;
index += 1;
while (index < tokens.length) {
const arg = tokens[index];
if (arg === '--' || arg === '-' || arg === '-i' || arg === '--ignore-environment') {
while (index < argv.length) {
const arg = argv[index];
if (arg === '--') {
index += 1;
break;
}
const option = wrapperValueOption(arg, ENV_VALUE_FLAGS);
if (option && (option.name === '-S' || option.name === '--split-string')) {
const separate = option.value === undefined;
const source = separate ? argv[index + 1] : option.value;
if (source === undefined || budget-- <= 0) return [];
const expanded = splitEnvWords(source);
if (!expanded) return [];
argv = [...expanded, ...argv.slice(index + (separate ? 2 : 1))];
index = 0;
continue;
}
if (arg === '-u' || arg === '--unset') {
index += 2;
if (option) {
index += option.value === undefined ? 2 : 1;
continue;
}
if (arg === '-C' || arg === '--chdir') {
index += 2;
continue;
}
if (/^--unset=.*$/.test(arg) || /^--chdir=.*$/.test(arg) || /^--argv0=.*$/.test(arg)) {
index += 1;
continue;
}
if (arg.startsWith('-') && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) {
index += 1;
continue;
}
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) {
if (arg.startsWith('-') || SHELL_ASSIGNMENT.test(arg)) {
index += 1;
continue;
}
@@ -487,9 +762,9 @@ function unwrapLeadWrappers(tokens) {
}
continue;
}
break;
return argv.slice(index);
}
return index;
return [];
}
/**
@@ -502,10 +777,9 @@ function unwrapLeadWrappers(tokens) {
*/
function isDestructiveSqlClient(tokens) {
if (!tokens || tokens.length === 0) return false;
const start = unwrapLeadWrappers(tokens);
if (start >= tokens.length) return false;
if (!SQL_CLIENT_COMMANDS.has(commandBasename(tokens[start]))) return false;
return DESTRUCTIVE_SQL_DD.test(stripSqlLiterals(tokens.slice(start).join(' ')));
const argv = unwrapLeadWrappers(tokens, true, true);
if (!SQL_CLIENT_COMMANDS.has(commandBasename(argv[0]))) return false;
return DESTRUCTIVE_SQL.test(stripSqlLiterals(argv.join(' ')));
}
/**
@@ -519,18 +793,23 @@ function isDestructiveSqlClient(tokens) {
*/
function isDestructiveQuoteAware(raw, depth = 0) {
if (depth > 4) return false;
for (const tokens of quoteAwareSegments(raw)) {
if (tokens.length === 0) continue;
if (isDestructiveRm(tokens)) return true;
if (isDestructiveGit(tokens)) return true;
if (isDestructiveSqlClient(tokens)) return true;
if (isDestructiveFindExec(tokens.join(' '))) return true;
const wi = unwrapLeadWrappers(tokens);
const base = wi < tokens.length ? commandBasename(tokens[wi]) : '';
if (SHELL_WRAPPERS.has(base)) {
const ci = tokens.indexOf('-c', wi);
if (ci !== -1 && tokens[ci + 1] && isDestructiveQuoteAware(tokens[ci + 1], depth + 1)) {
return true;
// The outer command was preprocessed already; shell -c introduces a new
// program whose literal heredoc data must also stay outside execution.
const executable = depth === 0 ? raw : stripHeredocBodies(raw);
for (const body of collectExecutableBodies(executable)) {
for (const tokens of quoteAwareSegments(body)) {
if (tokens.length === 0) continue;
if (isDestructiveRm(tokens)) return true;
if (isDestructiveGit(tokens)) return true;
if (isDestructiveDd(tokens)) return true;
if (isDestructiveSqlClient(tokens)) return true;
if (isDestructiveFindExec(tokens)) return true;
const argv = unwrapLeadWrappers(tokens, true, true);
if (SHELL_WRAPPERS.has(commandBasename(argv[0]))) {
const ci = argv.indexOf('-c', 1);
if (ci !== -1 && argv[ci + 1] && isDestructiveQuoteAware(argv[ci + 1], depth + 1)) {
return true;
}
}
}
}
@@ -552,6 +831,27 @@ function commandBasename(token) {
.toLowerCase();
}
/**
* Detect a `dd` invocation carrying an `if=` or `of=` operand.
* Keep the existing input-file gate and include output-only writes from stdin.
*
* Token-based rather than a regex arm because the verdict has to depend on
* `dd` being the command, not on `dd if=` appearing anywhere in the line:
* `echo dd if=/dev/zero` executes nothing. dd operands are order-free, so
* `dd of=/dev/sda if=/dev/zero` counts too — a text pattern anchored on
* `dd\s+if=` missed that spelling entirely.
*
* Leading `sudo` / `doas` / `env`, their flags, and `VAR=value` assignment
* prefixes are skipped so `sudo dd if=/dev/zero` stays the dd invocation it is.
*
* @param {string[]} tokens
* @returns {boolean}
*/
function isDestructiveDd(tokens, allowShellBuiltins = true) {
const argv = unwrapLeadWrappers(tokens, allowShellBuiltins, true);
return commandBasename(argv[0]) === 'dd' && argv.slice(1).some(operand => /^(?:if|of)=/i.test(operand));
}
/**
* Detect `rm` invocations that recursively force-delete files. Handles
* combined (`-rf`, `-fr`, `-Rf`) and split (`-r -f`) flag forms.
@@ -878,87 +1178,79 @@ function collectExecutableBodies(raw) {
return bodies;
}
// Find predicates consume their arguments even when a value spells '-exec'.
const FIND_VALUE_PREDICATES = new Set([
'-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename', '-regex', '-iregex',
'-type', '-xtype', '-maxdepth', '-mindepth', '-mtime', '-mmin', '-atime', '-amin',
'-ctime', '-cmin', '-newer', '-anewer', '-cnewer', '-used', '-uid', '-gid', '-user',
'-group', '-perm', '-size', '-inum', '-links', '-fstype', '-context', '-lname', '-ilname',
'-printf', '-fprint', '-fprint0', '-fls', '-samefile', '-files0-from', '-regextype'
]);
const FIND_EXEC_ACTIONS = new Set(['-exec', '-execdir', '-ok', '-okdir']);
/**
* Detect destructive commands inside `find ... -exec` invocations.
* Handles `-exec rm {} \;`, `-exec rm -rf {} \;`, `-exec rmdir {} \;`,
* `-exec unlink {} \;`, `-exec git reset --hard {} \;`.
* Inspect each find executable action without mistaking its argv for another
* action. -ok/-okdir still run the command after their own confirmation, so
* they retain the explicit destructive gate. A + terminates exec/execdir only
* after {}; elsewhere it remains an ordinary argument.
*
* @param {string} command
* @param {string | string[]} command raw segment or already dequoted argv
* @returns {boolean}
*/
function isDestructiveFindExec(command) {
const raw = String(command || '');
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
const quoteAware = Array.isArray(command);
const tokens = quoteAware ? command : tokenize(String(command || '').trim());
if (commandBasename(tokens[0]) !== 'find') return false;
// Tokenize the whole command line
const tokens = tokenize(trimmed);
if (!tokens || tokens.length === 0) {
return false;
}
// Must start with `find`
if (commandBasename(tokens[0]) !== 'find') {
return false;
}
// Find the `-exec` token
const execIndex = tokens.indexOf('-exec');
if (execIndex === -1) {
return false;
}
// Collect tokens after `-exec` until we hit a terminator (`;`, `\;`, or `+`)
const execTokens = [];
for (let i = execIndex + 1; i < tokens.length; i++) {
const token = tokens[i];
if (token === ';' || token === '\\;' || token === '+') {
break;
for (let index = 1; index < tokens.length; index += 1) {
const action = tokens[index];
if (action === '-fprintf') {
index += 2;
continue;
}
execTokens.push(token);
}
if (execTokens.length === 0) {
return false;
}
const baseCmd = commandBasename(execTokens[0]);
// Directly destructive commands inside -exec
if (baseCmd === 'rmdir' || baseCmd === 'unlink') {
return true;
}
// `rm` with any flags (including none) inside -exec is destructive
if (baseCmd === 'rm') {
return true;
}
// `git reset --hard` inside -exec
if (baseCmd === 'git') {
const sub = findGitSubcommand(execTokens);
if (sub && sub.command === 'reset' && sub.rest.includes('--hard')) {
return true;
if (FIND_VALUE_PREDICATES.has(action) || /^-newer[a-zA-Z]{2}$/.test(action)) {
index += 1;
continue;
}
if (!FIND_EXEC_ACTIONS.has(action)) continue;
const execTokens = [];
for (index += 1; index < tokens.length; index += 1) {
const token = tokens[index];
if (token === ';' || token === '\\;' || (
token === '+' && (action === '-exec' || action === '-execdir') &&
execTokens[execTokens.length - 1] === '{}'
)) break;
execTokens.push(token);
}
if (execTokens.length === 0) continue;
// The legacy raw fallback can split quoted prose into apparent actions.
// Preserve its old rm/Git coverage, but classify dd only from real argv.
if (quoteAware && isDestructiveDd(execTokens, false)) return true;
const baseCmd = commandBasename(execTokens[0]);
// Preserve main's existing rm/rmdir/unlink and git-reset handling.
if (baseCmd === 'rm' || baseCmd === 'rmdir' || baseCmd === 'unlink') return true;
if (baseCmd === 'git') {
const sub = findGitSubcommand(execTokens);
if (sub && sub.command === 'reset' && sub.rest.includes('--hard')) return true;
}
}
return false;
}
function isDestructiveBash(command) {
// The SQL/dd phrases live in command bodies, not as flag-bearing
// arguments, so we still match them by regex — but on the input
// SQL phrases live in command bodies, not as flag-bearing
// arguments, so we still match them by regex - but on the input
// after quoting AND subshell delimiters are normalized so phrases
// inside `$(...)` or backticks are also caught.
const raw = String(command || '');
// Keep main's heredoc stripping: a phrase inside a heredoc body is data, not a
// command. dd is no longer part of this regex — see DESTRUCTIVE_SQL.
const executable = stripHeredocBodies(raw);
const flattened = explodeSubshells(stripQuotedStrings(executable));
if (DESTRUCTIVE_SQL_DD.test(flattened)) return true;
if (DESTRUCTIVE_SQL.test(flattened)) return true;
// Operator-supplied additional destructive patterns. Same scope as the
// built-in SQL/dd regex: matched against the quote-stripped, subshell-
// built-in SQL regex: matched against the quote-stripped, subshell-
// exploded command so a phrase inside `$(...)` or backticks is caught.
const extra = getExtraDestructiveRegex();
if (extra && extra.test(flattened)) return true;
@@ -982,7 +1274,7 @@ function isDestructiveBash(command) {
const segments = bodies.flatMap(splitCommandSegments);
for (const segment of segments) {
const stripped = stripQuotedStrings(segment);
if (DESTRUCTIVE_SQL_DD.test(stripped)) return true;
if (DESTRUCTIVE_SQL.test(stripped)) return true;
if (extra && extra.test(stripped)) return true;
const tokens = tokenize(segment);
if (isDestructiveRm(tokens)) return true;
+406
View File
@@ -0,0 +1,406 @@
'use strict';
// Finite literal shell scanner for block-no-verify. This is not an interpreter:
// aliases, generated programs, expansion results and foreign languages remain
// opaque. Every scan/queued region spends one shared input-proportional budget.
function createBudget(length) {
let remaining = 24 * (length + 1) + 4096;
return { spend(amount = 1) {
remaining -= amount;
if (remaining < 0) throw new RangeError('Shell scan work budget exceeded');
} };
}
function continuationEnd(input, index) {
if (input[index] !== '\\') return index;
if (input[index + 1] === '\n') return index + 2;
if (input[index + 1] === '\r' && input[index + 2] === '\n') return index + 3;
return index;
}
// Delimiters undergo quote removal, not command expansion. Keeping this small
// reader shared with substitution matching prevents body punctuation becoming
// shell syntax while locating the enclosing execution region.
function heredocDelimiter(input, start, budget) {
if (!input.startsWith('<<', start) || input[start + 2] === '<') return null;
const operator = input[start + 2] === '-' ? '<<-' : '<<';
let i = start + operator.length;
while (i < input.length) {
budget.spend();
if (input[i] === ' ' || input[i] === '\t' || input[i] === '\r') { i++; continue; }
const continued = continuationEnd(input, i);
if (continued === i) break;
i = continued;
}
let value = '';
let quote = null;
let began = false;
let quoted = false;
for (; i < input.length; i++) {
budget.spend();
const c = input[i];
if (quote === "'") {
if (c === "'") quote = null;
else value += c;
continue;
}
if (c === '\\') {
const continued = continuationEnd(input, i);
if (continued !== i) { i = continued - 1; continue; }
began = true; quoted = true;
const next = input[i + 1];
if (next === undefined) { value += c; continue; }
if (quote === '"' && !'"$`\\'.includes(next)) value += '\\';
value += next; i++; continue;
}
if (quote === '"') {
if (c === '"') quote = null;
else value += c;
continue;
}
if (c === "'" || c === '"') { quote = c; began = true; quoted = true; continue; }
if (/[\s;|&()<>]/.test(c) || (!began && c === '#')) break;
began = true; value += c;
}
return began ? { operator, word: { value, quoted }, end: i } : null;
}
function heredocLine(input, start, joinContinuations, budget) {
const parts = [];
let i = start;
let fragment = start;
while (i < input.length && input[i] !== '\n') {
budget.spend();
if (joinContinuations && input[i] === '\\') {
const continued = continuationEnd(input, i);
if (continued !== i) {
budget.spend(i - fragment + 1);
parts.push(input.slice(fragment, i));
i = continued; fragment = i; continue;
}
// An escaped backslash cannot itself quote the following newline. Keep
// the pair unchanged; only the unpaired final slash joins physical lines.
if (i + 1 < input.length) { i += 2; continue; }
}
i++;
}
budget.spend(3 * (i - start + 1));
parts.push(input.slice(fragment, i));
const newline = i < input.length;
return { text: parts.join(''), newline, end: newline ? i + 1 : i };
}
function heredocBody(input, start, redirect, budget) {
const lines = [];
let length = 0;
let i = start;
while (i < input.length) {
// Bash removes unquoted backslash-newline before testing the ending
// delimiter. Quoted delimiters retain physical lines. The original end
// offset is kept separately so the next actual command is never swallowed.
const line = heredocLine(input, i, !redirect.word.quoted, budget);
budget.spend(2 * (line.text.length + 1));
const text = redirect.operator === '<<-' ? line.text.replace(/^\t+/, '') : line.text;
i = line.end;
if (text.replace(/\r$/, '') === redirect.word.value) break;
lines.push(text);
length += text.length;
if (line.newline) { lines.push('\n'); length++; }
}
budget.spend(length + 1);
return { body: lines.join(''), end: i };
}
function legacyRegion(input, start, budget) {
const decoded = [];
let i = start + 1;
for (; i < input.length; i++) {
budget.spend();
const c = input[i];
if (c === '`') break;
if (c === '\\' && '$`\\\n'.includes(input[i + 1] || '\u0000')) {
// One old-style substitution layer only. Escapes outside an executable
// backtick region are still handled by the outer lexer as literal data.
const next = input[++i];
if (next !== '\n') decoded.push(next);
} else decoded.push(c);
}
budget.spend(decoded.length + 1);
return { text: decoded.join(''), end: i < input.length ? i + 1 : i };
}
// Locate a nested execution region without evaluating any supplied text.
// Balanced substitutions have independent quote state; malformed regions extend
// to EOF and remain conservatively inspectable instead of silently disappearing.
function executionRegion(input, start, budget) {
if (input[start] === '`') return legacyRegion(input, start, budget);
const from = start + 2;
const frame = () => ({ quote: null, depth: 1, cases: [], word: '', quotedWord: false, commandStart: true, heredocs: [] });
const stack = [frame()];
function endWord(state) {
if (!state.word) return;
const phase = state.cases[state.cases.length - 1];
if (!state.quotedWord && state.word === 'esac' && (state.commandStart || phase === 'pattern')) state.cases.pop();
else if (!state.quotedWord && state.word === 'case' && state.commandStart) state.cases.push('subject');
else if (phase === 'subject') state.cases[state.cases.length - 1] = 'in';
else if (!state.quotedWord && state.word === 'in' && phase === 'in') state.cases[state.cases.length - 1] = 'pattern';
state.commandStart = false;
state.word = ''; state.quotedWord = false;
}
for (let i = from; i < input.length; i++) {
budget.spend();
const state = stack[stack.length - 1];
const c = input[i];
if (state.quote === "'" || state.quote === "$'") {
if (state.quote === "$'" && c === '\\' && i + 1 < input.length) { i++; continue; }
if (c === "'") state.quote = null;
continue;
}
if (c === '\\') { state.word += input[i + 1] || ''; state.quotedWord = true; i++; continue; }
if (c === '$' && input[i + 1] === '(') {
state.word += '$()'; state.quotedWord = true; stack.push(frame()); i++; continue;
}
if (c === '`') {
const region = legacyRegion(input, i, budget);
state.word += '`'; state.quotedWord = true; i = region.end - 1; continue;
}
if (state.quote === '"') {
if (c === '"') state.quote = null;
continue;
}
if (c === '$' && input[i + 1] === "'") {
state.quote = "$'"; state.word += "$'"; state.quotedWord = true; i++; continue;
}
if (c === '"' || c === "'") { state.quote = c; state.word += c; state.quotedWord = true; continue; }
if (c === '#' && /[\s;|&()]/.test(input[i - 1] || ' ')) {
while (i < input.length && input[i] !== '\n') { budget.spend(); i++; }
i--; // Process the newline, including any pending heredoc bodies.
continue;
}
if (c === '<' && input.startsWith('<<<', i)) { endWord(state); i += 2; continue; }
if (c === '<' && input[i + 1] === '<' && input[i + 2] !== '<') {
endWord(state);
const delimiter = heredocDelimiter(input, i, budget);
if (delimiter) { state.heredocs.push(delimiter); i = delimiter.end - 1; continue; }
}
if (c === '\n' && state.heredocs.length) {
endWord(state);
let next = i + 1;
for (const redirect of state.heredocs) next = heredocBody(input, next, redirect, budget).end;
state.heredocs.length = 0;
state.commandStart = true; i = next - 1; continue;
}
if (/[\s;|&()]/.test(c)) endWord(state);
else state.word += c;
const phase = state.cases[state.cases.length - 1];
// A case pattern's closing ')' is not the end of $(...). Only literal
// keyword positions affect this state; quoted or echo operands do not.
if (c === ')' && phase === 'pattern') {
state.cases[state.cases.length - 1] = 'body'; state.commandStart = true; continue;
}
if (c === '(' && phase === 'pattern') continue;
if (c === ';' && input[i + 1] === ';' && phase === 'body') {
state.cases[state.cases.length - 1] = 'pattern'; state.commandStart = true; i++; continue;
}
if (/[;|&\n]/.test(c)) state.commandStart = true;
if (c === '(') state.depth++;
if (c === ')') {
state.depth--;
if (state.depth === 0) {
stack.pop();
if (stack.length === 0) {
budget.spend(i - from + 1);
return { text: input.slice(from, i), end: i + 1 };
}
}
}
}
budget.spend(input.length - from + 1);
return { text: input.slice(from), end: input.length };
}
function hasExpansion(input, index, processSubstitution = false) {
return input[index] === '`' || (input[index] === '$' && input[index + 1] === '(') ||
(processSubstitution && '<>'.includes(input[index]) && input[index + 1] === '(');
}
// Unquoted heredoc bodies expand even inside quote characters in the body.
// Backslash still protects $, ` and backslash; quoted delimiters skip this pass.
function scanExpansions(input, budget) {
const nested = [];
for (let i = 0; i < input.length;) {
budget.spend();
if (input[i] === '\\' && /[$`\\\r\n]/.test(input[i + 1] || '')) {
const continued = continuationEnd(input, i);
i = continued !== i ? continued : i + 2;
continue;
}
if (hasExpansion(input, i)) {
const region = executionRegion(input, i, budget);
nested.push(region.text); i = region.end;
} else i++;
}
return nested;
}
function scanShell(input, budget) {
const commands = [];
const nested = [];
const pendingHeredocs = [];
const rootScope = { parent: null, isolated: false, conditional: false };
let scope = rootScope;
const command = pipeFrom => ({ words: [], redirects: [], pipeFrom, nested: [], scope });
let current = command(null);
let word = null;
let quote = null;
let pendingRedirect = null;
let i = 0;
function begin() {
if (!word) word = { value: '', start: i, end: i, quoted: false, literal: true, dynamic: false };
}
function flushWord() {
if (!word) return;
word.end = i;
word.raw = input.slice(word.start, i);
if (pendingRedirect) {
const redirect = { operator: pendingRedirect, word, body: '' };
current.redirects.push(redirect);
if (pendingRedirect === '<<' || pendingRedirect === '<<-') pendingHeredocs.push({ redirect, owner: current });
pendingRedirect = null;
} else current.words.push(word);
word = null;
}
function flushCommand(pipe = false, background = false) {
flushWord();
const previous = current;
previous.pipeTo = pipe;
previous.background = background;
if (previous.closedScope && (pipe || background)) {
previous.closedScope.isolated = true;
previous.closedScope.pipelineLast = false;
}
const first = previous.words[0];
if (first && !first.quoted && ['if', 'then', 'elif', 'else', 'while', 'until', 'do', 'case', 'for', 'select', 'function'].includes(first.value)) scope.conditional = true;
if (previous.words.length || previous.redirects.length) commands.push(previous);
current = command(pipe ? previous : null);
pendingRedirect = null;
}
function consumeHeredocs() {
for (const { redirect, owner } of pendingHeredocs) {
const region = heredocBody(input, i, redirect, budget);
redirect.body = region.body;
i = region.end;
if (!redirect.word.quoted) {
const regions = scanExpansions(redirect.body, budget);
for (const text of regions) { budget.spend(); nested.push(text); owner.nested.push(text); }
}
}
pendingHeredocs.length = 0;
}
while (i < input.length) {
budget.spend();
const c = input[i];
if (quote === "'" || quote === "$'") {
if (quote === "$'" && c === '\\' && i + 1 < input.length) {
const next = input[i + 1];
// Preserve boundaries without claiming general ANSI-C escape expansion.
word.value += next === "'" || next === '\\' ? next : c + next;
i += 2; continue;
}
if (c === "'") quote = null;
else word.value += c;
i++; continue;
}
const continued = continuationEnd(input, i);
if (continued !== i) { i = continued; continue; }
if (c === '\\') {
begin(); word.quoted = true;
const next = input[i + 1];
if (next === undefined) { word.value += c; i++; continue; }
if (quote === '"' && !'"$`\\'.includes(next)) word.value += '\\';
word.value += next; i += 2; continue;
}
if (hasExpansion(input, i, quote === null)) {
begin(); word.literal = false; word.dynamic = true;
const region = executionRegion(input, i, budget);
nested.push(region.text); current.nested.push(region.text); word.value += '\u0000'; i = region.end; continue;
}
// Parameter expansions remain opaque values. Escaped/single/ANSI-C
// quoted dollars have already been consumed as data above.
if (c === '$' && /[A-Za-z0-9_@*#?$!{-]/.test(input[i + 1] || '')) {
begin(); word.dynamic = true;
}
if (quote === '"') {
if (c === '"') quote = null;
else word.value += c;
i++; continue;
}
if (c === '$' && input[i + 1] === "'") {
// ANSI-C escaped quotes do not close the word; its contents never expand.
// Numeric/control escapes and generated names remain outside this grammar.
begin(); word.quoted = true; quote = "$'"; i += 2; continue;
}
if (c === '"' || c === "'") { begin(); word.quoted = true; quote = c; i++; continue; }
if (c === '#' && !word) {
while (i < input.length && input[i] !== '\n') { budget.spend(); i++; }
continue;
}
const braceKeyword = (c === '{' || c === '}') && !word && current.words.length === 0 && /[\s;&|]/.test(input[i + 1] || ' ');
if (c === '\n' || c === ';' || c === '&' || c === '|' || c === '(' || c === ')' || braceKeyword) {
const pipe = c === '|' && input[i + 1] !== '|';
const background = c === '&' && input[i + 1] !== '&';
if ((c === '&' || c === '|') && input[i + 1] === c) scope.conditional = true;
const incomingPipe = Boolean(current.pipeFrom);
if (c === '(') {
flushWord();
// A function definition does not execute its body. Function grammar
// is unsupported: keep pre-definition alternatives instead of using
// flattened body mutations to certify a later command as safe.
if (current.words.length === 1 && !current.words[0].quoted &&
/^[A-Za-z_][A-Za-z0-9_]*$/.test(current.words[0].value)) scope.conditional = true;
}
flushCommand(pipe, background);
if (c === '(' || (braceKeyword && c === '{')) {
scope = { parent: scope, isolated: c === '(' || incomingPipe, conditional: false, pipelineLast: c === '{' && incomingPipe };
current.scope = scope;
} else if ((c === ')' || (braceKeyword && c === '}')) && scope.parent) {
const closedScope = scope;
scope = scope.parent;
current.scope = scope;
current.closedScope = closedScope;
// Ordered metadata only; there is no executable argv in this event.
commands.push({ ...command(null), scopeExit: closedScope });
}
i += (c === '&' || c === '|') && input[i + 1] === c ? 2 : 1;
if (c === '\n') consumeHeredocs();
continue;
}
if (c === '<' && input[i + 1] === '<' && input[i + 2] !== '<') {
const delimiter = heredocDelimiter(input, i, budget);
if (delimiter) {
if (word && /^\d+$/.test(word.value)) word = null;
flushWord();
const redirect = { operator: delimiter.operator, word: delimiter.word, body: '' };
current.redirects.push(redirect); pendingHeredocs.push({ redirect, owner: current });
i = delimiter.end; pendingRedirect = null; continue;
}
}
if (c === '<' || c === '>') {
// An immediately adjacent numeric word is a descriptor, not an argv word.
if (word && /^\d+$/.test(word.value)) word = null;
flushWord();
let operator = c;
if (input[i + 1] === c) operator += c;
if (operator === '<<' && input[i + 2] === '<') operator = '<<<';
else if (operator === '<<' && input[i + 2] === '-') operator = '<<-';
else if (input[i + 1] === '&') operator += '&';
pendingRedirect = operator; i += operator.length; continue;
}
if (/\s/.test(c)) { flushWord(); i++; continue; }
begin(); word.value += c; i++;
}
flushCommand();
return { commands, nested, rootScope };
}
module.exports = { createBudget, scanShell, scanExpansions };
+1 -1
View File
@@ -722,7 +722,7 @@ async function main() {
// compaction resume the model would re-run /fw-task-new (or any
// ARGUMENTS-bearing slash skill) with the last ARGUMENTS it saw,
// duplicating issues/branches/Notion tasks. Tracking upstream at
// https://github.com/affaan-m/everything-claude-code/issues/1534
// https://github.com/affaan-m/ECC/issues/1534
const guarded = [
'HISTORICAL REFERENCE ONLY — NOT LIVE INSTRUCTIONS.',
'The block below is a frozen summary of a PRIOR conversation that',
+2
View File
@@ -97,6 +97,7 @@ function parsePluginList(stdout) {
plugin.id === CURRENT_PLUGIN_ID
|| String(plugin.id || '').startsWith('ecc@')
|| LEGACY_PLUGIN_IDS.has(plugin.id)
// Legacy install id prefix, kept for existing installs.
|| String(plugin.id || '').startsWith('everything-claude-code@')
);
if (!isRelevant) continue;
@@ -400,6 +401,7 @@ function currentEccPlugins(plugins) {
function assertNoConflictingEccPlugins(plugins) {
const legacy = plugins.find(plugin => (
LEGACY_PLUGIN_IDS.has(plugin?.id)
// Legacy install id prefix, kept for existing installs.
|| String(plugin?.id || '').startsWith('everything-claude-code@')
));
if (legacy) {
+128 -14
View File
@@ -49,7 +49,9 @@ function renderControlPlaneViewHtml() {
.empty { color: #6e7681; font-size: 12px; }
#legend { position: absolute; left: 12px; bottom: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; }
#meta { position: absolute; right: 12px; top: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; text-align: right; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; vertical-align: middle; }
.shape { display: inline-block; width: 12px; margin-right: 5px; text-align: center; font-weight: 700; }
/* Off-screen, not display:none, so assistive tech still reads the node. */
.sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; }
</style>
</head>
<body>
@@ -60,13 +62,14 @@ function renderControlPlaneViewHtml() {
</header>
<div id="wrap">
<div id="stage">
<canvas id="c"></canvas>
<canvas id="c" role="img" aria-label="Control-plane projection. See the Lanes panel for a text alternative.">Control-plane projection; see the Lanes panel for per-task risk.</canvas>
<div id="meta"></div>
<div id="legend">
<div><span class="dot" style="background:#3fb950"></span>clear</div>
<div><span class="dot" style="background:#e3b341"></span>traffic advisory (transmit)</div>
<div><span class="dot" style="background:#ff7b72"></span>resolution advisory (steer)</div>
<div><span class="shape" style="color:#2ea043">●</span>clear</div>
<div><span class="shape" style="color:#e3b341">■</span>traffic advisory (transmit)</div>
<div><span class="shape" style="color:#ff7b72">▲</span>resolution advisory (steer)</div>
</div>
<div id="announce" class="sr" role="status" aria-live="polite" aria-atomic="true"></div>
</div>
<div id="side">
<h2>Events</h2>
@@ -80,6 +83,9 @@ function renderControlPlaneViewHtml() {
var canvas = document.getElementById('c');
var ctx = canvas.getContext('2d');
var view = { tasks: [], lanes: [], pairs: [], events: [], projection: { agents: [] }, thresholds: { ta: 0.35, ra: 0.7 } };
// Last message handed to the live region, so a poll that changes nothing
// stays silent.
var lastSpoken = null;
function resize() {
var r = canvas.parentElement.getBoundingClientRect();
@@ -91,10 +97,35 @@ function renderControlPlaneViewHtml() {
}
window.addEventListener('resize', resize);
// The previous clear/resolution palette had similar relative luminance.
// Separate luminance values plus redundant shapes and text reduce reliance
// on hue; palette math alone does not establish a user's visual experience.
function riskLevel(risk) {
if (risk >= view.thresholds.ra) return 'resolution';
if (risk >= view.thresholds.ta) return 'traffic';
return 'clear';
}
function riskColor(risk) {
if (risk >= view.thresholds.ra) return '#ff7b72';
if (risk >= view.thresholds.ta) return '#e3b341';
return '#3fb950';
return '#2ea043';
}
// Shape is the second, non-colour channel: circle / square / triangle.
function drawRiskMarker(x, y, radius, level) {
ctx.beginPath();
if (level === 'resolution') {
ctx.moveTo(x, y - radius);
ctx.lineTo(x + radius, y + radius);
ctx.lineTo(x - radius, y + radius);
ctx.closePath();
} else if (level === 'traffic') {
ctx.rect(x - radius, y - radius, radius * 2, radius * 2);
} else {
ctx.arc(x, y, radius, 0, Math.PI * 2);
}
ctx.fill();
}
// Fit the projected points into the canvas with a margin. The PCA scores
@@ -142,8 +173,9 @@ function renderControlPlaneViewHtml() {
var t = taskById[a.agentId] || {};
var files = (t.workingSet && t.workingSet.fileCount) || 1;
var radius = 6 + Math.sqrt(files) * 3;
ctx.fillStyle = riskColor(a.maxRisk || 0);
ctx.beginPath(); ctx.arc(p[0], p[1], radius, 0, Math.PI * 2); ctx.fill();
var risk = a.maxRisk || 0;
ctx.fillStyle = riskColor(risk);
drawRiskMarker(p[0], p[1], radius, riskLevel(risk));
ctx.fillStyle = '#c9d1d9';
ctx.font = '11px -apple-system, system-ui, sans-serif';
ctx.fillText(String(a.agentId).slice(0, 18), p[0] + radius + 4, p[1] + 3);
@@ -198,7 +230,9 @@ function renderControlPlaneViewHtml() {
var st = document.createElement('span'); st.textContent = t.harness + ' / ' + t.state + ' / ' + (t.workingSet.fileCount || 0) + ' files';
var risk = document.createElement('span'); risk.className = 'risk';
risk.style.color = riskColor(t.projection.maxRisk || 0);
risk.textContent = t.projection.point ? Math.round((t.projection.maxRisk || 0) * 100) + '%' : 'no pair';
risk.textContent = (t.projection.point
? Math.round((t.projection.maxRisk || 0) * 100) + '% - ' + riskLevel(t.projection.maxRisk || 0)
: 'no pair');
row.appendChild(idEl); row.appendChild(st); row.appendChild(risk);
el.appendChild(row);
});
@@ -206,6 +240,40 @@ function renderControlPlaneViewHtml() {
});
}
// Wording shared by the canvas label and the live region so an outage reads
// the same way however the operator reaches it.
var UNAVAILABLE = 'Control-plane data is unavailable. Advisories and steering are unknown.';
// Polls are not sequenced, so a slow request can settle out of order. Only
// the newest poll that has already settled may update the view: a success
// from a superseded poll would show older counts, and a failure from a
// superseded poll would erase newer counts. Anchoring to the last settled
// poll rather than the last started one also lets a failure land while a
// newer poll is still pending, instead of leaving stale guidance on screen.
var settledPoll = 0;
// Monotonic id handed to each poll as it starts.
var pollSeq = 0;
// A poll that never answers must not stay pending forever, or the last
// steering guidance stays on screen indefinitely.
var TIMEOUT_MS = 10000;
// Polling runs every few seconds, so only speak when the advisory and
// steering counts actually move. Repeating an unchanged summary would talk
// over the operator without telling them anything new.
function announce(message) {
if (message === lastSpoken) return;
lastSpoken = message;
document.getElementById('announce').textContent = message;
}
function unavailable() {
document.getElementById('status').textContent = 'offline';
// The last guidance is now stale, so replace it rather than leaving the
// live region claiming the airspace is clear. The canvas label goes with
// it, or it would still report the last successful counts.
canvas.setAttribute('aria-label', UNAVAILABLE);
announce(UNAVAILABLE);
}
function apply(data) {
if (!data || data.schemaVersion !== 'ecc.control-plane.view.v1' ||
!['tasks', 'lanes', 'pairs', 'events'].every(function (key) { return Array.isArray(data[key]); }) ||
@@ -213,22 +281,68 @@ function renderControlPlaneViewHtml() {
!Number.isFinite(data.thresholds.ta) || !Number.isFinite(data.thresholds.ra)) {
throw new Error('Invalid control-plane view');
}
var previous = view;
var drawStarted = false;
view = Object.assign({}, data);
renderEvents(); renderLanes(); draw();
try {
renderEvents(); renderLanes();
drawStarted = true;
draw();
} catch (error) {
view = previous;
try {
renderEvents(); renderLanes();
if (drawStarted) draw();
} catch (_) {
// Keep the accepted model even if the DOM cannot be restored.
}
throw error;
}
var c = view.counts || {};
var summary = (c.tasks || 0) + ' tasks in ' + (c.lanes || 0) + ' lanes, ' +
(c.advisories || 0) + ' advisories, ' + (c.resolutions || 0) + ' steering. ' +
'See the Lanes panel for per-task risk.';
canvas.setAttribute('aria-label', summary);
document.getElementById('status').textContent =
(c.tasks || 0) + ' tasks in ' + (c.lanes || 0) + ' lanes | ' + (c.agents || 0) + ' with edits | ' +
(c.advisories || 0) + ' advisories (' + (c.resolutions || 0) + ' steering)' +
(view.inventory && view.inventory.status !== 'ok' ? ' | inventory ' + view.inventory.status : '');
announce((c.advisories || 0) + ' advisories, ' +
(c.resolutions || 0) + ' steering. ' +
((c.resolutions || 0) > 0 ? 'Steering is required.' : 'No steering is required.'));
}
function poll() {
fetch('/api/control-plane').then(function (r) {
var token = ++pollSeq;
var timer = null;
var controller = new AbortController();
// Reject on a timer so a hung request cannot keep the previous guidance on
// screen forever. The abort is what wakes this poll up, so a timeout is
// reported as an outage rather than swallowed.
timer = setTimeout(function () { controller.abort(); }, TIMEOUT_MS);
// Claim before rendering or reporting failure: two JSON bodies may settle
// in the same turn, before a later cleanup continuation can run. Equality
// lets a render failure report unavailable for the token that just claimed.
function claim() {
if (token < settledPoll) return false;
settledPoll = token;
return true;
}
function settle() {
clearTimeout(timer);
}
fetch('/api/control-plane', { signal: controller.signal }).then(function (r) {
if (!r.ok) throw new Error('Control-plane request failed');
return r.json();
}).then(apply).catch(function () {
document.getElementById('status').textContent = 'offline';
});
}).then(function (data) {
// A newer poll already owns the view, so do not resurrect older counts.
if (!claim()) return;
apply(data);
}).catch(function () {
if (!claim()) return;
unavailable();
}).then(settle, settle);
}
resize();
+144 -43
View File
@@ -410,7 +410,7 @@ function renderControlPaneHtml() {
</div>
<div id="app" hidden></div>
<script>
const state = { query: '' };
const state = { query: '', shownQuery: '' };
const $ = selector => document.querySelector(selector);
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, char => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
@@ -436,6 +436,31 @@ function renderControlPaneHtml() {
target.textContent = '';
}
// The board keeps the last snapshot on screen, so a failed refresh has to
// say that the data is no longer live, and since when.
let loadedAt = null;
// Loads are numbered as they start and finish in any order. The board shows
// the newest data any load brought, and the error box the outcome of the
// newest load that has finished, so a load that finishes late can neither
// replace newer data nor overrule a newer outcome.
const SNAPSHOT_DEADLINE_MS = 10000;
const SNAPSHOT_POLL_INTERVAL_MS = 15000;
let snapshotsInFlight = 0;
let loadsStarted = 0;
let newestFinished = 0;
let shownLoad = 0;
let failure = null;
function showRefreshFailure(error) {
const since = loadedAt
? ' The data below is from ' + loadedAt.toLocaleString() + '.'
: '';
showError('#app', 'Live refresh failed.' + since + '\\n' + formatError(error));
}
function showFailure() {
if (failure.live) showRefreshFailure(failure.error);
else showError('#app', failure.error);
}
async function readJsonResponse(response) {
let payload;
try {
@@ -471,18 +496,17 @@ function renderControlPaneHtml() {
['Unread', summary.unreadMessages],
['Tokens', fmt.format(summary.totalTokens || 0)],
];
$('#metrics').innerHTML = items.map(([label, value]) =>
return items.map(([label, value]) =>
'<div class="metric"><span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(value) + '</strong></div>'
).join('');
}
function renderSessions(sessions) {
if (!sessions.length) {
$('#sessions').innerHTML = '<div class="empty">No ECC2 sessions found.</div>';
return;
return '<div class="empty">No ECC2 sessions found.</div>';
}
$('#sessions').innerHTML = '<table><thead><tr><th>State</th><th>Session</th><th>Harness</th><th>Worktree</th><th>Updated</th></tr></thead><tbody>' +
return '<table><thead><tr><th>State</th><th>Session</th><th>Harness</th><th>Worktree</th><th>Updated</th></tr></thead><tbody>' +
sessions.map(session => '<tr>' +
'<td>' + statePill(session.state) + '</td>' +
'<td><strong>' + escapeHtml(session.id) + '</strong><br><span class="subtle">' + escapeHtml(session.task) + '</span></td>' +
@@ -493,13 +517,13 @@ function renderControlPaneHtml() {
'</tbody></table>';
}
function renderWorkItems(workItems) {
function renderWorkItems(workItems, allowActions) {
const summary = workItems || { totalCount: 0, openCount: 0, blockedCount: 0, doneCount: 0, kanban: {}, items: [] };
const items = Array.isArray(summary.items) ? summary.items : [];
const kanban = summary.kanban || {};
const needsAssignment = Array.isArray(summary.needsAssignment) ? summary.needsAssignment : [];
const assignment = summary.assignment || { agent: 0, human: 0, unassigned: 0 };
$('#work-item-count').textContent = summary.openCount + ' open / ' + summary.blockedCount + ' blocked'
const count = summary.openCount + ' open / ' + summary.blockedCount + ' blocked'
+ ' / ' + (assignment.agent || 0) + ' agent / ' + (assignment.human || 0) + ' human'
+ (needsAssignment.length ? ' / ' + needsAssignment.length + ' need owner' : '');
@@ -509,11 +533,10 @@ function renderControlPaneHtml() {
).join('') + '</div>';
if (!items.length) {
$('#work-items').innerHTML = laneHtml + '<div class="empty">No agent work items found.</div>';
return;
return { count, html: laneHtml + '<div class="empty">No agent work items found.</div>' };
}
$('#work-items').innerHTML = laneHtml + items.slice(0, 8).map(item => {
const html = laneHtml + items.slice(0, 8).map(item => {
const branch = item.branch || (item.metadata && item.metadata.branch) || '';
const mergeGate = item.mergeGate || (item.metadata && item.metadata.mergeGate) || '';
const blocker = item.blocker || (item.metadata && item.metadata.blocker) || '';
@@ -526,7 +549,7 @@ function renderControlPaneHtml() {
const moveButtons = ['ready', 'running', 'blocked', 'done'].map(lane =>
'<button type="button" data-wi-action="move" data-wi-id="' + idAttr + '" data-wi-lane="' + lane + '">' + escapeHtml(lane) + '</button>'
).join('');
const controls = state.allowActions
const controls = allowActions
? '<div class="row">'
+ (assigneeKind === 'unassigned' ? '<button type="button" data-wi-action="claim" data-wi-id="' + idAttr + '">Claim</button>' : '')
+ moveButtons
@@ -542,6 +565,10 @@ function renderControlPaneHtml() {
'</div>';
}).join('');
return { count, html };
}
function bindWorkItemActions() {
document.querySelectorAll('#work-items [data-wi-action]').forEach(button => {
button.addEventListener('click', () => {
const id = button.getAttribute('data-wi-id');
@@ -555,13 +582,12 @@ function renderControlPaneHtml() {
}
function renderKnowledge(knowledge) {
$('#knowledge-count').textContent = knowledge.entityCount + ' entities';
const count = knowledge.entityCount + ' entities';
if (!knowledge.results.length) {
$('#knowledge').innerHTML = '<div class="empty">No recall results for this query.</div>';
return;
return { count, html: '<div class="empty">No recall results for this query.</div>' };
}
$('#knowledge').innerHTML = knowledge.results.map(result => {
const html = knowledge.results.map(result => {
const entity = result.entity;
const obs = result.latestObservation;
return '<div class="result">' +
@@ -572,16 +598,16 @@ function renderControlPaneHtml() {
'<div class="subtle">terms: ' + escapeHtml((result.matchedTerms || []).join(', ') || '-') + '</div>' +
'</div>';
}).join('');
return { count, html };
}
function renderConnectors(connectors) {
$('#connector-count').textContent = connectors.length + ' configured';
const count = connectors.length + ' configured';
if (!connectors.length) {
$('#connectors').innerHTML = '<div class="empty">No memory connectors configured.</div>';
return;
return { count, html: '<div class="empty">No memory connectors configured.</div>' };
}
$('#connectors').innerHTML = connectors.map(connector => {
const html = connectors.map(connector => {
const status = connector.syncedSources > 0 ? '<span class="pill good">synced</span>' : '<span class="pill warn">not synced</span>';
return '<div class="connector">' +
'<div class="row"><strong>' + escapeHtml(connector.name) + '</strong>' + status + '</div>' +
@@ -589,16 +615,20 @@ function renderControlPaneHtml() {
'<div class="subtle">sources ' + escapeHtml(connector.syncedSources) + ' - last ' + escapeHtml(connector.lastSyncedAt || '-') + '</div>' +
'</div>';
}).join('');
return { count, html };
}
function renderActions(actions) {
$('#actions').innerHTML = actions.map(action => '<div class="action">' +
return actions.map(action => '<div class="action">' +
'<div class="row"><strong>' + escapeHtml(action.label) + '</strong>' +
(action.executable ? '<button data-action="' + escapeHtml(action.id) + '">Run</button>' : '<span class="pill">copy</span>') + '</div>' +
'<div class="subtle">' + escapeHtml(action.description) + '</div>' +
'<code>' + escapeHtml(action.commandLine) + '</code>' +
'</div>').join('');
}
function bindActions() {
document.querySelectorAll('[data-action]').forEach(button => {
button.addEventListener('click', () => {
runAction(button.dataset.action);
@@ -611,10 +641,12 @@ function renderControlPaneHtml() {
output.textContent = 'Running ' + actionId + '...';
try {
// An action runs for the query its card on the board was built for.
// After a failed load that is not always the query typed last.
const response = await fetch('/api/actions/' + encodeURIComponent(actionId), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query: state.query })
body: JSON.stringify({ query: state.shownQuery })
});
const payload = await readJsonResponse(response);
output.textContent = JSON.stringify(payload, null, 2);
@@ -624,27 +656,95 @@ function renderControlPaneHtml() {
}
}
async function load() {
const url = new URL('/api/snapshot', window.location.href);
if (state.query) url.searchParams.set('query', state.query);
const response = await fetch(url);
const snapshot = await readJsonResponse(response);
$('#query').value = snapshot.knowledge.query || state.query;
$('#db-path').textContent = snapshot.database.exists ? snapshot.dbPath : 'database missing';
state.allowActions = Boolean(snapshot.execution.allowActions);
$('#action-status').textContent = state.allowActions ? 'local allowlist' : 'read-only';
renderMetrics(snapshot.summary);
renderSessions(snapshot.sessions);
renderWorkItems(snapshot.workItems);
renderKnowledge(snapshot.knowledge);
renderConnectors(snapshot.connectors);
renderActions(snapshot.actions.map(action => ({
...action,
executable: snapshot.execution.allowActions && action.executable
})));
async function load(live = false) {
const id = ++loadsStarted;
const query = state.query;
snapshotsInFlight++;
let snapshotTimer = null;
try {
const url = new URL('/api/snapshot', window.location.href);
if (query) url.searchParams.set('query', query);
const controller = new AbortController();
let rejectDeadline;
const deadline = new Promise((_resolve, reject) => { rejectDeadline = reject; });
snapshotTimer = setTimeout(() => {
// Reject first so the fixed timeout wins over an abort rejection.
rejectDeadline(new Error('Snapshot request timed out after 10 seconds.'));
controller.abort();
}, SNAPSHOT_DEADLINE_MS);
// Only fetch/body decoding races the deadline. Late abort-ignoring
// responses have no rendering or state side effects after losing.
const snapshot = await Promise.race([
(async () => {
const response = await fetch(url, { signal: controller.signal });
return readJsonResponse(response);
})(),
deadline
]);
// A snapshot that cannot be shown fails its load like one that could
// not be fetched, and older data may still take the board.
if (id > shownLoad) {
render(snapshot, query);
shownLoad = id;
}
} catch (error) {
if (id < newestFinished) return;
newestFinished = id;
failure = { error, live };
showFailure();
return;
} finally {
if (snapshotTimer !== null) clearTimeout(snapshotTimer);
snapshotsInFlight--;
}
if (id < newestFinished) {
// A newer load failed first. This data is still the newest on the
// board, so the failure stays, dated by it.
if (failure && id === shownLoad) showFailure();
return;
}
newestFinished = id;
failure = null;
clearError('#app');
}
function render(snapshot, query) {
// Prepare every fragment before changing the board. Malformed late
// sections must not leave new results with the prior action context.
const allowActions = Boolean(snapshot.execution.allowActions);
const workItems = renderWorkItems(snapshot.workItems, allowActions);
const knowledge = renderKnowledge(snapshot.knowledge);
const connectors = renderConnectors(snapshot.connectors);
const updates = [
['#query', 'value', snapshot.knowledge.query ?? query],
['#db-path', 'textContent', snapshot.database.exists ? snapshot.dbPath : 'database missing'],
['#action-status', 'textContent', allowActions ? 'local allowlist' : 'read-only'],
['#metrics', 'innerHTML', renderMetrics(snapshot.summary)],
['#sessions', 'innerHTML', renderSessions(snapshot.sessions)],
['#work-item-count', 'textContent', workItems.count],
['#work-items', 'innerHTML', workItems.html],
['#knowledge-count', 'textContent', knowledge.count],
['#knowledge', 'innerHTML', knowledge.html],
['#connector-count', 'textContent', connectors.count],
['#connectors', 'innerHTML', connectors.html],
['#actions', 'innerHTML', renderActions(snapshot.actions.map(action => ({
...action,
executable: allowActions && action.executable
})))]
].map(([selector, property, value]) => {
const target = $(selector);
if (!target) throw new Error('Missing control pane element: ' + selector);
return { target, property, value };
});
const completedAt = new Date();
updates.forEach(({ target, property, value }) => { target[property] = value; });
state.allowActions = allowActions;
state.shownQuery = query;
loadedAt = completedAt;
bindWorkItemActions();
bindActions();
}
$('#query-form').addEventListener('submit', event => {
event.preventDefault();
state.query = $('#query').value.trim();
@@ -679,11 +779,12 @@ function renderControlPaneHtml() {
.catch(error => showError('#app', error));
};
// Live board: refresh on a gentle interval; pause while a prompt/tab is hidden.
// Only automatic polls coalesce. Manual/query loads retain ordered overlap.
// Browser suspension may delay timers; this is not a wall-clock server SLA.
setInterval(() => {
if (document.hidden) return;
load().catch(() => {});
}, 15000);
if (document.hidden || snapshotsInFlight > 0) return;
load(true).catch(error => showError('#app', error));
}, SNAPSHOT_POLL_INTERVAL_MS);
load().catch(error => showError('#app', error));
</script>
+395 -299
View File
@@ -10,8 +10,15 @@ const { readInstallState, validateInstallState } = require('./install-state');
const { assertWithinTrustedRoot } = require('./path-safety');
const { createInstallPlanFromRequest } = require('./install/runtime');
const { assertNoNewUserOwnedFile, prepareUserOwnedFileGuard } = require('./install/ownership-guard');
const { writeFileNoFollow: guardedWriteFile } = require('./install/guarded-write');
const { withOpenCodeInstallLocks } = require('./install/opencode-install-lock');
const { isCodexUserConfig } = require('./install/codex-user-config');
const { getRecordedHookConsent } = require('./install/hook-consent');
const {
disableOpenCodeHookPluginRegistration,
getDisabledOpenCodePluginContent,
getRecordedHookConsent,
withHookConsent,
} = require('./install/hook-consent');
const {
prepareClaudeSkillMigration,
} = require('./install/claude-skill-migration');
@@ -232,6 +239,12 @@ function transformCopyFileContent(operation, content) {
if (operation.contentTransform === 'antigravity-agent-frontmatter') {
return adaptAntigravityAgent(content, operation.sourceRelativePath);
}
if (operation.contentTransform === 'opencode-disable-ecc-hooks') {
return disableOpenCodeHookPluginRegistration(content, operation.sourceRelativePath);
}
if (operation.contentTransform === 'opencode-disable-plugin-entrypoint') {
return getDisabledOpenCodePluginContent();
}
throw new Error(`Unknown install content transform: ${operation.contentTransform}`);
}
@@ -450,66 +463,15 @@ function createChangedDestinationError(action) {
);
}
function getStableParentStat(filePath, action) {
const parentStat = fs.lstatSync(path.dirname(filePath));
if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) {
throw createChangedDestinationError(action);
}
return parentStat;
}
function assertPinnedWriteDestination(
filePath,
fileDescriptor,
expectedParentStat,
trustedRoot,
action
) {
const liveDestination = getManagedDestination(filePath, trustedRoot, action);
if (path.resolve(liveDestination.managedPath) !== path.resolve(filePath)) {
throw createChangedDestinationError(action);
}
const liveParentStat = getStableParentStat(filePath, action);
if (!hasSameFileIdentity(expectedParentStat, liveParentStat)) {
throw createChangedDestinationError(action);
}
const descriptorStat = fs.fstatSync(fileDescriptor);
const livePathStat = fs.lstatSync(liveDestination.managedPath);
if (
!descriptorStat.isFile()
|| !livePathStat.isFile()
|| livePathStat.isSymbolicLink()
|| !hasSameFileIdentity(descriptorStat, livePathStat)
) {
throw createChangedDestinationError(action);
}
}
function writeFileNoFollow(filePath, content, mode, trustedRoot, action) {
const expectedParentStat = getStableParentStat(filePath, action);
const flags = fs.constants.O_WRONLY
| fs.constants.O_CREAT
| (fs.constants.O_NOFOLLOW || 0);
const fileDescriptor = fs.openSync(filePath, flags, mode);
try {
assertPinnedWriteDestination(
filePath,
fileDescriptor,
expectedParentStat,
trustedRoot,
action
);
fs.ftruncateSync(fileDescriptor, 0);
fs.writeFileSync(fileDescriptor, content);
if (mode !== undefined) {
fs.fchmodSync(fileDescriptor, mode);
}
} finally {
fs.closeSync(fileDescriptor);
}
function writeFileNoFollow(filePath, content, mode, trustedRoot, action, writeOptions = {}) {
return guardedWriteFile(filePath, content, {
...writeOptions,
mode,
action,
validateDestination(destinationPath) {
return getManagedDestination(destinationPath, trustedRoot, action).managedPath;
},
});
}
function readFileWithMetadataNoFollow(filePath, encoding) {
@@ -564,7 +526,7 @@ function assertClaudeSettingsDestination(operation, trustedRoot, target = null)
assertClaudeSettingsPath(operation.destinationPath, trustedRoot);
}
function writeContainedFile(destinationPath, content, trustedRoot, action, mode) {
function writeContainedFile(destinationPath, content, trustedRoot, action, mode, writeOptions) {
const preparedDestination = prepareContainedWriteDestination(destinationPath, trustedRoot, action);
const finalDestination = getManagedDestination(
preparedDestination,
@@ -576,7 +538,8 @@ function writeContainedFile(destinationPath, content, trustedRoot, action, mode)
content,
mode,
trustedRoot,
action
action,
writeOptions
);
return finalDestination;
}
@@ -782,7 +745,8 @@ function executeRepairOperation(
trustedRoot,
linkIndex = null,
target = null,
settingsLockHeld = false
settingsLockHeld = false,
writeOptions = {}
) {
// Install-state is attacker-controllable; never write/delete outside the
// adapter-derived trusted root, regardless of what the state file claims
@@ -800,7 +764,8 @@ function executeRepairOperation(
getExpectedCopyFileContent(operation, source.content, linkIndex),
trustedRoot,
'repair',
source.mode & 0o777
source.mode & 0o777,
writeOptions
);
} else {
copyContainedFile(sourcePath, operation.destinationPath, trustedRoot, 'repair');
@@ -1179,7 +1144,11 @@ function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = n
let contentMatches;
try {
contentMatches = hasRecordedContentDigest(operation)
// A deactivation transform changes the desired bytes. A historical
// active-file digest proves ownership, not completion of that transition.
const deactivatesOpenCode = operation.contentTransform === 'opencode-disable-ecc-hooks'
|| operation.contentTransform === 'opencode-disable-plugin-entrypoint';
contentMatches = hasRecordedContentDigest(operation) && !deactivatesOpenCode
? fileMatchesRecordedContent(inspectedPath, operation)
: operation.contentTransform || isMarkdownPath(operation.destinationPath)
? readFileNoFollow(inspectedPath, 'utf8') === getExpectedCopyFileContent(
@@ -1629,6 +1598,24 @@ function analyzeRecord(record, context) {
};
}
let planningFailureReported = false;
if (record.adapter.target === 'opencode') {
let checks;
try {
checks = prepareOpenCodeHookDeactivationChecks(record, context, { requireInactive: true });
} catch (error) {
planningFailureReported = true;
issues.push(buildIssue('error', 'resolution-unavailable', error.message));
}
if (checks) {
try {
for (const check of checks) assertOpenCodeRepairHookDeactivation(check.plan, check.options);
} catch (error) {
issues.push(buildIssue('error', 'opencode-hook-consent-violation', error.message));
}
}
}
if (!fs.existsSync(state.target.root)) {
issues.push(buildIssue('error', 'missing-target-root', `Target root does not exist: ${state.target.root}`));
}
@@ -1731,7 +1718,7 @@ function analyzeRecord(record, context) {
issues.push(buildIssue('warning', 'repo-version-mismatch', `Recorded repo version ${state.source.repoVersion} differs from current repo version ${context.packageVersion}`));
}
if (!state.request.legacyMode) {
if (!state.request.legacyMode && !planningFailureReported) {
try {
const desiredPlan = resolveRecordedManifestPlan(record, context);
@@ -1758,7 +1745,7 @@ function analyzeRecord(record, context) {
}
function buildDoctorReport(options = {}) {
const repoRoot = options.repoRoot || DEFAULT_REPO_ROOT;
const repoRoot = options.repoRoot ? path.resolve(options.repoRoot) : DEFAULT_REPO_ROOT;
const manifests = loadInstallManifests({ repoRoot });
const records = discoverInstalledStates({
homeDir: options.homeDir,
@@ -1821,7 +1808,8 @@ function createRepairPlanFromRecord(record, context, options = {}) {
);
const statePreview = buildRecordedStatePreview(state, context, operations);
return {
const recordedPlan = {
sourceRoot: context.repoRoot,
mode: state.request.legacyMode ? 'legacy' : 'recorded',
target: record.adapter.target,
adapter: record.adapter,
@@ -1830,9 +1818,13 @@ function createRepairPlanFromRecord(record, context, options = {}) {
installStatePath: state.target.installStatePath,
warnings: [],
languages: Array.isArray(state.request.legacyLanguages) ? [...state.request.legacyLanguages] : [],
selectedModuleIds: [...(state.resolution.selectedModules || [])],
operations,
statePreview
};
return record.adapter.target === 'opencode'
? withHookConsent(recordedPlan, getRecordedHookConsent(state))
: recordedPlan;
}
const desiredPlan = resolveRecordedManifestPlan(record, context, options);
@@ -1882,8 +1874,11 @@ function writeRefreshedInstallState(record, statePreview, writtenPaths = []) {
return { ...operation };
}
// Refreshing a ledger is not a file write. Keep the last installed digest
// for untouched shared configs so a concurrent user edit is never claimed.
if (isCodexUserConfig(record, operation)
// for untouched shared configs and OpenCode activations so a concurrent
// user edit is never claimed, even by a partial repair checkpoint.
if ((isCodexUserConfig(record, operation)
|| (record.adapter.target === 'opencode'
&& require('./install/apply').getOpenCodeActivationKind(record, operation)))
&& !writtenPaths.some(writtenPath => path.relative(writtenPath, operation.destinationPath) === '')) {
const previousOperation = (record.state.operations || []).find(previous => (
previous.destinationPath
@@ -1923,10 +1918,13 @@ function prepareRepairMigration(plan, record) {
installStatePath: record.installStatePath,
statePreview: buildAdapterDerivedStatePreview(plan.statePreview, record),
};
const skillMigration = prepareClaudeSkillMigration(trustedPlan);
const migration = record.adapter.id === 'codex-home'
? prepareUserOwnedFileGuard(trustedPlan, skillMigration)
: skillMigration;
const initialMigration = prepareClaudeSkillMigration(trustedPlan);
const guardedMigration = record.adapter.id === 'codex-home'
? prepareUserOwnedFileGuard(trustedPlan, initialMigration)
: initialMigration;
const migration = trustedPlan.target === 'opencode'
? require('./install/apply').prepareHookConsentMigration(trustedPlan, guardedMigration)
: guardedMigration;
return {
migration,
plan: {
@@ -1941,8 +1939,58 @@ function prepareRepairMigration(plan, record) {
};
}
function assertOpenCodeRepairHookDeactivation(plan, options = {}) {
if (plan.target !== 'opencode') {
return new Map();
}
// The installer imports lifecycle helpers. Resolve this shared read-only
// guard lazily so both paths enforce the same discovery/ownership boundary.
const { assertOpenCodeHookDeactivationReady } = require('./install/apply');
return assertOpenCodeHookDeactivationReady(plan, options);
}
function prepareOpenCodeHookDeactivationChecks(record, context, options = {}) {
if (record.adapter.target !== 'opencode') {
return [];
}
const rawPlan = createRepairPlanFromRecord(record, context, {
// Planning must reject unsafe existing activations before a repair build
// writes compiled files. Missing payload is still validated/built later.
exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE],
});
if (record.legacyLayout === 'opencode') {
const state = record.state;
const legacyPlan = withHookConsent({
sourceRoot: context.repoRoot,
target: 'opencode',
adapter: record.adapter,
targetRoot: record.targetRoot,
installRoot: record.targetRoot,
installStatePath: record.installStatePath,
selectedModuleIds: [...(state.resolution.selectedModules || [])],
operations: getManagedOperations(state),
statePreview: buildAdapterDerivedStatePreview(state, record),
}, getRecordedHookConsent(state));
// Migration does not replay operations into the old root. Inspect existing
// activations there, without treating historical merge-json records as new
// writes; the canonical destination is validated separately below.
return [
{ plan: { ...legacyPlan, operations: [] }, options: { allowVerifiedLegacyRemoval: true } },
{ plan: rawPlan, options },
];
}
const { plan } = prepareRepairMigration(rawPlan, record);
return [{ plan, options }];
}
function preflightOpenCodeHookDeactivation(record, context, options = {}) {
for (const check of prepareOpenCodeHookDeactivationChecks(record, context, options)) {
assertOpenCodeRepairHookDeactivation(check.plan, check.options);
}
}
function repairInstalledStates(options = {}) {
const repoRoot = options.repoRoot || DEFAULT_REPO_ROOT;
const repoRoot = options.repoRoot ? path.resolve(options.repoRoot) : DEFAULT_REPO_ROOT;
const manifests = loadInstallManifests({ repoRoot });
const context = {
repoRoot,
@@ -1965,35 +2013,130 @@ function repairInstalledStates(options = {}) {
&& (!record.legacy || record.legacyLayout === 'opencode')
));
const results = records.map(record => {
if (record.error) {
return {
adapter: record.adapter,
status: 'error',
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs: [],
error: record.error
};
}
let releaseSettingsLock = null;
try {
const settingsPathToLock = !options.dryRun
&& getManagedOperations(record.state || {}).some(
operation => operation.kind === 'update-claude-settings'
)
? getClaudeSettingsPath(record.targetRoot)
: null;
if (settingsPathToLock) {
releaseSettingsLock = acquireSettingsLock(settingsPathToLock);
const results = records.map(initialRecord => {
let record = initialRecord;
const performRepair = (opencodeLease) => {
if (record.error) {
return {
adapter: record.adapter,
status: 'error',
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs: [],
error: record.error
};
}
const needsOpencodeBuild = record.adapter.target === 'opencode'
&& hasOpencodeBuildError(getOpencodeBuildValidationIssues(context));
const opencodeBuildRepairPath = path.join(context.repoRoot, OPENCODE_BUILD_ARTIFACT);
if (record.legacyLayout === 'opencode') {
if (needsOpencodeBuild && !options.dryRun) {
let releaseSettingsLock = null;
try {
preflightOpenCodeHookDeactivation(record, context);
const settingsPathToLock = !options.dryRun
&& getManagedOperations(record.state || {}).some(
operation => operation.kind === 'update-claude-settings'
)
? getClaudeSettingsPath(record.targetRoot)
: null;
if (settingsPathToLock) {
releaseSettingsLock = acquireSettingsLock(settingsPathToLock);
}
const needsOpencodeBuild = record.adapter.target === 'opencode'
&& hasOpencodeBuildError(getOpencodeBuildValidationIssues(context));
const opencodeBuildRepairPath = path.join(context.repoRoot, OPENCODE_BUILD_ARTIFACT);
if (record.legacyLayout === 'opencode') {
if (needsOpencodeBuild && !options.dryRun) {
try {
buildOpencodeRunner(context.repoRoot);
} catch (error) {
return {
adapter: record.adapter,
status: 'error',
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs: [],
error: formatBuildErrorMessage(error),
};
}
}
const canonicalPlan = createRepairPlanFromRecord(record, context, {
exemptValidationCodes: options.dryRun && needsOpencodeBuild
? [OPENCODE_PLUGIN_NOT_BUILT_CODE]
: [],
});
assertOpenCodeRepairHookDeactivation(canonicalPlan);
const plannedRepairs = [...new Set([
...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []),
...canonicalPlan.operations.map(operation => operation.destinationPath),
...getManagedOperations(record.state).map(operation => operation.destinationPath),
record.installStatePath,
])];
if (options.dryRun) {
return {
adapter: record.adapter,
status: 'planned',
installStatePath: canonicalPlan.installStatePath,
repairedPaths: [],
plannedRepairs,
stateRefreshed: false,
warnings: canonicalPlan.warnings,
error: null,
};
}
// Load lazily to avoid a module cycle during install-lifecycle startup.
const { applyInstallPlan } = require('./install/apply');
const appliedPlan = applyInstallPlan(canonicalPlan, { opencodeLease });
return {
adapter: record.adapter,
status: 'repaired',
installStatePath: canonicalPlan.installStatePath,
repairedPaths: [
...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []),
...canonicalPlan.operations.map(operation => operation.destinationPath),
],
plannedRepairs: [],
stateRefreshed: true,
warnings: appliedPlan.warnings,
error: null,
};
}
if (needsOpencodeBuild && options.dryRun) {
const rawPlan = createRepairPlanFromRecord(record, context, {
exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE],
});
const { plan: desiredPlan } = prepareRepairMigration(rawPlan, record);
const operationHealth = summarizeManagedOperationHealth(
context.repoRoot,
record.targetRoot,
desiredPlan.operations,
record.adapter.target
);
const unsafeOperationResult = getUnsafeOperationResult(
record,
operationHealth
);
if (unsafeOperationResult) {
return unsafeOperationResult;
}
const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))];
const plannedRepairs = [opencodeBuildRepairPath, ...repairOperations.map(operation => operation.destinationPath)];
return {
adapter: record.adapter,
status: 'planned',
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs,
stateRefreshed: false,
warnings: desiredPlan.warnings,
error: null
};
}
if (needsOpencodeBuild) {
try {
buildOpencodeRunner(context.repoRoot);
} catch (error) {
@@ -2003,65 +2146,24 @@ function repairInstalledStates(options = {}) {
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs: [],
error: formatBuildErrorMessage(error),
error: formatBuildErrorMessage(error)
};
}
}
const canonicalPlan = createRepairPlanFromRecord(record, context, {
exemptValidationCodes: options.dryRun && needsOpencodeBuild
? [OPENCODE_PLUGIN_NOT_BUILT_CODE]
: [],
});
const plannedRepairs = [...new Set([
...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []),
...canonicalPlan.operations.map(operation => operation.destinationPath),
...getManagedOperations(record.state).map(operation => operation.destinationPath),
record.installStatePath,
])];
if (options.dryRun) {
return {
adapter: record.adapter,
status: 'planned',
installStatePath: canonicalPlan.installStatePath,
repairedPaths: [],
plannedRepairs,
stateRefreshed: false,
warnings: canonicalPlan.warnings,
error: null,
};
}
// Load lazily to avoid a module cycle during install-lifecycle startup.
const { applyInstallPlan } = require('./install/apply');
const appliedPlan = applyInstallPlan(canonicalPlan);
return {
adapter: record.adapter,
status: 'repaired',
installStatePath: canonicalPlan.installStatePath,
repairedPaths: [
...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []),
...canonicalPlan.operations.map(operation => operation.destinationPath),
],
plannedRepairs: [],
stateRefreshed: true,
warnings: appliedPlan.warnings,
error: null,
};
}
if (needsOpencodeBuild && options.dryRun) {
const rawPlan = createRepairPlanFromRecord(record, context, {
exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE],
});
const { plan: desiredPlan } = prepareRepairMigration(rawPlan, record);
const rawPlan = createRepairPlanFromRecord(record, context);
const {
migration,
plan: desiredPlan,
} = prepareRepairMigration(rawPlan, record);
const activationSnapshot = assertOpenCodeRepairHookDeactivation(desiredPlan);
const operationHealth = summarizeManagedOperationHealth(
context.repoRoot,
record.targetRoot,
desiredPlan.operations,
record.adapter.target
);
const unsafeOperationResult = getUnsafeOperationResult(
record,
operationHealth
@@ -2069,175 +2171,169 @@ function repairInstalledStates(options = {}) {
if (unsafeOperationResult) {
return unsafeOperationResult;
}
const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))];
const plannedRepairs = [opencodeBuildRepairPath, ...repairOperations.map(operation => operation.destinationPath)];
return {
adapter: record.adapter,
status: 'planned',
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs,
stateRefreshed: false,
warnings: desiredPlan.warnings,
error: null
};
}
if (needsOpencodeBuild) {
try {
buildOpencodeRunner(context.repoRoot);
} catch (error) {
if (operationHealth.missingSource.length > 0) {
return {
adapter: record.adapter,
status: 'error',
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs: [],
error: formatBuildErrorMessage(error)
warnings: desiredPlan.warnings,
error: `Missing source file(s): ${operationHealth.missingSource.map(entry => entry.sourcePath).join(', ')}`
};
}
}
const rawPlan = createRepairPlanFromRecord(record, context);
const {
migration,
plan: desiredPlan,
} = prepareRepairMigration(rawPlan, record);
const operationHealth = summarizeManagedOperationHealth(
context.repoRoot,
record.targetRoot,
desiredPlan.operations,
record.adapter.target
);
const repairOperations = [
...operationHealth.missing.map(entry => ({ ...entry.operation })),
...operationHealth.drifted.map(entry => ({ ...entry.operation })),
...desiredPlan.operations
.filter(operation => (
operation.kind === 'update-claude-settings'
&& operation.previousManagedHooks
&& !isDeepStrictEqual(operation.previousManagedHooks, operation.managedHooks)
))
.map(operation => ({ ...operation })),
].filter((operation, index, items) => items.findIndex(candidate => (
candidate.kind === operation.kind
&& candidate.destinationPath === operation.destinationPath
)) === index);
const repairLinkIndex = buildLinkIndexForOperations(desiredPlan.operations, record.targetRoot);
const legacyMigrationPaths = migration.legacyOperationsToRemove.map(
operation => operation.destinationPath
);
const plannedRepairs = [...new Set([
...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []),
...repairOperations.map(operation => operation.destinationPath),
...legacyMigrationPaths,
])];
const unsafeOperationResult = getUnsafeOperationResult(
record,
operationHealth
);
if (unsafeOperationResult) {
return unsafeOperationResult;
}
if (options.dryRun) {
return {
adapter: record.adapter,
status: plannedRepairs.length > 0 ? 'planned' : 'ok',
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs,
stateRefreshed: plannedRepairs.length === 0,
warnings: desiredPlan.warnings,
error: null
};
}
if (operationHealth.missingSource.length > 0) {
const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0;
const repairedPaths = needsOpencodeBuild ? [opencodeBuildRepairPath] : [];
if (desiredPlan.target === 'opencode') {
const { assertOpenCodeActivationUnchanged } = require('./install/apply');
// Health inspection must not let a changed activation become owned by
// a bridge checkpoint before the per-write check can reject it.
for (const destinationPath of activationSnapshot.keys()) {
assertOpenCodeActivationUnchanged(desiredPlan, { destinationPath }, activationSnapshot);
}
}
if (migration.requiresBridgeState && (repairOperations.length > 0 || hasLegacyMigration)) {
writeRefreshedInstallState(record, migration.bridgeState);
}
for (const operation of repairOperations) {
if (desiredPlan.target === 'opencode') {
const { assertOpenCodeActivationUnchanged } = require('./install/apply');
assertOpenCodeActivationUnchanged(desiredPlan, operation, activationSnapshot);
}
if (record.adapter.id === 'codex-home') {
assertNoNewUserOwnedFile(migration, operation, desiredPlan);
}
const repairedPath = executeRepairOperation(
context.repoRoot,
operation,
record.targetRoot,
repairLinkIndex,
record.adapter.target,
Boolean(releaseSettingsLock),
require('./install/apply').getOpenCodeActivationWriteOptions(operation, activationSnapshot)
);
if (repairedPath) {
repairedPaths.push(repairedPath);
}
}
if (hasLegacyMigration) {
for (const operation of migration.legacyOperationsToRemove) {
const removedPath = removeContainedPath(
operation.destinationPath,
record.targetRoot,
'migrate managed Claude skill',
{ force: true }
);
if (removedPath) {
repairedPaths.push(removedPath);
}
}
}
const changedInstalledBytes = repairOperations.length > 0
|| needsOpencodeBuild
|| hasLegacyMigration;
const statePreviewToWrite = changedInstalledBytes
? desiredPlan.statePreview
: {
...desiredPlan.statePreview,
installedAt: record.state.installedAt,
source: { ...record.state.source },
};
assertOpenCodeRepairHookDeactivation(desiredPlan, { requireInactive: true });
writeRefreshedInstallState(record, statePreviewToWrite, repairedPaths);
return {
adapter: record.adapter,
status: (repairOperations.length > 0 || needsOpencodeBuild || hasLegacyMigration)
? 'repaired'
: 'ok',
installStatePath: record.installStatePath,
repairedPaths,
plannedRepairs: [],
stateRefreshed: true,
warnings: desiredPlan.warnings,
error: null
};
} catch (error) {
return {
adapter: record.adapter,
status: 'error',
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs: [],
warnings: desiredPlan.warnings,
error: `Missing source file(s): ${operationHealth.missingSource.map(entry => entry.sourcePath).join(', ')}`
error: error.message
};
} finally {
if (releaseSettingsLock) releaseSettingsLock();
}
const repairOperations = [
...operationHealth.missing.map(entry => ({ ...entry.operation })),
...operationHealth.drifted.map(entry => ({ ...entry.operation })),
...desiredPlan.operations
.filter(operation => (
operation.kind === 'update-claude-settings'
&& operation.previousManagedHooks
&& !isDeepStrictEqual(operation.previousManagedHooks, operation.managedHooks)
))
.map(operation => ({ ...operation })),
].filter((operation, index, items) => items.findIndex(candidate => (
candidate.kind === operation.kind
&& candidate.destinationPath === operation.destinationPath
)) === index);
const repairLinkIndex = buildLinkIndexForOperations(desiredPlan.operations, record.targetRoot);
const legacyMigrationPaths = migration.legacyOperationsToRemove.map(
operation => operation.destinationPath
);
const plannedRepairs = [...new Set([
...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []),
...repairOperations.map(operation => operation.destinationPath),
...legacyMigrationPaths,
])];
if (options.dryRun) {
return {
adapter: record.adapter,
status: plannedRepairs.length > 0 ? 'planned' : 'ok',
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs,
stateRefreshed: plannedRepairs.length === 0,
warnings: desiredPlan.warnings,
error: null
};
}
const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0;
const repairedPaths = needsOpencodeBuild ? [opencodeBuildRepairPath] : [];
if (migration.requiresBridgeState && (repairOperations.length > 0 || hasLegacyMigration)) {
writeRefreshedInstallState(record, migration.bridgeState);
}
for (const operation of repairOperations) {
if (record.adapter.id === 'codex-home') {
assertNoNewUserOwnedFile(migration, operation, desiredPlan);
};
if (record.adapter.target !== 'opencode' || options.dryRun) return performRepair();
let repairResult;
try {
const adapter = getInstallTargetAdapter('opencode');
const targetRoot = adapter.resolveRoot({
homeDir: context.homeDir, projectRoot: context.projectRoot,
repoRoot: context.projectRoot, env: context.env,
});
const { getOpenCodeInstallRoots, assertOpenCodeLeaseCoverage } = require('./install/apply');
const roots = getOpenCodeInstallRoots({ adapter, targetRoot, homeDir: context.homeDir });
return withOpenCodeInstallLocks(roots, lease => {
assertOpenCodeLeaseCoverage({ adapter, targetRoot, homeDir: context.homeDir }, lease);
// Discovery precedes acquisition. Never repair from that stale state.
record = buildDiscoveryRecord(adapter, context, record.legacyLayout === 'opencode'
? getLegacyOpencodeLocation(context.homeDir) : null);
if (!record.exists || record.error) {
throw new Error(record.error || 'OpenCode install-state disappeared before repair.');
}
const repairedPath = executeRepairOperation(
context.repoRoot,
operation,
record.targetRoot,
repairLinkIndex,
record.adapter.target,
Boolean(releaseSettingsLock)
);
if (repairedPath) {
repairedPaths.push(repairedPath);
}
}
if (hasLegacyMigration) {
for (const operation of migration.legacyOperationsToRemove) {
const removedPath = removeContainedPath(
operation.destinationPath,
record.targetRoot,
'migrate managed Claude skill',
{ force: true }
);
if (removedPath) {
repairedPaths.push(removedPath);
}
}
}
const changedInstalledBytes = repairOperations.length > 0
|| needsOpencodeBuild
|| hasLegacyMigration;
const statePreviewToWrite = changedInstalledBytes
? desiredPlan.statePreview
: {
...desiredPlan.statePreview,
installedAt: record.state.installedAt,
source: { ...record.state.source },
};
writeRefreshedInstallState(record, statePreviewToWrite, repairedPaths);
return {
adapter: record.adapter,
status: (repairOperations.length > 0 || needsOpencodeBuild || hasLegacyMigration)
? 'repaired'
: 'ok',
installStatePath: record.installStatePath,
repairedPaths,
plannedRepairs: [],
stateRefreshed: true,
warnings: desiredPlan.warnings,
error: null
};
repairResult = performRepair(lease);
return repairResult;
});
} catch (error) {
return {
adapter: record.adapter,
status: 'error',
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs: [],
error: error.message
};
} finally {
if (releaseSettingsLock) releaseSettingsLock();
if (repairResult?.status === 'error') {
return { ...repairResult, releaseError: error.message };
}
return { adapter: record.adapter, status: 'error', installStatePath: record.installStatePath,
repairedPaths: repairResult?.repairedPaths || [], plannedRepairs: [], error: error.message };
}
});
+3 -1
View File
@@ -15,7 +15,7 @@ const COMPONENT_FAMILY_PREFIXES = {
skill: 'skill:',
locale: 'locale:',
};
const SUPPORTED_LOCALES = Object.freeze(['ja', 'zh-CN', 'ko-KR', 'pt-BR', 'ru', 'tr', 'vi-VN', 'zh-TW', 'de-DE', 'uk-UA', 'bn']);
const SUPPORTED_LOCALES = Object.freeze(['ja', 'zh-CN', 'ko-KR', 'pt-BR', 'ru', 'tr', 'vi-VN', 'zh-TW', 'de-DE', 'uk-UA', 'pl', 'bn']);
const LOCALE_ALIAS_TO_COMPONENT_ID = Object.freeze({
'ja': 'locale:ja',
'ja-JP': 'locale:ja',
@@ -34,6 +34,8 @@ const LOCALE_ALIAS_TO_COMPONENT_ID = Object.freeze({
'de': 'locale:de-de',
'uk-UA': 'locale:uk-ua',
'uk': 'locale:uk-ua',
'pl': 'locale:pl',
'pl-PL': 'locale:pl',
'bn': 'locale:bn',
'bn-BD': 'locale:bn'
});
+335 -3
View File
@@ -9,7 +9,16 @@ const {
withCommitAttributionDisabled,
} = require('../claude-commit-attribution');
const { readInstallState, writeInstallState } = require('../install-state');
const { assertHookConsentReady, planMaterializesHookRuntime } = require('./hook-consent');
const {
assertHookConsentReady,
disableOpenCodeHookPluginRegistration,
getDisabledOpenCodePluginContent,
getRecordedHookConsent,
getOpenCodeActivationPathKind,
getOpenCodeSourceActivationKind,
planMaterializesHookRuntime,
shouldDisableOpenCodeHooks,
} = require('./hook-consent');
const {
getClaudeSettingsPath,
mergeManagedHooks,
@@ -33,7 +42,10 @@ const {
prepareUserOwnedFileGuard,
preserveUnwrittenFiles,
} = require('./ownership-guard');
const { cleanupLegacyOpencodeInstall } = require('./opencode-legacy-migration');
const { cleanupLegacyOpencodeInstall, getLegacyLocationForPlan, inspectLegacyOpencodeState,
verifyManagedLegacyFile } = require('./opencode-legacy-migration');
const { writeFileNoFollow } = require('./guarded-write');
const { withOpenCodeInstallLocks } = require('./opencode-install-lock');
const {
completeExcludedPathsReconciliation,
prepareExcludedPathsReconciliation,
@@ -52,6 +64,12 @@ function transformInstallContent(operation, content) {
if (operation.contentTransform === 'antigravity-agent-frontmatter') {
return adaptAntigravityAgent(content, operation.sourceRelativePath);
}
if (operation.contentTransform === 'opencode-disable-ecc-hooks') {
return disableOpenCodeHookPluginRegistration(content, operation.sourceRelativePath);
}
if (operation.contentTransform === 'opencode-disable-plugin-entrypoint') {
return getDisabledOpenCodePluginContent();
}
throw new Error(`Unknown install content transform: ${operation.contentTransform}`);
}
@@ -292,6 +310,254 @@ function comparablePath(filePath) {
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
}
function getOpenCodeActivationKind(plan, operation) {
const relative = operation.destinationPath
? path.relative(plan.targetRoot, operation.destinationPath).split(path.sep).join('/').toLowerCase()
: '';
return getOpenCodeActivationPathKind(relative) || getOpenCodeSourceActivationKind(operation);
}
function readOpenCodeAliasForAttribution(plan, destinationPath) {
try {
const operation = { destinationPath };
assertSafeInstallOperation(plan, operation);
if (!fs.lstatSync(destinationPath).isFile()) return null;
return readInstalledFileNoFollow(plan, operation);
} catch {
// Optional, unrecorded aliases have no ECC ownership until their bytes
// prove it. Never follow an unsafe path or relax recorded/planned guards.
return null;
}
}
// Finite, exact public ECC entrypoint history reachable from d3b8a3e908904e242ed2dbe66af62cca71131419.
// Refusal evidence only: matching bytes never grant ownership or permission to
// adopt, rewrite or delete an unrecorded file. Unknown modified/compiled variants
// are not covered. No history lookup or plugin execution occurs at runtime.
const LEGACY_OPENCODE_PLUGIN_DIGESTS = Object.freeze([
// a0600a00fbe3a193a44584ad55800ce82cec62af:.opencode/plugins/index.ts (blob 3a98f0ba6510d436cc9cf3e2161f8f69771d968a)
'7dd2d255da5d4344eb38ca93cf1765e425b0c01943f6e45428614662ebee0d4b',
// a0600a00fbe3a193a44584ad55800ce82cec62af:.opencode/plugins/ecc-hooks.ts (blob bf06c03f8ff6bdd835c5266921758a6db85152bf)
'5db9b59434af0d5971538f0176779733b8146d7a71fbd9055ae0183c26754068',
// 91ba9b4cf6c47c8130829004f8bb64762a76ccbb:.opencode/plugins/ecc-hooks.ts (blob 4aabde61203d4473e04d5a10803b0560b8c596e4)
'c683b9321d8b5fbc6889b1740f4583c4f94c84554ee97e2072f61de45c661bda',
// 1a8beb71c5282ddfe77c72ab0290961a820e3d89:.opencode/plugins/ecc-hooks.ts (blob 69b59727e552991a79c196aab8ec128173329d9a)
'1e890ce162325c9d7c579b0716383b6c297179edb78084c4b59cc8bf766ceb71',
// e65f12bf7ea474a6f5ac96991a251673f474b445:.opencode/plugins/index.ts (blob c1e17a1595403080490fcec6820c1485bb6afba9)
'965c5fac76ce0c3ceb3836814f5eb9ede8c9db50373a508c734f949cb321a21a',
// e65f12bf7ea474a6f5ac96991a251673f474b445:.opencode/plugins/ecc-hooks.ts (blob 54881ad868c276ff0d50cc83d8ae938464ad02da)
'5c043b84693a654fffe4b407e87411b28c9af8b92f5ef49a03caab7b27f03102',
// 2cdc218c45a81ce46035832b13bf68d91137301e:.opencode/plugins/ecc-hooks.ts (blob d496e61a538131ff6f33b2e6d941544e3d94c5d8)
'73692e599d271bbb9b7aac59f97e193518af2b5db3a3505af0376c8d8657220a',
// 5929d246946eeb5d147612ba06d60c575c5a4e21:.opencode/plugins/ecc-hooks.ts (blob 472f80f5ae9500fa9a0a7885b6ce4dc4b409d3d6)
'd7a410380ed2e0bcb613110b2810221d03a8944e50766bc7a4db2eb1b44446b6',
// ca185ef5f7667078a1e70a763bd3a9c71c48acf0:.opencode/plugins/ecc-hooks.ts (blob 22b1132f0964bd4ba5c1a4ad1bafa605de99eb6a)
'0345093b34e537d350c5b5aa0296511f558aa767e5104fb5ef05069013f3b5b6',
// 28e53a0bc10e286f68b53bb1e3b3f049021e57b9:.opencode/plugins/ecc-hooks.ts (blob 47265c0ebd031168d8e3a18f30036864338cd22c)
'e20ecd53714b1fd55baeff796c6f4538ebd4bae71ca1e791b2b8e29575061846',
// 591ab5cbd3f2f65860ea91c226e410b1502c8e2e:.opencode/plugins/ecc-hooks.ts (blob 49124c255003eb5517178a8ecad7dd453303df33)
'b9c22c76ae2464c9410963579ee5ff49003e4d79e32b69c228539ae7b15f5104',
// 6f452d48d258b39f4f6e1171b7ca18c6f7f61ad5:.opencode/plugins/ecc-hooks.ts (blob 6336081e97c4345f02adfdc0b9ad9e27dccdec04)
'c7122565cf97b896cc3da9009bf06daca7513b3e9ba7448c26b8872591dbf3f7',
// 3a08b0c7a85bda69ee9922a103e077a04d538150:.opencode/plugins/ecc-hooks.ts (blob bad6a4cecf2270a7d8a919daeb6541c94a810c48)
'e438603c13206365068b400063df0af98bd587842b80f09b97fe57f7ddef56e0',
// 29edd57708bee26f16363c16a28fec7f6b09f53f:.opencode/plugins/ecc-hooks.ts (blob 05792ce9ae86a785b746bdb843572e4b5bc93130)
'6a9063b2f67334a78d269d53f95679c33d6d126260f8e5fc7cc941fea9b903e8',
// 8141f6904f14fa8a83131e1cb5b6507d687e25bb:.opencode/plugins/ecc-hooks.ts (blob 606bcb7c59aa5e459d2093ffb9cf9208d1184c30)
'a198b640fd1faf1c75e96909eabf4ae24899de127b2447490eed813766013836',
// 6d613f67dd24189a8bb7fb1a2f5e535957f46a58:.opencode/plugins/index.ts (blob ca58596901d816147ac4eff525f1a885d36bd094)
'e89aaa309b7a0578bb69af4a2425744fcf14c2556cd34a1036bbcba448a0b517',
// 6d613f67dd24189a8bb7fb1a2f5e535957f46a58:.opencode/plugins/ecc-hooks.ts (blob 31cfa8ac31ac3cbc5c51b4b275017ef18b8f9033)
'd66a43e43ef9669589de593e8f94e750ee11d3c75cb5fe79e81636ef738c3e45',
// affbd334858368518c5baf5f84f74034dea1ea6f:.opencode/plugins/ecc-hooks.ts (blob ff8628b5fd47181cbee54367dc4e32e5392cde1a)
'4874a12639fd58da59a54fe5b2461c0ddd2eeca6770111615caa402ff0e95693',
// 0a87323eda77ee412fa3a3bf028a577536966505:.opencode/plugins/ecc-hooks.ts (blob fa96b805685e3c3e6f86535debca5ab8a5bea1ef)
'4e2330f340e074208cd323c1a833667032ce8db4febc4eaeda354bc49cb58bc4',
// a0a1eda8fc4828e58dc8aabcec4e25f9ef038a0a:.opencode/plugins/ecc-hooks.ts (blob 51bde010b4d426676b52ffc9567b1da80f4ca510)
'ca9abadee5d072121677168752fb4f3b16ce9fc7eb55a3c9c7f517377e0bf69b',
// 05acc275307a09eea89080619a35d7dbd20b128b:.opencode/plugins/ecc-hooks.ts (blob 9e4ab3fcd50f6610cfdfa5a7d0d71c2374f65745)
'96998990d6aac0b9535ab6ab60a0be1c284ffcca7e4ba04f1cfa0e67409a8146',
// a2b3cc1600e9cab58147ef01c03f9889b5a8cc86:.opencode/plugins/ecc-hooks.ts (blob 58a209283f70efe1dbfef5d78b4d72764c67f027)
'0697bfed6e6ad887443a32810e83adb5316d2c9c0490b98f9fcb6ea52bea84e3',
// 0c7deb26a344db095c04a213eba5634d4ccce030:.opencode/plugins/ecc-hooks.ts (blob 9193bb412920a1f1d5af98fda0a66d1e3295f46f)
'd666a94e9d0ccbcfdeffd59b624938cd44706571eec3771974c57fdbe28577c1',
// 48b883d7412914b04c8b185d9a82685b105d1734:.opencode/plugins/ecc-hooks.ts (blob 3053314750a61dbcdb06a9cca39492304457f582)
'16fe21ca801a613a0ae2fc1f8dd5c8474138dc31c75ea35cd8695884397f1f15',
// d70bab85e33af7a03b78c70dba7a7ce3b01d1b17:.opencode/plugins/ecc-hooks.ts (blob 1f158d7999f5f100e386587a94a90a08d512e278)
'0354270a5fc26809d0795ecc7eef1dee91de4905d58f26d5828d43af24767b96',
// 0e9f613fd196f6d4157765b17d39c2c42ebbf564:.opencode/plugins/ecc-hooks.ts (blob 50d23bfde3607832446fda26b25a3ed3e527e5d1)
'513190b6c935dac472efd11818b20d7f2479ec1f7be06ef7f4d241c2493ad9e3',
// 6d440c036df2c1b2fec957627d1202c3708e0627:.opencode/plugins/index.ts (blob d19a91f1a686d6ed060d08eddeb5aa05a4be6b75)
'e42c733adb177f84cea813663aa34c7868dbaa98c96950d0ef91cd211b8aa169',
// 6d440c036df2c1b2fec957627d1202c3708e0627:.opencode/plugins/ecc-hooks.ts (blob b64ffae7ce10cab9e5ed9b04cec23d62db9036e7)
'503ea491cbeadff5bf59b936a75bff65caaf1d71e47a953a9b9b790be780efef',
]);
function knownOpenCodePluginDigests(plan) {
// Historical refusal fingerprints do not depend on a current source checkout.
const digests = new Set(LEGACY_OPENCODE_PLUGIN_DIGESTS);
if (typeof plan.sourceRoot !== 'string' || !path.isAbsolute(plan.sourceRoot)) return digests;
const sourcePlan = { ...plan, targetRoot: plan.sourceRoot };
for (const directory of ['.opencode/plugins', '.opencode/dist/plugins']) {
for (const name of ['ecc-hooks', 'index']) {
for (const extension of ['ts', 'js', 'mjs', 'cjs']) {
const content = readOpenCodeAliasForAttribution(sourcePlan,
path.join(plan.sourceRoot, directory, `${name}.${extension}`));
if (content !== null) digests.add(crypto.createHash('sha256').update(content).digest('hex'));
}
}
}
return digests;
}
function openCodeActivationCandidates(plan, previousOperations) {
const candidates = new Map();
for (const operation of [...previousOperations, ...plan.operations]) {
if (getOpenCodeActivationKind(plan, operation) && operation.destinationPath) {
candidates.set(comparablePath(operation.destinationPath), operation);
}
}
// Old installs can leave unrecorded aliases, but names such as index.js
// are also used by unrelated plugins. Attribute only exact ECC artifacts.
const knownDigests = knownOpenCodePluginDigests(plan);
for (const name of ['ecc-hooks', 'index']) {
for (const extension of ['ts', 'js', 'mjs', 'cjs']) {
const destinationPath = path.join(plan.targetRoot, 'plugins', `${name}.${extension}`);
const key = comparablePath(destinationPath);
if (!candidates.has(key)) {
const content = readOpenCodeAliasForAttribution(plan, destinationPath);
const digest = content === null ? null : crypto.createHash('sha256').update(content).digest('hex');
if (knownDigests.has(digest)) {
candidates.set(key, {
sourceRelativePath: `.opencode/plugins/${name}.${extension}`,
destinationPath,
});
}
}
}
}
return candidates;
}
function activationIsInactive(kind, operation, content) {
if (kind === 'plugin') {
return content.toString('utf8') === getDisabledOpenCodePluginContent();
}
const text = content.toString('utf8');
// Validate first so malformed JSON retains its source context.
disableOpenCodeHookPluginRegistration(text, operation.sourceRelativePath || operation.destinationPath);
const config = JSON.parse(text);
return !Array.isArray(config.plugin) || !config.plugin.includes('./plugins');
}
function assertOpenCodeHookDeactivationReady(plan, options = {}) {
if (!shouldDisableOpenCodeHooks(plan)) {
return new Map();
}
assertSafeInstallOperation(plan, { destinationPath: plan.installStatePath });
const previousState = readPreviousInstallState(plan);
if (previousState && (
previousState.target.id !== plan.adapter.id
|| comparablePath(previousState.target.root) !== comparablePath(plan.targetRoot)
|| comparablePath(previousState.target.installStatePath) !== comparablePath(plan.installStatePath)
)) {
throw new Error('Refusing OpenCode hook deactivation: install-state target mismatch.');
}
const previous = new Map(((previousState && previousState.operations) || [])
.filter(operation => operation.ownership === 'managed' && operation.destinationPath)
.map(operation => [comparablePath(operation.destinationPath), operation]));
const desired = new Map(plan.operations.filter(operation => getOpenCodeActivationKind(plan, operation))
.map(operation => [comparablePath(operation.destinationPath), operation]));
const snapshot = new Map();
for (const [key, operation] of openCodeActivationCandidates(plan, [...previous.values(), ...(options.legacyOperations || [])])) {
const kind = getOpenCodeActivationKind(plan, operation);
if (kind === 'package') {
throw new Error(`Unsupported OpenCode package metadata deactivation: ${operation.destinationPath}`);
}
const expectedTransform = kind === 'plugin'
? 'opencode-disable-plugin-entrypoint' : 'opencode-disable-ecc-hooks';
const replacement = desired.get(key);
// Validate planned activation even when its destination does not yet exist.
// Recorded operations may name an unrelated source or use render-template.
if (replacement && (replacement.kind !== 'copy-file'
|| replacement.contentTransform !== expectedTransform)) {
throw new Error(`Refusing OpenCode hook deactivation: unsupported activation operation at ${operation.destinationPath}`);
}
const content = readInstalledFileNoFollow(plan, operation);
if (content === null && fs.existsSync(operation.destinationPath)) {
throw new Error(`Refusing OpenCode hook deactivation: non-file activation at ${operation.destinationPath}`);
}
const digest = content === null ? null : crypto.createHash('sha256').update(content).digest('hex');
snapshot.set(key, digest);
if (content === null) continue;
const inactive = activationIsInactive(kind, operation, content);
if (options.requireInactive) {
if (!inactive) {
throw new Error(`OpenCode hook activation remains active at ${operation.destinationPath}`);
}
continue;
}
const recorded = previous.get(key);
if (inactive && (kind === 'plugin' || options.allowVerifiedLegacyRemoval || !recorded)) continue;
if (options.allowVerifiedLegacyRemoval && recorded) {
const verified = verifyManagedLegacyFile(recorded, {
targetRoot: plan.targetRoot, installStatePath: plan.installStatePath,
}, plan.sourceRoot);
if (verified.destinationPath && verified.digest === digest) continue;
}
if (!replacement || replacement.kind !== 'copy-file'
|| replacement.contentTransform !== expectedTransform
|| !recorded || recorded.contentSha256 !== digest) {
throw new Error(`Refusing OpenCode hook deactivation: user-owned, modified, unverifiable or stale activation at ${operation.destinationPath}`);
}
}
return snapshot;
}
function assertOpenCodeActivationUnchanged(plan, operation, snapshot) {
const key = comparablePath(operation.destinationPath);
if (!snapshot.has(key)) return;
const content = readInstalledFileNoFollow(plan, operation);
const digest = content === null ? null : crypto.createHash('sha256').update(content).digest('hex');
if (digest !== snapshot.get(key)) {
throw new Error(`Refusing OpenCode hook deactivation: activation changed after preflight at ${operation.destinationPath}`);
}
}
function getOpenCodeActivationWriteOptions(operation, snapshot) {
const key = comparablePath(operation.destinationPath);
if (!snapshot.has(key)) return {};
const digest = snapshot.get(key);
return { expectedContent: Object.freeze(digest === null
? { kind: 'absent' } : { kind: 'sha256', digest }) };
}
function getOpenCodeInstallRoots(plan) {
const roots = [plan.targetRoot];
const legacy = getLegacyLocationForPlan(plan);
const inspection = inspectLegacyOpencodeState(legacy);
if (inspection.status === 'unreadable') throw new Error(inspection.error);
if (inspection.status === 'valid') roots.push(legacy.targetRoot);
return roots;
}
function inspectLegacyOpenCodeDeactivation(plan) {
const location = getLegacyLocationForPlan(plan);
if (!location || comparablePath(location.targetRoot) === comparablePath(plan.targetRoot)) return null;
const inspection = inspectLegacyOpencodeState(location);
if (inspection.status === 'unreadable') throw new Error(inspection.error);
if (inspection.status !== 'valid') return null;
const legacyPlan = { ...plan, ...location, operations: [] };
assertOpenCodeHookDeactivationReady(legacyPlan, { allowVerifiedLegacyRemoval: true });
return { plan: legacyPlan, operations: inspection.state.operations.filter(operation => operation.ownership === 'managed') };
}
function assertOpenCodeLeaseCoverage(plan, lease) {
if (plan.adapter?.target !== 'opencode') return;
// Reuse checks opaque ownership without acquiring extra roots out of order.
withOpenCodeInstallLocks(getOpenCodeInstallRoots(plan), () => {}, lease);
}
function findPreviousManagedHooks(previousState, plan, operation) {
if (
!previousState
@@ -347,6 +613,27 @@ function preflightClaudeSettingsOperations(plan) {
}
function prepareHookConsentMigration(plan, migration) {
if (shouldDisableOpenCodeHooks(plan) && migration.requiresBridgeState) {
const previousState = readPreviousInstallState(plan);
if (previousState) {
const previousConsent = getRecordedHookConsent(previousState);
return {
...migration,
// A checkpoint is not a completed consent transition. On failure,
// retain the previous decision until every activation is inactive.
bridgeState: {
...migration.bridgeState,
request: { ...migration.bridgeState.request, hookConsent: previousConsent },
resolution: {
...migration.bridgeState.resolution,
selectedModules: previousConsent === 'enabled'
? [...new Set([...migration.bridgeState.resolution.selectedModules, 'hooks-runtime'])]
: migration.bridgeState.resolution.selectedModules,
},
},
};
}
}
if (plan.hookConsent !== 'declined') {
return migration;
}
@@ -400,6 +687,7 @@ function prepareHookConsentMigration(plan, migration) {
}
function previewInstallPlan(plan) {
assertOpenCodeHookDeactivationReady(plan);
const migration = prepareHookConsentMigration(
plan,
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
@@ -429,6 +717,14 @@ function previewInstallPlan(plan) {
function applyInstallPlan(plan, dependencies = {}) {
assertHookConsentReady(plan);
if (plan.adapter?.target === 'opencode') {
assertSafeInstallOperation(plan, { destinationPath: plan.installStatePath });
return withOpenCodeInstallLocks(
getOpenCodeInstallRoots(plan),
lease => applyInstallPlanLocked(plan, { ...dependencies, opencodeLease: lease }, false),
dependencies.opencodeLease
);
}
const isClaudeManualTarget = plan.adapter
&& (plan.adapter.target === 'claude' || plan.adapter.target === 'claude-project');
const settingsPathToLock = isClaudeManualTarget
@@ -453,6 +749,9 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
if (typeof beforeInstallStateRead === 'function') {
beforeInstallStateRead({ plan });
}
assertOpenCodeLeaseCoverage(plan, dependencies.opencodeLease);
const legacyActivation = inspectLegacyOpenCodeDeactivation(plan);
const activationSnapshot = assertOpenCodeHookDeactivationReady(plan);
const migration = prepareExcludedPathsReconciliation(
plan,
prepareHookConsentMigration(
@@ -499,6 +798,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
beforeOperationWrite({ plan: appliedPlan, operation });
}
assertNoNewUserOwnedFile(migration, operation, appliedPlan);
assertOpenCodeActivationUnchanged(appliedPlan, operation, activationSnapshot);
if (
operation.kind === 'update-claude-settings'
@@ -585,7 +885,20 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
index: linkIndex,
})
: transformed;
fs.writeFileSync(operation.destinationPath, installedContent, 'utf8');
const writeOptions = getOpenCodeActivationWriteOptions(operation, activationSnapshot);
if (writeOptions.expectedContent) {
writeFileNoFollow(operation.destinationPath, installedContent, {
...writeOptions,
action: 'install OpenCode activation',
validateDestination(destinationPath) {
assertSafeInstallOperation(appliedPlan, { destinationPath });
assertSafeClaudeSkillOperation(appliedPlan, { destinationPath });
return destinationPath;
},
});
} else {
fs.writeFileSync(operation.destinationPath, installedContent, 'utf8');
}
writtenDestinations.add(operation.destinationPath);
continue;
}
@@ -605,6 +918,9 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
);
}
// Include preserved user configs omitted from the write plan: they must
// still be inactive before we record a completed install.
assertOpenCodeHookDeactivationReady(plan, { requireInactive: true });
finalState = stateWithContentDigests(migration.finalState, appliedPlan);
if (typeof beforeInstallStateWrite === 'function') {
beforeInstallStateWrite({ plan: appliedPlan, state: finalState });
@@ -658,6 +974,9 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
];
}
assertOpenCodeLeaseCoverage(appliedPlan, dependencies.opencodeLease);
// Recheck removable bytes after canonical writes, before legacy cleanup.
inspectLegacyOpenCodeDeactivation(appliedPlan);
let opencodeMigrationWarnings = [];
try {
const opencodeMigration = cleanupLegacyOpencodeInstall(appliedPlan);
@@ -673,6 +992,12 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
];
}
if (legacyActivation) {
assertOpenCodeHookDeactivationReady(legacyActivation.plan, {
requireInactive: true, legacyOperations: legacyActivation.operations,
});
}
let excludedPathsRemoved = [];
let excludedPathsWarnings = [];
try {
@@ -705,6 +1030,13 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
module.exports = {
applyInstallPlan,
assertOpenCodeActivationUnchanged,
assertOpenCodeLeaseCoverage,
assertOpenCodeHookDeactivationReady,
getOpenCodeActivationKind,
getOpenCodeActivationWriteOptions,
getOpenCodeInstallRoots,
assertSafeInstallOperation,
prepareHookConsentMigration,
previewInstallPlan,
};
+22 -10
View File
@@ -5,6 +5,7 @@ const fs = require('fs');
const path = require('path');
const INVALID_LOCK_STALE_MS = 5 * 60 * 1000;
const acquiredLockIdentities = new WeakMap();
function sameFileIdentity(left, right) {
if (left.ino !== right.ino) {
@@ -19,7 +20,7 @@ function sameFileIdentity(left, right) {
return left.dev === right.dev;
}
function createSettingsLock(lockPath) {
function createSettingsLock(lockPath, label = 'Claude settings') {
const tempPath = `${lockPath}.create-${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
let descriptor;
let ownedStats;
@@ -43,18 +44,28 @@ function createSettingsLock(lockPath) {
fs.rmSync(tempPath, { force: true });
let released = false;
return () => {
const release = () => {
if (released) return;
const quarantinePath = `${lockPath}.release-${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
fs.renameSync(lockPath, quarantinePath);
const quarantinedStats = fs.lstatSync(quarantinePath, { bigint: true });
if (!sameFileIdentity(quarantinedStats, ownedStats)) {
if (!fs.existsSync(lockPath)) fs.renameSync(quarantinePath, lockPath);
throw new Error(`Refusing to release a changed Claude settings lock: ${lockPath}`);
throw new Error(`Refusing to release a changed ${label} lock: ${lockPath}`);
}
released = true;
fs.rmSync(quarantinePath, { force: true });
};
// Retain the identity observed through the creation descriptor. A pathname
// sampled after publication may already refer to a replacement lock.
acquiredLockIdentities.set(release, Object.freeze({ dev: ownedStats.dev, ino: ownedStats.ino }));
return release;
}
function getSettingsLockIdentity(release) {
const identity = acquiredLockIdentities.get(release);
if (!identity) throw new Error('No acquired settings lock identity for this release function.');
return identity;
}
function inspectSettingsLock(lockPath) {
@@ -91,7 +102,7 @@ function processIsAlive(pid) {
}
}
function recoverSettingsLock(lockPath) {
function recoverSettingsLock(lockPath, label = 'Claude settings') {
const recoveryPath = `${lockPath}.recover`;
try {
fs.mkdirSync(recoveryPath, { mode: 0o700 });
@@ -106,7 +117,7 @@ function recoverSettingsLock(lockPath) {
try {
inspected = inspectSettingsLock(lockPath);
} catch (error) {
if (error && error.code === 'ENOENT') return createSettingsLock(lockPath);
if (error && error.code === 'ENOENT') return createSettingsLock(lockPath, label);
throw error;
}
const validOwner = Number.isSafeInteger(inspected.metadata && inspected.metadata.pid)
@@ -123,27 +134,27 @@ function recoverSettingsLock(lockPath) {
return null;
}
fs.rmSync(quarantinePath, { force: true });
return createSettingsLock(lockPath);
return createSettingsLock(lockPath, label);
} finally {
fs.rmSync(recoveryPath, { recursive: true, force: true });
fs.rmSync(quarantinePath, { force: true });
}
}
function acquireSettingsLock(settingsPath) {
function acquireSettingsLock(settingsPath, { label = 'Claude settings' } = {}) {
const lockPath = `${settingsPath}.ecc.lock`;
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
try {
return createSettingsLock(lockPath);
return createSettingsLock(lockPath, label);
} catch (error) {
if (!error || error.code !== 'EEXIST') {
throw error;
}
}
const recovered = recoverSettingsLock(lockPath);
const recovered = recoverSettingsLock(lockPath, label);
if (recovered) return recovered;
throw new Error(
`Another ECC process is updating Claude settings: ${settingsPath}. `
`Another ECC process is updating ${label}: ${settingsPath}. `
+ `If no ECC process is active, inspect and remove ${lockPath}.`
);
}
@@ -175,6 +186,7 @@ function runWithSettingsLock(settingsPath, callback) {
module.exports = {
acquireSettingsLock,
getSettingsLockIdentity,
runWithSettingsLock,
sameFileIdentity,
};
+3 -1
View File
@@ -10,7 +10,9 @@ let cachedValidator = null;
function readJson(filePath, label) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
// Windows PowerShell 5.1 writes a BOM with Set-Content -Encoding UTF8.
const content = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
return JSON.parse(content);
} catch (error) {
throw new Error(`Invalid JSON in ${label}: ${error.message}`);
}
+115
View File
@@ -0,0 +1,115 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { sameFileIdentity } = require('./claude-settings-lock');
function snapshotExpectedContent(expectedContent) {
if (expectedContent === undefined) return undefined;
if (expectedContent && expectedContent.kind === 'absent') {
return Object.freeze({ kind: 'absent' });
}
if (expectedContent && expectedContent.kind === 'sha256'
&& typeof expectedContent.digest === 'string' && /^[a-f0-9]{64}$/i.test(expectedContent.digest)) {
return Object.freeze({ kind: 'sha256', digest: expectedContent.digest.toLowerCase() });
}
throw new TypeError('Invalid expectedContent for guarded write.');
}
function changedDestination(action, filePath, expectedContent, cause) {
const message = expectedContent
? `Refusing OpenCode hook deactivation: activation changed after preflight at ${filePath}`
: `Refusing to ${action}: managed destination changed during the write.`;
const error = new Error(message);
if (cause) {
error.cause = cause;
if (cause.code) error.code = cause.code;
}
return error;
}
function writeFileNoFollow(filePath, content, {
mode,
action = 'write managed file',
validateDestination,
expectedContent: requestedContent,
} = {}) {
if (typeof validateDestination !== 'function') {
throw new TypeError('writeFileNoFollow requires validateDestination.');
}
const destination = path.resolve(filePath);
const expectedContent = snapshotExpectedContent(requestedContent);
const bytes = typeof content === 'string' ? Buffer.from(content) : content;
if (!Buffer.isBuffer(bytes)) throw new TypeError('Managed file content must be a string or Buffer.');
const changed = cause => changedDestination(action, destination, expectedContent, cause);
function validatePath() {
const validated = validateDestination(destination);
if (typeof validated !== 'string' || path.resolve(validated) !== destination) throw changed();
}
function parentStats() {
const stats = fs.lstatSync(path.dirname(destination), { bigint: true });
if (!stats.isDirectory() || stats.isSymbolicLink()) throw changed();
return stats;
}
validatePath();
const parent = parentStats();
const flags = (expectedContent ? fs.constants.O_RDWR : fs.constants.O_WRONLY)
| (!expectedContent || expectedContent.kind === 'absent' ? fs.constants.O_CREAT : 0)
| (expectedContent && expectedContent.kind === 'absent' ? fs.constants.O_EXCL : 0)
| (fs.constants.O_NOFOLLOW || 0);
let descriptor;
try {
descriptor = fs.openSync(destination, flags, mode);
} catch (error) {
if (expectedContent) throw changed(error);
throw error;
}
function assertPinned() {
validatePath();
const descriptorStat = fs.fstatSync(descriptor, { bigint: true });
const liveStat = fs.lstatSync(destination, { bigint: true });
if (!sameFileIdentity(parent, parentStats()) || !descriptorStat.isFile()
|| !liveStat.isFile() || liveStat.isSymbolicLink() || !sameFileIdentity(descriptorStat, liveStat)) {
throw changed();
}
return descriptorStat;
}
let primaryError;
try {
const before = assertPinned();
if (expectedContent && expectedContent.kind === 'absent' && before.size !== 0n) throw changed();
if (expectedContent && expectedContent.kind === 'sha256') {
const digest = crypto.createHash('sha256').update(fs.readFileSync(descriptor)).digest('hex');
const after = assertPinned();
if (!sameFileIdentity(before, after) || before.size !== after.size
|| before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs
|| digest !== expectedContent.digest) throw changed();
}
// Observed changes are rejected before truncation. This is not a CAS against
// arbitrary editors: callers coordinate participating writers with a lease.
fs.ftruncateSync(descriptor, 0);
for (let offset = 0; offset < bytes.length;) {
const written = fs.writeSync(descriptor, bytes, offset, bytes.length - offset, offset);
if (!Number.isInteger(written) || written <= 0 || written > bytes.length - offset) {
throw new Error(`Refusing to ${action}: file write made no valid progress.`);
}
offset += written;
}
if (mode !== undefined) fs.fchmodSync(descriptor, mode);
} catch (error) {
primaryError = error;
} finally {
try {
fs.closeSync(descriptor);
} catch (error) {
if (primaryError) primaryError.closeError = error;
else primaryError = error;
}
}
// An exclusive creation that later fails is not unlinked: the pathname may
// already belong to another writer. Keep the refusal visible to the caller.
if (primaryError) throw primaryError;
}
module.exports = { writeFileNoFollow };
+139 -3
View File
@@ -38,11 +38,65 @@ const HOOK_CAPABILITY_GROUPS = Object.freeze([
const HOOK_CONSENT_DECISIONS = Object.freeze(['enabled', 'declined']);
const HOOK_RUNTIME_MODULE_ID = 'hooks-runtime';
const OPENCODE_DISABLE_ECC_HOOKS_TRANSFORM = 'opencode-disable-ecc-hooks';
const OPENCODE_DISABLE_PLUGIN_TRANSFORM = 'opencode-disable-plugin-entrypoint';
function normalizeOperationPath(value) {
return String(value || '').replace(/\\/g, '/').toLowerCase();
}
function disableOpenCodeHookPluginRegistration(content, sourceRelativePath) {
let config;
try {
config = JSON.parse(content);
} catch (error) {
throw new Error(`Failed to parse ${sourceRelativePath}: ${error.message}`);
}
if (!config || typeof config !== 'object' || Array.isArray(config)) {
throw new Error(`Invalid ${sourceRelativePath}: expected a JSON object`);
}
if (config.plugin !== undefined && !Array.isArray(config.plugin)) {
throw new Error(`Invalid ${sourceRelativePath}: plugin must be an array`);
}
if (!Array.isArray(config.plugin)) {
return `${JSON.stringify(config, null, 2)}\n`;
}
return `${JSON.stringify({
...config,
plugin: config.plugin.filter(plugin => plugin !== './plugins'),
}, null, 2)}\n`;
}
function getOpenCodeActivationPathKind(value) {
const relative = normalizeOperationPath(value);
if (relative === 'opencode.json') return 'config';
if (/^plugins\/[^/]+\/package\.json$/.test(relative)) return 'package';
if (/^plugins\/(?:[^/]+\.(?:[cm]?js|ts)|[^/]+\/index\.(?:[cm]?js|ts))$/.test(relative)) return 'plugin';
return null;
}
function getOpenCodeSourceActivationKind(operation = {}) {
const source = normalizeOperationPath(operation.sourceRelativePath);
if (!source.startsWith('.opencode/')) return null;
return getOpenCodeActivationPathKind(source.replace(/^\.opencode\/(?:dist\/)?/, ''));
}
function isOpenCodePluginEntrypoint(operation = {}) {
return getOpenCodeSourceActivationKind(operation) === 'plugin';
}
function getDisabledOpenCodePluginContent() {
// OpenCode discovers plugins independently of opencode.json registration.
// Do not import the original module: even module initialization has effects.
return 'export default async () => ({});\n';
}
function isOpenCodeHookActivationOperation(operation = {}) {
return getOpenCodeSourceActivationKind(operation) !== null;
}
function isHookRuntimeOperation(operation = {}) {
if (
operation.kind === 'update-claude-settings'
@@ -51,6 +105,15 @@ function isHookRuntimeOperation(operation = {}) {
return true;
}
if (isOpenCodeHookActivationOperation(operation)) {
return !(
operation.kind === 'copy-file'
&& operation.contentTransform === (isOpenCodePluginEntrypoint(operation)
? OPENCODE_DISABLE_PLUGIN_TRANSFORM
: OPENCODE_DISABLE_ECC_HOOKS_TRANSFORM)
);
}
const source = normalizeOperationPath(operation.sourceRelativePath);
const destination = normalizeOperationPath(operation.destinationPath);
return (
@@ -93,6 +156,60 @@ function withoutHookRuntimeId(values) {
return (Array.isArray(values) ? values : []).filter(value => value !== HOOK_RUNTIME_MODULE_ID);
}
function withoutOpenCodeHookActivation(operation) {
if (getOpenCodeSourceActivationKind(operation) === 'package') {
throw new Error(`Unsupported OpenCode package metadata deactivation: ${operation.sourceRelativePath}`);
}
if (
!isOpenCodeHookActivationOperation(operation)
|| operation.kind !== 'copy-file'
) {
return operation;
}
return {
...operation,
contentTransform: isOpenCodePluginEntrypoint(operation)
? OPENCODE_DISABLE_PLUGIN_TRANSFORM
: OPENCODE_DISABLE_ECC_HOOKS_TRANSFORM,
};
}
function transformOpenCodeHookActivationOperations(operations) {
return (Array.isArray(operations) ? operations : []).map(withoutOpenCodeHookActivation);
}
function planSelectsHookRuntime(plan = {}) {
return (
Array.isArray(plan.selectedModuleIds)
&& plan.selectedModuleIds.includes(HOOK_RUNTIME_MODULE_ID)
) || (
Array.isArray(plan.operations)
&& plan.operations.some(operation => operation.moduleId === HOOK_RUNTIME_MODULE_ID)
);
}
function disableUnselectedOpenCodeHooks(plan) {
if (plan.target !== 'opencode' || planSelectsHookRuntime(plan)) {
return plan;
}
return {
...plan,
operations: transformOpenCodeHookActivationOperations(plan.operations),
statePreview: plan.statePreview
? {
...plan.statePreview,
operations: transformOpenCodeHookActivationOperations(plan.statePreview.operations),
}
: plan.statePreview,
};
}
function shouldDisableOpenCodeHooks(plan = {}) {
return plan.target === 'opencode'
&& (plan.hookConsent === 'declined' || !planSelectsHookRuntime(plan));
}
function setStatePreviewHookConsent(statePreview, hookConsent) {
if (!statePreview || !statePreview.request) {
return statePreview;
@@ -123,7 +240,11 @@ function getRecordedHookConsent(state = {}) {
return 'enabled';
}
if (planMaterializesHookRuntime(state)) {
// Older OpenCode installs copied activation files without asking for consent.
// Their presence cannot establish that the user opted in to automatic hooks.
if ((Array.isArray(state.operations) ? state.operations : []).some(operation => (
!isOpenCodeHookActivationOperation(operation) && isHookRuntimeOperation(operation)
))) {
return 'enabled';
}
@@ -134,11 +255,17 @@ function stripHookRuntimeFromPlan(plan) {
const hadHookRuntimeModule = Array.isArray(plan.selectedModuleIds)
&& plan.selectedModuleIds.includes('hooks-runtime');
const operations = (Array.isArray(plan.operations) ? plan.operations : [])
.map(operation => (
plan.target === 'opencode' ? withoutOpenCodeHookActivation(operation) : operation
))
.filter(operation => !isHookRuntimeOperation(operation));
const statePreview = plan.statePreview
? {
...plan.statePreview,
operations: (Array.isArray(plan.statePreview.operations) ? plan.statePreview.operations : [])
.map(operation => (
plan.target === 'opencode' ? withoutOpenCodeHookActivation(operation) : operation
))
.filter(operation => !isHookRuntimeOperation(operation)),
resolution: plan.statePreview.resolution
? {
@@ -167,10 +294,11 @@ function withHookConsent(plan, hookConsent = null) {
if (hookConsent === 'declined') {
return { ...stripHookRuntimeFromPlan(plan), hookConsent };
}
const effectivePlan = disableUnselectedOpenCodeHooks(plan);
return {
...plan,
...effectivePlan,
hookConsent,
statePreview: setStatePreviewHookConsent(plan.statePreview, hookConsent),
statePreview: setStatePreviewHookConsent(effectivePlan.statePreview, hookConsent),
};
}
@@ -193,10 +321,18 @@ function assertHookConsentReady(plan = {}) {
module.exports = {
HOOK_CAPABILITY_GROUPS,
assertHookConsentReady,
disableUnselectedOpenCodeHooks,
disableOpenCodeHookPluginRegistration,
getDisabledOpenCodePluginContent,
getOpenCodeActivationPathKind,
getOpenCodeSourceActivationKind,
formatHookCapabilityDisclosure,
getRecordedHookConsent,
isHookRuntimeOperation,
isOpenCodeHookActivationOperation,
isOpenCodePluginEntrypoint,
planMaterializesHookRuntime,
resolveHookConsentFlags,
shouldDisableOpenCodeHooks,
withHookConsent,
};
+2
View File
@@ -6,6 +6,7 @@ const path = require('path');
const { isWithinRoot, realpathNearestExisting } = require('../path-safety');
const CURRENT_PLUGIN_ID = 'ecc@ecc';
// Legacy plugin ids, kept so existing installs are still recognized.
const LEGACY_PLUGIN_IDS = new Set([
'everything-claude-code@everything-claude-code',
'everything-claude-code@ecc',
@@ -49,6 +50,7 @@ function findManualClaudePlugin(options = {}) {
['ecc', 'plugin.json'],
['ecc@ecc', '.claude-plugin', 'plugin.json'],
['ecc@ecc', 'plugin.json'],
// Legacy install dir, kept for existing installs.
['everything-claude-code', '.claude-plugin', 'plugin.json'],
['everything-claude-code', 'plugin.json'],
];
@@ -0,0 +1,145 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { realpathNearestExisting } = require('../path-safety');
const { acquireSettingsLock, getSettingsLockIdentity, sameFileIdentity } = require('./claude-settings-lock');
const activeLeases = new WeakMap();
const STATE_FILENAME = 'ecc-install-state.json';
const comparablePath = value => process.platform === 'win32' ? value.toLowerCase() : value;
function inspectEntry(filePath) {
try { return fs.lstatSync(filePath, { bigint: true }); }
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
}
function canonicalRoots(roots) {
if (!Array.isArray(roots)) throw new TypeError('OpenCode install roots must be an array.');
const deduplicated = new Map();
for (const root of roots) {
if (typeof root !== 'string' || !path.isAbsolute(root)) {
throw new TypeError('OpenCode install root must be an absolute trusted path.');
}
const stats = inspectEntry(root);
if (stats && (stats.isSymbolicLink() || !stats.isDirectory())) {
throw new Error(`Refusing OpenCode install lock through a non-directory or symlink root: ${root}`);
}
const canonical = realpathNearestExisting(root);
deduplicated.set(comparablePath(canonical), canonical);
}
return [...deduplicated.values()].sort((left, right) => {
const a = comparablePath(left);
const b = comparablePath(right);
return a < b ? -1 : a > b ? 1 : 0;
});
}
function assertLockLocation(root, expectedRoot) {
const stats = inspectEntry(root);
if (!stats || !stats.isDirectory() || stats.isSymbolicLink()
|| comparablePath(fs.realpathSync(root)) !== comparablePath(root)
|| (expectedRoot && !sameFileIdentity(stats, expectedRoot))) {
throw new Error(`Refusing changed OpenCode install lock root: ${root}`);
}
const lock = inspectEntry(path.join(root, `${STATE_FILENAME}.ecc.lock`));
if (lock && (!lock.isFile() || lock.isSymbolicLink())) {
throw new Error(`Refusing non-file or symlink OpenCode install lock: ${root}`);
}
return { root: stats, lock };
}
function assertOwnedLock(owned) {
const live = assertLockLocation(owned.root, owned.rootStats);
if (!live.lock || !sameFileIdentity(live.lock, owned.lockStats)) {
throw new Error(`Refusing changed OpenCode install lock: ${owned.root}`);
}
}
function annotateCleanupFailure(primary, property, value) {
try {
// Own data properties avoid invoking caller getters/setters. Frozen values,
// primitives and rejecting proxy traps simply retain no extra diagnostic.
Object.defineProperty(primary, property, { value, configurable: true, enumerable: true, writable: true });
} catch {
// Diagnostics must never replace the exact primary thrown value.
}
}
function releaseOwned(ownedLocks) {
const failures = [];
for (const owned of [...ownedLocks].reverse()) {
try {
assertOwnedLock(owned);
owned.release();
} catch (error) {
failures.push(error);
}
}
// Finish every safe release before touching any caller-owned error object.
if (failures.length > 0) {
if (failures.length > 1) annotateCleanupFailure(failures[0], 'releaseErrors', failures.slice(1));
throw failures[0];
}
}
function acquireOpenCodeInstallLocks(roots, existingLease) {
const orderedRoots = canonicalRoots(roots);
if (existingLease !== undefined) {
const existing = existingLease && typeof existingLease === 'object' && activeLeases.get(existingLease);
if (!existing) throw new Error('Invalid or inactive OpenCode install lease.');
const covered = new Set(existing.map(owned => comparablePath(owned.root)));
if (orderedRoots.some(root => !covered.has(comparablePath(root)))) {
throw new Error('OpenCode install lease does not cover every required root.');
}
existing.forEach(assertOwnedLock);
return { lease: existingLease, release() {} };
}
const ownedLocks = [];
try {
for (const root of orderedRoots) {
fs.mkdirSync(root, { recursive: true });
const before = assertLockLocation(root);
const release = acquireSettingsLock(path.join(root, STATE_FILENAME), { label: 'OpenCode installation' });
// Bind descriptor-derived ownership before any path revalidation. Never
// adopt the identity of a replacement published at the lock pathname.
const owned = { root, rootStats: before.root, release,
lockStats: getSettingsLockIdentity(release) };
ownedLocks.push(owned);
assertOwnedLock(owned);
}
} catch (error) {
try { releaseOwned(ownedLocks); }
catch (releaseError) { annotateCleanupFailure(error, 'releaseError', releaseError); }
throw error;
}
const lease = Object.freeze({});
activeLeases.set(lease, ownedLocks);
return {
lease,
release() {
if (!activeLeases.has(lease)) return;
activeLeases.delete(lease);
releaseOwned(ownedLocks);
},
};
}
function withOpenCodeInstallLocks(roots, callback, existingLease) {
if (typeof callback !== 'function') throw new TypeError('OpenCode install lock callback must be a function.');
const holder = acquireOpenCodeInstallLocks(roots, existingLease);
let didThrow = false;
let primaryError;
let result;
try { result = callback(holder.lease); }
catch (error) { didThrow = true; primaryError = error; }
try { holder.release(); }
catch (error) {
if (didThrow) annotateCleanupFailure(primaryError, 'releaseError', error);
else { didThrow = true; primaryError = error; }
}
if (didThrow) throw primaryError;
return result;
}
module.exports = { acquireOpenCodeInstallLocks, withOpenCodeInstallLocks };
@@ -4,7 +4,7 @@ const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { readInstallState } = require('../install-state');
const { readInstallState, validateInstallState } = require('../install-state');
const { assertWithinTrustedRoot } = require('../path-safety');
const OPENCODE_TARGET = 'opencode';
@@ -65,10 +65,14 @@ function inspectLegacyOpencodeState(location) {
return { status: 'absent', state: null, error: null };
}
try {
if (!pathExists(location.installStatePath)) {
if (!pathExists(location.targetRoot)) {
return { status: 'absent', state: null, error: null };
}
const rootStat = fs.lstatSync(location.targetRoot);
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
return { status: 'invalid', state: null, error: null };
}
if (!pathExists(location.installStatePath)) return { status: 'absent', state: null, error: null };
const stateStat = fs.lstatSync(location.installStatePath);
if (
!rootStat.isDirectory()
@@ -78,7 +82,11 @@ function inspectLegacyOpencodeState(location) {
) {
return { status: 'invalid', state: null, error: null };
}
const state = readInstallState(location.installStatePath);
const { content } = hashFileNoFollow(location.installStatePath);
let state;
try { state = JSON.parse(content.toString('utf8')); }
catch { return { status: 'invalid', state: null, error: null }; }
if (!validateInstallState(state).valid) return { status: 'invalid', state: null, error: null };
const isOpencode = state.target.target === OPENCODE_TARGET
|| state.target.id === 'opencode-home';
if (
@@ -98,17 +106,19 @@ function inspectLegacyOpencodeState(location) {
}
}
function hashFileNoFollow(filePath) {
function hashFileNoFollow(filePath, fileSystem = fs) {
// The filesystem seam supplies operations, never the read-only access policy.
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
const descriptor = fs.openSync(filePath, flags);
// Read-only opens ignore mode; adapters still receive an owner-only default.
const descriptor = fileSystem.openSync(filePath, flags, 0o600);
try {
const before = fs.fstatSync(descriptor, { bigint: true });
const before = fileSystem.fstatSync(descriptor, { bigint: true });
if (!before.isFile()) {
throw new Error(`Refusing to read a non-file at ${filePath}`);
}
const content = fs.readFileSync(descriptor);
const after = fs.fstatSync(descriptor, { bigint: true });
const finalPathStat = fs.lstatSync(filePath, { bigint: true });
const content = fileSystem.readFileSync(descriptor);
const after = fileSystem.fstatSync(descriptor, { bigint: true });
const finalPathStat = fileSystem.lstatSync(filePath, { bigint: true });
const unchanged = before.dev === after.dev
&& before.ino === after.ino
&& before.size === after.size
@@ -123,11 +133,12 @@ function hashFileNoFollow(filePath) {
throw new Error(`Refusing to read a file that changed during validation: ${filePath}`);
}
return {
content,
digest: crypto.createHash('sha256').update(content).digest('hex'),
stat: after,
};
} finally {
fs.closeSync(descriptor);
fileSystem.closeSync(descriptor);
}
}
@@ -202,7 +213,7 @@ function verifyManagedLegacyFile(operation, location, sourceRoot) {
if (source.digest !== destination.digest) {
return { retainedPath: destinationPath };
}
return { destinationPath, stat: destination.stat };
return { destinationPath, digest: destination.digest, stat: destination.stat };
}
function pathExistsWith(fileSystem, filePath) {
@@ -257,6 +268,13 @@ function removeVerifiedLegacyFile(entry, location, fileSystem = fs) {
identityError.code = 'ESTALE';
throw identityError;
}
// Recheck bytes after quarantine: an in-place edit retains the same inode.
// Production cleanup entries carry the digest verified against ledger/source.
if (entry.digest && hashFileNoFollow(quarantinePath, fileSystem).digest !== entry.digest) {
const changed = new Error(`Legacy OpenCode file changed during quarantine: ${safePath}`);
changed.code = 'ESTALE';
throw changed;
}
fileSystem.rmSync(quarantinePath);
fileSystem.rmdirSync(quarantineDir);
return true;
@@ -393,6 +411,8 @@ function cleanupLegacyOpencodeInstall(plan) {
module.exports = {
cleanupLegacyOpencodeInstall,
getLegacyOpencodeLocation,
getLegacyLocationForPlan,
inspectLegacyOpencodeState,
removeVerifiedLegacyFile,
verifyManagedLegacyFile,
};
+3 -2
View File
@@ -7,6 +7,7 @@ const { execFileSync } = require('child_process');
const { resolveInstallPlan } = require('../install-manifests');
const { getInstallTargetAdapter } = require('../install-targets/registry');
const { resolveInvocationEnvironment } = require('../invocation-environment');
const { disableUnselectedOpenCodeHooks } = require('./hook-consent');
const { readHooksConfig } = require('../hooks-config');
const {
materializeManagedHooks,
@@ -315,7 +316,7 @@ function createManifestInstallPlan(options = {}) {
source
});
return {
return disableUnselectedOpenCodeHooks({
mode: options.mode || 'manifest',
sourceRoot,
target,
@@ -341,7 +342,7 @@ function createManifestInstallPlan(options = {}) {
excludedModuleIds: plan.excludedModuleIds,
operations,
statePreview
};
});
}
module.exports = {
+1 -1
View File
@@ -11,7 +11,7 @@
* ranking degrades to confidence-only (unchanged behaviour).
*
* Resolves part (b) of:
* https://github.com/affaan-m/everything-claude-code/issues/2371
* https://github.com/affaan-m/ECC/issues/2371
*/
const fs = require('fs');
+141 -10
View File
@@ -15,7 +15,7 @@ const http = require('http');
const path = require('path');
const { buildAllowedHostnames, isAllowedHostHeader, isAllowedOrigin } = require('../loopback-guard');
const { renderMarkdown } = require('./markdown');
const { escapeHtml, renderMarkdown } = require('./markdown');
const { artifactSdkJs } = require('./sdk');
const {
canvasCss,
@@ -101,9 +101,21 @@ function sendJson(res, statusCode, payload) {
res.end(body);
}
// Artifact pages render inside a sandboxed iframe (no allow-same-origin) and
// legitimately run CDN scripts (Mermaid) plus inline loaders, so the default
// restrictive CSP cannot apply. A sandbox-only CSP mirrors the iframe
// attribute instead: scripts keep working, but the document gets an opaque
// origin, which neuters direct-navigation abuse of the loopback API (no CORS
// reads, JSON POSTs are preflight-blocked) without changing in-iframe
// behavior. A hostile <meta> CSP in a raw HTML artifact can only narrow this
// further, never loosen it.
const ARTIFACT_CSP = 'sandbox allow-scripts allow-forms allow-popups';
function sendHtml(res, statusCode, html, { csp = true } = {}) {
const headers = { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' };
if (csp) {
if (csp === 'artifact') {
headers['content-security-policy'] = ARTIFACT_CSP;
} else if (csp) {
headers['content-security-policy'] =
"default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'self'";
}
@@ -111,6 +123,112 @@ function sendHtml(res, statusCode, html, { csp = true } = {}) {
res.end(html);
}
// Sibling assets have an explicit 64 MiB resource limit (413 above it), separate
// from request-body limits. Reads use at most the sampled size plus one sentinel.
const MAX_ARTIFACT_ASSET_BYTES = 64 * 1024 * 1024;
function assetReadError(code = 'EARTIFACT_UNSAFE') {
return Object.assign(new Error('Artifact sibling read refused'), { code });
}
function sameAssetIdentity(before, after) {
return before.dev === after.dev && before.ino === after.ino && before.mode === after.mode;
}
function sameAssetFile(before, after) {
return after.isFile() && sameAssetIdentity(before, after) && before.size === after.size
&& (before.mtimeNs ?? before.mtimeMs) === (after.mtimeNs ?? after.mtimeMs)
&& (before.ctimeNs ?? before.ctimeMs) === (after.ctimeNs ?? after.ctimeMs);
}
function assetByteLength(stats) {
if (typeof stats.size !== 'bigint' && !Number.isSafeInteger(stats.size)) throw assetReadError();
const size = BigInt(stats.size);
if (size < 0n) throw assetReadError();
if (size > BigInt(MAX_ARTIFACT_ASSET_BYTES)) throw assetReadError('EARTIFACT_TOO_LARGE');
return Number(size);
}
function snapshotAssetChain(realTarget) {
const root = path.parse(realTarget).root;
const parts = path.relative(root, realTarget).split(path.sep).filter(Boolean);
const paths = [root];
for (const part of parts) paths.push(path.join(paths[paths.length - 1], part));
return paths.map((entryPath, index) => {
const stats = fs.lstatSync(entryPath, { bigint: true });
const leaf = index === paths.length - 1;
if (stats.isSymbolicLink() || !(leaf ? stats.isFile() : stats.isDirectory())) throw assetReadError();
return { path: entryPath, stats, leaf };
});
}
function revalidateAssetPath({ baseDir, resolved, realBase, realTarget, chain }) {
if (fs.realpathSync(baseDir) !== realBase || fs.realpathSync(resolved) !== realTarget) throw assetReadError();
for (const entry of chain) {
const current = fs.lstatSync(entry.path, { bigint: true });
if (current.isSymbolicLink() || !(entry.leaf ? sameAssetFile(entry.stats, current)
: current.isDirectory() && sameAssetIdentity(entry.stats, current))) throw assetReadError();
}
}
function readAssetDescriptor(fd, size) {
const buffer = Buffer.alloc(size + 1);
let bytes = 0;
while (bytes < buffer.length) {
const requested = buffer.length - bytes;
const count = fs.readSync(fd, buffer, bytes, requested, bytes);
if (!Number.isInteger(count) || count < 0 || count > requested) throw assetReadError();
if (count === 0) break;
bytes += count;
}
if (bytes !== size) throw assetReadError();
return buffer.subarray(0, bytes);
}
// Canonical ancestors are sampled from the filesystem root, not just realBase.
// Revalidation detects observed replacements, but portable pathname operations
// are not atomic openat confinement: repeated swap/restore or same-inode content
// races can evade observations. This helper covers siblings only, not the direct
// session.file read. O_NOFOLLOW protects the leaf only where the flag exists.
function readSiblingAsset(baseDir, resolved) {
const realBase = fs.realpathSync(baseDir);
const realTarget = fs.realpathSync(resolved);
const relative = path.relative(realBase, realTarget);
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw assetReadError();
const chain = snapshotAssetChain(realTarget);
const expected = chain[chain.length - 1].stats;
const size = assetByteLength(expected);
const snapshot = { baseDir, resolved, realBase, realTarget, chain };
revalidateAssetPath(snapshot);
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0);
let fd;
try {
fd = fs.openSync(realTarget, flags);
} catch (error) {
if (error.code === 'ELOOP' || error.code === 'ENOTDIR') throw assetReadError();
throw error;
}
let primaryError;
let data;
try {
if (!sameAssetFile(expected, fs.fstatSync(fd, { bigint: true }))) throw assetReadError();
revalidateAssetPath(snapshot);
data = readAssetDescriptor(fd, size);
if (!sameAssetFile(expected, fs.fstatSync(fd, { bigint: true }))) throw assetReadError();
revalidateAssetPath(snapshot);
} catch (error) {
primaryError = error;
} finally {
try { fs.closeSync(fd); } catch (error) {
// A close error may mean the fd is already released; never retry it or
// replace the primary read/validation failure. Close-only failure refuses.
if (!primaryError) primaryError = error;
}
}
if (primaryError) throw primaryError;
return { data, realTarget };
}
function createPlanCanvasServer({
store,
host = DEFAULT_HOST,
@@ -493,7 +611,7 @@ function createPlanCanvasServer({
try {
content = fs.readFileSync(session.file, 'utf8');
} catch {
return sendHtml(res, 404, `<h1>Artifact missing</h1><p>${session.file} no longer exists.</p>`, { csp: false });
return sendHtml(res, 404, `<h1>Artifact missing</h1><p>${escapeHtml(session.file)} no longer exists.</p>`);
}
const ext = path.extname(session.file).toLowerCase();
if (ext === '.md' || ext === '.markdown') {
@@ -501,30 +619,43 @@ function createPlanCanvasServer({
title: path.basename(session.file),
sdkSrc: '/sdk.js'
});
return sendHtml(res, 200, html, { csp: false });
return sendHtml(res, 200, html, { csp: 'artifact' });
}
const sdkTag = '<script src="/sdk.js"></script>';
const injected = content.includes('</body>')
? content.replace('</body>', `${sdkTag}\n</body>`)
: `${content}\n${sdkTag}`;
return sendHtml(res, 200, injected, { csp: false });
return sendHtml(res, 200, injected, { csp: 'artifact' });
}
// Sibling assets resolve relative to the artifact's directory and must
// stay confined to it.
// stay confined to it. The prefix check alone is insufficient: a symlink
// inside the directory can point outside it, so the check is repeated
// against the real paths and fails closed when they cannot be resolved.
const baseDir = path.dirname(session.file);
const resolved = path.resolve(baseDir, assetPath);
if (resolved !== baseDir && !resolved.startsWith(baseDir + path.sep)) {
return sendJson(res, 403, { error: 'asset path escapes artifact directory' });
}
let realTarget;
let data;
try {
data = fs.readFileSync(resolved);
} catch {
({ data, realTarget } = readSiblingAsset(baseDir, resolved));
} catch (error) {
if (error.code === 'EARTIFACT_TOO_LARGE') return sendJson(res, 413, { error: 'asset too large' });
if (error.code === 'EARTIFACT_UNSAFE') return sendJson(res, 403, { error: 'asset changed or unsafe' });
return sendJson(res, 404, { error: 'asset not found' });
}
const type = CONTENT_TYPES[path.extname(resolved).toLowerCase()] || 'application/octet-stream';
res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' });
// MIME comes from the link/request name first so a symlinked asset keeps
// the type the page asked for; the target extension is the fallback.
const type = CONTENT_TYPES[path.extname(resolved).toLowerCase()]
|| CONTENT_TYPES[path.extname(realTarget).toLowerCase()]
|| 'application/octet-stream';
const headers = { 'content-type': type, 'cache-control': 'no-store' };
if (type.startsWith('text/html') || type === 'image/svg+xml') {
headers['content-security-policy'] = ARTIFACT_CSP;
}
res.writeHead(200, headers);
return res.end(data);
}
+63 -15
View File
@@ -8,11 +8,12 @@
* tri-platform branch) and scripts/control-pane.js (which had a darwin-only
* branch that silently no-op'd on Windows/Linux). This helper:
*
* 1. Dispatches `open` / `cmd /c start` / `xdg-open` based on process.platform
* 2. Wires the child's 'error' event so ENOENT / EACCES propagate to the caller
* instead of being swallowed by detached spawns
* 3. Returns a structured { opened, reason } result so CLI consumers can
* surface the truth (browser did/did not open) instead of a lying true/false
* 1. Dispatches `open` / a fixed PowerShell launcher / `xdg-open` by platform
* 2. Handles the child's 'error' event so a missing launcher does not cause
* an unhandled error after a detached spawn
* 3. Returns a structured { opened, reason } result for the launch request.
* Later asynchronous errors cannot change the returned result; success
* does not prove a browser opened.
*
* The signature is intentionally small (single function, no class) so callers
* can import without picking up the rest of scripts/lib.
@@ -22,6 +23,44 @@
const { spawn } = require('child_process');
const WINDOWS_BROWSER_URL = 'ECC_BROWSER_URL';
// Only this constant is encoded as PowerShell source. The validated URL is
// process-environment data, never command text or an interpolated argument.
const WINDOWS_BROWSER_SCRIPT = `$ErrorActionPreference = 'Stop'
try {
$value = [System.Environment]::GetEnvironmentVariable('ECC_BROWSER_URL', 'Process')
[System.Environment]::SetEnvironmentVariable('ECC_BROWSER_URL', $null, 'Process')
$uri = $null
if (-not [System.Uri]::TryCreate($value, [System.UriKind]::Absolute, [ref]$uri) -or @('http', 'https') -notcontains $uri.Scheme -or $uri.UserInfo) { exit 1 }
$info = New-Object System.Diagnostics.ProcessStartInfo
$info.FileName = $value
$info.UseShellExecute = $true
[void][System.Diagnostics.Process]::Start($info)
} catch { exit 1 }
`;
const WINDOWS_BROWSER_COMMAND = Buffer.from(WINDOWS_BROWSER_SCRIPT, 'utf16le').toString('base64');
function normalizeBrowserUrl(value) {
if (typeof value !== 'string' || !value || value !== value.trim()
|| value.includes('\\') || !/^https?:\/\//i.test(value)) return null;
for (const character of value) {
const code = character.charCodeAt(0);
if (code < 32 || code === 127) return null;
}
try {
const url = new URL(value);
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password) return null;
return url.href;
} catch {
return null;
}
}
function windowsBrowserEnvironment(url, environment) {
const entries = Object.entries(environment).filter(([key]) => key.toUpperCase() !== WINDOWS_BROWSER_URL);
return { ...Object.fromEntries(entries), [WINDOWS_BROWSER_URL]: url };
}
/**
* Pick the platform-appropriate opener command + args.
* Returns [cmd, args] suitable for child_process.spawn.
@@ -32,32 +71,41 @@ const { spawn } = require('child_process');
*/
function openerCommandFor(platform, url) {
if (platform === 'darwin') return ['open', [url]];
if (platform === 'win32') return ['cmd', ['/c', 'start', '', url]];
if (platform === 'win32') {
return ['powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', WINDOWS_BROWSER_COMMAND]];
}
return ['xdg-open', [url]];
}
/**
* Open a URL in the user's default browser, dispatching per-platform.
* Open an absolute HTTP/S URL in the default browser, dispatching per-platform.
*
* Always returns a structured result so callers can:
* - show a clear error to the agent (no silent failures)
* - keep JSON CLI output truthful when browsers cannot launch
* Returns the synchronous launch-request result. Asynchronous child errors
* are handled, but are not an acknowledgment that a browser opened.
*
* @param {string} url
* @param {NodeJS.Platform} [platform] - injectable for tests; defaults to process.platform
* @param {typeof spawn} [spawnProcess] - injectable process launcher for tests
* @param {NodeJS.ProcessEnv} [environment] - optional Windows child environment for tests
* @returns {{ opened: boolean, reason: string }}
*/
function openBrowser(url, platform = process.platform) {
if (typeof url !== 'string' || url.length === 0) {
function openBrowser(url, platform = process.platform, spawnProcess = spawn, environment) {
const normalizedUrl = normalizeBrowserUrl(url);
if (!normalizedUrl) {
return { opened: false, reason: 'invalid-url' };
}
const [cmd, args] = openerCommandFor(platform, url);
const [cmd, args] = openerCommandFor(platform, normalizedUrl);
let child;
try {
child = spawn(cmd, args, {
child = spawnProcess(cmd, args, {
detached: true,
stdio: 'ignore',
shell: false,
...(platform === 'win32' ? {
windowsHide: true,
env: windowsBrowserEnvironment(normalizedUrl, environment === undefined ? process.env : environment),
} : {}),
});
} catch (err) {
return {
@@ -67,7 +115,7 @@ function openBrowser(url, platform = process.platform) {
}
// Listen for ENOENT/EACCES/etc that would otherwise be silently swallowed
// when the user has no `open` / `xdg-open` / `start` available.
// when the user has no `open` / `xdg-open` / `powershell.exe` available.
let capturedError = null;
child.on('error', (err) => {
capturedError = err && err.code ? err.code : 'spawn-error';
+18 -11
View File
@@ -423,18 +423,22 @@ function currentClause(prefix) {
return prefix.slice(clauseStart + 1).trim();
}
function invokesContainerResult(prefix) {
function invokesContainerResult(prefix, options = {}) {
const clause = currentClause(prefix);
const pipelineStart = clause.lastIndexOf('|');
const pipelineCommand = clause.slice(pipelineStart + 1).trim();
const isForeachLoopHeader = Boolean(options.groupingExpression) &&
pipelineStart === -1 &&
/^foreach$/i.test(pipelineCommand);
return /(?:^|\s)(?:&|\.)\s*$/.test(clause) ||
/\.\s*(?:foreach|where)\s*$/i.test(clause) ||
/-(?:action|begin|command|end|expression|filter|initializationscript|parallel|process|scriptblock)(?:\s*:\s*)?$/i.test(clause) ||
/^(?:(?:[\w.-]+\\)?(?:foreach-object|where-object|foreach|where|invoke-command|start-job|measure-command)|%|\?)(?:\s|$)/i.test(pipelineCommand);
(!isForeachLoopHeader &&
/^(?:(?:[\w.-]+\\)?(?:foreach-object|where-object|foreach|where|invoke-command|start-job|measure-command)|%|\?)(?:\s|$)/i.test(pipelineCommand));
}
function invokesDynamicResult(prefix) {
return invokesContainerResult(prefix) ||
function invokesDynamicResult(prefix, options = {}) {
return invokesContainerResult(prefix, options) ||
/(?:^|\s)(?:iex|invoke-expression)\s*$/i.test(currentClause(prefix));
}
@@ -850,6 +854,9 @@ function extractExecutableContainers(input, options = {}) {
const withinDoubleQuote = quote === '"';
const prefix = context;
const invokesContainer = invokesContainerResult(prefix, {
groupingExpression: isGroupingExpression,
});
const invokedAfter = isInvokedAfterContainer(input, group.end);
const createsScriptBlock = /\[\s*(?:system\.management\.automation\.)?scriptblock\s*\]\s*::\s*create\s*$/i.test(
currentClause(prefix)
@@ -863,7 +870,7 @@ function extractExecutableContainers(input, options = {}) {
options: {
executeBareScriptBlocks: Boolean(options.executeBareScriptBlocks) ||
invokedAfter || executesNestedScriptBlocks ||
(!isScriptBlock && invokesContainerResult(prefix)),
(!isScriptBlock && invokesContainer),
},
});
} else {
@@ -883,7 +890,7 @@ function extractExecutableContainers(input, options = {}) {
}
let resolvedCommand = null;
if (!isScriptBlock) {
if (isSubexpression || invokesContainerResult(prefix)) {
if (isSubexpression || invokesContainer) {
resolvedCommand = staticOutputResult(group.body);
if (resolvedCommand === null && isSubexpression) {
const scalarReference = variableReference(group.body);
@@ -904,16 +911,16 @@ function extractExecutableContainers(input, options = {}) {
const executableBlockExpression = /\{|\[\s*(?:system\.management\.automation\.)?scriptblock\s*\]\s*::\s*create/i.test(
maskQuotedStrings(group.body)
);
if (!resolvedCommand && !isScriptBlock && invokesDynamicResult(prefix) && !executableBlockExpression) {
if (!resolvedCommand && !isScriptBlock && invokesDynamicResult(prefix, {
groupingExpression: isGroupingExpression,
}) && !executableBlockExpression) {
resolvedCommand = DYNAMIC_EXECUTION_MARKER;
}
if (resolvedCommand) {
for (let offset = 0; offset < resolvedCommand.length; offset += 1) {
masked[index + offset] = resolvedCommand[offset];
}
masked[index] = resolvedCommand;
if (!withinDoubleQuote) appendContext(resolvedCommand);
} else if (isScriptBlock) {
if (invokesContainerResult(prefix)) {
if (invokesContainer) {
context = prefix;
} else {
resetContext();
+1 -1
View File
@@ -4,7 +4,7 @@
* Cross-platform (Windows, macOS, Linux) project type detection
* by inspecting files in the working directory.
*
* Resolves: https://github.com/affaan-m/everything-claude-code/issues/293
* Resolves: https://github.com/affaan-m/ECC/issues/293
*/
const fs = require('fs');
+1
View File
@@ -5,6 +5,7 @@ const path = require('path');
const os = require('os');
const CURRENT_PLUGIN_SLUG = 'ecc';
// Legacy install id, kept for existing installs.
const LEGACY_PLUGIN_SLUG = 'everything-claude-code';
const CURRENT_PLUGIN_HANDLE = `${CURRENT_PLUGIN_SLUG}@${CURRENT_PLUGIN_SLUG}`;
const LEGACY_PLUGIN_HANDLE = `${LEGACY_PLUGIN_SLUG}@${LEGACY_PLUGIN_SLUG}`;
+1 -1
View File
@@ -225,7 +225,7 @@ async function cmdOpen(file, args, { stateDir, port }) {
return {
status: 'open',
url,
browser: launched ? 'opened' : 'not opened',
browser: launched ? 'launch requested' : 'not opened',
browserReason: launchResult.reason,
next_step:
'Run `ecc-plan-canvas await <file>` and leave it running; it returns when the human sends feedback, a verdict, or ends the session.'
+4 -3
View File
@@ -77,7 +77,7 @@ fi
if [[ "$OLD_VERSION" == "$VERSION" ]]; then
echo "Error: Version $VERSION is already declared in release metadata."
echo "After the merged commit passes CI, publish it through the tag workflow:"
echo " git tag \"v$VERSION\""
echo " git tag -s \"v$VERSION\" -m \"Release v$VERSION\""
echo " git push origin \"v$VERSION\""
exit 1
fi
@@ -328,10 +328,11 @@ node scripts/build-opencode.js
node tests/scripts/build-opencode.test.js
node tests/plugin-manifest.test.js
# Stage, commit, tag, and push
# Stage, commit, explicitly sign an annotated tag, and push. Signing failure
# stops here under set -e; no personal tag.gpgSign default is assumed.
git add "$ROOT_PACKAGE_JSON" "$PACKAGE_LOCK_JSON" "$ROOT_AGENTS_MD" "$TR_AGENTS_MD" "$ZH_CN_AGENTS_MD" "$AGENT_YAML" "$VERSION_FILE" "$PLUGIN_JSON" "$MARKETPLACE_JSON" "$CODEX_MARKETPLACE_JSON" "$CODEX_PLUGIN_JSON" "$CODEX_MARKETPLACE_PLUGIN_JSON" "$OPENCODE_PACKAGE_JSON" "$OPENCODE_PACKAGE_LOCK_JSON" "$OPENCODE_ECC_HOOKS_PLUGIN" "$README_FILE" "$ROOT_ZH_CN_README_FILE" "$TR_README_FILE" "$PT_BR_README_FILE" "$ZH_CN_README_FILE" "$SELECTIVE_INSTALL_ARCHITECTURE_DOC"
git commit -m "chore: bump plugin version to $VERSION"
git tag "v$VERSION"
git tag -s "v$VERSION" -m "Release v$VERSION"
git push origin main "v$VERSION"
echo "Released v$VERSION"
+1 -1
View File
@@ -167,7 +167,7 @@ ${chalk.yellow('4.')} Evolve into skills: ${chalk.cyan('/evolve')}
footer() {
console.log(chalk.gray('─'.repeat(60)));
console.log(chalk.dim(` Powered by Everything Claude Code • ecc.tools`));
console.log(chalk.dim(` Powered by ECC • ecc.tools`));
console.log(chalk.dim(` GitHub App: github.com/apps/skill-creator`));
console.log('\n');
}
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# Sync Everything Claude Code (ECC) assets into a local Codex CLI setup.
# Sync ECC assets into a local Codex CLI setup.
# - Backs up ~/.codex config and AGENTS.md
# - Merges ECC AGENTS.md into existing AGENTS.md (marker-based, preserves user content)
# - Generates prompt files from commands/*.md