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,212 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// sh-instructions.js — Rule file integrity monitoring
|
|
3
|
+
// Spec: DETAILED_DESIGN.md §5.8
|
|
4
|
+
// Event: InstructionsLoaded
|
|
5
|
+
// Target response time: < 200ms
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const {
|
|
11
|
+
readHookInput,
|
|
12
|
+
allow,
|
|
13
|
+
sha256,
|
|
14
|
+
appendEvidence,
|
|
15
|
+
} = require("./lib/sh-utils");
|
|
16
|
+
|
|
17
|
+
const HOOK_NAME = "sh-instructions";
|
|
18
|
+
const CLAUDE_MD = "CLAUDE.md";
|
|
19
|
+
const RULES_DIR = path.join(".claude", "rules");
|
|
20
|
+
const HASHES_FILE = path.join(".claude", "logs", "instructions-hashes.json");
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Hash Collection
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Compute SHA-256 hash for a file.
|
|
28
|
+
* @param {string} filePath
|
|
29
|
+
* @returns {string|null}
|
|
30
|
+
*/
|
|
31
|
+
function hashFile(filePath) {
|
|
32
|
+
try {
|
|
33
|
+
return sha256(fs.readFileSync(filePath, "utf8"));
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Collect current hashes for CLAUDE.md and all rule files.
|
|
41
|
+
* @returns {Object} { filePath: hash }
|
|
42
|
+
*/
|
|
43
|
+
function collectCurrentHashes() {
|
|
44
|
+
const hashes = {};
|
|
45
|
+
|
|
46
|
+
// CLAUDE.md
|
|
47
|
+
if (fs.existsSync(CLAUDE_MD)) {
|
|
48
|
+
hashes[CLAUDE_MD] = hashFile(CLAUDE_MD);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// .claude/rules/*.md
|
|
52
|
+
if (fs.existsSync(RULES_DIR)) {
|
|
53
|
+
try {
|
|
54
|
+
const files = fs.readdirSync(RULES_DIR).filter((f) => f.endsWith(".md"));
|
|
55
|
+
for (const f of files) {
|
|
56
|
+
const fp = path.join(RULES_DIR, f);
|
|
57
|
+
hashes[fp] = hashFile(fp);
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// Directory read failure — non-critical
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return hashes;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Load stored hashes from disk.
|
|
69
|
+
* @returns {Object|null} null if no baseline exists
|
|
70
|
+
*/
|
|
71
|
+
function loadStoredHashes() {
|
|
72
|
+
try {
|
|
73
|
+
if (!fs.existsSync(HASHES_FILE)) return null;
|
|
74
|
+
return JSON.parse(fs.readFileSync(HASHES_FILE, "utf8"));
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Save hashes to disk.
|
|
82
|
+
* @param {Object} hashes
|
|
83
|
+
*/
|
|
84
|
+
function saveHashes(hashes) {
|
|
85
|
+
const dir = path.dirname(HASHES_FILE);
|
|
86
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
87
|
+
fs.writeFileSync(HASHES_FILE, JSON.stringify(hashes, null, 2));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Detect changes between stored and current hashes.
|
|
92
|
+
* @param {Object} stored
|
|
93
|
+
* @param {Object} current
|
|
94
|
+
* @returns {{ added: string[], modified: string[], removed: string[] }}
|
|
95
|
+
*/
|
|
96
|
+
function detectChanges(stored, current) {
|
|
97
|
+
const added = [];
|
|
98
|
+
const modified = [];
|
|
99
|
+
const removed = [];
|
|
100
|
+
|
|
101
|
+
// Check current against stored
|
|
102
|
+
for (const [file, hash] of Object.entries(current)) {
|
|
103
|
+
if (!(file in stored)) {
|
|
104
|
+
added.push(file);
|
|
105
|
+
} else if (stored[file] !== hash) {
|
|
106
|
+
modified.push(file);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Check for removed files
|
|
111
|
+
for (const file of Object.keys(stored)) {
|
|
112
|
+
if (!(file in current)) {
|
|
113
|
+
removed.push(file);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { added, modified, removed };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Main
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const input = readHookInput();
|
|
126
|
+
const currentHashes = collectCurrentHashes();
|
|
127
|
+
const storedHashes = loadStoredHashes();
|
|
128
|
+
|
|
129
|
+
// First run: save baseline, no warning
|
|
130
|
+
if (!storedHashes) {
|
|
131
|
+
saveHashes(currentHashes);
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
appendEvidence({
|
|
135
|
+
hook: HOOK_NAME,
|
|
136
|
+
event: "InstructionsLoaded",
|
|
137
|
+
decision: "allow",
|
|
138
|
+
action: "baseline_recorded",
|
|
139
|
+
file_count: Object.keys(currentHashes).length,
|
|
140
|
+
session_id: input.sessionId,
|
|
141
|
+
});
|
|
142
|
+
} catch {
|
|
143
|
+
// Non-blocking
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
allow(
|
|
147
|
+
`[${HOOK_NAME}] Baseline recorded: ${Object.keys(currentHashes).length} files`,
|
|
148
|
+
);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Detect changes
|
|
153
|
+
const changes = detectChanges(storedHashes, currentHashes);
|
|
154
|
+
const hasChanges =
|
|
155
|
+
changes.added.length > 0 ||
|
|
156
|
+
changes.modified.length > 0 ||
|
|
157
|
+
changes.removed.length > 0;
|
|
158
|
+
|
|
159
|
+
// Update stored hashes
|
|
160
|
+
saveHashes(currentHashes);
|
|
161
|
+
|
|
162
|
+
if (hasChanges) {
|
|
163
|
+
// Build warning message
|
|
164
|
+
const warnings = ["[RULE FILE CHANGE DETECTED]"];
|
|
165
|
+
|
|
166
|
+
if (changes.added.length > 0) {
|
|
167
|
+
warnings.push(` Added: ${changes.added.join(", ")}`);
|
|
168
|
+
}
|
|
169
|
+
if (changes.modified.length > 0) {
|
|
170
|
+
warnings.push(` Modified: ${changes.modified.join(", ")}`);
|
|
171
|
+
}
|
|
172
|
+
if (changes.removed.length > 0) {
|
|
173
|
+
warnings.push(` Removed: ${changes.removed.join(", ")}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
warnings.push("Re-read these files to ensure instructions are current.");
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
appendEvidence({
|
|
180
|
+
hook: HOOK_NAME,
|
|
181
|
+
event: "InstructionsLoaded",
|
|
182
|
+
decision: "allow",
|
|
183
|
+
action: "changes_detected",
|
|
184
|
+
changes,
|
|
185
|
+
session_id: input.sessionId,
|
|
186
|
+
});
|
|
187
|
+
} catch {
|
|
188
|
+
// Non-blocking
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
allow(warnings.join("\n"));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// No changes
|
|
196
|
+
allow();
|
|
197
|
+
} catch (_err) {
|
|
198
|
+
// Operational hook — fail-open
|
|
199
|
+
allow();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Exports (for testing)
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
module.exports = {
|
|
207
|
+
hashFile,
|
|
208
|
+
collectCurrentHashes,
|
|
209
|
+
loadStoredHashes,
|
|
210
|
+
saveHashes,
|
|
211
|
+
detectChanges,
|
|
212
|
+
};
|
|
@@ -0,0 +1,217 @@
|
|
|
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
|
+
// Dangerous system tags that could be used for prompt injection via tool output
|
|
38
|
+
const DANGEROUS_TAG_NAMES = [
|
|
39
|
+
"system-reminder",
|
|
40
|
+
"system-instruction",
|
|
41
|
+
"system-message",
|
|
42
|
+
"system-prompt",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
// Build a single regex that matches all dangerous tags (non-greedy, multiline)
|
|
46
|
+
const DANGEROUS_TAGS_RE = new RegExp(
|
|
47
|
+
DANGEROUS_TAG_NAMES.map((tag) => `<${tag}[^>]*>[\\s\\S]*?</${tag}>`).join(
|
|
48
|
+
"|",
|
|
49
|
+
),
|
|
50
|
+
"gi",
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Helper Functions
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get truncation limits for a given tool.
|
|
59
|
+
* @param {string} toolName
|
|
60
|
+
* @returns {{ max: number, head: number, tail: number }}
|
|
61
|
+
*/
|
|
62
|
+
function getLimits(toolName) {
|
|
63
|
+
return TRUNCATION_LIMITS[toolName] || TRUNCATION_LIMITS._default;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Strip dangerous system tags from tool output to prevent prompt injection.
|
|
68
|
+
* Replaces matched tags with [REDACTED]. Handles multiline content.
|
|
69
|
+
* Malformed/unclosed tags are left as-is (graceful degradation).
|
|
70
|
+
* @param {string} text
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
function stripDangerousTags(text) {
|
|
74
|
+
if (!text || typeof text !== "string") return text;
|
|
75
|
+
return text.replace(DANGEROUS_TAGS_RE, "[REDACTED]");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Truncate output if it exceeds the limit.
|
|
80
|
+
* @param {string} output
|
|
81
|
+
* @param {string} toolName
|
|
82
|
+
* @returns {{ text: string, truncated: boolean }}
|
|
83
|
+
*/
|
|
84
|
+
function truncateOutput(output, toolName) {
|
|
85
|
+
if (!output) return { text: output, truncated: false };
|
|
86
|
+
|
|
87
|
+
const limits = getLimits(toolName);
|
|
88
|
+
if (output.length <= limits.max) {
|
|
89
|
+
return { text: output, truncated: false };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const head = output.slice(0, limits.head);
|
|
93
|
+
const tail = output.slice(-limits.tail);
|
|
94
|
+
const omitted = output.length - limits.head - limits.tail;
|
|
95
|
+
const notice = `\n\n--- [sh-output-control] ${omitted} bytes omitted (${output.length} total → ${limits.head + limits.tail} retained) ---\n\n`;
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
text: head + notice + tail,
|
|
99
|
+
truncated: true,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Estimate token count from character length.
|
|
105
|
+
* @param {number} charCount
|
|
106
|
+
* @returns {number}
|
|
107
|
+
*/
|
|
108
|
+
function estimateTokens(charCount) {
|
|
109
|
+
return Math.ceil(charCount / CHARS_PER_TOKEN);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Track token budget and return warning context if thresholds are crossed.
|
|
114
|
+
* @param {number} outputSize - Size of tool output in characters
|
|
115
|
+
* @returns {string|null} Warning context or null
|
|
116
|
+
*/
|
|
117
|
+
function trackTokenBudget(outputSize) {
|
|
118
|
+
try {
|
|
119
|
+
const session = readSession();
|
|
120
|
+
const tokenBudget = session.token_budget;
|
|
121
|
+
if (!tokenBudget || !tokenBudget.session_limit) return null; // No budget configured
|
|
122
|
+
|
|
123
|
+
const budgetLimit = tokenBudget.session_limit;
|
|
124
|
+
const currentUsage = tokenBudget.used || 0;
|
|
125
|
+
const newTokens = estimateTokens(outputSize);
|
|
126
|
+
const updatedUsage = currentUsage + newTokens;
|
|
127
|
+
|
|
128
|
+
// Update session — write to token_budget.used (single source of truth)
|
|
129
|
+
writeSession({
|
|
130
|
+
...session,
|
|
131
|
+
token_budget: {
|
|
132
|
+
...tokenBudget,
|
|
133
|
+
used: updatedUsage,
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const ratio = updatedUsage / budgetLimit;
|
|
138
|
+
|
|
139
|
+
if (ratio >= BUDGET_LIMIT_RATIO) {
|
|
140
|
+
return `[${HOOK_NAME}] トークン予算を超過しました(${updatedUsage}/${budgetLimit} tokens)。ユーザー確認が必要です。`;
|
|
141
|
+
}
|
|
142
|
+
if (ratio >= BUDGET_WARNING_RATIO) {
|
|
143
|
+
return `[${HOOK_NAME}] トークン予算の 80% に到達しました(${updatedUsage}/${budgetLimit} tokens)。`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return null;
|
|
147
|
+
} catch {
|
|
148
|
+
// Budget tracking failure is non-blocking
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Main — only execute when run directly (not when require'd for testing)
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
if (require.main === module) {
|
|
158
|
+
try {
|
|
159
|
+
const input = readHookInput();
|
|
160
|
+
const { toolName, toolResult } = input;
|
|
161
|
+
|
|
162
|
+
const resultStr =
|
|
163
|
+
typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult);
|
|
164
|
+
|
|
165
|
+
// Strip dangerous system tags before any further processing
|
|
166
|
+
const sanitized = stripDangerousTags(resultStr);
|
|
167
|
+
|
|
168
|
+
// Truncate if necessary
|
|
169
|
+
const { text, truncated } = truncateOutput(sanitized, toolName);
|
|
170
|
+
|
|
171
|
+
// Track token budget (based on sanitized output)
|
|
172
|
+
const budgetWarning = trackTokenBudget(sanitized ? sanitized.length : 0);
|
|
173
|
+
|
|
174
|
+
// Build context messages
|
|
175
|
+
const context = [];
|
|
176
|
+
if (truncated) {
|
|
177
|
+
context.push(
|
|
178
|
+
`[${HOOK_NAME}] ${toolName} の出力を切り詰めました(制限超過)。`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
if (budgetWarning) {
|
|
182
|
+
context.push(budgetWarning);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Output result
|
|
186
|
+
if (truncated) {
|
|
187
|
+
// Must use allowWithResult to replace the tool output
|
|
188
|
+
if (context.length > 0) {
|
|
189
|
+
// allowWithResult doesn't support additionalContext, so prepend warnings to the result
|
|
190
|
+
const contextHeader = context.join("\n") + "\n\n";
|
|
191
|
+
allowWithResult(contextHeader + text);
|
|
192
|
+
} else {
|
|
193
|
+
allowWithResult(text);
|
|
194
|
+
}
|
|
195
|
+
} else if (context.length > 0) {
|
|
196
|
+
allow(context.join("\n"));
|
|
197
|
+
} else {
|
|
198
|
+
allow();
|
|
199
|
+
}
|
|
200
|
+
} catch (_err) {
|
|
201
|
+
// Operational hook — on error, pass through the original output.
|
|
202
|
+
allow();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Exports (for testing)
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
module.exports = {
|
|
211
|
+
TRUNCATION_LIMITS,
|
|
212
|
+
stripDangerousTags,
|
|
213
|
+
truncateOutput,
|
|
214
|
+
estimateTokens,
|
|
215
|
+
getLimits,
|
|
216
|
+
trackTokenBudget,
|
|
217
|
+
};
|
|
@@ -0,0 +1,227 @@
|
|
|
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
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Check 1: deny rule conflict
|
|
127
|
+
const denyRules = loadDenyRules();
|
|
128
|
+
const conflict = checkDenyConflict(permissionPattern, denyRules);
|
|
129
|
+
if (conflict) {
|
|
130
|
+
try {
|
|
131
|
+
appendEvidence({
|
|
132
|
+
hook: HOOK_NAME,
|
|
133
|
+
event: "PermissionRequest",
|
|
134
|
+
decision: "deny",
|
|
135
|
+
reason: "deny_rule_conflict",
|
|
136
|
+
pattern: permissionPattern,
|
|
137
|
+
conflicting_rule: conflict,
|
|
138
|
+
session_id: input.sessionId,
|
|
139
|
+
});
|
|
140
|
+
} catch {
|
|
141
|
+
// Non-blocking
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
deny(
|
|
145
|
+
`[${HOOK_NAME}] deny ルールは学習で上書きできません。衝突ルール: ${conflict}`,
|
|
146
|
+
);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Check 2: blacklist
|
|
151
|
+
if (isBlacklisted(permissionPattern)) {
|
|
152
|
+
try {
|
|
153
|
+
appendEvidence({
|
|
154
|
+
hook: HOOK_NAME,
|
|
155
|
+
event: "PermissionRequest",
|
|
156
|
+
decision: "deny",
|
|
157
|
+
reason: "blacklisted_pattern",
|
|
158
|
+
pattern: permissionPattern,
|
|
159
|
+
session_id: input.sessionId,
|
|
160
|
+
});
|
|
161
|
+
} catch {
|
|
162
|
+
// Non-blocking
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
deny(`[${HOOK_NAME}] パターンが広すぎます: ${permissionPattern}`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Check 3: learning limit
|
|
170
|
+
const currentCount = getLearnedRuleCount();
|
|
171
|
+
if (currentCount >= MAX_LEARNED_RULES) {
|
|
172
|
+
try {
|
|
173
|
+
appendEvidence({
|
|
174
|
+
hook: HOOK_NAME,
|
|
175
|
+
event: "PermissionRequest",
|
|
176
|
+
decision: "deny",
|
|
177
|
+
reason: "learning_limit_exceeded",
|
|
178
|
+
pattern: permissionPattern,
|
|
179
|
+
current_count: currentCount,
|
|
180
|
+
session_id: input.sessionId,
|
|
181
|
+
});
|
|
182
|
+
} catch {
|
|
183
|
+
// Non-blocking
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
deny(
|
|
187
|
+
`[${HOOK_NAME}] 学習上限に到達しました (${currentCount}/${MAX_LEARNED_RULES})`,
|
|
188
|
+
);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// All checks passed — allow
|
|
193
|
+
try {
|
|
194
|
+
appendEvidence({
|
|
195
|
+
hook: HOOK_NAME,
|
|
196
|
+
event: "PermissionRequest",
|
|
197
|
+
decision: "allow",
|
|
198
|
+
pattern: permissionPattern,
|
|
199
|
+
session_id: input.sessionId,
|
|
200
|
+
});
|
|
201
|
+
} catch {
|
|
202
|
+
// Non-blocking
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
allow();
|
|
206
|
+
} catch (err) {
|
|
207
|
+
// SECURITY hook — fail-close
|
|
208
|
+
process.stdout.write(
|
|
209
|
+
JSON.stringify({
|
|
210
|
+
reason: `[${HOOK_NAME}] Hook error (fail-close): ${err.message}`,
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
process.exit(2);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Exports (for testing)
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
module.exports = {
|
|
221
|
+
LEARNING_BLACKLIST,
|
|
222
|
+
MAX_LEARNED_RULES,
|
|
223
|
+
loadDenyRules,
|
|
224
|
+
getLearnedRuleCount,
|
|
225
|
+
checkDenyConflict,
|
|
226
|
+
isBlacklisted,
|
|
227
|
+
};
|