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,256 @@
|
|
|
1
|
+
// State layer SSOT for the novice plugin.
|
|
2
|
+
// All persistent state lives under CLAUDE_PLUGIN_DATA — never under CLAUDE_PLUGIN_ROOT.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
export const STATE_FILE_MAX_BYTES = 256 * 1024;
|
|
10
|
+
export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
export const BUILTIN_DEFAULTS = Object.freeze({ level: 1, enabled: true });
|
|
13
|
+
|
|
14
|
+
export function dataDir(env = process.env) {
|
|
15
|
+
const fromEnv = env.CLAUDE_PLUGIN_DATA;
|
|
16
|
+
if (fromEnv && fromEnv.trim() !== '') return fromEnv;
|
|
17
|
+
// Fallback keeps state out of the plugin root even on older runtimes.
|
|
18
|
+
return path.join(os.homedir(), '.claude', 'novice-plugin-data');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function projectKey(cwd = process.cwd()) {
|
|
22
|
+
let canonical;
|
|
23
|
+
try {
|
|
24
|
+
canonical = execFileSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], {
|
|
25
|
+
encoding: 'utf8',
|
|
26
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
27
|
+
timeout: 3000,
|
|
28
|
+
}).trim();
|
|
29
|
+
} catch {
|
|
30
|
+
canonical = null;
|
|
31
|
+
}
|
|
32
|
+
if (!canonical) {
|
|
33
|
+
try {
|
|
34
|
+
canonical = fs.realpathSync(cwd);
|
|
35
|
+
} catch {
|
|
36
|
+
canonical = path.resolve(cwd);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return crypto.createHash('sha256').update(canonical).digest('hex');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function assertSafeId(id, label) {
|
|
43
|
+
if (typeof id !== 'string' || id === '' || !/^[A-Za-z0-9_.-]+$/.test(id) || id.includes('..')) {
|
|
44
|
+
throw new Error(`unsafe ${label}: ${String(id)}`);
|
|
45
|
+
}
|
|
46
|
+
return id;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function projectOverridePath(key, env = process.env) {
|
|
50
|
+
return path.join(dataDir(env), 'projects', `${assertSafeId(key, 'project key')}.json`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function sessionDir(sessionId, env = process.env) {
|
|
54
|
+
return path.join(dataDir(env), 'sessions', assertSafeId(sessionId, 'session id'));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function sessionStatePath(sessionId, env = process.env) {
|
|
58
|
+
return path.join(sessionDir(sessionId, env), 'state.json');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function sessionEventsDir(sessionId, env = process.env) {
|
|
62
|
+
return path.join(sessionDir(sessionId, env), 'events');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isSymlink(file) {
|
|
66
|
+
try {
|
|
67
|
+
return fs.lstatSync(file).isSymbolicLink();
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Safe read: symlink targets, oversized files, and corrupt JSON all return the fallback.
|
|
74
|
+
export function readJsonSafe(file, fallback = null, maxBytes = STATE_FILE_MAX_BYTES) {
|
|
75
|
+
try {
|
|
76
|
+
if (isSymlink(file)) return fallback;
|
|
77
|
+
const st = fs.statSync(file);
|
|
78
|
+
if (!st.isFile() || st.size > maxBytes) return fallback;
|
|
79
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
80
|
+
} catch {
|
|
81
|
+
return fallback;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Atomic write: temp file in the same directory + rename, mode 0600, symlink refusal, size cap.
|
|
86
|
+
export function writeJsonAtomic(file, obj, maxBytes = STATE_FILE_MAX_BYTES) {
|
|
87
|
+
const payload = JSON.stringify(obj, null, 2);
|
|
88
|
+
if (Buffer.byteLength(payload, 'utf8') > maxBytes) {
|
|
89
|
+
throw new Error(`state payload exceeds size cap: ${file}`);
|
|
90
|
+
}
|
|
91
|
+
if (isSymlink(file)) {
|
|
92
|
+
throw new Error(`refusing to write through symlink: ${file}`);
|
|
93
|
+
}
|
|
94
|
+
const dir = path.dirname(file);
|
|
95
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
96
|
+
if (isSymlink(dir)) {
|
|
97
|
+
throw new Error(`refusing to write into symlinked directory: ${dir}`);
|
|
98
|
+
}
|
|
99
|
+
const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
100
|
+
fs.writeFileSync(tmp, payload, { mode: 0o600 });
|
|
101
|
+
fs.renameSync(tmp, file);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Atomic create for per-tool event files: fails if the file already exists (wx flag).
|
|
105
|
+
export function writeJsonExclusive(file, obj, maxBytes = STATE_FILE_MAX_BYTES) {
|
|
106
|
+
const payload = JSON.stringify(obj);
|
|
107
|
+
if (Buffer.byteLength(payload, 'utf8') > maxBytes) {
|
|
108
|
+
throw new Error(`event payload exceeds size cap: ${file}`);
|
|
109
|
+
}
|
|
110
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
111
|
+
fs.writeFileSync(file, payload, { mode: 0o600, flag: 'wx' });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- project mode config ----
|
|
115
|
+
|
|
116
|
+
function userConfigDefaults(env = process.env) {
|
|
117
|
+
// Claude Code exposes plugin userConfig values to command hooks via env.
|
|
118
|
+
const raw = env.CLAUDE_PLUGIN_CONFIG;
|
|
119
|
+
if (!raw) return {};
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(raw);
|
|
122
|
+
const out = {};
|
|
123
|
+
if (parsed.default_level !== undefined) {
|
|
124
|
+
const lvl = Number(parsed.default_level);
|
|
125
|
+
if ([1, 2, 3].includes(lvl)) out.level = lvl;
|
|
126
|
+
}
|
|
127
|
+
if (typeof parsed.novice_enabled === 'boolean') out.enabled = parsed.novice_enabled;
|
|
128
|
+
return out;
|
|
129
|
+
} catch {
|
|
130
|
+
return {};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Precedence: project override in CLAUDE_PLUGIN_DATA → userConfig default → built-in Level 1.
|
|
135
|
+
export function getProjectConfig(cwd = process.cwd(), env = process.env) {
|
|
136
|
+
const key = projectKey(cwd);
|
|
137
|
+
const override = readJsonSafe(projectOverridePath(key, env), {});
|
|
138
|
+
const user = userConfigDefaults(env);
|
|
139
|
+
const merged = { ...BUILTIN_DEFAULTS, ...user, ...sanitizeOverride(override) };
|
|
140
|
+
return {
|
|
141
|
+
key,
|
|
142
|
+
level: merged.level,
|
|
143
|
+
enabled: merged.enabled,
|
|
144
|
+
protected_branches_extra: merged.protected_branches_extra ?? [],
|
|
145
|
+
muted_terms: merged.muted_terms ?? [],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function sanitizeOverride(override) {
|
|
150
|
+
const out = {};
|
|
151
|
+
if (override && typeof override === 'object') {
|
|
152
|
+
if ([1, 2, 3].includes(Number(override.level))) out.level = Number(override.level);
|
|
153
|
+
if (typeof override.enabled === 'boolean') out.enabled = override.enabled;
|
|
154
|
+
if (Array.isArray(override.protected_branches_extra)) {
|
|
155
|
+
out.protected_branches_extra = override.protected_branches_extra.filter((b) => typeof b === 'string');
|
|
156
|
+
}
|
|
157
|
+
if (Array.isArray(override.muted_terms)) {
|
|
158
|
+
out.muted_terms = override.muted_terms.filter((t) => typeof t === 'string');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function setProjectMode(cwd, mode, env = process.env) {
|
|
165
|
+
const key = projectKey(cwd);
|
|
166
|
+
const file = projectOverridePath(key, env);
|
|
167
|
+
const current = sanitizeOverride(readJsonSafe(file, {}));
|
|
168
|
+
let next;
|
|
169
|
+
if (mode === 'off') {
|
|
170
|
+
next = { ...current, enabled: false };
|
|
171
|
+
} else {
|
|
172
|
+
const lvl = Number(mode);
|
|
173
|
+
if (![1, 2, 3].includes(lvl)) throw new Error(`invalid mode: ${mode}`);
|
|
174
|
+
next = { ...current, enabled: true, level: lvl };
|
|
175
|
+
}
|
|
176
|
+
next.updated_at = new Date().toISOString();
|
|
177
|
+
writeJsonAtomic(file, next);
|
|
178
|
+
return next;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Project-scoped mute list (persists across sessions, unlike per-session term counters).
|
|
182
|
+
function updateMutedTerms(cwd, env, mutate) {
|
|
183
|
+
const key = projectKey(cwd);
|
|
184
|
+
const file = projectOverridePath(key, env);
|
|
185
|
+
const current = sanitizeOverride(readJsonSafe(file, {}));
|
|
186
|
+
const set = new Set(current.muted_terms || []);
|
|
187
|
+
mutate(set);
|
|
188
|
+
const next = { ...current, muted_terms: [...set], updated_at: new Date().toISOString() };
|
|
189
|
+
writeJsonAtomic(file, next);
|
|
190
|
+
return next;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function muteProjectTerm(cwd, term, env = process.env) {
|
|
194
|
+
return updateMutedTerms(cwd, env, (set) => set.add(term));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function unmuteProjectTerm(cwd, term, env = process.env) {
|
|
198
|
+
return updateMutedTerms(cwd, env, (set) => set.delete(term));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---- session state ----
|
|
202
|
+
|
|
203
|
+
export function defaultSessionState() {
|
|
204
|
+
return {
|
|
205
|
+
schema_version: 1,
|
|
206
|
+
term_counts: {},
|
|
207
|
+
reset_terms: [],
|
|
208
|
+
last_message_hash: null,
|
|
209
|
+
capsule_revision: null,
|
|
210
|
+
glossary_revision: null,
|
|
211
|
+
skip_next_submit: false,
|
|
212
|
+
off_tombstone_emitted: false,
|
|
213
|
+
updated_at: null,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function loadSession(sessionId, env = process.env) {
|
|
218
|
+
const state = readJsonSafe(sessionStatePath(sessionId, env), null);
|
|
219
|
+
if (!state || typeof state !== 'object' || state.schema_version !== 1) {
|
|
220
|
+
return defaultSessionState();
|
|
221
|
+
}
|
|
222
|
+
return { ...defaultSessionState(), ...state };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function saveSession(sessionId, state, env = process.env) {
|
|
226
|
+
state.updated_at = new Date().toISOString();
|
|
227
|
+
writeJsonAtomic(sessionStatePath(sessionId, env), state);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function deleteSession(sessionId, env = process.env) {
|
|
231
|
+
try {
|
|
232
|
+
fs.rmSync(sessionDir(sessionId, env), { recursive: true, force: true });
|
|
233
|
+
} catch {
|
|
234
|
+
// best-effort cleanup
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function cleanupExpiredSessions(env = process.env, ttlMs = SESSION_TTL_MS, now = Date.now()) {
|
|
239
|
+
const root = path.join(dataDir(env), 'sessions');
|
|
240
|
+
let entries;
|
|
241
|
+
try {
|
|
242
|
+
entries = fs.readdirSync(root);
|
|
243
|
+
} catch {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
for (const entry of entries) {
|
|
247
|
+
const dir = path.join(root, entry);
|
|
248
|
+
try {
|
|
249
|
+
const st = fs.lstatSync(dir);
|
|
250
|
+
if (!st.isDirectory()) continue;
|
|
251
|
+
if (now - st.mtimeMs > ttlMs) fs.rmSync(dir, { recursive: true, force: true });
|
|
252
|
+
} catch {
|
|
253
|
+
// skip entries we cannot stat
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PostToolBatch: single writer over the per-tool event files. Aggregates
|
|
3
|
+
// success/failure fingerprints into session state, deletes processed events,
|
|
4
|
+
// and injects AT MOST ONE novice intervention per batch when the same work
|
|
5
|
+
// keeps failing past the level's threshold. novice off → aggregation still
|
|
6
|
+
// happens, intervention text does not.
|
|
7
|
+
// Observation hook: fail open (exit 0) on any internal error.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { readStdinJson, emitAdditionalContext, failOpen } from './lib/hookio.js';
|
|
11
|
+
import { getProjectConfig, loadSession, saveSession, sessionEventsDir, readJsonSafe } from './lib/state.js';
|
|
12
|
+
import { loadLevels } from './lib/capsule.js';
|
|
13
|
+
|
|
14
|
+
const MAX_TRACKED_FINGERPRINTS = 50;
|
|
15
|
+
|
|
16
|
+
function readAndClearEvents(sessionId) {
|
|
17
|
+
const dir = sessionEventsDir(sessionId);
|
|
18
|
+
let names;
|
|
19
|
+
try {
|
|
20
|
+
names = fs.readdirSync(dir);
|
|
21
|
+
} catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
const events = [];
|
|
25
|
+
for (const name of names) {
|
|
26
|
+
if (!name.endsWith('.json')) continue;
|
|
27
|
+
const file = path.join(dir, name);
|
|
28
|
+
const event = readJsonSafe(file, null);
|
|
29
|
+
if (event && typeof event.fingerprint === 'string') events.push(event);
|
|
30
|
+
try {
|
|
31
|
+
fs.rmSync(file, { force: true });
|
|
32
|
+
} catch {
|
|
33
|
+
// best-effort deletion; a leftover file is re-aggregated next batch at worst
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return events;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function aggregate(session, events) {
|
|
40
|
+
const stats = session.loop_stats && typeof session.loop_stats === 'object' ? session.loop_stats : {};
|
|
41
|
+
for (const event of events) {
|
|
42
|
+
const entry = stats[event.fingerprint] ?? { count: 0, failures: 0, intervened_at_failures: 0 };
|
|
43
|
+
entry.count += 1;
|
|
44
|
+
if (event.status === 'failure') entry.failures += 1;
|
|
45
|
+
entry.last_ts = event.ts ?? Date.now();
|
|
46
|
+
stats[event.fingerprint] = entry;
|
|
47
|
+
}
|
|
48
|
+
// Cap stored fingerprints: drop oldest by last_ts.
|
|
49
|
+
const keys = Object.keys(stats);
|
|
50
|
+
if (keys.length > MAX_TRACKED_FINGERPRINTS) {
|
|
51
|
+
keys
|
|
52
|
+
.sort((a, b) => (stats[a].last_ts ?? 0) - (stats[b].last_ts ?? 0))
|
|
53
|
+
.slice(0, keys.length - MAX_TRACKED_FINGERPRINTS)
|
|
54
|
+
.forEach((k) => delete stats[k]);
|
|
55
|
+
}
|
|
56
|
+
session.loop_stats = stats;
|
|
57
|
+
return stats;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Pick at most one fingerprint that crossed the threshold since its last intervention.
|
|
61
|
+
function pickIntervention(stats, threshold) {
|
|
62
|
+
let best = null;
|
|
63
|
+
for (const [fingerprint, entry] of Object.entries(stats)) {
|
|
64
|
+
const newFailures = entry.failures - (entry.intervened_at_failures ?? 0);
|
|
65
|
+
if (newFailures >= threshold && (!best || entry.failures > best.entry.failures)) {
|
|
66
|
+
best = { fingerprint, entry };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return best;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function main() {
|
|
73
|
+
const input = readStdinJson();
|
|
74
|
+
const sessionId = input.session_id;
|
|
75
|
+
const cwd = input.cwd || process.cwd();
|
|
76
|
+
if (typeof sessionId !== 'string' || sessionId === '') return;
|
|
77
|
+
|
|
78
|
+
const session = loadSession(sessionId);
|
|
79
|
+
const events = readAndClearEvents(sessionId);
|
|
80
|
+
if (events.length === 0) return;
|
|
81
|
+
|
|
82
|
+
const stats = aggregate(session, events);
|
|
83
|
+
const config = getProjectConfig(cwd);
|
|
84
|
+
|
|
85
|
+
if (config.enabled) {
|
|
86
|
+
const levels = loadLevels();
|
|
87
|
+
const threshold = levels.levels[String(config.level)]?.repeat_failure_threshold ?? 3;
|
|
88
|
+
const target = pickIntervention(stats, threshold);
|
|
89
|
+
if (target) {
|
|
90
|
+
target.entry.intervened_at_failures = target.entry.failures;
|
|
91
|
+
emitAdditionalContext(
|
|
92
|
+
'PostToolBatch',
|
|
93
|
+
`[novice] 같은 작업이 ${target.entry.failures}회 실패했어요. 같은 방법을 반복하면 시간과 사용량만 늘어날 수 있어요. ` +
|
|
94
|
+
'다음 중 하나를 권해요: (1) 마지막 오류 메시지를 사용자에게 그대로 보여 주고 원인을 함께 확인, ' +
|
|
95
|
+
'(2) 접근 방법을 바꿔 다시 시도, (3) 지금까지 시도한 내용을 정리하고 사용자 확인을 받기.',
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
saveSession(sessionId, session);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
main();
|
|
105
|
+
process.exit(0);
|
|
106
|
+
} catch {
|
|
107
|
+
failOpen();
|
|
108
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PostToolUseFailure: records a failure fingerprint event for the batch aggregator.
|
|
3
|
+
// Stores only a hash fingerprint and a coarse error class — never the raw error,
|
|
4
|
+
// argv, or output. Atomic-create per tool_use_id; PostToolBatch is the single
|
|
5
|
+
// writer that aggregates and deletes these events.
|
|
6
|
+
// Observation hook: fail open (exit 0) on any internal error.
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { readStdinJson, failOpen } from './lib/hookio.js';
|
|
9
|
+
import { sessionEventsDir, writeJsonExclusive } from './lib/state.js';
|
|
10
|
+
import { computeFingerprint, TOOL_USE_ID_RE } from './lib/fingerprint.js';
|
|
11
|
+
|
|
12
|
+
// Coarse classification only — the raw error text is never persisted.
|
|
13
|
+
function classifyError(input) {
|
|
14
|
+
const text = [input.error, input.tool_response && JSON.stringify(input.tool_response)]
|
|
15
|
+
.filter((x) => typeof x === 'string')
|
|
16
|
+
.join(' ')
|
|
17
|
+
.toLowerCase();
|
|
18
|
+
if (/timed?[ _-]?out/.test(text)) return 'timeout';
|
|
19
|
+
if (/(enoent|not found|no such file|command not found|404)/.test(text)) return 'not_found';
|
|
20
|
+
if (/(eacces|eperm|permission|denied|401|403)/.test(text)) return 'permission';
|
|
21
|
+
if (/(syntaxerror|parse error|unexpected token|invalid syntax)/.test(text)) return 'syntax';
|
|
22
|
+
return 'other';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function main() {
|
|
26
|
+
const input = readStdinJson();
|
|
27
|
+
const { session_id: sessionId, tool_name: toolName, tool_input: toolInput, tool_use_id: toolUseId } = input;
|
|
28
|
+
if (typeof sessionId !== 'string' || sessionId === '') return;
|
|
29
|
+
if (typeof toolUseId !== 'string' || !TOOL_USE_ID_RE.test(toolUseId)) return;
|
|
30
|
+
|
|
31
|
+
const file = path.join(sessionEventsDir(sessionId), `${toolUseId}.json`);
|
|
32
|
+
writeJsonExclusive(file, {
|
|
33
|
+
schema_version: 1,
|
|
34
|
+
tool_use_id: toolUseId,
|
|
35
|
+
tool_name: toolName,
|
|
36
|
+
status: 'failure',
|
|
37
|
+
error_class: classifyError(input),
|
|
38
|
+
fingerprint: computeFingerprint(toolName, toolInput),
|
|
39
|
+
ts: Date.now(),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
main();
|
|
45
|
+
process.exit(0);
|
|
46
|
+
} catch {
|
|
47
|
+
failOpen();
|
|
48
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PostToolUse: redacts known secret patterns out of Bash/MCP tool output before it
|
|
3
|
+
// reaches the model, and records a success fingerprint event for cost/repeat-loop
|
|
4
|
+
// detection. This hook never writes shared session state directly — it only
|
|
5
|
+
// atomic-creates an event file that PostToolBatch later aggregates and deletes.
|
|
6
|
+
// It fails open on any internal error: it must never block the tool call.
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { readStdinJson, emit, failOpen } from './lib/hookio.js';
|
|
9
|
+
import { sessionEventsDir, writeJsonExclusive } from './lib/state.js';
|
|
10
|
+
import { scanText, redactText } from './lib/secrets.js';
|
|
11
|
+
import { computeFingerprint, TOOL_USE_ID_RE } from './lib/fingerprint.js';
|
|
12
|
+
|
|
13
|
+
const FULL_REDACTION_FALLBACK = '[novice: 출력에 비밀값 패턴이 감지되어 전체를 가렸습니다]';
|
|
14
|
+
|
|
15
|
+
// Extract candidate text fields from the tool_response shapes the harness uses:
|
|
16
|
+
// a bare string, {output}, {stdout}/{stderr}, or MCP-style {content:[{type:"text",text}]}.
|
|
17
|
+
// path === null means the whole response IS the text (bare string case).
|
|
18
|
+
function extractTextFields(toolResponse) {
|
|
19
|
+
const fields = [];
|
|
20
|
+
if (typeof toolResponse === 'string') {
|
|
21
|
+
fields.push({ path: null, value: toolResponse });
|
|
22
|
+
return fields;
|
|
23
|
+
}
|
|
24
|
+
if (toolResponse && typeof toolResponse === 'object' && !Array.isArray(toolResponse)) {
|
|
25
|
+
if (typeof toolResponse.output === 'string') fields.push({ path: ['output'], value: toolResponse.output });
|
|
26
|
+
if (typeof toolResponse.stdout === 'string') fields.push({ path: ['stdout'], value: toolResponse.stdout });
|
|
27
|
+
if (typeof toolResponse.stderr === 'string') fields.push({ path: ['stderr'], value: toolResponse.stderr });
|
|
28
|
+
if (Array.isArray(toolResponse.content)) {
|
|
29
|
+
toolResponse.content.forEach((item, idx) => {
|
|
30
|
+
if (item && typeof item === 'object' && item.type === 'text' && typeof item.text === 'string') {
|
|
31
|
+
fields.push({ path: ['content', idx], value: item.text });
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return fields;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Rebuild tool_response with each text field redacted, preserving the original
|
|
40
|
+
// structure as closely as possible (string in -> string out, object in -> same
|
|
41
|
+
// object shape with text fields replaced).
|
|
42
|
+
function applyRedactedFields(toolResponse, fields) {
|
|
43
|
+
if (fields.length === 1 && fields[0].path === null) {
|
|
44
|
+
return redactText(fields[0].value).text;
|
|
45
|
+
}
|
|
46
|
+
const clone = { ...toolResponse };
|
|
47
|
+
if (Array.isArray(toolResponse.content)) clone.content = toolResponse.content.slice();
|
|
48
|
+
for (const f of fields) {
|
|
49
|
+
const redacted = redactText(f.value).text;
|
|
50
|
+
if (f.path[0] === 'content') {
|
|
51
|
+
const idx = f.path[1];
|
|
52
|
+
clone.content[idx] = { ...clone.content[idx], text: redacted };
|
|
53
|
+
} else {
|
|
54
|
+
clone[f.path[0]] = redacted;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return clone;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function main() {
|
|
61
|
+
let input;
|
|
62
|
+
try {
|
|
63
|
+
input = readStdinJson();
|
|
64
|
+
} catch {
|
|
65
|
+
failOpen();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// (a) Redaction — only touches output text, never logs the original anywhere.
|
|
70
|
+
try {
|
|
71
|
+
const fields = extractTextFields(input.tool_response);
|
|
72
|
+
const scanView = fields.map((f) => f.value).join('\n');
|
|
73
|
+
if (scanView.length > 0) {
|
|
74
|
+
const findings = scanText(scanView);
|
|
75
|
+
if (findings.length > 0) {
|
|
76
|
+
let updatedToolOutput;
|
|
77
|
+
try {
|
|
78
|
+
updatedToolOutput = applyRedactedFields(input.tool_response, fields);
|
|
79
|
+
} catch {
|
|
80
|
+
// Secrets were confirmed present (pre-scan found candidates) but
|
|
81
|
+
// structured redaction failed — never let the original leak through.
|
|
82
|
+
updatedToolOutput = FULL_REDACTION_FALLBACK;
|
|
83
|
+
}
|
|
84
|
+
emit({ hookSpecificOutput: { hookEventName: 'PostToolUse', updatedToolOutput } });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// Could not even determine whether secrets are present (extraction/scan
|
|
89
|
+
// itself failed). No pre-scan hit was confirmed, so fail open silently.
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// (b) Fingerprint event — best-effort, independent of the redaction outcome.
|
|
93
|
+
try {
|
|
94
|
+
const { session_id: sessionId, tool_name: toolName, tool_input: toolInput, tool_use_id: toolUseId } = input;
|
|
95
|
+
if (typeof sessionId === 'string' && sessionId !== '' && typeof toolUseId === 'string' && TOOL_USE_ID_RE.test(toolUseId)) {
|
|
96
|
+
const file = path.join(sessionEventsDir(sessionId), `${toolUseId}.json`);
|
|
97
|
+
writeJsonExclusive(file, {
|
|
98
|
+
schema_version: 1,
|
|
99
|
+
tool_use_id: toolUseId,
|
|
100
|
+
tool_name: toolName,
|
|
101
|
+
status: 'success',
|
|
102
|
+
fingerprint: computeFingerprint(toolName, toolInput),
|
|
103
|
+
ts: Date.now(),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// EEXIST (duplicate delivery) or any other issue — event write is best-effort
|
|
108
|
+
// observation and must never crash or block the tool.
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
main();
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PreToolUse safety gate: destructive commands, git history damage, secret
|
|
3
|
+
// commit/deploy, and destructive MCP tools. Runs regardless of novice level/off
|
|
4
|
+
// while the plugin is enabled.
|
|
5
|
+
//
|
|
6
|
+
// This hook is a thin wrapper: stdin/stdout I/O + fail-closed behavior. The
|
|
7
|
+
// analysis lives in scripts/lib/safety.js (Bash/PowerShell grammar, git subgrammar,
|
|
8
|
+
// secret scan, target classification).
|
|
9
|
+
//
|
|
10
|
+
// Deny-only: safety.js emits a deny decision solely for positively-identified
|
|
11
|
+
// catastrophic actions and exposed secret values; everything else (including any
|
|
12
|
+
// command it cannot fully parse) gets no opinion and is delegated to Claude Code's
|
|
13
|
+
// native permission prompt. There is no "ask" tier.
|
|
14
|
+
//
|
|
15
|
+
// FAIL CLOSED: invalid JSON stdin, internal exceptions, and input-cap overflows
|
|
16
|
+
// deny the tool call (exit 2 or an explicit deny decision).
|
|
17
|
+
import { readStdinJson, emitPreToolDecision, failClosed } from './lib/hookio.js';
|
|
18
|
+
import { getProjectConfig } from './lib/state.js';
|
|
19
|
+
import { loadSafetyRules } from './lib/secrets.js';
|
|
20
|
+
import { analyzeBash, analyzeMcp } from './lib/safety.js';
|
|
21
|
+
|
|
22
|
+
function main() {
|
|
23
|
+
let input;
|
|
24
|
+
try {
|
|
25
|
+
input = readStdinJson();
|
|
26
|
+
} catch (err) {
|
|
27
|
+
failClosed(`novice safety gate: invalid hook input (${err.code ?? 'parse error'})`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const rules = loadSafetyRules();
|
|
32
|
+
const cwd = typeof input.cwd === 'string' && input.cwd !== '' ? input.cwd : process.cwd();
|
|
33
|
+
const toolName = input.tool_name;
|
|
34
|
+
|
|
35
|
+
let verdict = null;
|
|
36
|
+
if (toolName === 'Bash') {
|
|
37
|
+
let extraProtected = [];
|
|
38
|
+
try {
|
|
39
|
+
extraProtected = getProjectConfig(cwd).protected_branches_extra;
|
|
40
|
+
} catch {
|
|
41
|
+
// config unavailable → builtin protected branches only
|
|
42
|
+
}
|
|
43
|
+
verdict = analyzeBash(input.tool_input?.command, cwd, rules, extraProtected);
|
|
44
|
+
} else if (typeof toolName === 'string' && /^mcp__/.test(toolName)) {
|
|
45
|
+
verdict = analyzeMcp(toolName, input.tool_input ?? {}, rules);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (verdict) {
|
|
49
|
+
emitPreToolDecision(verdict.decision, verdict.reason);
|
|
50
|
+
}
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
main();
|
|
56
|
+
} catch (err) {
|
|
57
|
+
failClosed(`novice safety gate: internal error (${err?.message ?? 'unknown'})`);
|
|
58
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SessionEnd: session cache lifecycle. `/clear` deletes the session state;
|
|
3
|
+
// resumable sessions are kept until the 30-day TTL sweep.
|
|
4
|
+
// Learning hook: fail open (exit 0, no output) on any internal error.
|
|
5
|
+
import { readStdinJson, failOpen } from './lib/hookio.js';
|
|
6
|
+
import { deleteSession, cleanupExpiredSessions } from './lib/state.js';
|
|
7
|
+
|
|
8
|
+
function main() {
|
|
9
|
+
const input = readStdinJson();
|
|
10
|
+
if (input.reason === 'clear' && typeof input.session_id === 'string' && input.session_id !== '') {
|
|
11
|
+
deleteSession(input.session_id);
|
|
12
|
+
}
|
|
13
|
+
cleanupExpiredSessions();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
main();
|
|
18
|
+
process.exit(0);
|
|
19
|
+
} catch {
|
|
20
|
+
failOpen();
|
|
21
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SessionStart: restore the current novice state after startup/resume/clear/compact.
|
|
3
|
+
// Active project → inject one capsule + glossary and prime skip_next_submit so the
|
|
4
|
+
// immediately following UserPromptSubmit does not duplicate the same capsule_revision.
|
|
5
|
+
// Off project that previously injected capsules → inject one OFF tombstone.
|
|
6
|
+
// Learning hook: fail open (exit 0, no output) on any internal error.
|
|
7
|
+
import { readStdinJson, emitAdditionalContext, failOpen } from './lib/hookio.js';
|
|
8
|
+
import { getProjectConfig, loadSession, saveSession } from './lib/state.js';
|
|
9
|
+
import { loadTerms, buildGlossary, glossaryRevision, buildTombstone, capsuleForState } from './lib/capsule.js';
|
|
10
|
+
|
|
11
|
+
function main() {
|
|
12
|
+
const input = readStdinJson();
|
|
13
|
+
const sessionId = input.session_id;
|
|
14
|
+
const cwd = input.cwd || process.cwd();
|
|
15
|
+
if (typeof sessionId !== 'string' || sessionId === '') return;
|
|
16
|
+
|
|
17
|
+
const config = getProjectConfig(cwd);
|
|
18
|
+
const session = loadSession(sessionId);
|
|
19
|
+
|
|
20
|
+
if (config.enabled) {
|
|
21
|
+
const { revision, capsule } = capsuleForState(config.level, session, config.muted_terms);
|
|
22
|
+
const terms = loadTerms();
|
|
23
|
+
const glossary = buildGlossary(terms);
|
|
24
|
+
emitAdditionalContext('SessionStart', `${capsule}\n\n${glossary}`);
|
|
25
|
+
session.capsule_revision = revision;
|
|
26
|
+
session.glossary_revision = glossaryRevision(terms);
|
|
27
|
+
session.skip_next_submit = true;
|
|
28
|
+
session.off_tombstone_emitted = false;
|
|
29
|
+
saveSession(sessionId, session);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Disabled: only speak once, and only if a capsule was previously injected this session.
|
|
34
|
+
if (session.capsule_revision != null && session.off_tombstone_emitted !== true) {
|
|
35
|
+
emitAdditionalContext('SessionStart', buildTombstone());
|
|
36
|
+
session.off_tombstone_emitted = true;
|
|
37
|
+
session.capsule_revision = null;
|
|
38
|
+
session.skip_next_submit = false;
|
|
39
|
+
saveSession(sessionId, session);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
main();
|
|
45
|
+
process.exit(0);
|
|
46
|
+
} catch {
|
|
47
|
+
failOpen();
|
|
48
|
+
}
|