Files
ECC/scripts/lib/install/hook-consent.js
T
f0cea4f3df 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>
2026-08-09 17:16:03 -04:00

161 lines
5.2 KiB
JavaScript

'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']);
function normalizeOperationPath(value) {
return String(value || '').replace(/\\/g, '/').toLowerCase();
}
function isHookRuntimeOperation(operation = {}) {
if (operation.moduleId === 'hooks-runtime') {
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 !== 'hooks-runtime');
}
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,
selectedModuleIds: withoutHookRuntimeId(plan.selectedModuleIds),
excludedModuleIds: hadHookRuntimeModule && Array.isArray(plan.excludedModuleIds)
? [...new Set([...plan.excludedModuleIds, 'hooks-runtime'])]
: 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 };
}
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,
isHookRuntimeOperation,
planMaterializesHookRuntime,
resolveHookConsentFlags,
withHookConsent,
};