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
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env node
2
+ // sh-output-control.js — Output truncation + token budget tracking
3
+ // Spec: DETAILED_DESIGN.md §4.2
4
+ // Hook event: PostToolUse
5
+ // Matcher: "" (all tools)
6
+ // Target response time: < 20ms
7
+ "use strict";
8
+
9
+ const {
10
+ readHookInput,
11
+ allow,
12
+ allowWithResult,
13
+ readSession,
14
+ writeSession,
15
+ } = require("./lib/sh-utils");
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Constants
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const HOOK_NAME = "sh-output-control";
22
+
23
+ // Truncation limits per tool (bytes)
24
+ const TRUNCATION_LIMITS = {
25
+ Bash: { max: 20 * 1024, head: 10 * 1024, tail: 5 * 1024 },
26
+ Task: { max: 6 * 1024, head: 3 * 1024, tail: 2 * 1024 },
27
+ _default: { max: 50 * 1024, head: 25 * 1024, tail: 10 * 1024 },
28
+ };
29
+
30
+ // Token budget thresholds
31
+ const BUDGET_WARNING_RATIO = 0.8;
32
+ const BUDGET_LIMIT_RATIO = 1.0;
33
+
34
+ // Rough token estimation: ~4 chars per token
35
+ const CHARS_PER_TOKEN = 4;
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Helper Functions
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Get truncation limits for a given tool.
43
+ * @param {string} toolName
44
+ * @returns {{ max: number, head: number, tail: number }}
45
+ */
46
+ function getLimits(toolName) {
47
+ return TRUNCATION_LIMITS[toolName] || TRUNCATION_LIMITS._default;
48
+ }
49
+
50
+ /**
51
+ * Truncate output if it exceeds the limit.
52
+ * @param {string} output
53
+ * @param {string} toolName
54
+ * @returns {{ text: string, truncated: boolean }}
55
+ */
56
+ function truncateOutput(output, toolName) {
57
+ if (!output) return { text: output, truncated: false };
58
+
59
+ const limits = getLimits(toolName);
60
+ if (output.length <= limits.max) {
61
+ return { text: output, truncated: false };
62
+ }
63
+
64
+ const head = output.slice(0, limits.head);
65
+ const tail = output.slice(-limits.tail);
66
+ const omitted = output.length - limits.head - limits.tail;
67
+ const notice = `\n\n--- [sh-output-control] ${omitted} bytes omitted (${output.length} total → ${limits.head + limits.tail} retained) ---\n\n`;
68
+
69
+ return {
70
+ text: head + notice + tail,
71
+ truncated: true,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Estimate token count from character length.
77
+ * @param {number} charCount
78
+ * @returns {number}
79
+ */
80
+ function estimateTokens(charCount) {
81
+ return Math.ceil(charCount / CHARS_PER_TOKEN);
82
+ }
83
+
84
+ /**
85
+ * Track token budget and return warning context if thresholds are crossed.
86
+ * @param {number} outputSize - Size of tool output in characters
87
+ * @returns {string|null} Warning context or null
88
+ */
89
+ function trackTokenBudget(outputSize) {
90
+ try {
91
+ const session = readSession();
92
+ const tokenBudget = session.token_budget;
93
+ if (!tokenBudget || !tokenBudget.session_limit) return null; // No budget configured
94
+
95
+ const budgetLimit = tokenBudget.session_limit;
96
+ const currentUsage = tokenBudget.used || 0;
97
+ const newTokens = estimateTokens(outputSize);
98
+ const updatedUsage = currentUsage + newTokens;
99
+
100
+ // Update session — write to token_budget.used (single source of truth)
101
+ writeSession({
102
+ ...session,
103
+ token_budget: {
104
+ ...tokenBudget,
105
+ used: updatedUsage,
106
+ },
107
+ });
108
+
109
+ const ratio = updatedUsage / budgetLimit;
110
+
111
+ if (ratio >= BUDGET_LIMIT_RATIO) {
112
+ return `[${HOOK_NAME}] トークン予算を超過しました(${updatedUsage}/${budgetLimit} tokens)。ユーザー確認が必要です。`;
113
+ }
114
+ if (ratio >= BUDGET_WARNING_RATIO) {
115
+ return `[${HOOK_NAME}] トークン予算の 80% に到達しました(${updatedUsage}/${budgetLimit} tokens)。`;
116
+ }
117
+
118
+ return null;
119
+ } catch {
120
+ // Budget tracking failure is non-blocking
121
+ return null;
122
+ }
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Main
127
+ // ---------------------------------------------------------------------------
128
+
129
+ try {
130
+ const input = readHookInput();
131
+ const { toolName, toolResult } = input;
132
+
133
+ const resultStr =
134
+ typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult);
135
+
136
+ // Truncate if necessary
137
+ const { text, truncated } = truncateOutput(resultStr, toolName);
138
+
139
+ // Track token budget
140
+ const budgetWarning = trackTokenBudget(resultStr ? resultStr.length : 0);
141
+
142
+ // Build context messages
143
+ const context = [];
144
+ if (truncated) {
145
+ context.push(
146
+ `[${HOOK_NAME}] ${toolName} の出力を切り詰めました(制限超過)。`,
147
+ );
148
+ }
149
+ if (budgetWarning) {
150
+ context.push(budgetWarning);
151
+ }
152
+
153
+ // Output result
154
+ if (truncated) {
155
+ // Must use allowWithResult to replace the tool output
156
+ if (context.length > 0) {
157
+ // allowWithResult doesn't support additionalContext, so prepend warnings to the result
158
+ const contextHeader = context.join("\n") + "\n\n";
159
+ allowWithResult(contextHeader + text);
160
+ } else {
161
+ allowWithResult(text);
162
+ }
163
+ } else if (context.length > 0) {
164
+ allow(context.join("\n"));
165
+ } else {
166
+ allow();
167
+ }
168
+ } catch (_err) {
169
+ // Operational hook — on error, pass through the original output.
170
+ allow();
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Exports (for testing)
175
+ // ---------------------------------------------------------------------------
176
+
177
+ module.exports = {
178
+ TRUNCATION_LIMITS,
179
+ truncateOutput,
180
+ estimateTokens,
181
+ getLimits,
182
+ trackTokenBudget,
183
+ };
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env node
2
+ // sh-permission-learn.js — Permission learning guard
3
+ // Spec: DETAILED_DESIGN.md §5.7
4
+ // Event: PermissionRequest
5
+ // Target response time: < 20ms
6
+ "use strict";
7
+
8
+ const fs = require("fs");
9
+ const path = require("path");
10
+ const {
11
+ readHookInput,
12
+ allow,
13
+ deny,
14
+ appendEvidence,
15
+ } = require("./lib/sh-utils");
16
+
17
+ const HOOK_NAME = "sh-permission-learn";
18
+ const SETTINGS_FILE = path.join(".claude", "settings.json");
19
+ const SETTINGS_LOCAL_FILE = path.join(".claude", "settings.local.json");
20
+ const MAX_LEARNED_RULES = 100;
21
+
22
+ // Overly broad patterns that should never be learned
23
+ const LEARNING_BLACKLIST = [
24
+ /^Bash\(\*\)$/, // Too broad — allows all Bash commands
25
+ /^Edit\(\*\)$/, // Too broad — allows editing any file
26
+ /^Write\(\*\)$/, // Too broad — allows writing any file
27
+ /^Bash\(curl\s/, // Network access
28
+ /^Bash\(wget\s/, // Network access
29
+ /^Edit\(\.claude\//, // Self-modification
30
+ /^Write\(\.claude\//, // Self-modification
31
+ ];
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Checks
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * Load deny rules from settings.json.
39
+ * @returns {string[]}
40
+ */
41
+ function loadDenyRules() {
42
+ try {
43
+ if (!fs.existsSync(SETTINGS_FILE)) return [];
44
+ const settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf8"));
45
+ return (settings.permissions && settings.permissions.deny) || [];
46
+ } catch {
47
+ return [];
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Load current learned allow rules count from settings.local.json.
53
+ * @returns {number}
54
+ */
55
+ function getLearnedRuleCount() {
56
+ try {
57
+ if (!fs.existsSync(SETTINGS_LOCAL_FILE)) return 0;
58
+ const local = JSON.parse(fs.readFileSync(SETTINGS_LOCAL_FILE, "utf8"));
59
+ return ((local.permissions && local.permissions.allow) || []).length;
60
+ } catch {
61
+ return 0;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Check if a permission pattern conflicts with any deny rule.
67
+ * A conflict exists when the requested permission would match something denied.
68
+ * @param {string} permissionPattern - e.g., "Bash(rm -rf *)"
69
+ * @param {string[]} denyRules
70
+ * @returns {string|null} - Conflicting deny rule, or null
71
+ */
72
+ function checkDenyConflict(permissionPattern, denyRules) {
73
+ // Simple substring/overlap check
74
+ // Extract tool name from pattern
75
+ const toolMatch = permissionPattern.match(/^(\w+)\((.+)\)$/);
76
+ if (!toolMatch) return null;
77
+
78
+ const [, tool, pattern] = toolMatch;
79
+
80
+ for (const denyRule of denyRules) {
81
+ const denyMatch = denyRule.match(/^(\w+)\((.+)\)$/);
82
+ if (!denyMatch) continue;
83
+
84
+ const [, denyTool, denyPattern] = denyMatch;
85
+
86
+ // Same tool type
87
+ if (tool !== denyTool) continue;
88
+
89
+ // Check if the requested pattern would overlap with deny
90
+ // If the requested pattern contains the denied path/command, it conflicts
91
+ if (
92
+ pattern.includes(denyPattern.replace(/\*/g, "")) ||
93
+ denyPattern.includes(pattern.replace(/\*/g, ""))
94
+ ) {
95
+ return denyRule;
96
+ }
97
+ }
98
+
99
+ return null;
100
+ }
101
+
102
+ /**
103
+ * Check if a permission pattern is in the blacklist.
104
+ * @param {string} permissionPattern
105
+ * @returns {boolean}
106
+ */
107
+ function isBlacklisted(permissionPattern) {
108
+ return LEARNING_BLACKLIST.some((re) => re.test(permissionPattern));
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Main
113
+ // ---------------------------------------------------------------------------
114
+
115
+ try {
116
+ const input = readHookInput();
117
+ // Permission pattern from the request
118
+ const permissionPattern =
119
+ input.toolInput.permission || input.toolInput.tool_pattern || "";
120
+
121
+ if (!permissionPattern) {
122
+ allow();
123
+ }
124
+
125
+ // Check 1: deny rule conflict
126
+ const denyRules = loadDenyRules();
127
+ const conflict = checkDenyConflict(permissionPattern, denyRules);
128
+ if (conflict) {
129
+ try {
130
+ appendEvidence({
131
+ hook: HOOK_NAME,
132
+ event: "PermissionRequest",
133
+ decision: "deny",
134
+ reason: "deny_rule_conflict",
135
+ pattern: permissionPattern,
136
+ conflicting_rule: conflict,
137
+ session_id: input.sessionId,
138
+ });
139
+ } catch {
140
+ // Non-blocking
141
+ }
142
+
143
+ deny(
144
+ `[${HOOK_NAME}] deny ルールは学習で上書きできません。衝突ルール: ${conflict}`,
145
+ );
146
+ }
147
+
148
+ // Check 2: blacklist
149
+ if (isBlacklisted(permissionPattern)) {
150
+ try {
151
+ appendEvidence({
152
+ hook: HOOK_NAME,
153
+ event: "PermissionRequest",
154
+ decision: "deny",
155
+ reason: "blacklisted_pattern",
156
+ pattern: permissionPattern,
157
+ session_id: input.sessionId,
158
+ });
159
+ } catch {
160
+ // Non-blocking
161
+ }
162
+
163
+ deny(`[${HOOK_NAME}] パターンが広すぎます: ${permissionPattern}`);
164
+ }
165
+
166
+ // Check 3: learning limit
167
+ const currentCount = getLearnedRuleCount();
168
+ if (currentCount >= MAX_LEARNED_RULES) {
169
+ try {
170
+ appendEvidence({
171
+ hook: HOOK_NAME,
172
+ event: "PermissionRequest",
173
+ decision: "deny",
174
+ reason: "learning_limit_exceeded",
175
+ pattern: permissionPattern,
176
+ current_count: currentCount,
177
+ session_id: input.sessionId,
178
+ });
179
+ } catch {
180
+ // Non-blocking
181
+ }
182
+
183
+ deny(
184
+ `[${HOOK_NAME}] 学習上限に到達しました (${currentCount}/${MAX_LEARNED_RULES})`,
185
+ );
186
+ }
187
+
188
+ // All checks passed — allow
189
+ try {
190
+ appendEvidence({
191
+ hook: HOOK_NAME,
192
+ event: "PermissionRequest",
193
+ decision: "allow",
194
+ pattern: permissionPattern,
195
+ session_id: input.sessionId,
196
+ });
197
+ } catch {
198
+ // Non-blocking
199
+ }
200
+
201
+ allow();
202
+ } catch (err) {
203
+ // SECURITY hook — fail-close
204
+ process.stdout.write(
205
+ JSON.stringify({
206
+ reason: `[${HOOK_NAME}] Hook error (fail-close): ${err.message}`,
207
+ }),
208
+ );
209
+ process.exit(2);
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Exports (for testing)
214
+ // ---------------------------------------------------------------------------
215
+
216
+ module.exports = {
217
+ LEARNING_BLACKLIST,
218
+ MAX_LEARNED_RULES,
219
+ loadDenyRules,
220
+ getLearnedRuleCount,
221
+ checkDenyConflict,
222
+ isBlacklisted,
223
+ };
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env node
2
+ // sh-permission.js — 4-category tool governance (Tier 2)
3
+ // Spec: DETAILED_DESIGN.md §3.1
4
+ // Hook event: PreToolUse
5
+ // Matcher: Bash|Edit|Write|Read|WebFetch|MCP
6
+ // Target response time: < 50ms
7
+ "use strict";
8
+
9
+ const { readHookInput, allow, deny } = require("./lib/sh-utils");
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Category Constants
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const CATEGORY = {
16
+ READONLY: 1,
17
+ AGENT_SPAWN: 2,
18
+ EXECUTION: 3,
19
+ WRITE: 4,
20
+ };
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // READONLY_PATTERNS (Category 1 — auto-approve for Bash commands)
24
+ // Spec: §3.1 READONLY_PATTERNS
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const READONLY_PATTERNS = [
28
+ /^git\s+(status|diff|log|branch|show|blame|stash\s+list)\b/,
29
+ /^(ls|dir|pwd|whoami|date|uname|cat|head|tail|wc|find|which|type|file)\b/,
30
+ /^npm\s+(test|run|list|outdated|audit)\b/,
31
+ /^(node|bun|python|python3)\s+--version\b/,
32
+ /^(grep|rg|ag|awk)\s/,
33
+ /^sed\s+[^-]/, // sed without flags (read-only pipe usage only)
34
+ ];
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // WRITE_PATTERNS (Category 4 — write operation detection for Bash commands)
38
+ // Spec: §3.1 WRITE_PATTERNS
39
+ // ---------------------------------------------------------------------------
40
+
41
+ const WRITE_PATTERNS = [
42
+ /^(rm|del|rmdir|mkdir|mv|cp|chmod|chown)\b/,
43
+ /^git\s+(push|commit|merge|rebase|reset|checkout|clean)\b/,
44
+ /^npm\s+(install|publish|uninstall|update|link)\b/,
45
+ /^pip3?\s+install\b/,
46
+ ];
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Classification Logic
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * Classify a tool invocation into one of 4 categories.
54
+ *
55
+ * @param {string} toolName - Claude Code tool name
56
+ * @param {Object} toolInput - Tool input parameters
57
+ * @returns {{ category: number, label: string }}
58
+ */
59
+ function classify(toolName, toolInput) {
60
+ // --- Category 1: Read-only tools (always auto-approve) ---
61
+ if (
62
+ toolName === "Read" ||
63
+ toolName === "Grep" ||
64
+ toolName === "Glob" ||
65
+ toolName === "WebSearch"
66
+ ) {
67
+ return { category: CATEGORY.READONLY, label: "read-only tool" };
68
+ }
69
+
70
+ // --- Category 2: Agent spawn (delegate to SubagentStart hook) ---
71
+ if (toolName === "Task" || toolName === "Agent") {
72
+ return { category: CATEGORY.AGENT_SPAWN, label: "agent spawn" };
73
+ }
74
+
75
+ // --- Bash command classification (Categories 1, 3, or 4) ---
76
+ if (toolName === "Bash") {
77
+ const command = (toolInput.command || "").trim();
78
+
79
+ // Check read-only patterns first (Category 1)
80
+ for (const pattern of READONLY_PATTERNS) {
81
+ if (pattern.test(command)) {
82
+ return { category: CATEGORY.READONLY, label: "read-only command" };
83
+ }
84
+ }
85
+
86
+ // Check write patterns (Category 4)
87
+ for (const pattern of WRITE_PATTERNS) {
88
+ if (pattern.test(command)) {
89
+ return { category: CATEGORY.WRITE, label: "write command" };
90
+ }
91
+ }
92
+
93
+ // Neither read-only nor write: Execution (Category 3)
94
+ return { category: CATEGORY.EXECUTION, label: "execution command" };
95
+ }
96
+
97
+ // --- Category 4: Write tools ---
98
+ if (toolName === "Edit" || toolName === "Write") {
99
+ return { category: CATEGORY.WRITE, label: "file write" };
100
+ }
101
+
102
+ // --- Category 3: WebFetch ---
103
+ if (toolName === "WebFetch") {
104
+ return { category: CATEGORY.EXECUTION, label: "web fetch" };
105
+ }
106
+
107
+ // --- Category 3: MCP tools ---
108
+ // Any tool not matched above is treated as MCP / unknown execution
109
+ return { category: CATEGORY.EXECUTION, label: "MCP tool" };
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Main
114
+ // ---------------------------------------------------------------------------
115
+
116
+ try {
117
+ const input = readHookInput();
118
+ const toolName = input.toolName;
119
+ const toolInput = input.toolInput;
120
+
121
+ const { category, label } = classify(toolName, toolInput);
122
+
123
+ switch (category) {
124
+ case CATEGORY.READONLY:
125
+ // Category 1: auto-approve, no context needed
126
+ allow();
127
+ break;
128
+
129
+ case CATEGORY.AGENT_SPAWN:
130
+ // Category 2: allow here, SubagentStart hook handles governance
131
+ allow();
132
+ break;
133
+
134
+ case CATEGORY.EXECUTION:
135
+ // Category 3: allow with context for awareness
136
+ allow(`[sh-permission] Category 3 (execution): ${label}`);
137
+ break;
138
+
139
+ case CATEGORY.WRITE:
140
+ // Category 4: allow (protected path checks are gate.sh's responsibility)
141
+ allow();
142
+ break;
143
+
144
+ default:
145
+ // Unknown category — fail-close
146
+ deny("Unknown category in sh-permission");
147
+ break;
148
+ }
149
+ } catch (err) {
150
+ // fail-close: any uncaught error = deny
151
+ process.stdout.write(
152
+ JSON.stringify({
153
+ reason: `Hook error (sh-permission): ${err.message}`,
154
+ }),
155
+ );
156
+ process.exit(2);
157
+ }