shield-harness 1.0.0
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/.claude/hooks/lib/session-modules/.gitkeep +0 -0
- package/.claude/hooks/lib/sh-utils.js +241 -0
- package/.claude/hooks/lint-on-save.js +240 -0
- package/.claude/hooks/sh-circuit-breaker.js +111 -0
- package/.claude/hooks/sh-config-guard.js +252 -0
- package/.claude/hooks/sh-data-boundary.js +315 -0
- package/.claude/hooks/sh-dep-audit.js +101 -0
- package/.claude/hooks/sh-elicitation.js +241 -0
- package/.claude/hooks/sh-evidence.js +193 -0
- package/.claude/hooks/sh-gate.js +330 -0
- package/.claude/hooks/sh-injection-guard.js +165 -0
- package/.claude/hooks/sh-instructions.js +210 -0
- package/.claude/hooks/sh-output-control.js +183 -0
- package/.claude/hooks/sh-permission-learn.js +223 -0
- package/.claude/hooks/sh-permission.js +157 -0
- package/.claude/hooks/sh-pipeline.js +639 -0
- package/.claude/hooks/sh-postcompact.js +173 -0
- package/.claude/hooks/sh-precompact.js +114 -0
- package/.claude/hooks/sh-quiet-inject.js +147 -0
- package/.claude/hooks/sh-session-end.js +143 -0
- package/.claude/hooks/sh-session-start.js +196 -0
- package/.claude/hooks/sh-subagent.js +86 -0
- package/.claude/hooks/sh-task-gate.js +138 -0
- package/.claude/hooks/sh-user-prompt.js +181 -0
- package/.claude/hooks/sh-worktree.js +227 -0
- package/.claude/patterns/injection-patterns.json +137 -0
- package/.claude/rules/binding-governance.md +62 -0
- package/.claude/rules/channel-security.md +90 -0
- package/.claude/rules/coding-principles.md +79 -0
- package/.claude/rules/dev-environment.md +37 -0
- package/.claude/rules/implementation-context.md +112 -0
- package/.claude/rules/language.md +26 -0
- package/.claude/rules/security.md +109 -0
- package/.claude/rules/testing.md +43 -0
- package/LICENSE +21 -0
- package/README.ja.md +107 -0
- package/README.md +105 -0
- package/bin/shield-harness.js +141 -0
- package/package.json +33 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// sh-postcompact.js — Post-compaction state restoration & verification
|
|
3
|
+
// Spec: DETAILED_DESIGN.md §5.8
|
|
4
|
+
// Event: PostCompact
|
|
5
|
+
// Matcher: auto
|
|
6
|
+
// Target response time: < 200ms
|
|
7
|
+
"use strict";
|
|
8
|
+
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const {
|
|
12
|
+
readHookInput,
|
|
13
|
+
allow,
|
|
14
|
+
sha256,
|
|
15
|
+
readSession,
|
|
16
|
+
writeSession,
|
|
17
|
+
appendEvidence,
|
|
18
|
+
SH_DIR,
|
|
19
|
+
} = require("./lib/sh-utils");
|
|
20
|
+
|
|
21
|
+
const HOOK_NAME = "sh-postcompact";
|
|
22
|
+
const BACKUP_DIR = path.join(SH_DIR, "compact-backup");
|
|
23
|
+
const CLAUDE_MD = "CLAUDE.md";
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Restore Logic
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Restore session state from compact-backup.
|
|
31
|
+
* Merges backup into current session (backup values take precedence for key fields).
|
|
32
|
+
* @returns {Object} restored session
|
|
33
|
+
*/
|
|
34
|
+
function restoreSessionState() {
|
|
35
|
+
const backupFile = path.join(BACKUP_DIR, "session.json");
|
|
36
|
+
const currentSession = readSession();
|
|
37
|
+
|
|
38
|
+
if (!fs.existsSync(backupFile)) {
|
|
39
|
+
return currentSession;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const backup = JSON.parse(fs.readFileSync(backupFile, "utf8"));
|
|
44
|
+
// Merge: preserve backup's critical fields
|
|
45
|
+
return {
|
|
46
|
+
...currentSession,
|
|
47
|
+
...backup,
|
|
48
|
+
// Always update these from current state
|
|
49
|
+
last_compact: new Date().toISOString(),
|
|
50
|
+
};
|
|
51
|
+
} catch {
|
|
52
|
+
return currentSession;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Verify CLAUDE.md integrity after compaction.
|
|
58
|
+
* @param {Object} session - Session with baseline hash
|
|
59
|
+
* @returns {{ valid: boolean, message: string }}
|
|
60
|
+
*/
|
|
61
|
+
function verifyCLAUDEMD(session) {
|
|
62
|
+
if (!fs.existsSync(CLAUDE_MD)) {
|
|
63
|
+
return { valid: false, message: "CLAUDE.md not found" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const currentHash = sha256(fs.readFileSync(CLAUDE_MD, "utf8"));
|
|
67
|
+
|
|
68
|
+
// If we don't have a baseline, just record it
|
|
69
|
+
if (!session.claude_md_hash) {
|
|
70
|
+
return {
|
|
71
|
+
valid: true,
|
|
72
|
+
message: `CLAUDE.md hash recorded: ${currentHash.slice(0, 12)}...`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (currentHash !== session.claude_md_hash) {
|
|
77
|
+
return {
|
|
78
|
+
valid: false,
|
|
79
|
+
message: `WARNING: CLAUDE.md has been modified since session start! Expected: ${session.claude_md_hash.slice(0, 12)}..., Got: ${currentHash.slice(0, 12)}...`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { valid: true, message: "CLAUDE.md integrity verified" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build restoration context for systemMessage injection.
|
|
88
|
+
* @param {Object} session
|
|
89
|
+
* @param {{ valid: boolean, message: string }} integrityCheck
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
function buildRestorationContext(session, integrityCheck) {
|
|
93
|
+
const parts = [];
|
|
94
|
+
|
|
95
|
+
parts.push("=== Shield Harness Post-Compaction Restore ===");
|
|
96
|
+
|
|
97
|
+
// Integrity check result
|
|
98
|
+
if (!integrityCheck.valid) {
|
|
99
|
+
parts.push(`⚠ ${integrityCheck.message}`);
|
|
100
|
+
} else {
|
|
101
|
+
parts.push(`✓ ${integrityCheck.message}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Session state
|
|
105
|
+
if (session.session_start) {
|
|
106
|
+
parts.push(`Session started: ${session.session_start}`);
|
|
107
|
+
}
|
|
108
|
+
if (session.token_budget) {
|
|
109
|
+
parts.push(
|
|
110
|
+
`Token budget: ${session.token_budget.used || 0}/${session.token_budget.session_limit || "?"}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Key reminders
|
|
115
|
+
parts.push("");
|
|
116
|
+
parts.push("Key files to re-read if needed:");
|
|
117
|
+
parts.push(" - CLAUDE.md (project instructions)");
|
|
118
|
+
parts.push(" - .claude/rules/ (security & coding rules)");
|
|
119
|
+
parts.push(" - tasks/backlog.yaml (task SoT)");
|
|
120
|
+
parts.push(" - docs/DETAILED_DESIGN.md (hook specifications)");
|
|
121
|
+
parts.push("==========================================");
|
|
122
|
+
|
|
123
|
+
return parts.join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Main
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const input = readHookInput();
|
|
132
|
+
|
|
133
|
+
// Restore session state from backup
|
|
134
|
+
const session = restoreSessionState();
|
|
135
|
+
|
|
136
|
+
// Verify CLAUDE.md integrity
|
|
137
|
+
const integrityCheck = verifyCLAUDEMD(session);
|
|
138
|
+
|
|
139
|
+
// Update and save session
|
|
140
|
+
writeSession(session);
|
|
141
|
+
|
|
142
|
+
// Record evidence
|
|
143
|
+
try {
|
|
144
|
+
appendEvidence({
|
|
145
|
+
hook: HOOK_NAME,
|
|
146
|
+
event: "PostCompact",
|
|
147
|
+
decision: "allow",
|
|
148
|
+
integrity_valid: integrityCheck.valid,
|
|
149
|
+
integrity_message: integrityCheck.message,
|
|
150
|
+
session_id: input.sessionId,
|
|
151
|
+
});
|
|
152
|
+
} catch {
|
|
153
|
+
// Evidence failure is non-blocking
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Inject restoration context
|
|
157
|
+
const context = buildRestorationContext(session, integrityCheck);
|
|
158
|
+
allow(context);
|
|
159
|
+
} catch (_err) {
|
|
160
|
+
// Operational hook — fail-open
|
|
161
|
+
allow();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Exports (for testing)
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
module.exports = {
|
|
169
|
+
restoreSessionState,
|
|
170
|
+
verifyCLAUDEMD,
|
|
171
|
+
buildRestorationContext,
|
|
172
|
+
BACKUP_DIR,
|
|
173
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// sh-precompact.js — Pre-compaction state backup & context injection
|
|
3
|
+
// Spec: DETAILED_DESIGN.md §5.8
|
|
4
|
+
// Event: PreCompact
|
|
5
|
+
// Matcher: auto
|
|
6
|
+
// Target response time: < 200ms
|
|
7
|
+
"use strict";
|
|
8
|
+
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const {
|
|
12
|
+
readHookInput,
|
|
13
|
+
allow,
|
|
14
|
+
readSession,
|
|
15
|
+
appendEvidence,
|
|
16
|
+
SESSION_FILE,
|
|
17
|
+
SH_DIR,
|
|
18
|
+
} = require("./lib/sh-utils");
|
|
19
|
+
|
|
20
|
+
const HOOK_NAME = "sh-precompact";
|
|
21
|
+
const BACKUP_DIR = path.join(SH_DIR, "compact-backup");
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Backup Logic
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Copy session.json to compact-backup directory.
|
|
29
|
+
*/
|
|
30
|
+
function backupSessionState() {
|
|
31
|
+
if (!fs.existsSync(BACKUP_DIR)) {
|
|
32
|
+
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Backup session.json
|
|
36
|
+
if (fs.existsSync(SESSION_FILE)) {
|
|
37
|
+
fs.copyFileSync(SESSION_FILE, path.join(BACKUP_DIR, "session.json"));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build context output for injection into compacted conversation.
|
|
43
|
+
* @param {Object} session - Current session state
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
function buildContextOutput(session) {
|
|
47
|
+
const parts = [];
|
|
48
|
+
|
|
49
|
+
parts.push("=== Shield Harness Pre-Compaction Snapshot ===");
|
|
50
|
+
|
|
51
|
+
// Session state
|
|
52
|
+
if (session.session_start) {
|
|
53
|
+
parts.push(`Session started: ${session.session_start}`);
|
|
54
|
+
}
|
|
55
|
+
if (session.token_budget) {
|
|
56
|
+
parts.push(
|
|
57
|
+
`Token budget used: ${session.token_budget.used || 0}/${session.token_budget.session_limit || "?"}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Key project files reminder
|
|
62
|
+
parts.push("");
|
|
63
|
+
parts.push("Key files:");
|
|
64
|
+
parts.push(" - CLAUDE.md (project instructions)");
|
|
65
|
+
parts.push(" - .claude/rules/ (security & coding rules)");
|
|
66
|
+
parts.push(" - tasks/backlog.yaml (task SoT — read-only)");
|
|
67
|
+
parts.push(" - docs/DETAILED_DESIGN.md (hook specifications)");
|
|
68
|
+
|
|
69
|
+
parts.push("=========================================");
|
|
70
|
+
|
|
71
|
+
return parts.join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Main
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const input = readHookInput();
|
|
80
|
+
const session = readSession();
|
|
81
|
+
|
|
82
|
+
// Backup state before compaction
|
|
83
|
+
backupSessionState();
|
|
84
|
+
|
|
85
|
+
// Record evidence
|
|
86
|
+
try {
|
|
87
|
+
appendEvidence({
|
|
88
|
+
hook: HOOK_NAME,
|
|
89
|
+
event: "PreCompact",
|
|
90
|
+
decision: "allow",
|
|
91
|
+
backup_created: true,
|
|
92
|
+
session_id: input.sessionId,
|
|
93
|
+
});
|
|
94
|
+
} catch {
|
|
95
|
+
// Evidence failure is non-blocking
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Inject context
|
|
99
|
+
const context = buildContextOutput(session);
|
|
100
|
+
allow(context);
|
|
101
|
+
} catch (_err) {
|
|
102
|
+
// Operational hook — fail-open
|
|
103
|
+
allow();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Exports (for testing)
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
module.exports = {
|
|
111
|
+
backupSessionState,
|
|
112
|
+
buildContextOutput,
|
|
113
|
+
BACKUP_DIR,
|
|
114
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// sh-quiet-inject.js — Auto-inject quiet flags to save tokens
|
|
3
|
+
// Spec: DETAILED_DESIGN.md §3.5
|
|
4
|
+
// Hook event: PreToolUse
|
|
5
|
+
// Matcher: Bash
|
|
6
|
+
// Target response time: < 10ms
|
|
7
|
+
"use strict";
|
|
8
|
+
|
|
9
|
+
const { readHookInput, allow, allowWithUpdate } = require("./lib/sh-utils");
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Quiet Injection Rules
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Each rule: { pattern, flag, verboseCheck }
|
|
17
|
+
* - pattern: RegExp matching the command (e.g., /\bgit\s+clone\b/)
|
|
18
|
+
* - flag: The quiet flag to inject (e.g., "-q", "--silent")
|
|
19
|
+
* - verboseCheck: RegExp matching verbose flags that should prevent injection
|
|
20
|
+
*/
|
|
21
|
+
const QUIET_RULES = [
|
|
22
|
+
// Git commands: inject -q unless -v/--verbose present
|
|
23
|
+
{
|
|
24
|
+
pattern: /\bgit\s+clone\b/,
|
|
25
|
+
flag: "-q",
|
|
26
|
+
verboseCheck: /\s-v\b|\s--verbose\b/,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
pattern: /\bgit\s+fetch\b/,
|
|
30
|
+
flag: "-q",
|
|
31
|
+
verboseCheck: /\s-v\b|\s--verbose\b/,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
pattern: /\bgit\s+pull\b/,
|
|
35
|
+
flag: "-q",
|
|
36
|
+
verboseCheck: /\s-v\b|\s--verbose\b/,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
pattern: /\bgit\s+push\b/,
|
|
40
|
+
flag: "-q",
|
|
41
|
+
verboseCheck: /\s-v\b|\s--verbose\b/,
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
// npm commands: inject --silent unless --verbose present
|
|
45
|
+
{
|
|
46
|
+
pattern: /\bnpm\s+install\b/,
|
|
47
|
+
flag: "--silent",
|
|
48
|
+
verboseCheck: /\s--verbose\b/,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
pattern: /\bnpm\s+ci\b/,
|
|
52
|
+
flag: "--silent",
|
|
53
|
+
verboseCheck: /\s--verbose\b/,
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
// cargo build: inject -q unless --verbose/-v present
|
|
57
|
+
{
|
|
58
|
+
pattern: /\bcargo\s+build\b/,
|
|
59
|
+
flag: "-q",
|
|
60
|
+
verboseCheck: /\s--verbose\b|\s-v\b/,
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
// pip install: inject -q unless --verbose/-v present
|
|
64
|
+
{
|
|
65
|
+
pattern: /\bpip3?\s+install\b/,
|
|
66
|
+
flag: "-q",
|
|
67
|
+
verboseCheck: /\s--verbose\b|\s-v\b/,
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
// docker pull: inject -q unless --verbose/-v present
|
|
71
|
+
{
|
|
72
|
+
pattern: /\bdocker\s+pull\b/,
|
|
73
|
+
flag: "-q",
|
|
74
|
+
verboseCheck: /\s--verbose\b|\s-v\b/,
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Injection Logic
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Attempt to inject a quiet flag into a command string.
|
|
84
|
+
*
|
|
85
|
+
* Finds the matching subcommand (e.g., "git clone") and inserts the flag
|
|
86
|
+
* immediately after it.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} command - The original command string.
|
|
89
|
+
* @returns {{ modified: boolean, command: string }}
|
|
90
|
+
*/
|
|
91
|
+
function injectQuietFlag(command) {
|
|
92
|
+
for (const rule of QUIET_RULES) {
|
|
93
|
+
const match = command.match(rule.pattern);
|
|
94
|
+
if (!match) continue;
|
|
95
|
+
|
|
96
|
+
// Skip if already has quiet flag injected
|
|
97
|
+
// Check for common quiet flags in the command
|
|
98
|
+
if (/\s-q\b|\s--quiet\b|\s--silent\b/.test(command)) continue;
|
|
99
|
+
|
|
100
|
+
// Skip if verbose flag is present
|
|
101
|
+
if (rule.verboseCheck.test(command)) continue;
|
|
102
|
+
|
|
103
|
+
// Insert flag right after the matched subcommand
|
|
104
|
+
const insertPos = match.index + match[0].length;
|
|
105
|
+
const modified =
|
|
106
|
+
command.slice(0, insertPos) + " " + rule.flag + command.slice(insertPos);
|
|
107
|
+
|
|
108
|
+
return { modified: true, command: modified };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { modified: false, command };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Main
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const input = readHookInput();
|
|
120
|
+
const command = (input.toolInput.command || "").trim();
|
|
121
|
+
|
|
122
|
+
// Empty command — nothing to inject
|
|
123
|
+
if (!command) {
|
|
124
|
+
allow();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const result = injectQuietFlag(command);
|
|
128
|
+
|
|
129
|
+
if (result.modified) {
|
|
130
|
+
allowWithUpdate({ command: result.command });
|
|
131
|
+
} else {
|
|
132
|
+
allow();
|
|
133
|
+
}
|
|
134
|
+
} catch (_err) {
|
|
135
|
+
// Operational hook, not security — on error, just allow.
|
|
136
|
+
// Worst case: extra output tokens, not a security breach.
|
|
137
|
+
allow();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Exports (for testing)
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
QUIET_RULES,
|
|
146
|
+
injectQuietFlag,
|
|
147
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// sh-session-end.js — Session cleanup & statistics
|
|
3
|
+
// Spec: DETAILED_DESIGN.md §5.8
|
|
4
|
+
// Event: SessionEnd
|
|
5
|
+
// Target response time: < 200ms
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const {
|
|
10
|
+
readHookInput,
|
|
11
|
+
allow,
|
|
12
|
+
readSession,
|
|
13
|
+
writeSession,
|
|
14
|
+
appendEvidence,
|
|
15
|
+
EVIDENCE_FILE,
|
|
16
|
+
} = require("./lib/sh-utils");
|
|
17
|
+
|
|
18
|
+
const HOOK_NAME = "sh-session-end";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Statistics computation
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Compute session statistics from evidence ledger.
|
|
26
|
+
* @param {string} sessionId
|
|
27
|
+
* @returns {{ toolCalls: number, denials: number, topTools: string[], duration: string }}
|
|
28
|
+
*/
|
|
29
|
+
function computeStats(sessionId) {
|
|
30
|
+
const stats = { toolCalls: 0, denials: 0, topTools: [], duration: "unknown" };
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
if (!fs.existsSync(EVIDENCE_FILE)) return stats;
|
|
34
|
+
|
|
35
|
+
const lines = fs.readFileSync(EVIDENCE_FILE, "utf8").trim().split("\n");
|
|
36
|
+
const toolCounts = {};
|
|
37
|
+
let sessionStart = null;
|
|
38
|
+
|
|
39
|
+
for (const line of lines) {
|
|
40
|
+
if (!line) continue;
|
|
41
|
+
try {
|
|
42
|
+
const entry = JSON.parse(line);
|
|
43
|
+
// Only count entries from this session
|
|
44
|
+
if (entry.session_id && entry.session_id !== sessionId) continue;
|
|
45
|
+
|
|
46
|
+
if (entry.event === "SessionStart") {
|
|
47
|
+
sessionStart = entry.recorded_at;
|
|
48
|
+
}
|
|
49
|
+
if (entry.tool) {
|
|
50
|
+
stats.toolCalls++;
|
|
51
|
+
toolCounts[entry.tool] = (toolCounts[entry.tool] || 0) + 1;
|
|
52
|
+
}
|
|
53
|
+
if (entry.decision === "deny") {
|
|
54
|
+
stats.denials++;
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// Skip malformed lines
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Top 3 tools
|
|
62
|
+
stats.topTools = Object.entries(toolCounts)
|
|
63
|
+
.sort((a, b) => b[1] - a[1])
|
|
64
|
+
.slice(0, 3)
|
|
65
|
+
.map(([tool, count]) => `${tool}(${count})`);
|
|
66
|
+
|
|
67
|
+
// Duration
|
|
68
|
+
if (sessionStart) {
|
|
69
|
+
const startMs = new Date(sessionStart).getTime();
|
|
70
|
+
const endMs = Date.now();
|
|
71
|
+
const diffMin = Math.round((endMs - startMs) / 60000);
|
|
72
|
+
if (diffMin < 60) {
|
|
73
|
+
stats.duration = `${diffMin}m`;
|
|
74
|
+
} else {
|
|
75
|
+
const h = Math.floor(diffMin / 60);
|
|
76
|
+
const m = diffMin % 60;
|
|
77
|
+
stats.duration = `${h}h${m}m`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// Stats computation failure is non-critical
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return stats;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Main
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const input = readHookInput();
|
|
93
|
+
const { sessionId } = input;
|
|
94
|
+
|
|
95
|
+
// Compute session stats
|
|
96
|
+
const stats = computeStats(sessionId);
|
|
97
|
+
|
|
98
|
+
// Record close marker in evidence ledger
|
|
99
|
+
try {
|
|
100
|
+
appendEvidence({
|
|
101
|
+
hook: HOOK_NAME,
|
|
102
|
+
event: "SessionEnd",
|
|
103
|
+
decision: "allow",
|
|
104
|
+
summary: {
|
|
105
|
+
tool_calls: stats.toolCalls,
|
|
106
|
+
denials: stats.denials,
|
|
107
|
+
top_tools: stats.topTools,
|
|
108
|
+
duration: stats.duration,
|
|
109
|
+
},
|
|
110
|
+
session_id: sessionId,
|
|
111
|
+
});
|
|
112
|
+
} catch {
|
|
113
|
+
// Evidence failure is non-blocking
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Reset session.json
|
|
117
|
+
const session = readSession();
|
|
118
|
+
session.retry_count = 0;
|
|
119
|
+
session.stop_hook_active = false;
|
|
120
|
+
session.session_end = new Date().toISOString();
|
|
121
|
+
writeSession(session);
|
|
122
|
+
|
|
123
|
+
// Output summary
|
|
124
|
+
const summary = [
|
|
125
|
+
`[${HOOK_NAME}] Session closed.`,
|
|
126
|
+
` Tool calls: ${stats.toolCalls}, Denials: ${stats.denials}`,
|
|
127
|
+
` Top tools: ${stats.topTools.join(", ") || "none"}`,
|
|
128
|
+
` Duration: ${stats.duration}`,
|
|
129
|
+
].join("\n");
|
|
130
|
+
|
|
131
|
+
allow(summary);
|
|
132
|
+
} catch (_err) {
|
|
133
|
+
// Operational hook — fail-open
|
|
134
|
+
allow();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Exports (for testing)
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
module.exports = {
|
|
142
|
+
computeStats,
|
|
143
|
+
};
|