shield-harness 0.1.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.
Files changed (43) hide show
  1. package/.claude/hooks/lib/ocsf-mapper.js +279 -0
  2. package/.claude/hooks/lib/openshell-detect.js +235 -0
  3. package/.claude/hooks/lib/policy-compat.js +176 -0
  4. package/.claude/hooks/lib/session-modules/.gitkeep +0 -0
  5. package/.claude/hooks/lib/sh-utils.js +340 -0
  6. package/.claude/hooks/lint-on-save.js +240 -0
  7. package/.claude/hooks/sh-circuit-breaker.js +113 -0
  8. package/.claude/hooks/sh-config-guard.js +275 -0
  9. package/.claude/hooks/sh-data-boundary.js +390 -0
  10. package/.claude/hooks/sh-dep-audit.js +101 -0
  11. package/.claude/hooks/sh-elicitation.js +244 -0
  12. package/.claude/hooks/sh-evidence.js +193 -0
  13. package/.claude/hooks/sh-gate.js +365 -0
  14. package/.claude/hooks/sh-injection-guard.js +196 -0
  15. package/.claude/hooks/sh-instructions.js +212 -0
  16. package/.claude/hooks/sh-output-control.js +217 -0
  17. package/.claude/hooks/sh-permission-learn.js +227 -0
  18. package/.claude/hooks/sh-permission.js +157 -0
  19. package/.claude/hooks/sh-pipeline.js +623 -0
  20. package/.claude/hooks/sh-postcompact.js +173 -0
  21. package/.claude/hooks/sh-precompact.js +114 -0
  22. package/.claude/hooks/sh-quiet-inject.js +148 -0
  23. package/.claude/hooks/sh-session-end.js +143 -0
  24. package/.claude/hooks/sh-session-start.js +277 -0
  25. package/.claude/hooks/sh-subagent.js +86 -0
  26. package/.claude/hooks/sh-task-gate.js +141 -0
  27. package/.claude/hooks/sh-user-prompt.js +185 -0
  28. package/.claude/hooks/sh-worktree.js +230 -0
  29. package/.claude/patterns/injection-patterns.json +137 -0
  30. package/.claude/policies/openshell-default.yaml +65 -0
  31. package/.claude/rules/binding-governance.md +62 -0
  32. package/.claude/rules/channel-security.md +90 -0
  33. package/.claude/rules/coding-principles.md +79 -0
  34. package/.claude/rules/dev-environment.md +40 -0
  35. package/.claude/rules/implementation-context.md +132 -0
  36. package/.claude/rules/language.md +26 -0
  37. package/.claude/rules/security.md +109 -0
  38. package/.claude/rules/testing.md +43 -0
  39. package/LICENSE +21 -0
  40. package/README.ja.md +176 -0
  41. package/README.md +174 -0
  42. package/bin/shield-harness.js +241 -0
  43. package/package.json +42 -0
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env node
2
+ // sh-utils.js — Shared utilities for all Shield Harness hooks (Node.js)
3
+ // Spec: DETAILED_DESIGN.md §2.2b
4
+ "use strict";
5
+
6
+ const fs = require("fs");
7
+ const path = require("path");
8
+ const crypto = require("crypto");
9
+ const { execSync } = require("child_process");
10
+
11
+ // --- Constants ---
12
+
13
+ const SH_DIR = ".shield-harness";
14
+ const EVIDENCE_FILE = path.join(SH_DIR, "logs", "evidence-ledger.jsonl");
15
+ const SESSION_FILE = path.join(SH_DIR, "session.json");
16
+ const PATTERNS_FILE = path.join(
17
+ ".claude",
18
+ "patterns",
19
+ "injection-patterns.json",
20
+ );
21
+ const CHAIN_GENESIS_HASH = "0".repeat(64);
22
+
23
+ // --- Hook I/O ---
24
+
25
+ /**
26
+ * Read and parse hook input from stdin.
27
+ * @returns {Object} { raw, hookType, toolName, toolInput, toolResult, sessionId, timestamp }
28
+ */
29
+ function readHookInput() {
30
+ let raw;
31
+ try {
32
+ raw = fs.readFileSync("/dev/stdin", "utf8");
33
+ } catch {
34
+ // Windows fallback: file descriptor 0
35
+ raw = fs.readFileSync(0, "utf8");
36
+ }
37
+ const input = JSON.parse(raw);
38
+ return {
39
+ raw,
40
+ hookType: input.hook_type || "",
41
+ toolName: input.tool_name || "",
42
+ toolInput: input.tool_input || {},
43
+ toolResult: input.tool_result || "",
44
+ sessionId: input.session_id || "",
45
+ timestamp: input.timestamp || "",
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Output allow response and exit 0.
51
+ * @param {string} [context] - Optional additionalContext
52
+ */
53
+ function allow(context) {
54
+ if (context) {
55
+ process.stdout.write(JSON.stringify({ additionalContext: context }));
56
+ } else {
57
+ process.stdout.write("{}");
58
+ }
59
+ process.exit(0);
60
+ }
61
+
62
+ /**
63
+ * Output allow response with updatedInput and exit 0.
64
+ * @param {Object} updatedInput - Modified tool input
65
+ */
66
+ function allowWithUpdate(updatedInput) {
67
+ process.stdout.write(JSON.stringify({ updatedInput }));
68
+ process.exit(0);
69
+ }
70
+
71
+ /**
72
+ * Output allow response with updatedToolResult and exit 0.
73
+ * @param {string} updatedToolResult - Modified tool output
74
+ */
75
+ function allowWithResult(updatedToolResult) {
76
+ process.stdout.write(JSON.stringify({ updatedToolResult }));
77
+ process.exit(0);
78
+ }
79
+
80
+ /**
81
+ * Output deny response and exit 2.
82
+ * @param {string} reason - Denial reason
83
+ */
84
+ function deny(reason) {
85
+ process.stdout.write(JSON.stringify({ reason }));
86
+ process.exit(2);
87
+ }
88
+
89
+ // --- Normalization ---
90
+
91
+ /**
92
+ * NFKC normalization (native — no subprocess).
93
+ * @param {string} input
94
+ * @returns {string}
95
+ */
96
+ function nfkcNormalize(input) {
97
+ return input.normalize("NFKC");
98
+ }
99
+
100
+ /**
101
+ * Normalize file path (Windows backslash -> forward slash, resolve).
102
+ * @param {string} filePath
103
+ * @returns {string}
104
+ */
105
+ function normalizePath(filePath) {
106
+ return path.resolve(filePath.replace(/\\/g, "/"));
107
+ }
108
+
109
+ // --- Crypto ---
110
+
111
+ /**
112
+ * SHA-256 hash (native crypto).
113
+ * @param {string} input
114
+ * @returns {string} hex digest
115
+ */
116
+ function sha256(input) {
117
+ return crypto.createHash("sha256").update(input).digest("hex");
118
+ }
119
+
120
+ // --- Session ---
121
+
122
+ /**
123
+ * Read session.json (fail-safe: returns {} on error).
124
+ * @returns {Object}
125
+ */
126
+ function readSession() {
127
+ try {
128
+ return JSON.parse(fs.readFileSync(SESSION_FILE, "utf8"));
129
+ } catch {
130
+ return {};
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Write session.json atomically (tmp + rename).
136
+ * @param {Object} data
137
+ */
138
+ function writeSession(data) {
139
+ const dir = path.dirname(SESSION_FILE);
140
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
141
+ const tmp = `${SESSION_FILE}.tmp`;
142
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
143
+ fs.renameSync(tmp, SESSION_FILE);
144
+ }
145
+
146
+ // --- Evidence ---
147
+
148
+ /**
149
+ * Append evidence entry to JSONL ledger with SHA-256 hash chain.
150
+ * Entries are transformed to OCSF Detection Finding (class_uid: 2004) format.
151
+ * @param {Object} entry
152
+ */
153
+ function appendEvidence(entry) {
154
+ const dir = path.dirname(EVIDENCE_FILE);
155
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
156
+
157
+ // OCSF transformation (lazy require to avoid startup cost)
158
+ let ocsfEntry;
159
+ try {
160
+ const { toDetectionFinding } = require("./ocsf-mapper");
161
+ ocsfEntry = toDetectionFinding(entry);
162
+ } catch {
163
+ // Fallback: use raw entry if OCSF mapper is unavailable
164
+ ocsfEntry = { ...entry };
165
+ }
166
+
167
+ // Read last hash for chain continuity
168
+ let prevHash = CHAIN_GENESIS_HASH;
169
+ try {
170
+ const content = fs.readFileSync(EVIDENCE_FILE, "utf8").trim();
171
+ if (content) {
172
+ const lines = content.split("\n");
173
+ const lastLine = lines[lines.length - 1];
174
+ const lastEntry = JSON.parse(lastLine);
175
+ if (lastEntry.hash) prevHash = lastEntry.hash;
176
+ }
177
+ } catch {
178
+ // First entry or file doesn't exist — use genesis hash
179
+ }
180
+
181
+ const record = {
182
+ ...ocsfEntry,
183
+ recorded_at: new Date().toISOString(),
184
+ prev_hash: prevHash,
185
+ };
186
+ record.hash = sha256(JSON.stringify(record));
187
+
188
+ fs.appendFileSync(EVIDENCE_FILE, JSON.stringify(record) + "\n");
189
+ }
190
+
191
+ // --- Hash Chain Verification ---
192
+
193
+ /**
194
+ * Verify the integrity of the evidence-ledger hash chain.
195
+ * @param {string} [ledgerPath] - Path to evidence-ledger.jsonl (defaults to EVIDENCE_FILE)
196
+ * @returns {{ valid: boolean, entries: number, brokenAt?: number, reason?: string }}
197
+ */
198
+ function verifyHashChain(ledgerPath) {
199
+ const filePath = ledgerPath || EVIDENCE_FILE;
200
+
201
+ let content;
202
+ try {
203
+ content = fs.readFileSync(filePath, "utf8").trim();
204
+ } catch {
205
+ // File does not exist — empty chain is valid
206
+ return { valid: true, entries: 0 };
207
+ }
208
+
209
+ if (!content) {
210
+ return { valid: true, entries: 0 };
211
+ }
212
+
213
+ const lines = content.split("\n");
214
+ let expectedPrevHash = CHAIN_GENESIS_HASH;
215
+
216
+ for (let i = 0; i < lines.length; i++) {
217
+ const entry = JSON.parse(lines[i]);
218
+
219
+ // Check prev_hash linkage
220
+ if (entry.prev_hash !== expectedPrevHash) {
221
+ return {
222
+ valid: false,
223
+ entries: lines.length,
224
+ brokenAt: i,
225
+ reason: "prev_hash_mismatch",
226
+ };
227
+ }
228
+
229
+ // Recompute hash: remove 'hash' field, hash the rest
230
+ const { hash, ...rest } = entry;
231
+ const computed = sha256(JSON.stringify(rest));
232
+ if (computed !== hash) {
233
+ return {
234
+ valid: false,
235
+ entries: lines.length,
236
+ brokenAt: i,
237
+ reason: "hash_mismatch",
238
+ };
239
+ }
240
+
241
+ expectedPrevHash = hash;
242
+ }
243
+
244
+ return { valid: true, entries: lines.length };
245
+ }
246
+
247
+ // --- YAML ---
248
+
249
+ /**
250
+ * Read YAML file (requires js-yaml). Fail-close if js-yaml unavailable.
251
+ * @param {string} filePath
252
+ * @returns {Object}
253
+ */
254
+ function readYaml(filePath) {
255
+ let yaml;
256
+ try {
257
+ yaml = require("js-yaml");
258
+ } catch {
259
+ deny("js-yaml is not installed. Required for YAML operations.");
260
+ }
261
+ return yaml.load(fs.readFileSync(filePath, "utf8"));
262
+ }
263
+
264
+ // --- Command Detection ---
265
+
266
+ /**
267
+ * Check if a command exists on the system.
268
+ * Tries 'which' (Unix/Git Bash) first, then 'where' (Windows cmd).
269
+ * @param {string} cmd
270
+ * @returns {boolean}
271
+ */
272
+ function commandExists(cmd) {
273
+ // Reject non-alphanumeric command names to prevent injection
274
+ if (!/^[a-zA-Z0-9_\-]+$/.test(cmd)) {
275
+ return false;
276
+ }
277
+ for (const checker of ["which", "where"]) {
278
+ try {
279
+ execSync(`${checker} ${cmd}`, {
280
+ encoding: "utf8",
281
+ stdio: ["pipe", "pipe", "pipe"],
282
+ });
283
+ return true;
284
+ } catch {
285
+ // Try next checker
286
+ }
287
+ }
288
+ return false;
289
+ }
290
+
291
+ // --- Patterns ---
292
+
293
+ /**
294
+ * Load injection patterns from JSON file.
295
+ * Fail-close: if file missing or corrupted, deny.
296
+ * @returns {Object} parsed patterns
297
+ */
298
+ function loadPatterns() {
299
+ if (!fs.existsSync(PATTERNS_FILE)) {
300
+ deny("injection-patterns.json not found. Run npx shield-harness init.");
301
+ }
302
+ try {
303
+ return JSON.parse(fs.readFileSync(PATTERNS_FILE, "utf8"));
304
+ } catch {
305
+ deny("injection-patterns.json is corrupted.");
306
+ }
307
+ }
308
+
309
+ module.exports = {
310
+ // Constants
311
+ SH_DIR,
312
+ EVIDENCE_FILE,
313
+ SESSION_FILE,
314
+ PATTERNS_FILE,
315
+ CHAIN_GENESIS_HASH,
316
+ // Hook I/O
317
+ readHookInput,
318
+ allow,
319
+ allowWithUpdate,
320
+ allowWithResult,
321
+ deny,
322
+ // Normalization
323
+ nfkcNormalize,
324
+ normalizePath,
325
+ // Crypto
326
+ sha256,
327
+ // Session
328
+ readSession,
329
+ writeSession,
330
+ // Evidence
331
+ appendEvidence,
332
+ // YAML
333
+ readYaml,
334
+ // Command Detection
335
+ commandExists,
336
+ // Patterns
337
+ loadPatterns,
338
+ // Hash Chain
339
+ verifyHashChain,
340
+ };
@@ -0,0 +1,240 @@
1
+ "use strict";
2
+ /**
3
+ * Post-tool hook: Run linter/formatter on source files after Edit/Write.
4
+ *
5
+ * Triggered after Edit or Write tools modify files.
6
+ * - Python files (.py): Runs ruff (format + lint) and ty (type check) if available
7
+ * - PowerShell files (.ps1, .psm1): Runs PSScriptAnalyzer if available
8
+ * - Other files: Skips silently
9
+ *
10
+ * All tool checks use graceful degradation — missing tools are silently skipped.
11
+ */
12
+
13
+ const { execFileSync } = require("child_process");
14
+ const path = require("path");
15
+ const { readHookInput } = require("./lib/sh-utils");
16
+
17
+ // --- Constants ---
18
+
19
+ const MAX_PATH_LENGTH = 4096;
20
+ const COMMAND_TIMEOUT = 30000;
21
+
22
+ // --- Path Validation ---
23
+
24
+ /**
25
+ * Validate file path for security.
26
+ * @param {string} filePath
27
+ * @returns {boolean}
28
+ */
29
+ function validatePath(filePath) {
30
+ if (!filePath || filePath.length > MAX_PATH_LENGTH) return false;
31
+ if (filePath.includes("..")) return false;
32
+ return true;
33
+ }
34
+
35
+ // --- Command Execution ---
36
+
37
+ /**
38
+ * Run a command and return { code, stdout, stderr }.
39
+ * @param {string} cmd
40
+ * @param {string[]} args
41
+ * @param {string} cwd
42
+ * @returns {{ code: number, stdout: string, stderr: string }}
43
+ */
44
+ function runCommand(cmd, args, cwd) {
45
+ try {
46
+ const stdout = execFileSync(cmd, args, {
47
+ cwd,
48
+ timeout: COMMAND_TIMEOUT,
49
+ encoding: "utf8",
50
+ stdio: ["pipe", "pipe", "pipe"],
51
+ });
52
+ return { code: 0, stdout: stdout || "", stderr: "" };
53
+ } catch (e) {
54
+ if (e.code === "ENOENT") {
55
+ return { code: -1, stdout: "", stderr: `Command not found: ${cmd}` };
56
+ }
57
+ if (e.killed) {
58
+ return { code: 1, stdout: "", stderr: "Command timed out" };
59
+ }
60
+ return {
61
+ code: e.status || 1,
62
+ stdout: e.stdout || "",
63
+ stderr: e.stderr || "",
64
+ };
65
+ }
66
+ }
67
+
68
+ // --- Python Linting ---
69
+
70
+ /**
71
+ * Run Python linters (ruff, ty) if available.
72
+ * @param {string} filePath
73
+ * @param {string} projectDir
74
+ * @param {string} relPath
75
+ * @returns {string[]} issues found
76
+ */
77
+ function lintPython(filePath, projectDir, relPath) {
78
+ const issues = [];
79
+
80
+ // Run ruff format
81
+ let result = runCommand(
82
+ "uv",
83
+ ["run", "ruff", "format", filePath],
84
+ projectDir,
85
+ );
86
+ if (result.code === -1) return []; // uv not found, skip all Python linting
87
+ if (result.code !== 0) {
88
+ issues.push(`ruff format failed:\n${result.stderr || result.stdout}`);
89
+ }
90
+
91
+ // Run ruff check with auto-fix
92
+ result = runCommand(
93
+ "uv",
94
+ ["run", "ruff", "check", "--fix", filePath],
95
+ projectDir,
96
+ );
97
+ if (result.code !== 0) {
98
+ const output = result.stdout || result.stderr;
99
+ if (output.trim()) {
100
+ issues.push(`ruff check issues:\n${output}`);
101
+ }
102
+ }
103
+
104
+ // Run ty type check
105
+ result = runCommand("uv", ["run", "ty", "check", filePath], projectDir);
106
+ if (result.code !== 0) {
107
+ const output = result.stdout || result.stderr;
108
+ if (output.trim()) {
109
+ issues.push(`ty check issues:\n${output}`);
110
+ }
111
+ }
112
+
113
+ if (issues.length > 0) {
114
+ process.stderr.write(`[lint-on-save] Issues found in ${relPath}:\n`);
115
+ for (const issue of issues) {
116
+ process.stderr.write(issue + "\n");
117
+ }
118
+ process.stderr.write("\nPlease review and fix these issues.\n");
119
+ } else {
120
+ process.stdout.write(`[lint-on-save] OK: ${relPath} passed all checks\n`);
121
+ }
122
+
123
+ return issues;
124
+ }
125
+
126
+ // --- PowerShell Linting ---
127
+
128
+ /**
129
+ * Escape a string for safe use inside PowerShell single-quoted string.
130
+ * @param {string} s
131
+ * @returns {string} escaped string, or empty if unsafe
132
+ */
133
+ function escapePowershellString(s) {
134
+ if (s.includes("\x00") || s.includes("\n") || s.includes("\r")) {
135
+ return "";
136
+ }
137
+ return s.replace(/'/g, "''");
138
+ }
139
+
140
+ /**
141
+ * Run PowerShell linter (PSScriptAnalyzer) if available.
142
+ * @param {string} filePath
143
+ * @param {string} projectDir
144
+ * @param {string} relPath
145
+ * @returns {string[]} issues found
146
+ */
147
+ function lintPowershell(filePath, projectDir, relPath) {
148
+ const issues = [];
149
+
150
+ const safePath = escapePowershellString(filePath);
151
+ if (!safePath) {
152
+ process.stderr.write(
153
+ `[lint-on-save] WARNING: Unsafe path rejected for PSScriptAnalyzer: ${relPath}\n`,
154
+ );
155
+ return [];
156
+ }
157
+
158
+ const result = runCommand(
159
+ "pwsh",
160
+ [
161
+ "-NoProfile",
162
+ "-Command",
163
+ `Invoke-ScriptAnalyzer -Path '${safePath}' -Severity Warning,Error`,
164
+ ],
165
+ projectDir,
166
+ );
167
+
168
+ if (result.code === -1) return []; // pwsh not found, skip
169
+ if (result.code === 0 && result.stdout.trim()) {
170
+ process.stderr.write(
171
+ `[lint-on-save] PSScriptAnalyzer issues in ${relPath}:\n`,
172
+ );
173
+ process.stderr.write(result.stdout + "\n");
174
+ issues.push(result.stdout);
175
+ } else if (result.code === 0) {
176
+ process.stdout.write(
177
+ `[lint-on-save] OK: ${relPath} passed PSScriptAnalyzer\n`,
178
+ );
179
+ }
180
+
181
+ return issues;
182
+ }
183
+
184
+ // --- Hook handler ---
185
+
186
+ /**
187
+ * @param {object} data - PostToolUse hook input
188
+ */
189
+ function handler(data) {
190
+ const toolInput = data.toolInput || data.tool_input || {};
191
+ const filePath = toolInput.file_path;
192
+
193
+ if (!filePath) return;
194
+ if (!validatePath(filePath)) {
195
+ process.stderr.write(
196
+ `[lint-on-save] WARNING: Invalid path rejected: ${filePath}\n`,
197
+ );
198
+ return;
199
+ }
200
+
201
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
202
+
203
+ let relPath;
204
+ if (filePath.startsWith(projectDir)) {
205
+ relPath = path.relative(projectDir, filePath);
206
+ } else {
207
+ relPath = filePath;
208
+ }
209
+
210
+ if (filePath.endsWith(".py")) {
211
+ lintPython(filePath, projectDir, relPath);
212
+ } else if (filePath.endsWith(".ps1") || filePath.endsWith(".psm1")) {
213
+ lintPowershell(filePath, projectDir, relPath);
214
+ }
215
+ // Other file types: skip silently
216
+ }
217
+
218
+ // --- Exports (for testing) ---
219
+
220
+ module.exports = {
221
+ validatePath,
222
+ runCommand,
223
+ lintPython,
224
+ lintPowershell,
225
+ escapePowershellString,
226
+ handler,
227
+ MAX_PATH_LENGTH,
228
+ COMMAND_TIMEOUT,
229
+ };
230
+
231
+ // --- Entry point ---
232
+
233
+ if (require.main === module) {
234
+ try {
235
+ const input = readHookInput();
236
+ handler(input);
237
+ } catch (e) {
238
+ process.stderr.write(`[lint-on-save] Error: ${e.message}\n`);
239
+ }
240
+ }
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ // sh-circuit-breaker.js — Retry loop before agent stops
3
+ // Spec: DETAILED_DESIGN.md §5.2
4
+ // Event: Stop
5
+ // Target response time: < 50ms
6
+ "use strict";
7
+
8
+ const {
9
+ readHookInput,
10
+ allow,
11
+ deny,
12
+ readSession,
13
+ writeSession,
14
+ appendEvidence,
15
+ } = require("./lib/sh-utils");
16
+
17
+ const HOOK_NAME = "sh-circuit-breaker";
18
+ const MAX_RETRIES = 3;
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Main
22
+ // ---------------------------------------------------------------------------
23
+
24
+ try {
25
+ const input = readHookInput();
26
+ const session = readSession();
27
+
28
+ // Step 1: Check stop_hook_active flag (infinite loop prevention)
29
+ if (session.stop_hook_active === true) {
30
+ session.stop_hook_active = false;
31
+ writeSession(session);
32
+
33
+ try {
34
+ appendEvidence({
35
+ hook: HOOK_NAME,
36
+ event: "Stop",
37
+ decision: "allow",
38
+ reason:
39
+ "stop_hook_active flag was set — allowing to prevent infinite loop",
40
+ retry_count: session.retry_count || 0,
41
+ session_id: input.sessionId,
42
+ });
43
+ } catch {
44
+ // Evidence failure is non-blocking
45
+ }
46
+
47
+ allow(
48
+ "[sh-circuit-breaker] stop_hook_active detected — allowing stop to prevent loop.",
49
+ );
50
+ return;
51
+ }
52
+
53
+ // Step 2: Read and evaluate retry count
54
+ const currentRetry = (session.retry_count || 0) + 1;
55
+
56
+ if (currentRetry > MAX_RETRIES) {
57
+ // Retry limit reached — allow the stop
58
+ session.retry_count = 0;
59
+ session.stop_hook_active = false;
60
+ writeSession(session);
61
+
62
+ try {
63
+ appendEvidence({
64
+ hook: HOOK_NAME,
65
+ event: "Stop",
66
+ decision: "allow",
67
+ reason: `Retry limit reached (${MAX_RETRIES}/${MAX_RETRIES})`,
68
+ retry_count: MAX_RETRIES,
69
+ session_id: input.sessionId,
70
+ });
71
+ } catch {
72
+ // Evidence failure is non-blocking
73
+ }
74
+
75
+ allow(
76
+ `[sh-circuit-breaker] リトライ上限(${MAX_RETRIES}回)に到達しました。停止を許可します。`,
77
+ );
78
+ return;
79
+ }
80
+
81
+ // Step 3: Retry — deny the stop request
82
+ session.retry_count = currentRetry;
83
+ session.stop_hook_active = true;
84
+ writeSession(session);
85
+
86
+ try {
87
+ appendEvidence({
88
+ hook: HOOK_NAME,
89
+ event: "Stop",
90
+ decision: "deny",
91
+ reason: `Retry ${currentRetry}/${MAX_RETRIES}`,
92
+ retry_count: currentRetry,
93
+ session_id: input.sessionId,
94
+ });
95
+ } catch {
96
+ // Evidence failure is non-blocking
97
+ }
98
+
99
+ deny(
100
+ `[sh-circuit-breaker] リトライ ${currentRetry}/${MAX_RETRIES}。まだ停止しないでください。別のアプローチを試してください。`,
101
+ );
102
+ } catch (_err) {
103
+ // Control hook — fail-open (allow stop on error)
104
+ allow();
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Exports (for testing)
109
+ // ---------------------------------------------------------------------------
110
+
111
+ module.exports = {
112
+ MAX_RETRIES,
113
+ };