feat(install): require an explicit hook decision at the apply layer

The guided installer asks how ECC hooks should run, but that consent
lived only in the wizard path. Running install-apply directly with a
profile that includes hooks-runtime still materialized the hook runtime
with no disclosure and no decision.

Gate the apply layer instead, so every entry point is covered:

- disclose the six hook capability groups when a plan would materialize
  the hook runtime, and refuse to apply until the caller decides
- --enable-hooks confirms the hook runtime; --no-hooks installs the rest
  of the selection without it and records the reduced module closure in
  install-state
- surface the pending decision as a dry-run warning
- show the same capability disclosure in the guided installer's plan
  preview, so the wizard's hook question states what it is asking about

Plans that never materialize hooks (Kimi, --profile minimal,
--without baseline:hooks) are unaffected and need no flag. Repair and
uninstall operate on already-recorded state and stay unchanged.

The capability taxonomy and the held-materialization behavior come from
Samarjeet Singh Tomar's PR #2634, reworked to fit the single-decision
consent model that shipped with the guided installer in #2649.

Co-Authored-By: Samarjeet Singh Tomar <samar_tomar@hotmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
haelyra
2026-08-30 15:09:34 -04:00
co-authored by Samarjeet Singh Tomar Claude Fable 5
parent a89cec9658
commit 6aaa41e028
26 changed files with 768 additions and 58 deletions
+8
View File
@@ -6,6 +6,7 @@ const path = require('path');
const { spawnSync } = require('child_process');
const { discoverInstalledStates } = require('./lib/install-lifecycle');
const { getRecordedHookConsent } = require('./lib/install/hook-consent');
const { SUPPORTED_INSTALL_TARGETS } = require('./lib/install-manifests');
function showHelp(exitCode = 0) {
@@ -85,6 +86,7 @@ function buildInstallApplyArgs(record) {
const target = state.target.target || record.adapter.target;
const request = state.request || {};
const args = [];
const hookConsent = getRecordedHookConsent(state);
if (target) {
args.push('--target', target);
@@ -106,6 +108,12 @@ function buildInstallApplyArgs(record) {
args.push('--without', componentId);
}
if (hookConsent === 'enabled') {
args.push('--enable-hooks');
} else if (hookConsent === 'declined') {
args.push('--no-hooks');
}
for (const language of Array.isArray(request.legacyLanguages) ? request.legacyLanguages : []) {
args.push(language);
}
+3
View File
@@ -59,6 +59,9 @@ Options:
--locale <code> Install translated docs to ~/.claude/docs/<locale>/ (or ./.claude/docs/<locale>/ for claude-project)
(claude or claude-project target only; can be combined with --profile or --with)
--config <path> Load install intent from ecc-install.json
--enable-hooks Confirm installing the automatic hook runtime (required
when the selected profile/modules materialize hooks)
--no-hooks Install everything except the automatic hook runtime
--dry-run Show the install plan without copying files
--json Emit machine-readable plan/result JSON
--help Show this help text
+8
View File
@@ -16,6 +16,7 @@ const {
createMultiHarnessPlan,
normalizeGuidedInstallRequest,
} = require('./lib/multi-harness-setup');
const { formatHookCapabilityDisclosure } = require('./lib/install/hook-consent');
const { startTerminalSpinner } = require('./lib/terminal-spinner');
const { showTerminalWelcome } = require('./lib/terminal-welcome');
const { stripAnsi } = require('./lib/utils');
@@ -209,6 +210,13 @@ function printPlan(plan, output) {
if (plan.request.harnesses.includes('kimi')) {
output.write('\nKimi note: ECC hooks are not configured; model, provider, and authentication settings are unchanged.\n');
}
if (plan.request.harnesses.includes('claude') && plan.request.claudeHooks && plan.request.claudeHooks !== 'off') {
output.write(
`\nClaude hook profile '${plan.request.claudeHooks}' enables automation that can:\n`
+ `${formatHookCapabilityDisclosure()}\n`
+ "Choose '--claude-hooks off' to install without automatic hook behavior.\n"
);
}
}
async function confirmPlan(terminal, output) {
+1
View File
@@ -547,6 +547,7 @@ function createLegacyCompatInstallPlan(options = {}) {
legacyLanguages: selection.legacyLanguages,
ruleLanguages: selection.ruleLanguages,
legacyMode: true,
exemptValidationCodes: options.exemptValidationCodes || [],
requestProfileId: null,
requestModuleIds: [],
requestIncludeComponentIds: includeComponentIds,
+31 -25
View File
@@ -4,10 +4,11 @@ const { execFileSync } = require('child_process');
const os = require('os');
const path = require('path');
const { resolveInstallPlan, loadInstallManifests } = require('./install-manifests');
const { loadInstallManifests } = require('./install-manifests');
const { readInstallState, validateInstallState } = require('./install-state');
const { assertWithinTrustedRoot } = require('./path-safety');
const { createManifestInstallPlan } = require('./install-executor');
const { createInstallPlanFromRequest } = require('./install/runtime');
const { getRecordedHookConsent } = require('./install/hook-consent');
const {
prepareClaudeSkillMigration,
} = require('./install/claude-skill-migration');
@@ -65,6 +66,32 @@ function compareStringArrays(left, right) {
return leftValues.every((value, index) => value === rightValues[index]);
}
function buildRecordedManifestRequest(record) {
const state = record.state || {};
const request = state.request || {};
return {
mode: 'manifest',
target: state.target && state.target.target ? state.target.target : record.adapter.target,
profileId: request.profile || null,
moduleIds: Array.isArray(request.modules) ? [...request.modules] : [],
includeComponentIds: Array.isArray(request.includeComponents) ? [...request.includeComponents] : [],
excludeComponentIds: Array.isArray(request.excludeComponents) ? [...request.excludeComponents] : [],
legacyLanguages: Array.isArray(request.legacyLanguages) ? [...request.legacyLanguages] : [],
hookConsent: getRecordedHookConsent(state),
};
}
function resolveRecordedManifestPlan(record, context, options = {}) {
return createInstallPlanFromRequest(buildRecordedManifestRequest(record), {
sourceRoot: context.repoRoot,
projectRoot: context.projectRoot,
homeDir: context.homeDir,
env: context.env,
exemptValidationCodes: options.exemptValidationCodes || [],
});
}
function hasOpencodeBuildError(issues) {
return Array.isArray(issues) && issues.some(issue => issue.code === OPENCODE_PLUGIN_NOT_BUILT_CODE);
}
@@ -1506,17 +1533,7 @@ function analyzeRecord(record, context) {
if (!state.request.legacyMode) {
try {
const desiredPlan = resolveInstallPlan({
repoRoot: context.repoRoot,
projectRoot: context.projectRoot,
homeDir: context.homeDir,
env: context.env,
target: record.adapter.target,
profileId: state.request.profile || null,
moduleIds: state.request.modules || [],
includeComponentIds: state.request.includeComponents || [],
excludeComponentIds: state.request.excludeComponents || []
});
const desiredPlan = resolveRecordedManifestPlan(record, context);
if (!compareStringArrays(desiredPlan.selectedModuleIds, state.resolution.selectedModules) || !compareStringArrays(desiredPlan.skippedModuleIds, state.resolution.skippedModules)) {
issues.push(
@@ -1614,18 +1631,7 @@ function createRepairPlanFromRecord(record, context, options = {}) {
};
}
const desiredPlan = createManifestInstallPlan({
sourceRoot: context.repoRoot,
target: record.adapter.target,
profileId: state.request.profile || null,
moduleIds: state.request.modules || [],
includeComponentIds: state.request.includeComponents || [],
excludeComponentIds: state.request.excludeComponents || [],
projectRoot: context.projectRoot,
homeDir: context.homeDir,
env: context.env,
exemptValidationCodes: options.exemptValidationCodes || [],
});
const desiredPlan = resolveRecordedManifestPlan(record, context, options);
return {
...desiredPlan,
+12 -1
View File
@@ -127,7 +127,7 @@ function createFallbackValidator() {
validateNoAdditionalProperties(
request,
'/request',
['profile', 'modules', 'includeComponents', 'excludeComponents', 'legacyLanguages', 'legacyMode']
['profile', 'modules', 'includeComponents', 'excludeComponents', 'legacyLanguages', 'legacyMode', 'hookConsent']
);
if (!(Object.prototype.hasOwnProperty.call(request, 'profile') && (request.profile === null || typeof request.profile === 'string'))) {
pushError('/request/profile', 'must be string or null');
@@ -139,6 +139,14 @@ function createFallbackValidator() {
if (typeof request.legacyMode !== 'boolean') {
pushError('/request/legacyMode', 'must be boolean');
}
if (
request.hookConsent !== undefined
&& request.hookConsent !== null
&& request.hookConsent !== 'enabled'
&& request.hookConsent !== 'declined'
) {
pushError('/request/hookConsent', 'must be enabled, declined, or null');
}
}
const resolution = state.resolution;
@@ -258,6 +266,9 @@ function createInstallState(options) {
? [...options.request.legacyLanguages]
: [],
legacyMode: Boolean(options.request.legacyMode),
hookConsent: Object.prototype.hasOwnProperty.call(options.request, 'hookConsent')
? options.request.hookConsent
: null,
},
resolution: {
selectedModules: Array.isArray(options.resolution.selectedModules)
+6
View File
@@ -9,6 +9,7 @@ const {
withCommitAttributionDisabled,
} = require('../claude-commit-attribution');
const { writeInstallState } = require('../install-state');
const { assertHookConsentReady, planMaterializesHookRuntime } = require('./hook-consent');
const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config');
const { assertWithinTrustedRoot } = require('../path-safety');
const {
@@ -335,6 +336,9 @@ function buildResolvedClaudeHooks(plan) {
function previewInstallPlan(plan) {
const migration = prepareClaudeSkillMigration(plan);
const hookConsentWarnings = planMaterializesHookRuntime(plan) && plan.hookConsent !== 'enabled'
? ['Applying this plan requires an explicit hook decision: --enable-hooks or --no-hooks.']
: [];
return {
...plan,
statePreview: migration.finalState,
@@ -344,12 +348,14 @@ function previewInstallPlan(plan) {
warnings: [
...(Array.isArray(plan.warnings) ? plan.warnings : []),
...migration.warnings,
...hookConsentWarnings,
],
applied: false,
};
}
function applyInstallPlan(plan, dependencies = {}) {
assertHookConsentReady(plan);
const persistInstallState = dependencies.writeInstallState || writeInstallState;
const beforeInstallStateRead = dependencies.beforeInstallStateRead;
const beforeOperationWrite = dependencies.beforeOperationWrite;
+203
View File
@@ -0,0 +1,203 @@
'use strict';
/**
* Explicit consent gate for materializing the automatic hook runtime.
*
* The capability disclosure and held-materialization semantics were
* contributed in PR #2634 by Samarjeet Singh Tomar (@samartomar); this
* module integrates them with the single-decision consent model used by
* the guided installer.
*/
const HOOK_CAPABILITY_GROUPS = Object.freeze([
Object.freeze({
id: 'automatic-source-writes',
description: 'Automatically format or otherwise modify project source files.',
}),
Object.freeze({
id: 'command-rewrite-and-process-control',
description: 'Rewrite requested commands and start, replace, or terminate processes.',
}),
Object.freeze({
id: 'transcript-derived-llm-egress',
description: 'Send transcript-derived conversation text to an external LLM.',
}),
Object.freeze({
id: 'mcp-network-and-process-activity',
description: 'Probe MCP endpoints and launch, reconnect, or terminate MCP processes.',
}),
Object.freeze({
id: 'automatic-permission-gates',
description: 'Automatically deny or alter Edit, Write, Bash, and configuration operations.',
}),
Object.freeze({
id: 'session-observation-and-cost-records',
description: 'Persist session, observation, governance, notification, and cost records.',
}),
]);
const HOOK_CONSENT_DECISIONS = Object.freeze(['enabled', 'declined']);
const HOOK_RUNTIME_MODULE_ID = 'hooks-runtime';
function normalizeOperationPath(value) {
return String(value || '').replace(/\\/g, '/').toLowerCase();
}
function isHookRuntimeOperation(operation = {}) {
if (operation.moduleId === HOOK_RUNTIME_MODULE_ID) {
return true;
}
const source = normalizeOperationPath(operation.sourceRelativePath);
const destination = normalizeOperationPath(operation.destinationPath);
return (
source === 'hooks'
|| source.startsWith('hooks/')
|| source === '.cursor/hooks'
|| source.startsWith('.cursor/hooks/')
|| source === '.cursor/hooks.json'
|| source === '.opencode/plugins'
|| source.startsWith('.opencode/plugins/')
|| source === '.opencode/dist/plugins'
|| source.startsWith('.opencode/dist/plugins/')
|| destination.endsWith('/hooks/hooks.json')
|| destination.endsWith('/.cursor/hooks.json')
|| destination.includes('/.cursor/hooks/')
);
}
function planMaterializesHookRuntime(plan = {}) {
const operations = Array.isArray(plan.operations) ? plan.operations : [];
return operations.some(isHookRuntimeOperation);
}
function formatHookCapabilityDisclosure(indent = ' ') {
return HOOK_CAPABILITY_GROUPS
.map((group, index) => `${indent}${index + 1}. ${group.description}`)
.join('\n');
}
function resolveHookConsentFlags({ enableHooks = false, noHooks = false } = {}) {
if (enableHooks && noHooks) {
throw new Error('--enable-hooks and --no-hooks are mutually exclusive');
}
if (enableHooks) {
return 'enabled';
}
if (noHooks) {
return 'declined';
}
return null;
}
function withoutHookRuntimeId(values) {
return (Array.isArray(values) ? values : []).filter(value => value !== HOOK_RUNTIME_MODULE_ID);
}
function setStatePreviewHookConsent(statePreview, hookConsent) {
if (!statePreview || !statePreview.request) {
return statePreview;
}
return {
...statePreview,
request: {
...statePreview.request,
hookConsent,
},
};
}
function getRecordedHookConsent(state = {}) {
const explicitDecision = state.request && HOOK_CONSENT_DECISIONS.includes(state.request.hookConsent)
? state.request.hookConsent
: null;
if (explicitDecision) {
return explicitDecision;
}
if (Array.isArray(state.request && state.request.modules) && state.request.modules.includes(HOOK_RUNTIME_MODULE_ID)) {
return 'enabled';
}
if (Array.isArray(state.resolution && state.resolution.selectedModules) && state.resolution.selectedModules.includes(HOOK_RUNTIME_MODULE_ID)) {
return 'enabled';
}
if (planMaterializesHookRuntime(state)) {
return 'enabled';
}
return null;
}
function stripHookRuntimeFromPlan(plan) {
const hadHookRuntimeModule = Array.isArray(plan.selectedModuleIds)
&& plan.selectedModuleIds.includes('hooks-runtime');
const operations = (Array.isArray(plan.operations) ? plan.operations : [])
.filter(operation => !isHookRuntimeOperation(operation));
const statePreview = plan.statePreview
? {
...plan.statePreview,
operations: (Array.isArray(plan.statePreview.operations) ? plan.statePreview.operations : [])
.filter(operation => !isHookRuntimeOperation(operation)),
resolution: plan.statePreview.resolution
? {
...plan.statePreview.resolution,
selectedModules: withoutHookRuntimeId(plan.statePreview.resolution.selectedModules),
}
: plan.statePreview.resolution,
}
: plan.statePreview;
return {
...plan,
operations,
statePreview: setStatePreviewHookConsent(statePreview, 'declined'),
selectedModuleIds: withoutHookRuntimeId(plan.selectedModuleIds),
excludedModuleIds: hadHookRuntimeModule && Array.isArray(plan.excludedModuleIds)
? [...new Set([...plan.excludedModuleIds, HOOK_RUNTIME_MODULE_ID])]
: plan.excludedModuleIds,
};
}
function withHookConsent(plan, hookConsent = null) {
if (hookConsent !== null && !HOOK_CONSENT_DECISIONS.includes(hookConsent)) {
throw new Error(`Unknown hook consent decision: ${hookConsent}`);
}
if (hookConsent === 'declined') {
return { ...stripHookRuntimeFromPlan(plan), hookConsent };
}
return {
...plan,
hookConsent,
statePreview: setStatePreviewHookConsent(plan.statePreview, hookConsent),
};
}
function assertHookConsentReady(plan = {}) {
if (!planMaterializesHookRuntime(plan)) {
return;
}
if (plan.hookConsent === 'enabled') {
return;
}
throw new Error(
'This install would enable ECC\'s automatic hook runtime, which can:\n'
+ `${formatHookCapabilityDisclosure()}\n`
+ 'Confirm with --enable-hooks to install it, or --no-hooks to install '
+ 'everything else without the hook runtime. The guided installer '
+ '(ecc install --guided) collects this choice interactively.'
);
}
module.exports = {
HOOK_CAPABILITY_GROUPS,
assertHookConsentReady,
formatHookCapabilityDisclosure,
getRecordedHookConsent,
isHookRuntimeOperation,
planMaterializesHookRuntime,
resolveHookConsentFlags,
withHookConsent,
};
+12
View File
@@ -1,6 +1,7 @@
'use strict';
const { validateInstallModuleIds, LOCALE_ALIAS_TO_COMPONENT_ID, listSupportedLocales } = require('../install-manifests');
const { resolveHookConsentFlags } = require('./hook-consent');
const LEGACY_INSTALL_TARGETS = ['claude', 'claude-project', 'cursor', 'antigravity'];
@@ -28,6 +29,8 @@ function parseInstallArgs(argv) {
excludeComponentIds: [],
languages: [],
locale: null,
enableHooks: false,
noHooks: false,
};
for (let index = 0; index < args.length; index += 1) {
@@ -68,6 +71,10 @@ function parseInstallArgs(argv) {
}
parsed.locale = locale;
index += 1;
} else if (arg === '--enable-hooks') {
parsed.enableHooks = true;
} else if (arg === '--no-hooks') {
parsed.noHooks = true;
} else if (arg === '--dry-run') {
parsed.dryRun = true;
} else if (arg === '--json') {
@@ -119,6 +126,10 @@ function normalizeInstallRequest(options = {}) {
...(Array.isArray(options.legacyLanguages) ? options.legacyLanguages : []),
...(Array.isArray(options.languages) ? options.languages : []),
]).map(language => language.toLowerCase()));
const hookConsent = resolveHookConsentFlags(options);
if (hookConsent === 'declined' && moduleIds.includes('hooks-runtime')) {
throw new Error('--no-hooks cannot be combined with an explicit hooks-runtime module selection');
}
const hasManifestBaseSelection = Boolean(profileId) || moduleIds.length > 0 || includeComponentIds.length > 0;
const hasNonLocaleManifestSelection = Boolean(profileId)
|| moduleIds.length > 0
@@ -146,6 +157,7 @@ function normalizeInstallRequest(options = {}) {
includeComponentIds,
excludeComponentIds,
legacyLanguages,
hookConsent,
configPath: config?.path || options.configPath || null,
};
}
+7
View File
@@ -6,12 +6,17 @@ const {
createManifestInstallPlan,
} = require('../install-executor');
const { resolveInvocationEnvironment } = require('../invocation-environment');
const { withHookConsent } = require('./hook-consent');
function createInstallPlanFromRequest(request, options = {}) {
if (!request || typeof request !== 'object') {
throw new Error('A normalized install request is required');
}
return withHookConsent(createRawInstallPlan(request, options), request.hookConsent || null);
}
function createRawInstallPlan(request, options = {}) {
if (request.mode === 'manifest') {
return createManifestInstallPlan({
target: request.target,
@@ -23,6 +28,7 @@ function createInstallPlanFromRequest(request, options = {}) {
homeDir: options.homeDir,
env: resolveInvocationEnvironment(options),
sourceRoot: options.sourceRoot,
exemptValidationCodes: options.exemptValidationCodes || [],
});
}
@@ -37,6 +43,7 @@ function createInstallPlanFromRequest(request, options = {}) {
env: resolveInvocationEnvironment(options),
claudeRulesDir: options.claudeRulesDir,
sourceRoot: options.sourceRoot,
exemptValidationCodes: options.exemptValidationCodes || [],
});
}