mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-10 19:57:57 +02:00
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:
co-authored by
Samarjeet Singh Tomar
Claude Fable 5
parent
649def769b
commit
f0cea4f3df
@@ -58,6 +58,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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -5,6 +5,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { writeInstallState } = require('../install-state');
|
||||
const { assertHookConsentReady, planMaterializesHookRuntime } = require('./hook-consent');
|
||||
const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config');
|
||||
const { assertWithinTrustedRoot } = require('../path-safety');
|
||||
const {
|
||||
@@ -209,6 +210,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,
|
||||
@@ -218,12 +222,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 beforeOperationWrite = dependencies.beforeOperationWrite;
|
||||
const beforeInstallStateWrite = dependencies.beforeInstallStateWrite;
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
'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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,12 +5,17 @@ const {
|
||||
createLegacyInstallPlan,
|
||||
createManifestInstallPlan,
|
||||
} = require('../install-executor');
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user