avorelo 0.4.0-rc.2 → 0.4.0-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/lib/activation/activation-self-healing.js +24 -0
- package/scripts/lib/activation/activation-state.js +1 -1
- package/scripts/lib/activation/repair-engine.js +2 -2
- package/scripts/lib/activation/run-entry.js +1 -1
- package/scripts/lib/agent-enforcement.js +27 -0
- package/scripts/lib/agent-security/sensitive-targets.js +94 -0
- package/scripts/lib/brain-pack.js +51 -24
- package/scripts/lib/dashboard.js +1 -1
- package/scripts/lib/hook-apply.js +146 -21
- package/scripts/lib/host-provenance.js +114 -0
- package/scripts/lib/lifecycle-hooks.js +487 -24
- package/scripts/lib/orchestration/index.js +27 -1
- package/scripts/lib/outcome-evidence-stack.js +24 -1
- package/scripts/lib/public-cli.js +44 -8
- package/scripts/lib/runtime-provenance.js +43 -0
- package/scripts/lib/security-risk-classifier.js +59 -2
- package/scripts/lib/security-scan.js +10 -3
- package/scripts/lib/session-context-optimizer.js +45 -3
package/package.json
CHANGED
|
@@ -33,6 +33,8 @@ const READINESS_CHECKS = Object.freeze([
|
|
|
33
33
|
{ id: "brain_pack", label: "Brain pack", required: true },
|
|
34
34
|
{ id: "session_context", label: "Session context ready", required: true },
|
|
35
35
|
{ id: "operating_value", label: "Value tracking baseline", required: true },
|
|
36
|
+
{ id: "handler_sanity", label: "Handler sanity (structural)", required: true },
|
|
37
|
+
{ id: "runtime_sanity", label: "Live runtime verified", required: false },
|
|
36
38
|
{ id: "dashboard_preview", label: "Dashboard ready", required: false },
|
|
37
39
|
{ id: "proof_summary", label: "Proof summary", required: false },
|
|
38
40
|
{ id: "security_baseline", label: "Security checks ready", required: false },
|
|
@@ -232,6 +234,28 @@ function checkReadiness(cwd, context) {
|
|
|
232
234
|
// security_baseline (non-blocking — intake may not have run yet)
|
|
233
235
|
checks.security_baseline = safeExists(cwd, ".claude/cco/security");
|
|
234
236
|
|
|
237
|
+
// handler_sanity (RC5) — STRUCTURAL behavioral check, required. Runs a benign and a
|
|
238
|
+
// mutating turn through the real handlers and requires the benign turn is NOT
|
|
239
|
+
// self-blocked. This is NOT live-host proof — it cannot show Claude Code loaded the
|
|
240
|
+
// hooks. It only guarantees Avorelo's own handlers won't self-trap a conversation.
|
|
241
|
+
try {
|
|
242
|
+
const { buildHandlerSanityProof } = require("../lifecycle-hooks");
|
|
243
|
+
const proof = buildHandlerSanityProof({});
|
|
244
|
+
checks.handler_sanity = proof.status === "pass";
|
|
245
|
+
} catch {
|
|
246
|
+
checks.handler_sanity = false;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// runtime_sanity (RC5) — LIVE-HOST state, NON-blocking. Passed only after a real
|
|
250
|
+
// CLAUDE_CODE_LIVE benign turn; pending at activation time (the customer has not taken
|
|
251
|
+
// a turn yet). Surfaced honestly; never satisfied by simulation/handler/test.
|
|
252
|
+
try {
|
|
253
|
+
const { readRuntimeSanity } = require("../lifecycle-hooks");
|
|
254
|
+
checks.runtime_sanity = readRuntimeSanity(cwd).state === "passed";
|
|
255
|
+
} catch {
|
|
256
|
+
checks.runtime_sanity = false;
|
|
257
|
+
}
|
|
258
|
+
|
|
235
259
|
// model_routing (from context, non-blocking)
|
|
236
260
|
checks.model_routing = Boolean(context && context.detectedModelRouting && context.detectedModelRouting.status);
|
|
237
261
|
|
|
@@ -35,7 +35,7 @@ function defaultActivationState(cwd) {
|
|
|
35
35
|
manualFixesRequired: [],
|
|
36
36
|
nextAction: {
|
|
37
37
|
label: "Check setup",
|
|
38
|
-
command: "
|
|
38
|
+
command: require("../runtime-provenance").versionAwareActivateCommand("activate"),
|
|
39
39
|
reason: "Activation has not run yet.",
|
|
40
40
|
blocking: false,
|
|
41
41
|
},
|
|
@@ -39,7 +39,7 @@ function buildContextPack(cwd, context) {
|
|
|
39
39
|
commands: {
|
|
40
40
|
status: "avorelo status --text",
|
|
41
41
|
audit: "avorelo audit",
|
|
42
|
-
activate: "
|
|
42
|
+
activate: require("../runtime-provenance").versionAwareActivateCommand("activate"),
|
|
43
43
|
},
|
|
44
44
|
hosts: (context.detectedHosts || [])
|
|
45
45
|
.filter((host) => host.detected)
|
|
@@ -145,7 +145,7 @@ function runRepairEngine(cwd, context, options = {}) {
|
|
|
145
145
|
manualFixesRequired.push({
|
|
146
146
|
id: "run_activation_with_repair",
|
|
147
147
|
label: "Run activation with repair enabled",
|
|
148
|
-
command: "
|
|
148
|
+
command: require("../runtime-provenance").versionAwareActivateCommand("activate --repair"),
|
|
149
149
|
reason: "Avorelo can recreate missing local state automatically when repair is enabled.",
|
|
150
150
|
blocking: false,
|
|
151
151
|
});
|
|
@@ -302,7 +302,7 @@ function verifyRunEntry(cwd, options = {}) {
|
|
|
302
302
|
checks,
|
|
303
303
|
missingRequired,
|
|
304
304
|
advisoryMissing: advisory,
|
|
305
|
-
repairCommand: "
|
|
305
|
+
repairCommand: require("../runtime-provenance").versionAwareActivateCommand("activate"),
|
|
306
306
|
};
|
|
307
307
|
}
|
|
308
308
|
|
|
@@ -193,6 +193,9 @@ function getMatchedRules(input = {}, classification, profile) {
|
|
|
193
193
|
const unknownTool = reasons.has("UNKNOWN_MCP_TOOL");
|
|
194
194
|
const writeCapableTool = reasons.has("WRITE_CAPABLE_TOOL");
|
|
195
195
|
const destructiveCommand = reasons.has("DESTRUCTIVE_COMMAND") || reasons.has("FORCE_PUSH_OR_HISTORY_REWRITE");
|
|
196
|
+
const secretFileRead = reasons.has("SECRET_FILE_READ");
|
|
197
|
+
const broadPermissionChange = reasons.has("BROAD_PERMISSION_CHANGE");
|
|
198
|
+
const writeOutsideProject = reasons.has("WRITE_OUTSIDE_PROJECT");
|
|
196
199
|
const externalExport = reasons.has("EXTERNAL_REPO_EXPORT");
|
|
197
200
|
const externalNetwork = reasons.has("EXTERNAL_NETWORK_REQUEST");
|
|
198
201
|
const localhostVisualQa = actionType === "visual_qa_run" && reasons.has("VISUAL_QA_LOCAL_TARGET");
|
|
@@ -309,6 +312,30 @@ function getMatchedRules(input = {}, classification, profile) {
|
|
|
309
312
|
});
|
|
310
313
|
}
|
|
311
314
|
|
|
315
|
+
if (secretFileRead) {
|
|
316
|
+
rules.push({
|
|
317
|
+
id: "secret_file_read_block",
|
|
318
|
+
outcome: "block",
|
|
319
|
+
summary: "Reading secret-bearing files (.env, keys, credentials) is blocked.",
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (writeOutsideProject) {
|
|
324
|
+
rules.push({
|
|
325
|
+
id: "write_outside_project_block",
|
|
326
|
+
outcome: "block",
|
|
327
|
+
summary: "Writes outside the project root are blocked.",
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (broadPermissionChange) {
|
|
332
|
+
rules.push({
|
|
333
|
+
id: "broad_permission_change_guard",
|
|
334
|
+
outcome: "approval_required",
|
|
335
|
+
summary: "Broad or recursive permission changes require explicit approval.",
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
312
339
|
if (externalExport) {
|
|
313
340
|
rules.push({
|
|
314
341
|
id: "external_export_guard",
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Shared detection for secret-bearing file targets, secret-file reads,
|
|
4
|
+
// and broad permission changes. Used by both the live lifecycle PreToolUse
|
|
5
|
+
// hook and the guard/agent-enforcement CLI path so the two surfaces cannot drift.
|
|
6
|
+
|
|
7
|
+
function normalize(value) {
|
|
8
|
+
return String(value || "").replace(/\\/g, "/").trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Secret-bearing file patterns (env files, package/registry auth, ssh/tls keys, secret bundles).
|
|
12
|
+
const SECRET_FILE_PATTERNS = Object.freeze([
|
|
13
|
+
/(^|\/)\.env(\.[A-Za-z0-9._-]+)?$/i,
|
|
14
|
+
/(^|\/)\.npmrc$/i,
|
|
15
|
+
/(^|\/)\.pypirc$/i,
|
|
16
|
+
/(^|\/)\.netrc$/i,
|
|
17
|
+
/(^|\/)id_rsa(\.pub)?$/i,
|
|
18
|
+
/(^|\/)id_ed25519(\.pub)?$/i,
|
|
19
|
+
/(^|\/)id_dsa(\.pub)?$/i,
|
|
20
|
+
/(^|\/)id_ecdsa(\.pub)?$/i,
|
|
21
|
+
/\.pem$/i,
|
|
22
|
+
/\.key$/i,
|
|
23
|
+
/\.p12$/i,
|
|
24
|
+
/\.pfx$/i,
|
|
25
|
+
/(^|\/)secrets?(\.[A-Za-z0-9._-]+)?$/i,
|
|
26
|
+
/(^|\/)credentials?(\.[A-Za-z0-9._-]+)?$/i,
|
|
27
|
+
/(^|\/)\.ssh(\/|$)/i,
|
|
28
|
+
/(^|\/)\.aws(\/|$)/i,
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
function isSecretFilePath(target) {
|
|
32
|
+
const value = normalize(target);
|
|
33
|
+
if (!value) return false;
|
|
34
|
+
return SECRET_FILE_PATTERNS.some((pattern) => pattern.test(value));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Shell verbs that read/print file contents (POSIX + PowerShell/Windows).
|
|
38
|
+
const READ_VERB = /\b(cat|type|more|less|head|tail|nl|strings|xxd|od|hexdump|base64|grep|egrep|fgrep|rg|ag|findstr|sort|uniq|awk|sed|Get-Content|gc|Select-String|sls)\b/i;
|
|
39
|
+
|
|
40
|
+
// A token inside a command that looks like a secret file path.
|
|
41
|
+
const SECRET_TOKEN = /(?:^|[\s"'=(])(?:[^\s"'|;&()]*\/)?(?:\.env(?:\.[A-Za-z0-9._-]+)?|\.npmrc|\.pypirc|\.netrc|id_rsa|id_ed25519|id_dsa|id_ecdsa|[^\s"'|;&()]*\.pem|[^\s"'|;&()]*\.key|[^\s"'|;&()]*\.p12|[^\s"'|;&()]*\.pfx|secrets?(?:\.[A-Za-z0-9._-]+)?|credentials?(?:\.[A-Za-z0-9._-]+)?)(?=$|[\s"'|;&)])/i;
|
|
42
|
+
|
|
43
|
+
function detectSecretFileRead(command) {
|
|
44
|
+
const text = String(command || "");
|
|
45
|
+
if (!text.trim()) return null;
|
|
46
|
+
if (!READ_VERB.test(text)) return null;
|
|
47
|
+
if (!SECRET_TOKEN.test(text)) return null;
|
|
48
|
+
return {
|
|
49
|
+
code: "SECRET_FILE_READ",
|
|
50
|
+
message: "Reading secret-bearing files (.env, keys, credentials) is blocked.",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Broad / recursive permission changes: chmod 777, recursive chmod, or broad Windows ACL grants.
|
|
55
|
+
function detectBroadPermissionChange(command) {
|
|
56
|
+
const text = String(command || "");
|
|
57
|
+
if (!text.trim()) return null;
|
|
58
|
+
|
|
59
|
+
const chmod = /\bchmod\b/i.test(text);
|
|
60
|
+
const recursive = /\b(-R|--recursive)\b/i.test(text) || /\bchmod\b[^\n]*\s-\w*R/i.test(text);
|
|
61
|
+
const worldWritable = /\b(777|666|a\+rwx|ugo\+rwx|o\+w|a\+w)\b/i.test(text);
|
|
62
|
+
const broadTarget = /\bchmod\b[^\n]*\s(\.|\/|\*|~|\$HOME)(\s|$)/i.test(text);
|
|
63
|
+
|
|
64
|
+
if (chmod && worldWritable && (recursive || broadTarget)) {
|
|
65
|
+
return {
|
|
66
|
+
code: "BROAD_PERMISSION_CHANGE",
|
|
67
|
+
severity: "high",
|
|
68
|
+
message: "Broad recursive permission change (chmod 777 across the repo) is blocked.",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (chmod && worldWritable) {
|
|
72
|
+
return {
|
|
73
|
+
code: "BROAD_PERMISSION_CHANGE",
|
|
74
|
+
severity: "medium",
|
|
75
|
+
message: "World-writable chmod is unsafe. Prefer least privilege.",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// Windows broad ACL grant to Everyone / full control across a tree.
|
|
79
|
+
if (/\bicacls\b/i.test(text) && /\/grant\b/i.test(text) && /(Everyone|Users):\(?[^)]*F/i.test(text) && /\/t\b/i.test(text)) {
|
|
80
|
+
return {
|
|
81
|
+
code: "BROAD_PERMISSION_CHANGE",
|
|
82
|
+
severity: "high",
|
|
83
|
+
message: "Broad recursive Windows ACL grant is blocked.",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = {
|
|
90
|
+
SECRET_FILE_PATTERNS,
|
|
91
|
+
isSecretFilePath,
|
|
92
|
+
detectSecretFileRead,
|
|
93
|
+
detectBroadPermissionChange,
|
|
94
|
+
};
|
|
@@ -80,42 +80,68 @@ function readLatestDogfoodSignals(cwd) {
|
|
|
80
80
|
|
|
81
81
|
// ── Project builder ───────────────────────────────────────────────────────────
|
|
82
82
|
|
|
83
|
+
function extractClaudeMdBullets(content, headingRegex) {
|
|
84
|
+
const lines = String(content || "").split("\n");
|
|
85
|
+
const out = [];
|
|
86
|
+
let capturing = false;
|
|
87
|
+
for (const line of lines) {
|
|
88
|
+
if (/^#{1,6}\s/.test(line)) {
|
|
89
|
+
if (capturing) break; // a new heading ends the captured section
|
|
90
|
+
capturing = headingRegex.test(line);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (capturing) {
|
|
94
|
+
const m = line.match(/^\s*[-*]\s+(.*)$/);
|
|
95
|
+
if (m && m[1].trim()) out.push(m[1].trim().slice(0, 120));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return out.slice(0, 8);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Derive project facts from real evidence (CLAUDE.md, package.json, work-control).
|
|
102
|
+
// Never inject Avorelo's own roadmap/architecture into an unrelated project.
|
|
83
103
|
function buildProject(cwd, pkg) {
|
|
84
104
|
const name = pkg?.name || path.basename(cwd);
|
|
85
105
|
const productName = pkg?.productName || pkg?.name || name;
|
|
86
106
|
|
|
87
|
-
|
|
88
|
-
let
|
|
89
|
-
let
|
|
90
|
-
|
|
91
|
-
"deterministic, no LLM calls in core logic",
|
|
92
|
-
"compact receipts, not huge JSON",
|
|
93
|
-
"capability-router pattern: one router, no duplicates",
|
|
94
|
-
"evidence-backed decisions",
|
|
95
|
-
];
|
|
96
|
-
let nonGoals = [
|
|
97
|
-
"AI Workspace Registry (deferred)",
|
|
98
|
-
"Browser Proof (deferred)",
|
|
99
|
-
"Workflow Intelligence Radar (deferred)",
|
|
100
|
-
"Full model gateway (deferred)",
|
|
101
|
-
"Pricing / website (deferred)",
|
|
102
|
-
];
|
|
107
|
+
let productDirection = "";
|
|
108
|
+
let architecturePrinciples = [];
|
|
109
|
+
let nonGoals = [];
|
|
110
|
+
let evidenceBasis = "none";
|
|
103
111
|
|
|
112
|
+
let claudeMd = "";
|
|
104
113
|
try {
|
|
105
114
|
const claudeMdPath = path.join(cwd, "CLAUDE.md");
|
|
106
|
-
if (fs.existsSync(claudeMdPath))
|
|
107
|
-
const content = fs.readFileSync(claudeMdPath, "utf8");
|
|
108
|
-
// Extract first non-empty line after # heading as direction hint
|
|
109
|
-
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#")).slice(0, 3);
|
|
110
|
-
if (lines.length) productDirection = lines[0].slice(0, 200);
|
|
111
|
-
}
|
|
115
|
+
if (fs.existsSync(claudeMdPath)) claudeMd = fs.readFileSync(claudeMdPath, "utf8");
|
|
112
116
|
} catch {}
|
|
113
117
|
|
|
114
|
-
|
|
118
|
+
if (claudeMd) {
|
|
119
|
+
evidenceBasis = "claude_md";
|
|
120
|
+
const lines = claudeMd
|
|
121
|
+
.split("\n")
|
|
122
|
+
.map((l) => l.trim())
|
|
123
|
+
.filter((l) => l && !l.startsWith("#") && !l.startsWith("<!--") && !l.startsWith("-->") && !l.startsWith("<"))
|
|
124
|
+
.slice(0, 5);
|
|
125
|
+
if (lines.length) productDirection = lines[0].slice(0, 200);
|
|
126
|
+
architecturePrinciples = extractClaudeMdBullets(claudeMd, /(architecture|convention|principle)/i);
|
|
127
|
+
nonGoals = extractClaudeMdBullets(claudeMd, /(non[- ]?goal|boundaries|out of scope|do not)/i);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!productDirection) {
|
|
131
|
+
if (pkg?.description) {
|
|
132
|
+
productDirection = String(pkg.description).slice(0, 200);
|
|
133
|
+
if (evidenceBasis === "none") evidenceBasis = "package_json";
|
|
134
|
+
} else {
|
|
135
|
+
productDirection = "No project-specific direction recorded yet.";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
115
139
|
const wcReceipt = readWorkControlForBrainPack(cwd);
|
|
116
140
|
const currentRoadmapStage = wcReceipt?.task?.text
|
|
117
141
|
? String(wcReceipt.task.text).slice(0, 100)
|
|
118
|
-
:
|
|
142
|
+
: pkg?.version
|
|
143
|
+
? `v${pkg.version}`
|
|
144
|
+
: "No project-specific stage recorded yet.";
|
|
119
145
|
|
|
120
146
|
return {
|
|
121
147
|
name: redactIfSecret(name),
|
|
@@ -125,6 +151,7 @@ function buildProject(cwd, pkg) {
|
|
|
125
151
|
productDirection,
|
|
126
152
|
nonGoals,
|
|
127
153
|
architecturePrinciples,
|
|
154
|
+
evidenceBasis,
|
|
128
155
|
};
|
|
129
156
|
}
|
|
130
157
|
|
package/scripts/lib/dashboard.js
CHANGED
|
@@ -711,7 +711,7 @@ function buildStatusDashboard(cwd, options = {}) {
|
|
|
711
711
|
const scopeRepair = (() => { try { return buildScopeRepairSurface(cwd); } catch { return null; } })();
|
|
712
712
|
const nextRunContinuity = (() => { try { return buildNextRunContinuitySurface(cwd, { buildIfMissing: false }); } catch { return null; } })();
|
|
713
713
|
const dataPermissions = (() => { try { return buildDataPermissionsGuardSurface(cwd, { buildIfMissing: false }); } catch { return null; } })();
|
|
714
|
-
const deploymentEnvironment = (() => { try { return buildDeploymentEnvironmentGuardSurface(cwd, { buildIfMissing:
|
|
714
|
+
const deploymentEnvironment = (() => { try { return buildDeploymentEnvironmentGuardSurface(cwd, { buildIfMissing: true }); } catch { return null; } })();
|
|
715
715
|
const supplyChainDependency = (() => { try { return buildSupplyChainDependencyGuardSurface(cwd, { buildIfMissing: false }); } catch { return null; } })();
|
|
716
716
|
const observabilityRecovery = (() => { try { return buildObservabilityRecoveryGuardSurface(cwd, { buildIfMissing: false }); } catch { return null; } })();
|
|
717
717
|
const costAbuse = (() => { try { return buildCostAbuseGuardSurface(cwd, { buildIfMissing: false }); } catch { return null; } })();
|
|
@@ -16,15 +16,28 @@ const BACKUP_DIR_REL = `${APPLY_DIR_REL}/backups`;
|
|
|
16
16
|
// Avorelo hook marker used to identify managed entries in config
|
|
17
17
|
const AVORELO_HOOK_MARKER = "_avorelo_managed";
|
|
18
18
|
|
|
19
|
-
//
|
|
20
|
-
//
|
|
19
|
+
// Runtime-provenance policy lives in runtime-provenance.js (single source of truth):
|
|
20
|
+
// prereleases pin an exact package spec so `npx` cannot silently resolve to `latest`.
|
|
21
|
+
const {
|
|
22
|
+
resolveAvoreloVersion,
|
|
23
|
+
isPrereleaseVersion,
|
|
24
|
+
avoreloNpxSpec,
|
|
25
|
+
} = require("./runtime-provenance");
|
|
26
|
+
|
|
27
|
+
function lifecycleHookCommand(hookId, version = resolveAvoreloVersion()) {
|
|
28
|
+
return `npx ${avoreloNpxSpec(version)} lifecycle-hook ${hookId} --json`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The lifecycle hooks that Avorelo manages. The `command` field is derived
|
|
32
|
+
// version-aware at build time via lifecycleHookCommand(); the static string
|
|
33
|
+
// here is a stable-spec reference used only for shape/metadata.
|
|
21
34
|
const AVORELO_LIFECYCLE_COMMANDS = Object.freeze([
|
|
22
|
-
{ event: "SessionStart", command: "npx avorelo lifecycle-hook session-start --json", blocking: false, matcher: "" },
|
|
23
|
-
{ event: "UserPromptSubmit", command: "npx avorelo lifecycle-hook user-prompt-submit --json", blocking: false, matcher: "" },
|
|
24
|
-
{ event: "PreToolUse", command: "npx avorelo lifecycle-hook pre-tool-use --json", blocking: true, matcher: "" },
|
|
25
|
-
{ event: "PostToolUse", command: "npx avorelo lifecycle-hook post-tool-use --json", blocking: false, matcher: "" },
|
|
26
|
-
{ event: "Stop", command: "npx avorelo lifecycle-hook stop --json", blocking: true, matcher: "" },
|
|
27
|
-
{ event: "SessionEnd", command: "npx avorelo lifecycle-hook session-end --json", blocking: false, matcher: "" },
|
|
35
|
+
{ event: "SessionStart", hookId: "session-start", command: "npx avorelo lifecycle-hook session-start --json", blocking: false, matcher: "" },
|
|
36
|
+
{ event: "UserPromptSubmit", hookId: "user-prompt-submit", command: "npx avorelo lifecycle-hook user-prompt-submit --json", blocking: false, matcher: "" },
|
|
37
|
+
{ event: "PreToolUse", hookId: "pre-tool-use", command: "npx avorelo lifecycle-hook pre-tool-use --json", blocking: true, matcher: "" },
|
|
38
|
+
{ event: "PostToolUse", hookId: "post-tool-use", command: "npx avorelo lifecycle-hook post-tool-use --json", blocking: false, matcher: "" },
|
|
39
|
+
{ event: "Stop", hookId: "stop", command: "npx avorelo lifecycle-hook stop --json", blocking: true, matcher: "" },
|
|
40
|
+
{ event: "SessionEnd", hookId: "session-end", command: "npx avorelo lifecycle-hook session-end --json", blocking: false, matcher: "" },
|
|
28
41
|
]);
|
|
29
42
|
|
|
30
43
|
// Supported host configs
|
|
@@ -115,24 +128,70 @@ function parseExistingConfig(configAbsPath, format) {
|
|
|
115
128
|
function isAvoreloManagedHook(entry) {
|
|
116
129
|
if (!entry || typeof entry !== "object") return false;
|
|
117
130
|
if (entry[AVORELO_HOOK_MARKER] === true) return true;
|
|
131
|
+
const isLifecycleCommand = (value) => /avorelo(@\S+)?\s+lifecycle-hook/i.test(String(value || ""));
|
|
118
132
|
if (Array.isArray(entry.hooks)) {
|
|
119
|
-
return entry.hooks.some((h) => h &&
|
|
133
|
+
return entry.hooks.some((h) => h && isLifecycleCommand(h.command));
|
|
134
|
+
}
|
|
135
|
+
return isLifecycleCommand(entry.command);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── User-scope (global) hook blast-radius ─────────────────────────────────────
|
|
139
|
+
// Avorelo installs hooks project-scoped only. This detects Avorelo-managed hooks
|
|
140
|
+
// that ended up in the USER settings (~/.claude/settings.json) — e.g. migration
|
|
141
|
+
// residue or a manual global install — so `doctor` can warn and offer safe removal.
|
|
142
|
+
function userSettingsPath(homeDir) {
|
|
143
|
+
return path.join(homeDir || os.homedir(), ".claude", "settings.json");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function detectUserScopeAvoreloHooks(homeDir) {
|
|
147
|
+
const settingsPath = userSettingsPath(homeDir);
|
|
148
|
+
if (!safeExists(settingsPath)) return { present: false, exists: false, avoreloEvents: [], settingsPath };
|
|
149
|
+
const data = safeReadJson(settingsPath);
|
|
150
|
+
if (!data || typeof data.hooks !== "object" || Array.isArray(data.hooks)) {
|
|
151
|
+
return { present: false, exists: true, avoreloEvents: [], settingsPath };
|
|
152
|
+
}
|
|
153
|
+
const avoreloEvents = [];
|
|
154
|
+
for (const [event, entries] of Object.entries(data.hooks)) {
|
|
155
|
+
if (Array.isArray(entries) && entries.some(isAvoreloManagedHook)) avoreloEvents.push(event);
|
|
120
156
|
}
|
|
121
|
-
|
|
122
|
-
|
|
157
|
+
return { present: avoreloEvents.length > 0, exists: true, avoreloEvents, settingsPath };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Remove ONLY Avorelo-managed hooks from user settings, preserving every other
|
|
161
|
+
// setting (env, permissions, theme, effort, unrelated hooks). Dry-run by default.
|
|
162
|
+
function removeUserScopeAvoreloHooks(homeDir, options = {}) {
|
|
163
|
+
const settingsPath = userSettingsPath(homeDir);
|
|
164
|
+
if (!safeExists(settingsPath)) {
|
|
165
|
+
return { status: "nothing_to_remove", settingsPath, applied: false };
|
|
166
|
+
}
|
|
167
|
+
const data = safeReadJson(settingsPath);
|
|
168
|
+
if (!data) return { status: "invalid_json", settingsPath, applied: false };
|
|
169
|
+
const before = detectUserScopeAvoreloHooks(homeDir).avoreloEvents;
|
|
170
|
+
if (before.length === 0) return { status: "nothing_to_remove", settingsPath, applied: false };
|
|
171
|
+
|
|
172
|
+
const cleaned = removeAvoreloHooks(data, "claude_code_settings");
|
|
173
|
+
if (!options.apply && !options.yes) {
|
|
174
|
+
return { status: "dry_run", settingsPath, wouldRemoveEvents: before, applied: false };
|
|
175
|
+
}
|
|
176
|
+
// Backup then write, preserving all non-Avorelo settings.
|
|
177
|
+
try {
|
|
178
|
+
fs.copyFileSync(settingsPath, `${settingsPath}.avorelo-backup-${makeTimestamp()}`);
|
|
179
|
+
} catch {}
|
|
180
|
+
safeWriteJson(settingsPath, cleaned);
|
|
181
|
+
return { status: "removed", settingsPath, removedEvents: before, applied: true };
|
|
123
182
|
}
|
|
124
183
|
|
|
125
184
|
/**
|
|
126
185
|
* Build the Avorelo hook entries for Claude Code settings.json format.
|
|
127
186
|
* Claude Code v2.1+ expects: {matcher: string, hooks: [{type, command}]}
|
|
128
187
|
*/
|
|
129
|
-
function buildClaudeCodeHookEntries() {
|
|
188
|
+
function buildClaudeCodeHookEntries(version = resolveAvoreloVersion()) {
|
|
130
189
|
return AVORELO_LIFECYCLE_COMMANDS.map((lc) => ({
|
|
131
190
|
matcher: lc.matcher,
|
|
132
191
|
hooks: [
|
|
133
192
|
{
|
|
134
193
|
type: "command",
|
|
135
|
-
command: lc.
|
|
194
|
+
command: lifecycleHookCommand(lc.hookId, version),
|
|
136
195
|
},
|
|
137
196
|
],
|
|
138
197
|
[AVORELO_HOOK_MARKER]: true,
|
|
@@ -143,10 +202,10 @@ function buildClaudeCodeHookEntries() {
|
|
|
143
202
|
/**
|
|
144
203
|
* Build the Avorelo hook entries for OpenHands hooks.json format.
|
|
145
204
|
*/
|
|
146
|
-
function buildOpenHandsHookEntries() {
|
|
205
|
+
function buildOpenHandsHookEntries(version = resolveAvoreloVersion()) {
|
|
147
206
|
return AVORELO_LIFECYCLE_COMMANDS.map((lc) => ({
|
|
148
207
|
event: lc.event,
|
|
149
|
-
command: lc.
|
|
208
|
+
command: lifecycleHookCommand(lc.hookId, version),
|
|
150
209
|
blocking: lc.blocking,
|
|
151
210
|
[AVORELO_HOOK_MARKER]: true,
|
|
152
211
|
}));
|
|
@@ -525,6 +584,15 @@ function applyHookConfig(cwd, plan, options = {}) {
|
|
|
525
584
|
redacted: true,
|
|
526
585
|
};
|
|
527
586
|
|
|
587
|
+
// RC5: applying/changing hooks invalidates any prior runtime sanity — the customer
|
|
588
|
+
// must restart Claude Code and take a fresh benign turn before we claim live-verified.
|
|
589
|
+
if (hostsApplied.length > 0) {
|
|
590
|
+
try {
|
|
591
|
+
const { invalidateRuntimeSanity } = require("./lifecycle-hooks");
|
|
592
|
+
invalidateRuntimeSanity(cwd, "hooks_applied");
|
|
593
|
+
} catch {}
|
|
594
|
+
}
|
|
595
|
+
|
|
528
596
|
safeWriteJson(receiptPath, receipt);
|
|
529
597
|
writeLedgerEntry(cwd, "hook_apply", receipt);
|
|
530
598
|
return receipt;
|
|
@@ -837,6 +905,39 @@ function runHookDoctor(cwd, options = {}) {
|
|
|
837
905
|
checks.push({ id: "user_hooks_preserved", host: hostKey, status: "pass", message: `${userHooks.length} user hook(s) preserved.` });
|
|
838
906
|
}
|
|
839
907
|
|
|
908
|
+
// Check 1b: HANDLER SANITY (RC5) — structural behavioral check ONLY. Proves the
|
|
909
|
+
// benign handler sequence does not self-block and unverified mutation gates. This is
|
|
910
|
+
// NOT proof that Claude Code loaded the hooks or that the host is usable (that is
|
|
911
|
+
// runtime sanity, below).
|
|
912
|
+
try {
|
|
913
|
+
const { buildHandlerSanityProof } = require("./lifecycle-hooks");
|
|
914
|
+
const sanity = buildHandlerSanityProof({});
|
|
915
|
+
if (sanity.status === "pass") {
|
|
916
|
+
checks.push({ id: "handler_sanity", status: "pass", message: "Handler sanity: benign sequence does not self-block; unverified mutation gates with a reason. (Not a live-host verdict.)" });
|
|
917
|
+
} else if (sanity.conversationCompletes === false) {
|
|
918
|
+
checks.push({ id: "handler_sanity", status: "fail", message: `Handler sanity FAIL: a benign handler sequence self-blocks (Stop=${sanity.evidence?.conversationTurn?.decision}). Do not treat activation as ready.` });
|
|
919
|
+
} else {
|
|
920
|
+
checks.push({ id: "handler_sanity", status: "warn", message: `Handler sanity: intervention did not fire cleanly (${sanity.summary}).` });
|
|
921
|
+
}
|
|
922
|
+
} catch (e) {
|
|
923
|
+
checks.push({ id: "handler_sanity", status: "warn", message: `Handler sanity probe skipped: ${e.message}` });
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Check 1c: RUNTIME SANITY STATE (RC5) — honest live-host state. Passed only after a
|
|
927
|
+
// real CLAUDE_CODE_LIVE benign turn; pending after activation / hook change until the
|
|
928
|
+
// customer takes a first normal turn in a fresh Claude Code process.
|
|
929
|
+
try {
|
|
930
|
+
const { readRuntimeSanity } = require("./lifecycle-hooks");
|
|
931
|
+
const rs = readRuntimeSanity(cwd);
|
|
932
|
+
if (rs.state === "passed") {
|
|
933
|
+
checks.push({ id: "runtime_sanity", status: "pass", message: `Runtime sanity PASSED from a real live benign turn (${rs.provenance || "live"}).` });
|
|
934
|
+
} else {
|
|
935
|
+
checks.push({ id: "runtime_sanity", status: "warn", message: "Runtime sanity PENDING: restart Claude Code once; it will verify automatically on your first normal turn. (Not yet live-verified — this is honest, not a failure.)" });
|
|
936
|
+
}
|
|
937
|
+
} catch (e) {
|
|
938
|
+
checks.push({ id: "runtime_sanity", status: "warn", message: `Runtime sanity state unavailable: ${e.message}` });
|
|
939
|
+
}
|
|
940
|
+
|
|
840
941
|
// Check 2: Lifecycle handler commands work
|
|
841
942
|
try {
|
|
842
943
|
const { handleLifecycleHook } = require("./lifecycle-hooks");
|
|
@@ -896,12 +997,28 @@ function runHookDoctor(cwd, options = {}) {
|
|
|
896
997
|
: "No backup found. Apply hooks first to enable rollback.",
|
|
897
998
|
});
|
|
898
999
|
|
|
899
|
-
// Check 7:
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1000
|
+
// Check 7: User-scope (global) hook blast-radius. Avorelo installs project-scoped
|
|
1001
|
+
// only; warn if Avorelo-managed hooks are present in user settings (cross-project).
|
|
1002
|
+
try {
|
|
1003
|
+
const userScope = detectUserScopeAvoreloHooks(options.homeDir);
|
|
1004
|
+
if (userScope.present) {
|
|
1005
|
+
checks.push({
|
|
1006
|
+
id: "no_global_hook_blast_radius",
|
|
1007
|
+
status: "warn",
|
|
1008
|
+
message: `Avorelo-managed hooks found in USER settings (${userScope.settingsPath}) for: ${userScope.avoreloEvents.join(", ")}. These affect every repository. Avorelo installs project-scoped only — run \`avorelo hooks uninstall --scope user --yes\` to remove just the Avorelo entries (other settings preserved).`,
|
|
1009
|
+
scope: "user",
|
|
1010
|
+
avoreloEvents: userScope.avoreloEvents,
|
|
1011
|
+
});
|
|
1012
|
+
} else {
|
|
1013
|
+
checks.push({
|
|
1014
|
+
id: "no_global_hook_blast_radius",
|
|
1015
|
+
status: "pass",
|
|
1016
|
+
message: "No Avorelo hooks in user/global settings. Hook install is project-scoped only.",
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
} catch {
|
|
1020
|
+
checks.push({ id: "no_global_hook_blast_radius", status: "pass", message: "User-scope hook check skipped (no user settings)." });
|
|
1021
|
+
}
|
|
905
1022
|
|
|
906
1023
|
const failCount = checks.filter((c) => c.status === "fail").length;
|
|
907
1024
|
const warnCount = checks.filter((c) => c.status === "warn").length;
|
|
@@ -1056,6 +1173,8 @@ module.exports = {
|
|
|
1056
1173
|
validateAppliedHooks,
|
|
1057
1174
|
rollbackHookApply,
|
|
1058
1175
|
uninstallAvoreloHooks,
|
|
1176
|
+
detectUserScopeAvoreloHooks,
|
|
1177
|
+
removeUserScopeAvoreloHooks,
|
|
1059
1178
|
runHookDoctor,
|
|
1060
1179
|
writeHookApplyReceipt,
|
|
1061
1180
|
buildHookApplySurface,
|
|
@@ -1064,6 +1183,8 @@ module.exports = {
|
|
|
1064
1183
|
formatHookDoctorText,
|
|
1065
1184
|
// Helpers (exported for testing)
|
|
1066
1185
|
isAvoreloManagedHook,
|
|
1186
|
+
buildClaudeCodeHookEntries,
|
|
1187
|
+
buildOpenHandsHookEntries,
|
|
1067
1188
|
mergeClaudeCodeConfig,
|
|
1068
1189
|
mergeOpenHandsConfig,
|
|
1069
1190
|
removeAvoreloHooks,
|
|
@@ -1072,4 +1193,8 @@ module.exports = {
|
|
|
1072
1193
|
parseExistingConfig,
|
|
1073
1194
|
requiresExplicitApproval,
|
|
1074
1195
|
isDryRun,
|
|
1196
|
+
resolveAvoreloVersion,
|
|
1197
|
+
isPrereleaseVersion,
|
|
1198
|
+
avoreloNpxSpec,
|
|
1199
|
+
lifecycleHookCommand,
|
|
1075
1200
|
};
|