claude-novice 0.2.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-plugin/marketplace.json +25 -0
- package/.claude-plugin/plugin.json +22 -0
- package/ARCHITECTURE.md +59 -0
- package/LICENSE +21 -0
- package/README.md +372 -0
- package/config/bootstrap-manifests/github-cli.json +80 -0
- package/config/bootstrap-manifests/supabase.json +71 -0
- package/config/bootstrap-manifests/vercel.json +69 -0
- package/config/levels.json +33 -0
- package/config/safety-rules.json +263 -0
- package/config/service-capabilities.json +51 -0
- package/config/terms.json +198 -0
- package/hooks/hooks.json +107 -0
- package/package.json +45 -0
- package/scripts/bootstrap-engine.js +240 -0
- package/scripts/lib/capability-router.js +170 -0
- package/scripts/lib/capsule.js +178 -0
- package/scripts/lib/fingerprint.js +17 -0
- package/scripts/lib/grammar.js +287 -0
- package/scripts/lib/hookio.js +49 -0
- package/scripts/lib/manifest.js +154 -0
- package/scripts/lib/safety.js +370 -0
- package/scripts/lib/secrets.js +104 -0
- package/scripts/lib/state.js +256 -0
- package/scripts/post-tool-batch.js +108 -0
- package/scripts/post-tool-use-failure.js +48 -0
- package/scripts/post-tool-use.js +114 -0
- package/scripts/pre-tool-use.js +58 -0
- package/scripts/session-end.js +21 -0
- package/scripts/session-start.js +48 -0
- package/scripts/stop.js +69 -0
- package/scripts/user-prompt-expansion.js +66 -0
- package/scripts/user-prompt-submit.js +158 -0
- package/scripts/verify-docs.mjs +93 -0
- package/skills/mode/SKILL.md +48 -0
- package/skills/novice/SKILL.md +41 -0
- package/skills/setup-service/SKILL.md +88 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// Safety-gate analysis (L2 lib). Minimal deny-only core for the PreToolUse hook.
|
|
2
|
+
//
|
|
3
|
+
// Philosophy (PRD §4.5): the gate blocks ONLY on positively-identified, catastrophic,
|
|
4
|
+
// irreversible actions and on secret VALUES exposed on a command line / in a commit.
|
|
5
|
+
// Everything else — including any command whose grammar we cannot fully parse — returns
|
|
6
|
+
// null (no opinion) and is delegated to Claude Code's native permission prompt. There is
|
|
7
|
+
// no "ask" tier: we never second-guess unparseable shell, which is what produced constant
|
|
8
|
+
// false confirmations on benign piped/redirected commands.
|
|
9
|
+
//
|
|
10
|
+
// Returns { decision: 'deny', reason } or null. The hook wrapper (scripts/pre-tool-use.js)
|
|
11
|
+
// owns stdin/stdout and fail-closed behavior on malformed input.
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { scanText } from './secrets.js';
|
|
17
|
+
import { tokenizeShell, isPowershellCommand, parseGit, baseName } from './grammar.js';
|
|
18
|
+
|
|
19
|
+
const SPLIT_GUIDANCE = '명령이나 파일을 더 작은 단위로 나눠 다시 시도하세요.';
|
|
20
|
+
|
|
21
|
+
function git(cwd, args) {
|
|
22
|
+
return execFileSync('git', ['-C', cwd, ...args], {
|
|
23
|
+
encoding: 'utf8',
|
|
24
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
25
|
+
timeout: 5000,
|
|
26
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function deny(reason) {
|
|
31
|
+
return { decision: 'deny', reason };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---- protected branches ----
|
|
35
|
+
|
|
36
|
+
function branchPatternToRegex(pattern) {
|
|
37
|
+
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.+');
|
|
38
|
+
return new RegExp(`^${escaped}$`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isProtectedBranch(branch, rules, extra) {
|
|
42
|
+
if (typeof branch !== 'string' || branch === '') return false;
|
|
43
|
+
const patterns = [...rules.protected_branches.builtin, ...(extra || [])];
|
|
44
|
+
return patterns.some((p) => branchPatternToRegex(p).test(branch));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---- target classification (deploy CLIs / MCP) ----
|
|
48
|
+
|
|
49
|
+
function classifyTargetFromArgv(argv, rules) {
|
|
50
|
+
const joined = argv.join(' ');
|
|
51
|
+
for (const [cls, hints] of Object.entries(rules.target_flag_hints)) {
|
|
52
|
+
for (const hint of hints) {
|
|
53
|
+
if (hint.includes(' ')) {
|
|
54
|
+
if (joined.includes(hint)) return cls;
|
|
55
|
+
} else if (argv.includes(hint)) {
|
|
56
|
+
return cls;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return 'unknown';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function classifyTargetFromValues(toolInput) {
|
|
64
|
+
const values = [];
|
|
65
|
+
(function collect(v) {
|
|
66
|
+
if (typeof v === 'string') values.push(v);
|
|
67
|
+
else if (Array.isArray(v)) v.forEach(collect);
|
|
68
|
+
else if (v && typeof v === 'object') Object.values(v).forEach(collect);
|
|
69
|
+
})(toolInput);
|
|
70
|
+
const joined = values.join(' ');
|
|
71
|
+
if (/\bprod(uction)?\b/i.test(joined)) return 'production';
|
|
72
|
+
if (/\b(preview|staging)\b/i.test(joined)) return 'staging';
|
|
73
|
+
if (/\b(dev|development|local)\b/i.test(joined)) return 'development';
|
|
74
|
+
return 'unknown';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function safeRealpath(p) {
|
|
78
|
+
try {
|
|
79
|
+
return fs.realpathSync(p);
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Files under a configured fixture path (e.g. tests/fixtures/) carry intentional synthetic
|
|
86
|
+
// secrets for the corpus, so they are excluded from the secret scan — otherwise the plugin
|
|
87
|
+
// would deny commits of its own test data. Path-anchored on segment boundaries.
|
|
88
|
+
function isScanSkippedPath(file, rules) {
|
|
89
|
+
const prefixes = rules.scan_path_skip;
|
|
90
|
+
if (!Array.isArray(prefixes) || prefixes.length === 0) return false;
|
|
91
|
+
const norm = '/' + String(file).replace(/\\/g, '/').replace(/^\.?\/+/, '');
|
|
92
|
+
return prefixes.some((p) => norm.includes('/' + String(p).replace(/^\/+/, '')));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---- rm: deny only when the target is home / root / project root / parent / '*' ----
|
|
96
|
+
|
|
97
|
+
function checkRm(argv, cwd) {
|
|
98
|
+
const targets = [];
|
|
99
|
+
for (const t of argv.slice(1)) {
|
|
100
|
+
if (t === '--') continue;
|
|
101
|
+
if (t.startsWith('-')) continue; // flags (recursive-only rm of a normal path is allowed)
|
|
102
|
+
targets.push(t);
|
|
103
|
+
}
|
|
104
|
+
const home = os.homedir();
|
|
105
|
+
const cwdReal = safeRealpath(cwd) ?? path.resolve(cwd);
|
|
106
|
+
for (const raw of targets) {
|
|
107
|
+
const stripped = raw.replace(/\/\*$/, '');
|
|
108
|
+
if (raw === '*' || stripped === '' || stripped === '/' || stripped === '~' || stripped === '$HOME' || stripped === '.' || stripped === '..') {
|
|
109
|
+
return deny(`'rm ${raw}'은 홈·프로젝트 전체 또는 상위 디렉토리를 삭제할 수 있어 차단했어요. 삭제 대상을 구체적인 파일·폴더 경로로 지정하세요.`);
|
|
110
|
+
}
|
|
111
|
+
const resolved = path.resolve(cwd, stripped.replace(/^~(?=\/|$)/, home).replace(/^\$HOME(?=\/|$)/, home));
|
|
112
|
+
const resolvedReal = safeRealpath(resolved) ?? resolved;
|
|
113
|
+
if (resolvedReal === path.parse(resolvedReal).root || resolvedReal === home || resolvedReal === cwdReal || cwdReal.startsWith(resolvedReal + path.sep)) {
|
|
114
|
+
return deny(`'rm' 대상(${raw})이 홈 디렉토리·프로젝트 루트·상위 경로를 가리켜 차단했어요. 복구가 어려운 삭제예요.`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- git: deny force-push to a protected branch and secret-bearing commits ----
|
|
121
|
+
|
|
122
|
+
function currentBranch(cwd) {
|
|
123
|
+
try {
|
|
124
|
+
const b = git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']).trim();
|
|
125
|
+
return b === 'HEAD' ? null : b;
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function checkGit(argv, cwd, rules, extraProtected) {
|
|
132
|
+
const parsed = parseGit(argv);
|
|
133
|
+
|
|
134
|
+
if (parsed.sub === 'push') {
|
|
135
|
+
if (!parsed.force) return null;
|
|
136
|
+
const target = parsed.targetBranch ?? currentBranch(cwd);
|
|
137
|
+
if (isProtectedBranch(target, rules, extraProtected)) {
|
|
138
|
+
return deny(`보호된 branch(${target})로의 force push는 원격 기록을 영구히 덮어써서 차단했어요. 새 branch에 push하거나 force 없이 push하세요.`);
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (parsed.sub === 'commit') {
|
|
144
|
+
return checkGitCommit(parsed, cwd, rules);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function stagedFiles(cwd) {
|
|
151
|
+
const out = git(cwd, ['diff', '--cached', '--name-only', '-z']);
|
|
152
|
+
return out.split('\0').filter(Boolean);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function trackedModifiedFiles(cwd) {
|
|
156
|
+
const out = git(cwd, ['diff', '--name-only', '-z', 'HEAD']);
|
|
157
|
+
return out.split('\0').filter(Boolean);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Scan the commit's candidate files for secret VALUES; deny if any is found.
|
|
161
|
+
// Anything we cannot scan (exotic options, unborn HEAD, oversize, read error) returns
|
|
162
|
+
// null — the gate blocks only on a positive secret detection, never on inability to scan.
|
|
163
|
+
function checkGitCommit(parsed, cwd, rules) {
|
|
164
|
+
if (parsed.exotic) return null;
|
|
165
|
+
try {
|
|
166
|
+
git(cwd, ['rev-parse', '--verify', 'HEAD']);
|
|
167
|
+
} catch {
|
|
168
|
+
return null; // unborn HEAD
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const caps = rules.input_caps;
|
|
172
|
+
const skipExt = new Set(rules.scan_file_extensions_skip);
|
|
173
|
+
let files;
|
|
174
|
+
let readContent;
|
|
175
|
+
try {
|
|
176
|
+
if (parsed.pathspec.length > 0) {
|
|
177
|
+
files = parsed.pathspec.map((p) => p);
|
|
178
|
+
readContent = (f) => {
|
|
179
|
+
const abs = path.resolve(cwd, f);
|
|
180
|
+
const st = fs.statSync(abs);
|
|
181
|
+
if (!st.isFile()) return null;
|
|
182
|
+
if (st.size > caps.single_file_bytes) return null;
|
|
183
|
+
return { content: fs.readFileSync(abs, 'utf8') };
|
|
184
|
+
};
|
|
185
|
+
} else if (parsed.allFlag) {
|
|
186
|
+
files = trackedModifiedFiles(cwd);
|
|
187
|
+
readContent = (f) => {
|
|
188
|
+
const abs = path.resolve(cwd, f);
|
|
189
|
+
let st;
|
|
190
|
+
try {
|
|
191
|
+
st = fs.statSync(abs);
|
|
192
|
+
} catch {
|
|
193
|
+
return null; // deleted file
|
|
194
|
+
}
|
|
195
|
+
if (!st.isFile()) return null;
|
|
196
|
+
if (st.size > caps.single_file_bytes) return null;
|
|
197
|
+
return { content: fs.readFileSync(abs, 'utf8') };
|
|
198
|
+
};
|
|
199
|
+
} else {
|
|
200
|
+
files = stagedFiles(cwd);
|
|
201
|
+
readContent = (f) => {
|
|
202
|
+
let size;
|
|
203
|
+
try {
|
|
204
|
+
size = Number(git(cwd, ['cat-file', '-s', `:0:${f}`]).trim());
|
|
205
|
+
} catch {
|
|
206
|
+
return null; // deleted from index
|
|
207
|
+
}
|
|
208
|
+
if (size > caps.single_file_bytes) return null;
|
|
209
|
+
return { content: git(cwd, ['show', `:0:${f}`]) };
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let total = 0;
|
|
214
|
+
for (const f of files) {
|
|
215
|
+
if (skipExt.has(path.extname(f).toLowerCase())) continue;
|
|
216
|
+
if (isScanSkippedPath(f, rules)) continue;
|
|
217
|
+
const r = readContent(f);
|
|
218
|
+
if (r == null) continue;
|
|
219
|
+
total += Buffer.byteLength(r.content, 'utf8');
|
|
220
|
+
if (total > caps.total_candidate_bytes) return null;
|
|
221
|
+
const findings = scanText(r.content, rules);
|
|
222
|
+
if (findings.length > 0) {
|
|
223
|
+
const ids = [...new Set(findings.map((x) => x.id))].join(', ');
|
|
224
|
+
return deny(
|
|
225
|
+
`commit 대상 파일(${f})에서 비밀값 패턴(${ids})이 발견되어 차단했어요. ` +
|
|
226
|
+
'해당 값을 파일에서 제거하고 environment variable(환경변수)로 옮긴 뒤 다시 commit하세요. 오탐이라면 값 형태를 바꾸거나 fixture 표기를 추가하세요.',
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
} catch {
|
|
232
|
+
return null; // scan error → no opinion
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---- deploy CLIs: deny production/unknown destructive verbs and secret-bearing deploy args ----
|
|
237
|
+
|
|
238
|
+
function checkDeployCli(svc, argv, cwd, rules) {
|
|
239
|
+
const svcRules = rules.deploy_clis[svc];
|
|
240
|
+
const operands = argv.slice(1).filter((t) => !t.startsWith('-'));
|
|
241
|
+
const subcommand = operands.join(' ');
|
|
242
|
+
const target = classifyTargetFromArgv(argv, rules);
|
|
243
|
+
|
|
244
|
+
for (const [verb, actions] of Object.entries(svcRules.destructive_verbs ?? {})) {
|
|
245
|
+
if (subcommand === verb || subcommand.startsWith(`${verb} `)) {
|
|
246
|
+
const effectiveTarget = target === 'preview' ? 'preview' : target;
|
|
247
|
+
const action = actions[effectiveTarget] ?? actions.unknown ?? 'deny';
|
|
248
|
+
if (action === 'deny') {
|
|
249
|
+
return deny(
|
|
250
|
+
`'${svc} ${verb}'는 ${describeTarget(effectiveTarget)}의 리소스를 삭제·초기화할 수 있어 차단했어요. ` +
|
|
251
|
+
'정말 필요하면 해당 서비스 콘솔에서 대상과 백업을 확인하고 직접 실행하세요.',
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
return null; // non-deny destructive verbs are delegated to Claude Code's native prompt
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Deploy args that point at an env file get a secret scan (deny on a real secret value).
|
|
259
|
+
if (svcRules.secret_scan_on_deploy) {
|
|
260
|
+
for (const t of argv.slice(1)) {
|
|
261
|
+
if (/^\.?env(\..+)?$/i.test(baseName(t))) {
|
|
262
|
+
const verdict = scanEnvFileArg(t, cwd, rules);
|
|
263
|
+
if (verdict) return verdict;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function scanEnvFileArg(fileArg, cwd, rules) {
|
|
271
|
+
if (isScanSkippedPath(fileArg, rules)) return null;
|
|
272
|
+
try {
|
|
273
|
+
const abs = path.resolve(cwd, fileArg);
|
|
274
|
+
const st = fs.statSync(abs);
|
|
275
|
+
if (!st.isFile()) return null;
|
|
276
|
+
if (st.size > rules.input_caps.single_file_bytes) return null;
|
|
277
|
+
const findings = scanText(fs.readFileSync(abs, 'utf8'), rules);
|
|
278
|
+
if (findings.length > 0) {
|
|
279
|
+
const ids = [...new Set(findings.map((x) => x.id))].join(', ');
|
|
280
|
+
return deny(`배포 인자 파일(${fileArg})에서 비밀값 패턴(${ids})이 발견되어 차단했어요. 값은 서비스의 env 설정 화면·대화형 명령으로 직접 입력하세요.`);
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function describeTarget(cls) {
|
|
289
|
+
return {
|
|
290
|
+
production: 'production(실서비스)',
|
|
291
|
+
staging: 'preview/staging(테스트 환경)',
|
|
292
|
+
preview: 'preview/staging(테스트 환경)',
|
|
293
|
+
development: 'development(개발 환경)',
|
|
294
|
+
unknown: '알 수 없는 환경(unknown)',
|
|
295
|
+
}[cls] ?? cls;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---- PowerShell: deny only the catastrophic disk/format cmdlets ----
|
|
299
|
+
|
|
300
|
+
function analyzePowershell(command, rules) {
|
|
301
|
+
const argv = command.trim().split(/\s+/);
|
|
302
|
+
const name = argv[0].toLowerCase();
|
|
303
|
+
if (rules.shell_rules.powershell_dangerous_deny.some((c) => c.toLowerCase() === name)) {
|
|
304
|
+
return deny(`'${argv[0]}'은 디스크·파티션을 복구 불가능하게 파괴할 수 있어 이 플러그인에서는 차단해요.`);
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---- Bash pipeline (public) ----
|
|
310
|
+
|
|
311
|
+
export function analyzeBash(command, cwd, rules, extraProtected) {
|
|
312
|
+
if (typeof command !== 'string') {
|
|
313
|
+
return deny('명령이 문자열이 아니라 검증할 수 없어 차단했어요.');
|
|
314
|
+
}
|
|
315
|
+
if (Buffer.byteLength(command, 'utf8') > rules.input_caps.command_bytes) {
|
|
316
|
+
return deny(`명령이 64KiB 입력 상한을 넘어 검사 없이 실행할 수 없어요. ${SPLIT_GUIDANCE}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Raw-argv secret exposure beats everything: a secret VALUE on the command line is denied
|
|
320
|
+
// regardless of grammar (this scan does not need the command to be parseable).
|
|
321
|
+
const rawFindings = scanText(command, rules);
|
|
322
|
+
if (rawFindings.length > 0) {
|
|
323
|
+
const ids = [...new Set(rawFindings.map((x) => x.id))].join(', ');
|
|
324
|
+
return deny(
|
|
325
|
+
`명령 인자에 비밀값 패턴(${ids})이 노출되어 차단했어요. ` +
|
|
326
|
+
'값은 대화·명령줄·shell history에 남으면 안 돼요. 해당 서비스의 대화형 입력이나 env 설정 화면을 사용하세요.',
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const psKnown = rules.shell_rules.powershell_dangerous_deny;
|
|
331
|
+
if (isPowershellCommand(command, psKnown)) {
|
|
332
|
+
return analyzePowershell(command, rules);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const tokenized = tokenizeShell(command);
|
|
336
|
+
if (!tokenized.supported) return null; // unparseable grammar → no opinion (delegate to CC native)
|
|
337
|
+
|
|
338
|
+
const argv = tokenized.argv;
|
|
339
|
+
const base = baseName(argv[0]);
|
|
340
|
+
|
|
341
|
+
if (base === 'rm') return checkRm(argv, cwd);
|
|
342
|
+
if (rules.shell_rules.dangerous_commands_deny.includes(base)) {
|
|
343
|
+
return deny(`'${base}'는 디스크·파일을 복구 불가능하게 파괴할 수 있어 이 플러그인에서는 차단해요.`);
|
|
344
|
+
}
|
|
345
|
+
if (base === 'git') return checkGit(argv, cwd, rules, extraProtected);
|
|
346
|
+
if (Object.prototype.hasOwnProperty.call(rules.deploy_clis, base)) {
|
|
347
|
+
return checkDeployCli(base, argv, cwd, rules);
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---- MCP pipeline (public) ----
|
|
353
|
+
|
|
354
|
+
export function analyzeMcp(toolName, toolInput, rules) {
|
|
355
|
+
const parts = toolName.split('__');
|
|
356
|
+
const bareName = (parts[2] ?? parts[parts.length - 1] ?? '').toLowerCase();
|
|
357
|
+
const destructive = rules.mcp_rules.destructive_name_patterns.some((p) => bareName.includes(p));
|
|
358
|
+
if (!destructive) return null;
|
|
359
|
+
|
|
360
|
+
const target = classifyTargetFromValues(toolInput);
|
|
361
|
+
const effective = target === 'preview' ? 'staging' : target;
|
|
362
|
+
const action = rules.mcp_rules.action[effective] ?? 'deny';
|
|
363
|
+
if (action === 'deny') {
|
|
364
|
+
return deny(
|
|
365
|
+
`외부 도구 '${toolName}' — 대상 환경: ${describeTarget(effective)}. ` +
|
|
366
|
+
`원격 리소스를 삭제·초기화하는 작업은 ${describeTarget(effective)}에서 차단해요. 필요하면 서비스 콘솔에서 직접 실행하세요.`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
return null; // non-production destructive MCP is delegated to Claude Code's native prompt
|
|
370
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Known-secret scanner shared by the PreToolUse gate and PostToolUse redaction.
|
|
2
|
+
// Candidate bytes are inspected in process memory only; raw matches are never
|
|
3
|
+
// returned, logged, or persisted — callers only see pattern ids and positions.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
|
|
10
|
+
export function pluginRoot(env = process.env) {
|
|
11
|
+
const fromEnv = env.CLAUDE_PLUGIN_ROOT;
|
|
12
|
+
if (fromEnv && fromEnv.trim() !== '') return fromEnv;
|
|
13
|
+
return path.resolve(HERE, '..', '..');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let cachedRules = null;
|
|
17
|
+
export function loadSafetyRules(env = process.env) {
|
|
18
|
+
if (cachedRules) return cachedRules;
|
|
19
|
+
const file = path.join(pluginRoot(env), 'config', 'safety-rules.json');
|
|
20
|
+
cachedRules = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
21
|
+
return cachedRules;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function compilePattern(spec) {
|
|
25
|
+
const inlineCaseInsensitive = spec.pattern.startsWith('(?i)');
|
|
26
|
+
const source = spec.pattern.replace(/^\(\?i\)/, '');
|
|
27
|
+
return {
|
|
28
|
+
id: spec.id,
|
|
29
|
+
description: spec.description,
|
|
30
|
+
entropyCheck: spec.entropy_check === true,
|
|
31
|
+
regex: new RegExp(source, inlineCaseInsensitive ? 'gi' : 'g'),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let cachedCompiled = null;
|
|
36
|
+
export function compiledSecretPatterns(rules = loadSafetyRules()) {
|
|
37
|
+
if (cachedCompiled) return cachedCompiled;
|
|
38
|
+
cachedCompiled = rules.secret_patterns.map(compilePattern);
|
|
39
|
+
return cachedCompiled;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function shannonEntropy(str) {
|
|
43
|
+
if (!str || str.length === 0) return 0;
|
|
44
|
+
const freq = new Map();
|
|
45
|
+
for (const ch of str) freq.set(ch, (freq.get(ch) ?? 0) + 1);
|
|
46
|
+
let entropy = 0;
|
|
47
|
+
for (const count of freq.values()) {
|
|
48
|
+
const p = count / str.length;
|
|
49
|
+
entropy -= p * Math.log2(p);
|
|
50
|
+
}
|
|
51
|
+
return entropy;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let cachedPlaceholders = null;
|
|
55
|
+
function placeholderRegexes(rules) {
|
|
56
|
+
if (!cachedPlaceholders) {
|
|
57
|
+
cachedPlaceholders = rules.entropy.placeholder_allowlist.map((p) => {
|
|
58
|
+
const inlineCaseInsensitive = p.startsWith('(?i)');
|
|
59
|
+
return new RegExp(p.replace(/^\(\?i\)/, ''), inlineCaseInsensitive ? 'i' : '');
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return cachedPlaceholders;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isPlaceholderValue(value, rules = loadSafetyRules()) {
|
|
66
|
+
return placeholderRegexes(rules).some((re) => re.test(value));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Scan text for known secret patterns.
|
|
70
|
+
// Returns findings without the matched secret content: [{ id, index, length }].
|
|
71
|
+
export function scanText(text, rules = loadSafetyRules()) {
|
|
72
|
+
if (typeof text !== 'string' || text.length === 0) return [];
|
|
73
|
+
const findings = [];
|
|
74
|
+
for (const pattern of compiledSecretPatterns(rules)) {
|
|
75
|
+
pattern.regex.lastIndex = 0;
|
|
76
|
+
let match;
|
|
77
|
+
while ((match = pattern.regex.exec(text)) !== null) {
|
|
78
|
+
const candidate = match[1] ?? match[0];
|
|
79
|
+
if (pattern.entropyCheck) {
|
|
80
|
+
if (candidate.length < rules.entropy.min_length) continue;
|
|
81
|
+
if (isPlaceholderValue(candidate, rules)) continue;
|
|
82
|
+
if (shannonEntropy(candidate) < rules.entropy.shannon_threshold) continue;
|
|
83
|
+
}
|
|
84
|
+
findings.push({ id: pattern.id, index: match.index, length: match[0].length });
|
|
85
|
+
if (match.index === pattern.regex.lastIndex) pattern.regex.lastIndex++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return findings.sort((a, b) => a.index - b.index);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Replace every finding with a marker. Returns redacted text and finding ids only.
|
|
92
|
+
export function redactText(text, rules = loadSafetyRules()) {
|
|
93
|
+
const findings = scanText(text, rules);
|
|
94
|
+
if (findings.length === 0) return { text, count: 0, ids: [] };
|
|
95
|
+
let out = '';
|
|
96
|
+
let cursor = 0;
|
|
97
|
+
for (const f of findings) {
|
|
98
|
+
if (f.index < cursor) continue; // overlapping match already covered
|
|
99
|
+
out += text.slice(cursor, f.index) + `[REDACTED:${f.id}]`;
|
|
100
|
+
cursor = f.index + f.length;
|
|
101
|
+
}
|
|
102
|
+
out += text.slice(cursor);
|
|
103
|
+
return { text: out, count: findings.length, ids: [...new Set(findings.map((f) => f.id))] };
|
|
104
|
+
}
|