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.
Files changed (39) hide show
  1. package/.claude/hooks/lib/session-modules/.gitkeep +0 -0
  2. package/.claude/hooks/lib/sh-utils.js +241 -0
  3. package/.claude/hooks/lint-on-save.js +240 -0
  4. package/.claude/hooks/sh-circuit-breaker.js +111 -0
  5. package/.claude/hooks/sh-config-guard.js +252 -0
  6. package/.claude/hooks/sh-data-boundary.js +315 -0
  7. package/.claude/hooks/sh-dep-audit.js +101 -0
  8. package/.claude/hooks/sh-elicitation.js +241 -0
  9. package/.claude/hooks/sh-evidence.js +193 -0
  10. package/.claude/hooks/sh-gate.js +330 -0
  11. package/.claude/hooks/sh-injection-guard.js +165 -0
  12. package/.claude/hooks/sh-instructions.js +210 -0
  13. package/.claude/hooks/sh-output-control.js +183 -0
  14. package/.claude/hooks/sh-permission-learn.js +223 -0
  15. package/.claude/hooks/sh-permission.js +157 -0
  16. package/.claude/hooks/sh-pipeline.js +639 -0
  17. package/.claude/hooks/sh-postcompact.js +173 -0
  18. package/.claude/hooks/sh-precompact.js +114 -0
  19. package/.claude/hooks/sh-quiet-inject.js +147 -0
  20. package/.claude/hooks/sh-session-end.js +143 -0
  21. package/.claude/hooks/sh-session-start.js +196 -0
  22. package/.claude/hooks/sh-subagent.js +86 -0
  23. package/.claude/hooks/sh-task-gate.js +138 -0
  24. package/.claude/hooks/sh-user-prompt.js +181 -0
  25. package/.claude/hooks/sh-worktree.js +227 -0
  26. package/.claude/patterns/injection-patterns.json +137 -0
  27. package/.claude/rules/binding-governance.md +62 -0
  28. package/.claude/rules/channel-security.md +90 -0
  29. package/.claude/rules/coding-principles.md +79 -0
  30. package/.claude/rules/dev-environment.md +37 -0
  31. package/.claude/rules/implementation-context.md +112 -0
  32. package/.claude/rules/language.md +26 -0
  33. package/.claude/rules/security.md +109 -0
  34. package/.claude/rules/testing.md +43 -0
  35. package/LICENSE +21 -0
  36. package/README.ja.md +107 -0
  37. package/README.md +105 -0
  38. package/bin/shield-harness.js +141 -0
  39. package/package.json +33 -0
File without changes
@@ -0,0 +1,241 @@
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
+
10
+ // --- Constants ---
11
+
12
+ const SH_DIR = ".shield-harness";
13
+ const EVIDENCE_FILE = path.join(SH_DIR, "logs", "evidence-ledger.jsonl");
14
+ const SESSION_FILE = path.join(SH_DIR, "session.json");
15
+ const PATTERNS_FILE = path.join(
16
+ ".claude",
17
+ "patterns",
18
+ "injection-patterns.json",
19
+ );
20
+ const CHAIN_GENESIS_HASH = "0".repeat(64);
21
+
22
+ // --- Hook I/O ---
23
+
24
+ /**
25
+ * Read and parse hook input from stdin.
26
+ * @returns {Object} { raw, hookType, toolName, toolInput, toolResult, sessionId, timestamp }
27
+ */
28
+ function readHookInput() {
29
+ let raw;
30
+ try {
31
+ raw = fs.readFileSync("/dev/stdin", "utf8");
32
+ } catch {
33
+ // Windows fallback: file descriptor 0
34
+ raw = fs.readFileSync(0, "utf8");
35
+ }
36
+ const input = JSON.parse(raw);
37
+ return {
38
+ raw,
39
+ hookType: input.hook_type || "",
40
+ toolName: input.tool_name || "",
41
+ toolInput: input.tool_input || {},
42
+ toolResult: input.tool_result || "",
43
+ sessionId: input.session_id || "",
44
+ timestamp: input.timestamp || "",
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Output allow response and exit 0.
50
+ * @param {string} [context] - Optional additionalContext
51
+ */
52
+ function allow(context) {
53
+ if (context) {
54
+ process.stdout.write(JSON.stringify({ additionalContext: context }));
55
+ } else {
56
+ process.stdout.write("{}");
57
+ }
58
+ process.exit(0);
59
+ }
60
+
61
+ /**
62
+ * Output allow response with updatedInput and exit 0.
63
+ * @param {Object} updatedInput - Modified tool input
64
+ */
65
+ function allowWithUpdate(updatedInput) {
66
+ process.stdout.write(JSON.stringify({ updatedInput }));
67
+ process.exit(0);
68
+ }
69
+
70
+ /**
71
+ * Output allow response with updatedToolResult and exit 0.
72
+ * @param {string} updatedToolResult - Modified tool output
73
+ */
74
+ function allowWithResult(updatedToolResult) {
75
+ process.stdout.write(JSON.stringify({ updatedToolResult }));
76
+ process.exit(0);
77
+ }
78
+
79
+ /**
80
+ * Output deny response and exit 2.
81
+ * @param {string} reason - Denial reason
82
+ */
83
+ function deny(reason) {
84
+ process.stdout.write(JSON.stringify({ reason }));
85
+ process.exit(2);
86
+ }
87
+
88
+ // --- Normalization ---
89
+
90
+ /**
91
+ * NFKC normalization (native — no subprocess).
92
+ * @param {string} input
93
+ * @returns {string}
94
+ */
95
+ function nfkcNormalize(input) {
96
+ return input.normalize("NFKC");
97
+ }
98
+
99
+ /**
100
+ * Normalize file path (Windows backslash -> forward slash, resolve).
101
+ * @param {string} filePath
102
+ * @returns {string}
103
+ */
104
+ function normalizePath(filePath) {
105
+ return path.resolve(filePath.replace(/\\/g, "/"));
106
+ }
107
+
108
+ // --- Crypto ---
109
+
110
+ /**
111
+ * SHA-256 hash (native crypto).
112
+ * @param {string} input
113
+ * @returns {string} hex digest
114
+ */
115
+ function sha256(input) {
116
+ return crypto.createHash("sha256").update(input).digest("hex");
117
+ }
118
+
119
+ // --- Session ---
120
+
121
+ /**
122
+ * Read session.json (fail-safe: returns {} on error).
123
+ * @returns {Object}
124
+ */
125
+ function readSession() {
126
+ try {
127
+ return JSON.parse(fs.readFileSync(SESSION_FILE, "utf8"));
128
+ } catch {
129
+ return {};
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Write session.json atomically (tmp + rename).
135
+ * @param {Object} data
136
+ */
137
+ function writeSession(data) {
138
+ const dir = path.dirname(SESSION_FILE);
139
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
140
+ const tmp = `${SESSION_FILE}.tmp`;
141
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
142
+ fs.renameSync(tmp, SESSION_FILE);
143
+ }
144
+
145
+ // --- Evidence ---
146
+
147
+ /**
148
+ * Append evidence entry to JSONL ledger with SHA-256 hash chain.
149
+ * @param {Object} entry
150
+ */
151
+ function appendEvidence(entry) {
152
+ const dir = path.dirname(EVIDENCE_FILE);
153
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
154
+
155
+ // Read last hash for chain continuity
156
+ let prevHash = CHAIN_GENESIS_HASH;
157
+ try {
158
+ const content = fs.readFileSync(EVIDENCE_FILE, "utf8").trim();
159
+ if (content) {
160
+ const lines = content.split("\n");
161
+ const lastLine = lines[lines.length - 1];
162
+ const lastEntry = JSON.parse(lastLine);
163
+ if (lastEntry.hash) prevHash = lastEntry.hash;
164
+ }
165
+ } catch {
166
+ // First entry or file doesn't exist — use genesis hash
167
+ }
168
+
169
+ const record = {
170
+ ...entry,
171
+ recorded_at: new Date().toISOString(),
172
+ prev_hash: prevHash,
173
+ };
174
+ record.hash = sha256(JSON.stringify(record));
175
+
176
+ fs.appendFileSync(EVIDENCE_FILE, JSON.stringify(record) + "\n");
177
+ }
178
+
179
+ // --- YAML ---
180
+
181
+ /**
182
+ * Read YAML file (requires js-yaml). Fail-close if js-yaml unavailable.
183
+ * @param {string} filePath
184
+ * @returns {Object}
185
+ */
186
+ function readYaml(filePath) {
187
+ let yaml;
188
+ try {
189
+ yaml = require("js-yaml");
190
+ } catch {
191
+ deny("js-yaml is not installed. Required for YAML operations.");
192
+ }
193
+ return yaml.load(fs.readFileSync(filePath, "utf8"));
194
+ }
195
+
196
+ // --- Patterns ---
197
+
198
+ /**
199
+ * Load injection patterns from JSON file.
200
+ * Fail-close: if file missing or corrupted, deny.
201
+ * @returns {Object} parsed patterns
202
+ */
203
+ function loadPatterns() {
204
+ if (!fs.existsSync(PATTERNS_FILE)) {
205
+ deny("injection-patterns.json not found. Run npx shield-harness init.");
206
+ }
207
+ try {
208
+ return JSON.parse(fs.readFileSync(PATTERNS_FILE, "utf8"));
209
+ } catch {
210
+ deny("injection-patterns.json is corrupted.");
211
+ }
212
+ }
213
+
214
+ module.exports = {
215
+ // Constants
216
+ SH_DIR,
217
+ EVIDENCE_FILE,
218
+ SESSION_FILE,
219
+ PATTERNS_FILE,
220
+ CHAIN_GENESIS_HASH,
221
+ // Hook I/O
222
+ readHookInput,
223
+ allow,
224
+ allowWithUpdate,
225
+ allowWithResult,
226
+ deny,
227
+ // Normalization
228
+ nfkcNormalize,
229
+ normalizePath,
230
+ // Crypto
231
+ sha256,
232
+ // Session
233
+ readSession,
234
+ writeSession,
235
+ // Evidence
236
+ appendEvidence,
237
+ // YAML
238
+ readYaml,
239
+ // Patterns
240
+ loadPatterns,
241
+ };
@@ -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,111 @@
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
+ }
51
+
52
+ // Step 2: Read and evaluate retry count
53
+ const currentRetry = (session.retry_count || 0) + 1;
54
+
55
+ if (currentRetry > MAX_RETRIES) {
56
+ // Retry limit reached — allow the stop
57
+ session.retry_count = 0;
58
+ session.stop_hook_active = false;
59
+ writeSession(session);
60
+
61
+ try {
62
+ appendEvidence({
63
+ hook: HOOK_NAME,
64
+ event: "Stop",
65
+ decision: "allow",
66
+ reason: `Retry limit reached (${MAX_RETRIES}/${MAX_RETRIES})`,
67
+ retry_count: MAX_RETRIES,
68
+ session_id: input.sessionId,
69
+ });
70
+ } catch {
71
+ // Evidence failure is non-blocking
72
+ }
73
+
74
+ allow(
75
+ `[sh-circuit-breaker] リトライ上限(${MAX_RETRIES}回)に到達しました。停止を許可します。`,
76
+ );
77
+ }
78
+
79
+ // Step 3: Retry — deny the stop request
80
+ session.retry_count = currentRetry;
81
+ session.stop_hook_active = true;
82
+ writeSession(session);
83
+
84
+ try {
85
+ appendEvidence({
86
+ hook: HOOK_NAME,
87
+ event: "Stop",
88
+ decision: "deny",
89
+ reason: `Retry ${currentRetry}/${MAX_RETRIES}`,
90
+ retry_count: currentRetry,
91
+ session_id: input.sessionId,
92
+ });
93
+ } catch {
94
+ // Evidence failure is non-blocking
95
+ }
96
+
97
+ deny(
98
+ `[sh-circuit-breaker] リトライ ${currentRetry}/${MAX_RETRIES}。まだ停止しないでください。別のアプローチを試してください。`,
99
+ );
100
+ } catch (_err) {
101
+ // Control hook — fail-open (allow stop on error)
102
+ allow();
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Exports (for testing)
107
+ // ---------------------------------------------------------------------------
108
+
109
+ module.exports = {
110
+ MAX_RETRIES,
111
+ };