fix(install): gate OpenCode hook activation

Signed-off-by: Samarjeet  Singh Tomar <samar_tomar@hotmail.com>
This commit is contained in:
Samarjeet Singh Tomar
2026-09-07 02:26:37 -05:00
parent e04ea0b9cc
commit f8c0c2c182
7 changed files with 295 additions and 6 deletions
+7 -1
View File
@@ -8,7 +8,10 @@ const { loadInstallManifests } = require('./install-manifests');
const { readInstallState, validateInstallState } = require('./install-state');
const { assertWithinTrustedRoot } = require('./path-safety');
const { createInstallPlanFromRequest } = require('./install/runtime');
const { getRecordedHookConsent } = require('./install/hook-consent');
const {
disableOpenCodeHookPluginRegistration,
getRecordedHookConsent,
} = require('./install/hook-consent');
const {
prepareClaudeSkillMigration,
} = require('./install/claude-skill-migration');
@@ -217,6 +220,9 @@ 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);
}
throw new Error(`Unknown install content transform: ${operation.contentTransform}`);
}
+8 -1
View File
@@ -9,7 +9,11 @@ const {
withCommitAttributionDisabled,
} = require('../claude-commit-attribution');
const { writeInstallState } = require('../install-state');
const { assertHookConsentReady, planMaterializesHookRuntime } = require('./hook-consent');
const {
assertHookConsentReady,
disableOpenCodeHookPluginRegistration,
planMaterializesHookRuntime,
} = require('./hook-consent');
const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config');
const { assertWithinTrustedRoot } = require('../path-safety');
const {
@@ -33,6 +37,9 @@ 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);
}
throw new Error(`Unknown install content transform: ${operation.contentTransform}`);
}
+91 -2
View File
@@ -38,16 +38,52 @@ 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';
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 isOpenCodeHookActivationOperation(operation = {}) {
return normalizeOperationPath(operation.sourceRelativePath) === '.opencode/opencode.json';
}
function isHookRuntimeOperation(operation = {}) {
if (operation.moduleId === HOOK_RUNTIME_MODULE_ID) {
return true;
}
if (isOpenCodeHookActivationOperation(operation)) {
return !(
operation.kind === 'copy-file'
&& operation.contentTransform === OPENCODE_DISABLE_ECC_HOOKS_TRANSFORM
);
}
const source = normalizeOperationPath(operation.sourceRelativePath);
const destination = normalizeOperationPath(operation.destinationPath);
return (
@@ -90,6 +126,50 @@ function withoutHookRuntimeId(values) {
return (Array.isArray(values) ? values : []).filter(value => value !== HOOK_RUNTIME_MODULE_ID);
}
function withoutOpenCodeHookActivation(operation) {
if (
!isOpenCodeHookActivationOperation(operation)
|| operation.kind !== 'copy-file'
) {
return operation;
}
return {
...operation,
contentTransform: 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 setStatePreviewHookConsent(statePreview, hookConsent) {
if (!statePreview || !statePreview.request) {
return statePreview;
@@ -131,11 +211,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
? {
@@ -164,10 +250,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),
};
}
@@ -190,6 +277,8 @@ function assertHookConsentReady(plan = {}) {
module.exports = {
HOOK_CAPABILITY_GROUPS,
assertHookConsentReady,
disableUnselectedOpenCodeHooks,
disableOpenCodeHookPluginRegistration,
formatHookCapabilityDisclosure,
getRecordedHookConsent,
isHookRuntimeOperation,
+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 EXCLUDED_GENERATED_SOURCE_SUFFIXES = ['/ecc-install-state.json', '/ecc/install-state.json'];
const IGNORED_DIRECTORY_NAMES = new Set([
@@ -285,7 +286,7 @@ function createManifestInstallPlan(options = {}) {
source
});
return {
return disableUnselectedOpenCodeHooks({
mode: options.mode || 'manifest',
sourceRoot,
target,
@@ -311,7 +312,7 @@ function createManifestInstallPlan(options = {}) {
excludedModuleIds: plan.excludedModuleIds,
operations,
statePreview
};
});
}
module.exports = {
+35
View File
@@ -7,6 +7,7 @@ const assert = require('assert');
const {
HOOK_CAPABILITY_GROUPS,
assertHookConsentReady,
disableOpenCodeHookPluginRegistration,
formatHookCapabilityDisclosure,
isHookRuntimeOperation,
planMaterializesHookRuntime,
@@ -80,6 +81,23 @@ function runTests() {
}),
false
);
assert.strictEqual(isHookRuntimeOperation({
kind: 'copy-file',
moduleId: 'platform-configs',
sourceRelativePath: '.opencode/opencode.json',
}), true);
assert.strictEqual(isHookRuntimeOperation({
kind: 'copy-file',
moduleId: 'platform-configs',
sourceRelativePath: '.opencode/opencode.json',
contentTransform: 'opencode-disable-ecc-hooks',
}), false);
assert.strictEqual(isHookRuntimeOperation({
kind: 'merge-json',
moduleId: 'platform-configs',
sourceRelativePath: '.opencode/opencode.json',
contentTransform: 'opencode-disable-ecc-hooks',
}), true);
assert.strictEqual(isHookRuntimeOperation({ sourceRelativePath: 'rules/common.md' }), false);
assert.strictEqual(
isHookRuntimeOperation({ sourceRelativePath: 'skills/webhooks-guide.md' }),
@@ -96,6 +114,23 @@ function runTests() {
assert.strictEqual(planMaterializesHookRuntime({}), false);
})) passed++; else failed++;
if (test('removes only ECC hook activation from OpenCode config', () => {
const transformed = disableOpenCodeHookPluginRegistration(JSON.stringify({
plugin: ['./plugins', 'example-plugin'],
instructions: ['AGENTS.md'],
}), '.opencode/opencode.json');
assert.deepStrictEqual(JSON.parse(transformed), {
plugin: ['example-plugin'],
instructions: ['AGENTS.md'],
});
assert.deepStrictEqual(JSON.parse(disableOpenCodeHookPluginRegistration(
JSON.stringify({ instructions: ['AGENTS.md'] }),
'.opencode/opencode.json'
)), {
instructions: ['AGENTS.md'],
});
})) passed++; else failed++;
if (test('formats one numbered disclosure line per capability group', () => {
const disclosure = formatHookCapabilityDisclosure();
const lines = disclosure.split('\n');
+102
View File
@@ -19,6 +19,8 @@ const {
listAvailableLanguages,
} = require('../../scripts/lib/install-executor');
const { applyInstallPlan: applyInstallPlanDirect } = require('../../scripts/lib/install/apply');
const { normalizeInstallRequest } = require('../../scripts/lib/install/request');
const { createInstallPlanFromRequest } = require('../../scripts/lib/install/runtime');
const REPO_ROOT = path.resolve(__dirname, '..', '..');
@@ -640,6 +642,106 @@ function runTests() {
}
})) passed++; else failed++;
if (test('OpenCode profile keeps plugin source dormant until hook opt-in is consented', () => {
const homeDir = createTempDir('install-executor-opencode-boundary-');
try {
const planOptions = {
sourceRoot: REPO_ROOT,
homeDir,
projectRoot: homeDir,
exemptValidationCodes: ['opencode-plugin-not-built'],
};
const rawDefaultPlan = createManifestInstallPlan({
...planOptions,
target: 'opencode',
profileId: 'opencode',
});
assert.strictEqual(
rawDefaultPlan.operations.find(operation => (
operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json'
)).contentTransform,
'opencode-disable-ecc-hooks'
);
const defaultPlan = createInstallPlanFromRequest(
normalizeInstallRequest({ target: 'opencode', profileId: 'opencode' }),
planOptions
);
const defaultConfig = defaultPlan.operations.find(operation => (
operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json'
));
const defaultStateConfig = defaultPlan.statePreview.operations.find(operation => (
operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json'
));
assert.strictEqual(defaultConfig.contentTransform, 'opencode-disable-ecc-hooks');
assert.strictEqual(defaultStateConfig.contentTransform, 'opencode-disable-ecc-hooks');
assert.ok(defaultPlan.operations.some(operation => (
operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/plugins/ecc-hooks.ts'
)), 'Default plan should still copy dormant plugin source');
applyInstallPlanDirect(defaultPlan, { writeInstallState() {} });
const installedConfig = JSON.parse(fs.readFileSync(
path.join(homeDir, '.config', 'opencode', 'opencode.json'),
'utf8'
));
assert.ok(!installedConfig.plugin.includes('./plugins'));
const enabledWithoutRuntime = createInstallPlanFromRequest(
normalizeInstallRequest({
target: 'opencode',
profileId: 'opencode',
enableHooks: true,
}),
planOptions
);
assert.strictEqual(
enabledWithoutRuntime.operations.find(operation => (
operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json'
)).contentTransform,
'opencode-disable-ecc-hooks'
);
const declinedPlan = createInstallPlanFromRequest(
normalizeInstallRequest({ target: 'opencode', profileId: 'core', noHooks: true }),
planOptions
);
assert.strictEqual(
declinedPlan.operations.find(operation => (
operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json'
)).contentTransform,
'opencode-disable-ecc-hooks'
);
assert.ok(!declinedPlan.operations.some(operation => operation.moduleId === 'hooks-runtime'));
const pendingPlan = createInstallPlanFromRequest(
normalizeInstallRequest({
target: 'opencode',
moduleIds: ['platform-configs', 'hooks-runtime'],
}),
planOptions
);
assert.throws(
() => applyInstallPlanDirect(pendingPlan, { writeInstallState() {} }),
/automatic hook runtime/
);
const enabledPlan = createInstallPlanFromRequest(
normalizeInstallRequest({
target: 'opencode',
moduleIds: ['platform-configs', 'hooks-runtime'],
enableHooks: true,
}),
planOptions
);
const enabledConfig = enabledPlan.operations.find(operation => (
operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json'
));
assert.strictEqual(enabledConfig.contentTransform, undefined);
} finally {
cleanup(homeDir);
}
})) passed++; else failed++;
if (test('Claude hooks install refuses a symlinked hooks destination', () => {
if (process.platform === 'win32') return;
+49
View File
@@ -1869,6 +1869,55 @@ function runTests() {
}
})) passed++; else failed++;
if (test('doctor dispatches the OpenCode hook-disable content transform consistently', () => {
const projectRoot = createTempDir('install-lifecycle-opencode-transform-');
try {
const targetRoot = path.join(projectRoot, '.cursor');
const installStatePath = path.join(targetRoot, 'ecc-install-state.json');
const destinationPath = path.join(targetRoot, 'opencode.json');
const sourceConfig = JSON.parse(fs.readFileSync(
path.join(REPO_ROOT, '.opencode', 'opencode.json'),
'utf8'
));
const expectedContent = `${JSON.stringify({
...sourceConfig,
plugin: sourceConfig.plugin.filter(plugin => plugin !== './plugins'),
}, null, 2)}\n`;
fs.mkdirSync(targetRoot, { recursive: true });
fs.writeFileSync(destinationPath, expectedContent, 'utf8');
writeState(installStatePath, createCursorStateOptions(projectRoot, {
targetRoot,
installStatePath,
operations: [{
kind: 'copy-file',
moduleId: 'platform-configs',
sourceRelativePath: '.opencode/opencode.json',
destinationPath,
strategy: 'preserve-relative-path',
ownership: 'managed',
scaffoldOnly: false,
contentTransform: 'opencode-disable-ecc-hooks',
}],
}));
const report = buildDoctorReport({
repoRoot: REPO_ROOT,
homeDir: projectRoot,
projectRoot,
targets: ['cursor'],
});
assert.strictEqual(report.results.length, 1);
assert.ok(!report.results[0].issues.some(issue => (
issue.code === 'drifted-managed-files'
|| issue.code === 'unverified-managed-operations'
)));
} finally {
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('doctor infers enabled hooks from older manifest install-state records', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');