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.
- package/.claude/hooks/lib/ocsf-mapper.js +279 -0
- package/.claude/hooks/lib/openshell-detect.js +235 -0
- package/.claude/hooks/lib/policy-compat.js +176 -0
- package/.claude/hooks/lib/session-modules/.gitkeep +0 -0
- package/.claude/hooks/lib/sh-utils.js +340 -0
- package/.claude/hooks/lint-on-save.js +240 -0
- package/.claude/hooks/sh-circuit-breaker.js +113 -0
- package/.claude/hooks/sh-config-guard.js +275 -0
- package/.claude/hooks/sh-data-boundary.js +390 -0
- package/.claude/hooks/sh-dep-audit.js +101 -0
- package/.claude/hooks/sh-elicitation.js +244 -0
- package/.claude/hooks/sh-evidence.js +193 -0
- package/.claude/hooks/sh-gate.js +365 -0
- package/.claude/hooks/sh-injection-guard.js +196 -0
- package/.claude/hooks/sh-instructions.js +212 -0
- package/.claude/hooks/sh-output-control.js +217 -0
- package/.claude/hooks/sh-permission-learn.js +227 -0
- package/.claude/hooks/sh-permission.js +157 -0
- package/.claude/hooks/sh-pipeline.js +623 -0
- package/.claude/hooks/sh-postcompact.js +173 -0
- package/.claude/hooks/sh-precompact.js +114 -0
- package/.claude/hooks/sh-quiet-inject.js +148 -0
- package/.claude/hooks/sh-session-end.js +143 -0
- package/.claude/hooks/sh-session-start.js +277 -0
- package/.claude/hooks/sh-subagent.js +86 -0
- package/.claude/hooks/sh-task-gate.js +141 -0
- package/.claude/hooks/sh-user-prompt.js +185 -0
- package/.claude/hooks/sh-worktree.js +230 -0
- package/.claude/patterns/injection-patterns.json +137 -0
- package/.claude/policies/openshell-default.yaml +65 -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 +40 -0
- package/.claude/rules/implementation-context.md +132 -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 +176 -0
- package/README.md +174 -0
- package/bin/shield-harness.js +241 -0
- 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
|
+
};
|