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,178 @@
|
|
|
1
|
+
// Learning-seam capsule/glossary builders.
|
|
2
|
+
// Pure string construction over config/terms.json + config/levels.json — no I/O side effects
|
|
3
|
+
// beyond reading the versioned config files, and no network. Callers (SessionStart,
|
|
4
|
+
// UserPromptSubmit, UserPromptExpansion) inject the returned strings as additionalContext.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import { pluginRoot } from './secrets.js';
|
|
9
|
+
|
|
10
|
+
// ---- config loading (cached per resolved plugin root) ----
|
|
11
|
+
|
|
12
|
+
const cache = new Map();
|
|
13
|
+
function readConfig(name, env = process.env) {
|
|
14
|
+
const file = path.join(pluginRoot(env), 'config', name);
|
|
15
|
+
const key = file;
|
|
16
|
+
if (cache.has(key)) return cache.get(key);
|
|
17
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
18
|
+
cache.set(key, parsed);
|
|
19
|
+
return parsed;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadTerms(env = process.env) {
|
|
23
|
+
return readConfig('terms.json', env);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function loadLevels(env = process.env) {
|
|
27
|
+
return readConfig('levels.json', env);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---- level-rule vocabulary (levels.json codes → Korean summary) ----
|
|
31
|
+
|
|
32
|
+
const TERM_RULE = {
|
|
33
|
+
'explain-until-fade': '실제 용어 뒤에 괄호로 한국어 설명을 병기하고, faded 용어는 실제 용어만 쓴다',
|
|
34
|
+
'explain-first-occurrence': '세션 첫 등장 때만 실제 용어 뒤에 설명을 병기하고 이후에는 실제 용어만 쓴다',
|
|
35
|
+
'on-request-only': '요청받을 때만 설명하고 평소에는 실제 용어만 쓴다',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const NARRATION = {
|
|
39
|
+
'before-and-after': '실행 전 무엇을·왜 하는지, 실행 후 무엇이 바뀌었는지 설명한다',
|
|
40
|
+
'key-decisions-only': '핵심 결정만 짧게 해설한다',
|
|
41
|
+
'architecture-and-userflow': '아키텍처와 유저플로우 중심으로 설명한다',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const VIZ = {
|
|
45
|
+
'steps3plus-or-branch-or-risk-or-recovery': '3단계 이상 작업·분기·위험·복구 상황에서 표 또는 체크리스트를 쓴다',
|
|
46
|
+
'major-branches': '중요한 분기에서 표를 쓴다',
|
|
47
|
+
'on-request-or-risk': '요청받거나 위험할 때만 표를 쓴다',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const SUPERSESSION =
|
|
51
|
+
'이 NOVICE_STATE capsule은 이전 turn의 모든 NOVICE_STATE 지시를 대체한다. 다른 과거 level 지시는 무시한다.';
|
|
52
|
+
|
|
53
|
+
// ---- faded counter ----
|
|
54
|
+
|
|
55
|
+
// Term names whose exposure count has reached the fade threshold, minus terms whose
|
|
56
|
+
// counters were explicitly reset. Threshold 0 (level 3) fades every counted term, which
|
|
57
|
+
// expresses "auto-explanations off" — the level rule summary carries the same meaning.
|
|
58
|
+
export function computeFadedTerms(termCounts, fadeThreshold, resetTerms = [], mutedTerms = []) {
|
|
59
|
+
const reset = new Set(resetTerms || []);
|
|
60
|
+
// Muted terms are force-faded regardless of exposure count and beat reset —
|
|
61
|
+
// the user explicitly asked to stop explaining them ("novice mute <term>").
|
|
62
|
+
const out = new Set(mutedTerms || []);
|
|
63
|
+
for (const [term, count] of Object.entries(termCounts || {})) {
|
|
64
|
+
if (out.has(term)) continue;
|
|
65
|
+
if (reset.has(term)) continue;
|
|
66
|
+
if (Number(count) >= fadeThreshold) out.add(term);
|
|
67
|
+
}
|
|
68
|
+
return [...out].sort();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- revisions ----
|
|
72
|
+
|
|
73
|
+
export function capsuleRevision(level, fadedTerms, schemaVersion) {
|
|
74
|
+
const v = schemaVersion ?? loadLevels().schema_version;
|
|
75
|
+
const payload = JSON.stringify({
|
|
76
|
+
v,
|
|
77
|
+
level: String(level),
|
|
78
|
+
faded: [...(fadedTerms || [])].sort(),
|
|
79
|
+
});
|
|
80
|
+
return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 8);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function glossaryRevision(termsJson) {
|
|
84
|
+
const payload = JSON.stringify({
|
|
85
|
+
v: termsJson.schema_version,
|
|
86
|
+
terms: termsJson.terms.map((t) => t.term).sort(),
|
|
87
|
+
});
|
|
88
|
+
return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 8);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---- glossary ----
|
|
92
|
+
|
|
93
|
+
function koreanGloss(term) {
|
|
94
|
+
const hangul = (term.aliases || []).find((a) => /[가-힣]/.test(a));
|
|
95
|
+
return hangul || (term.aliases || [])[0] || '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// One canonical dictionary injection per session (SessionStart). Format per line:
|
|
99
|
+
// - commit(커밋): 현재 변경을 하나의 저장 지점으로 기록하는 것
|
|
100
|
+
export function buildGlossary(termsJson) {
|
|
101
|
+
const version = termsJson.schema_version;
|
|
102
|
+
const header = [
|
|
103
|
+
`NOVICE_GLOSSARY v${version}`,
|
|
104
|
+
'아래는 실제 개발 용어 사전이다. 설명이 활성화된 레벨에서는 실제 용어 뒤에 괄호로 이 설명을 병기하고, 용어 자체를 쉬운 말로 대체하지 않는다.',
|
|
105
|
+
];
|
|
106
|
+
const lines = termsJson.terms.map((t) => {
|
|
107
|
+
const gloss = koreanGloss(t);
|
|
108
|
+
return gloss ? `- ${t.term}(${gloss}): ${t.explanation}` : `- ${t.term}: ${t.explanation}`;
|
|
109
|
+
});
|
|
110
|
+
let out = [...header, ...lines].join('\n');
|
|
111
|
+
const max = loadLevels().glossary_max_chars ?? 5000;
|
|
112
|
+
if (out.length > max) out = out.slice(0, max);
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---- capsule ----
|
|
117
|
+
|
|
118
|
+
function levelRules(level, env) {
|
|
119
|
+
const levels = loadLevels(env);
|
|
120
|
+
const rule = levels.levels[String(level)] || levels.levels['1'];
|
|
121
|
+
return {
|
|
122
|
+
schema: levels.schema_version,
|
|
123
|
+
max: levels.capsule_max_chars ?? 800,
|
|
124
|
+
term: TERM_RULE[rule.term_explanation] ?? rule.term_explanation,
|
|
125
|
+
narration: NARRATION[rule.action_narration] ?? rule.action_narration,
|
|
126
|
+
viz: VIZ[rule.visualization] ?? rule.visualization,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function buildCapsule({ level, fadedTerms, revision }, env = process.env) {
|
|
131
|
+
const r = levelRules(level, env);
|
|
132
|
+
const render = (fadedText) =>
|
|
133
|
+
[
|
|
134
|
+
`[NOVICE_STATE] schema_version:${r.schema} level:${level} rev:${revision}`,
|
|
135
|
+
`용어 병기 규칙: ${r.term}`,
|
|
136
|
+
`행동 해설: ${r.narration}`,
|
|
137
|
+
`시각화 조건: ${r.viz}`,
|
|
138
|
+
`설명 제외(faded) 용어: ${fadedText}`,
|
|
139
|
+
SUPERSESSION,
|
|
140
|
+
].join('\n');
|
|
141
|
+
|
|
142
|
+
const listed = (fadedTerms && fadedTerms.length) ? fadedTerms.join(', ') : '없음';
|
|
143
|
+
let capsule = render(listed);
|
|
144
|
+
if (capsule.length > r.max) {
|
|
145
|
+
// Keep the payload bounded: drop the enumeration, keep the count.
|
|
146
|
+
capsule = render(`${fadedTerms.length}개 (목록 생략)`);
|
|
147
|
+
}
|
|
148
|
+
if (capsule.length > r.max) capsule = capsule.slice(0, r.max);
|
|
149
|
+
return capsule;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---- OFF tombstone ----
|
|
153
|
+
|
|
154
|
+
export function buildTombstone(env = process.env) {
|
|
155
|
+
const max = loadLevels(env).tombstone_max_chars ?? 300;
|
|
156
|
+
const text =
|
|
157
|
+
'NOVICE_STATE: OFF\n' +
|
|
158
|
+
'novice가 꺼졌다. 이전 turn의 모든 NOVICE_STATE 지시와 NOVICE_GLOSSARY 용어 사전 지시를 무시한다. ' +
|
|
159
|
+
'지금부터 novice 톤·용어 병기·시각화 지시를 적용하지 않는다. (플러그인 안전 게이트는 그대로 유지된다.)';
|
|
160
|
+
return text.length > max ? text.slice(0, max) : text;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- orchestration convenience (shared by the hook scripts) ----
|
|
164
|
+
|
|
165
|
+
function fadedForLevel(level, session, mutedTerms = [], env = process.env) {
|
|
166
|
+
const levels = loadLevels(env);
|
|
167
|
+
const threshold = levels.levels[String(level)].fade_threshold;
|
|
168
|
+
return computeFadedTerms(session.term_counts || {}, threshold, session.reset_terms || [], mutedTerms);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// mutedTerms is the project-scoped mute list (getProjectConfig().muted_terms) so mutes
|
|
172
|
+
// persist across sessions; term_counts/reset_terms remain per-session.
|
|
173
|
+
export function capsuleForState(level, session, mutedTerms = [], env = process.env) {
|
|
174
|
+
const faded = fadedForLevel(level, session, mutedTerms, env);
|
|
175
|
+
const revision = capsuleRevision(level, faded, loadLevels(env).schema_version);
|
|
176
|
+
const capsule = buildCapsule({ level, fadedTerms: faded, revision }, env);
|
|
177
|
+
return { faded, revision, capsule };
|
|
178
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Shared tool-call fingerprinting for the PostToolUse/PostToolUseFailure event hooks.
|
|
2
|
+
// Fingerprints are short hashes — the raw tool input is never persisted.
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export const TOOL_USE_ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
6
|
+
|
|
7
|
+
function stableStringify(value) {
|
|
8
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
9
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
10
|
+
const keys = Object.keys(value).sort();
|
|
11
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function computeFingerprint(toolName, toolInput) {
|
|
15
|
+
const payload = `${toolName}\n${stableStringify(toolInput ?? null)}`;
|
|
16
|
+
return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 16);
|
|
17
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Finite lexical grammar for the PreToolUse safety gate.
|
|
2
|
+
//
|
|
3
|
+
// This is NOT a general shell parser. It recognizes a single command plus an
|
|
4
|
+
// argv vector (whitespace split, single/double quotes as literals, backslash
|
|
5
|
+
// escapes, flags, `--`, path arguments) and nothing else. Any control operator,
|
|
6
|
+
// redirect, substitution, heredoc, eval, nested shell, or UNQUOTED glob makes a
|
|
7
|
+
// command "unsupported" — the caller then falls back to token-based classification.
|
|
8
|
+
//
|
|
9
|
+
// Keeping the grammar finite is a safety decision: we never try to reason about
|
|
10
|
+
// shell constructs whose runtime effect we cannot bound.
|
|
11
|
+
|
|
12
|
+
// Shells/interpreters whose `-c`/`-Command` form embeds an unparsed sub-command.
|
|
13
|
+
const SHELL_INTERPRETERS = new Set([
|
|
14
|
+
'bash', 'sh', 'zsh', 'dash', 'ksh', 'csh', 'tcsh', 'fish', 'ash', 'mksh', 'rbash',
|
|
15
|
+
'powershell', 'pwsh',
|
|
16
|
+
]);
|
|
17
|
+
const NESTED_COMMAND_FLAGS = new Set(['-c', '-Command', '-command', '-EncodedCommand', '/c', '/C']);
|
|
18
|
+
|
|
19
|
+
export function baseName(p) {
|
|
20
|
+
if (typeof p !== 'string' || p === '') return '';
|
|
21
|
+
const parts = p.split(/[\\/]/);
|
|
22
|
+
return parts[parts.length - 1] || p;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isSpecialEscapeTarget(ch) {
|
|
26
|
+
// Characters where a leading backslash means "literal this shell-special char".
|
|
27
|
+
return ch === ' ' || ch === '\t' || ch === ';' || ch === '&' || ch === '|' ||
|
|
28
|
+
ch === '<' || ch === '>' || ch === '(' || ch === ')' || ch === '"' || ch === "'" ||
|
|
29
|
+
ch === '`' || ch === '$' || ch === '*' || ch === '?' || ch === '[' || ch === ']' ||
|
|
30
|
+
ch === '{' || ch === '}' || ch === '\\' || ch === '#';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Tokenize a single command line into argv, or report why it is unsupported.
|
|
34
|
+
// Returns { supported: true, argv } or { supported: false, reason, argv? }.
|
|
35
|
+
export function tokenizeShell(command) {
|
|
36
|
+
if (typeof command !== 'string') return { supported: false, reason: 'not-a-string' };
|
|
37
|
+
if (command.trim() === '') return { supported: false, reason: 'empty' };
|
|
38
|
+
|
|
39
|
+
const argv = [];
|
|
40
|
+
let cur = '';
|
|
41
|
+
let hasCur = false;
|
|
42
|
+
let sawUnquotedGlob = false;
|
|
43
|
+
let i = 0;
|
|
44
|
+
const n = command.length;
|
|
45
|
+
|
|
46
|
+
const pushCur = () => { if (hasCur) { argv.push(cur); cur = ''; hasCur = false; } };
|
|
47
|
+
|
|
48
|
+
while (i < n) {
|
|
49
|
+
const ch = command[i];
|
|
50
|
+
|
|
51
|
+
if (ch === '\n' || ch === '\r') return { supported: false, reason: 'newline' };
|
|
52
|
+
|
|
53
|
+
if (ch === ' ' || ch === '\t') { pushCur(); i++; continue; }
|
|
54
|
+
|
|
55
|
+
// control operators / redirects / subshell / substitution
|
|
56
|
+
if (ch === ';') return { supported: false, reason: 'control-operator:semicolon' };
|
|
57
|
+
if (ch === '&') return { supported: false, reason: 'control-operator:ampersand' };
|
|
58
|
+
if (ch === '|') return { supported: false, reason: 'control-operator:pipe' };
|
|
59
|
+
if (ch === '<') return { supported: false, reason: 'redirect-or-heredoc:lt' };
|
|
60
|
+
if (ch === '>') return { supported: false, reason: 'redirect:gt' };
|
|
61
|
+
if (ch === '`') return { supported: false, reason: 'command-substitution:backtick' };
|
|
62
|
+
if (ch === '(' || ch === ')') return { supported: false, reason: 'subshell' };
|
|
63
|
+
|
|
64
|
+
if (ch === '$') {
|
|
65
|
+
const next = command[i + 1];
|
|
66
|
+
if (next === '(') return { supported: false, reason: 'command-substitution:dollar-paren' };
|
|
67
|
+
if (next === '{') return { supported: false, reason: 'parameter-expansion:dollar-brace' };
|
|
68
|
+
// bare $VAR — keep as a literal token char (e.g. rm '$HOME' deny-list, or benign cd $HOME)
|
|
69
|
+
cur += ch; hasCur = true; i++; continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (ch === "'") {
|
|
73
|
+
hasCur = true; i++;
|
|
74
|
+
let closed = false;
|
|
75
|
+
while (i < n) {
|
|
76
|
+
if (command[i] === "'") { closed = true; i++; break; }
|
|
77
|
+
cur += command[i]; i++;
|
|
78
|
+
}
|
|
79
|
+
if (!closed) return { supported: false, reason: 'unterminated-single-quote' };
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (ch === '"') {
|
|
84
|
+
hasCur = true; i++;
|
|
85
|
+
let closed = false;
|
|
86
|
+
while (i < n) {
|
|
87
|
+
const c = command[i];
|
|
88
|
+
if (c === '"') { closed = true; i++; break; }
|
|
89
|
+
if (c === '\\') {
|
|
90
|
+
const nx = command[i + 1];
|
|
91
|
+
if (nx === '"' || nx === '\\' || nx === '$' || nx === '`') { cur += nx; i += 2; continue; }
|
|
92
|
+
cur += c; i++; continue; // keep backslash literally (e.g. Windows path)
|
|
93
|
+
}
|
|
94
|
+
if (c === '$') {
|
|
95
|
+
const nx = command[i + 1];
|
|
96
|
+
if (nx === '(') return { supported: false, reason: 'command-substitution:dollar-paren' };
|
|
97
|
+
if (nx === '{') return { supported: false, reason: 'parameter-expansion:dollar-brace' };
|
|
98
|
+
cur += c; i++; continue;
|
|
99
|
+
}
|
|
100
|
+
if (c === '`') return { supported: false, reason: 'command-substitution:backtick' };
|
|
101
|
+
cur += c; i++;
|
|
102
|
+
}
|
|
103
|
+
if (!closed) return { supported: false, reason: 'unterminated-double-quote' };
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (ch === '\\') {
|
|
108
|
+
const nx = command[i + 1];
|
|
109
|
+
if (nx === undefined) { cur += '\\'; hasCur = true; i++; continue; }
|
|
110
|
+
if (isSpecialEscapeTarget(nx)) { cur += nx; hasCur = true; i += 2; continue; }
|
|
111
|
+
cur += '\\'; hasCur = true; i++; continue; // preserve backslash (Windows path etc.)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (ch === '*' || ch === '?' || ch === '[' || ch === ']' || ch === '{' || ch === '}') {
|
|
115
|
+
sawUnquotedGlob = true; cur += ch; hasCur = true; i++; continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
cur += ch; hasCur = true; i++;
|
|
119
|
+
}
|
|
120
|
+
pushCur();
|
|
121
|
+
|
|
122
|
+
if (argv.length === 0) return { supported: false, reason: 'empty' };
|
|
123
|
+
|
|
124
|
+
const base = baseName(argv[0]);
|
|
125
|
+
if (base === 'eval') return { supported: false, reason: 'eval', argv };
|
|
126
|
+
if (SHELL_INTERPRETERS.has(base) && argv.slice(1).some((a) => NESTED_COMMAND_FLAGS.has(a))) {
|
|
127
|
+
return { supported: false, reason: 'nested-shell', argv };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (sawUnquotedGlob) return { supported: false, reason: 'unquoted-glob', argv };
|
|
131
|
+
|
|
132
|
+
return { supported: true, argv };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- PowerShell routing ----
|
|
136
|
+
|
|
137
|
+
// Detect a PowerShell command line: canonical Verb-Noun capitalization
|
|
138
|
+
// (Remove-Item, Get-ChildItem) or a case-insensitive hit in the known cmdlet
|
|
139
|
+
// rule lists (so `remove-item` still routes here). Lowercase dashed Unix
|
|
140
|
+
// commands (apt-get, docker-compose) stay on the Bash grammar.
|
|
141
|
+
export function isPowershellCommand(command, knownCmdlets = []) {
|
|
142
|
+
if (typeof command !== 'string') return false;
|
|
143
|
+
const first = command.trimStart().split(/[\s;|&<>(){}]/, 1)[0];
|
|
144
|
+
if (!first || !first.includes('-')) return false;
|
|
145
|
+
if (/^[A-Z][a-z]+-[A-Z][A-Za-z]+$/.test(first)) return true;
|
|
146
|
+
const lower = first.toLowerCase();
|
|
147
|
+
return knownCmdlets.some((c) => c.toLowerCase() === lower);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---- Git subgrammar (commit / push / reset / clean) ----
|
|
151
|
+
|
|
152
|
+
const GLOBAL_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path']);
|
|
153
|
+
|
|
154
|
+
export function parseGit(argv) {
|
|
155
|
+
let i = 1;
|
|
156
|
+
const globalOpts = [];
|
|
157
|
+
while (i < argv.length && argv[i].startsWith('-')) {
|
|
158
|
+
const t = argv[i];
|
|
159
|
+
globalOpts.push(t);
|
|
160
|
+
if (GLOBAL_WITH_VALUE.has(t)) { i++; if (i < argv.length) globalOpts.push(argv[i]); }
|
|
161
|
+
i++;
|
|
162
|
+
}
|
|
163
|
+
const sub = argv[i];
|
|
164
|
+
const rest = argv.slice(i + 1);
|
|
165
|
+
if (sub === 'commit') return parseGitCommit(rest, globalOpts);
|
|
166
|
+
if (sub === 'push') return parseGitPush(rest, globalOpts);
|
|
167
|
+
if (sub === 'reset') return parseGitReset(rest, globalOpts);
|
|
168
|
+
if (sub === 'clean') return parseGitClean(rest, globalOpts);
|
|
169
|
+
return { sub: sub ?? null, flags: rest.filter((x) => x.startsWith('-')), pathspec: [], options: {}, globalOpts, exotic: false };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const COMMIT_ALLOWED_SHORT = new Set(['a', 'm', 'q', 'v', 's']);
|
|
173
|
+
|
|
174
|
+
function parseGitCommit(rest, globalOpts) {
|
|
175
|
+
let allFlag = false;
|
|
176
|
+
let message = false;
|
|
177
|
+
let exotic = false;
|
|
178
|
+
let sawDashDash = false;
|
|
179
|
+
const pathspec = [];
|
|
180
|
+
for (let k = 0; k < rest.length; k++) {
|
|
181
|
+
const t = rest[k];
|
|
182
|
+
if (sawDashDash) { pathspec.push(t); continue; }
|
|
183
|
+
if (t === '--') { sawDashDash = true; continue; }
|
|
184
|
+
if (t.startsWith('--')) {
|
|
185
|
+
if (t === '--all') { allFlag = true; continue; }
|
|
186
|
+
if (t === '--message') { message = true; if (k + 1 < rest.length) k++; continue; }
|
|
187
|
+
if (t.startsWith('--message=')) { message = true; continue; }
|
|
188
|
+
if (t === '--quiet' || t === '--verbose' || t === '--signoff' || t === '--no-verify') continue;
|
|
189
|
+
exotic = true; continue; // --amend, --fixup, --squash, --file, --reuse-message, ...
|
|
190
|
+
}
|
|
191
|
+
if (t.startsWith('-') && t.length > 1) {
|
|
192
|
+
let hasMessage = false;
|
|
193
|
+
for (const c of t.slice(1)) {
|
|
194
|
+
if (c === 'a') allFlag = true;
|
|
195
|
+
else if (c === 'm') { message = true; hasMessage = true; }
|
|
196
|
+
else if (!COMMIT_ALLOWED_SHORT.has(c)) exotic = true;
|
|
197
|
+
}
|
|
198
|
+
if (hasMessage && k + 1 < rest.length) k++;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
pathspec.push(t); // bare operand → explicit pathspec commit
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
sub: 'commit', allFlag, message, pathspec,
|
|
205
|
+
exotic: exotic || globalOpts.length > 0, globalOpts,
|
|
206
|
+
flags: rest.filter((x) => x.startsWith('-')),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const PUSH_RECOGNIZED = new Set([
|
|
211
|
+
'-u', '--set-upstream', '--tags', '--follow-tags', '-n', '--dry-run',
|
|
212
|
+
'-v', '--verbose', '-q', '--quiet', '--porcelain', '--progress', '--no-verify', '--atomic',
|
|
213
|
+
]);
|
|
214
|
+
|
|
215
|
+
function parseGitPush(rest, globalOpts) {
|
|
216
|
+
let force = false;
|
|
217
|
+
let exotic = false;
|
|
218
|
+
const operands = [];
|
|
219
|
+
for (const t of rest) {
|
|
220
|
+
if (t.startsWith('-')) {
|
|
221
|
+
if (t === '--force' || t === '-f' || t === '--force-with-lease' || t.startsWith('--force-with-lease=')) { force = true; continue; }
|
|
222
|
+
if (PUSH_RECOGNIZED.has(t)) continue;
|
|
223
|
+
if (/^-[a-z]+$/i.test(t) && !t.startsWith('--') && /f/.test(t.slice(1))) { force = true; continue; }
|
|
224
|
+
exotic = true; continue; // --mirror, --delete, --receive-pack, ...
|
|
225
|
+
}
|
|
226
|
+
operands.push(t);
|
|
227
|
+
}
|
|
228
|
+
const remote = operands[0] ?? null;
|
|
229
|
+
const branch = operands[1] ?? null;
|
|
230
|
+
const targetBranch = branch && branch.includes(':') ? branch.split(':').pop() : branch;
|
|
231
|
+
return {
|
|
232
|
+
sub: 'push', force, remote, branch, targetBranch,
|
|
233
|
+
exotic: exotic || globalOpts.length > 0, globalOpts,
|
|
234
|
+
flags: rest.filter((x) => x.startsWith('-')),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const RESET_MODES = new Set(['--hard', '--soft', '--mixed', '--keep', '--merge']);
|
|
239
|
+
|
|
240
|
+
function parseGitReset(rest, globalOpts) {
|
|
241
|
+
let mode = 'mixed';
|
|
242
|
+
let exotic = false;
|
|
243
|
+
const operands = [];
|
|
244
|
+
for (const t of rest) {
|
|
245
|
+
if (t.startsWith('-')) {
|
|
246
|
+
if (RESET_MODES.has(t)) { mode = t.slice(2); continue; }
|
|
247
|
+
if (t === '-q' || t === '--quiet') continue;
|
|
248
|
+
exotic = true; continue;
|
|
249
|
+
}
|
|
250
|
+
operands.push(t);
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
sub: 'reset', mode, ref: operands[0] ?? null,
|
|
254
|
+
exotic: exotic || globalOpts.length > 0, globalOpts,
|
|
255
|
+
flags: rest.filter((x) => x.startsWith('-')),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function parseGitClean(rest, globalOpts) {
|
|
260
|
+
let force = false;
|
|
261
|
+
let dirs = false;
|
|
262
|
+
let ignored = false;
|
|
263
|
+
let dryRun = false;
|
|
264
|
+
let exotic = false;
|
|
265
|
+
const operands = [];
|
|
266
|
+
for (const t of rest) {
|
|
267
|
+
if (t.startsWith('-')) {
|
|
268
|
+
if (t === '--force') { force = true; continue; }
|
|
269
|
+
if (t === '--dry-run') { dryRun = true; continue; }
|
|
270
|
+
if (t.startsWith('--')) { if (t !== '--quiet') exotic = true; continue; }
|
|
271
|
+
for (const c of t.slice(1)) {
|
|
272
|
+
if (c === 'f') force = true;
|
|
273
|
+
else if (c === 'd') dirs = true;
|
|
274
|
+
else if (c === 'x' || c === 'X') ignored = true;
|
|
275
|
+
else if (c === 'n') dryRun = true;
|
|
276
|
+
else if (c !== 'q') exotic = true;
|
|
277
|
+
}
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
operands.push(t);
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
sub: 'clean', force, dirs, ignored, dryRun,
|
|
284
|
+
exotic: exotic || globalOpts.length > 0, globalOpts,
|
|
285
|
+
flags: rest.filter((x) => x.startsWith('-')),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Hook stdin/stdout contract helpers.
|
|
2
|
+
// Command hooks receive one JSON payload on stdin and answer with JSON on stdout.
|
|
3
|
+
// Exit code 2 is the blocking/deny path for safety hooks (fail closed).
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
|
|
6
|
+
export const STDIN_MAX_BYTES = 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
export function readStdinJson(maxBytes = STDIN_MAX_BYTES) {
|
|
9
|
+
const buf = fs.readFileSync(0);
|
|
10
|
+
if (buf.length > maxBytes) {
|
|
11
|
+
const err = new Error('hook input exceeds size cap');
|
|
12
|
+
err.code = 'E_INPUT_CAP';
|
|
13
|
+
throw err;
|
|
14
|
+
}
|
|
15
|
+
return JSON.parse(buf.toString('utf8'));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function emit(obj) {
|
|
19
|
+
process.stdout.write(JSON.stringify(obj));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function emitAdditionalContext(hookEventName, text) {
|
|
23
|
+
emit({ hookSpecificOutput: { hookEventName, additionalContext: text } });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function emitPreToolDecision(decision, reason) {
|
|
27
|
+
emit({
|
|
28
|
+
hookSpecificOutput: {
|
|
29
|
+
hookEventName: 'PreToolUse',
|
|
30
|
+
permissionDecision: decision,
|
|
31
|
+
permissionDecisionReason: reason,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function emitBlock(reason) {
|
|
37
|
+
emit({ decision: 'block', reason });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Fail-closed exit for safety hooks: stderr message + exit 2.
|
|
41
|
+
export function failClosed(message) {
|
|
42
|
+
process.stderr.write(String(message ?? 'novice safety hook error'));
|
|
43
|
+
process.exit(2);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Fail-open exit for non-safety hooks: never block the turn on a learning-layer bug.
|
|
47
|
+
export function failOpen() {
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Bootstrap manifest loading and validation (2-tier).
|
|
2
|
+
// Tier 1: reviewed manifests shipped in config/bootstrap-manifests/.
|
|
3
|
+
// Tier 2: ad-hoc manifests built from officially sourced provenance, same schema,
|
|
4
|
+
// executable only after the user has seen and approved the evidence.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { pluginRoot } from './secrets.js';
|
|
8
|
+
|
|
9
|
+
const SHELL_META = /[|;&><`]/;
|
|
10
|
+
const FORBIDDEN_ARGV0 = new Set(['curl', 'wget', 'sh', 'bash', 'zsh', 'eval']);
|
|
11
|
+
|
|
12
|
+
function checkArgv(argv, label, errors) {
|
|
13
|
+
if (!Array.isArray(argv) || argv.length === 0 || argv.some((t) => typeof t !== 'string')) {
|
|
14
|
+
errors.push(`${label}: argv must be a non-empty string array (exec-form)`);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
for (const token of argv) {
|
|
18
|
+
if (SHELL_META.test(token)) errors.push(`${label}: shell metacharacter in argv token: ${token}`);
|
|
19
|
+
}
|
|
20
|
+
if (FORBIDDEN_ARGV0.has(argv[0])) {
|
|
21
|
+
errors.push(`${label}: remote-script/shell interpreter argv[0] is forbidden: ${argv[0]}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function validateManifest(manifest) {
|
|
26
|
+
const errors = [];
|
|
27
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
28
|
+
return { valid: false, errors: ['manifest must be an object'] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const field of ['service_id', 'binary', 'docs_url']) {
|
|
32
|
+
if (typeof manifest[field] !== 'string' || manifest[field] === '') {
|
|
33
|
+
errors.push(`missing required string field: ${field}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (typeof manifest.docs_url === 'string' && !manifest.docs_url.startsWith('https://')) {
|
|
37
|
+
errors.push('docs_url must be https');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!Array.isArray(manifest.installers) || manifest.installers.length === 0) {
|
|
41
|
+
errors.push('installers[] required');
|
|
42
|
+
} else {
|
|
43
|
+
manifest.installers.forEach((inst, i) => {
|
|
44
|
+
if (!Array.isArray(inst.os) || inst.os.length === 0) errors.push(`installers[${i}]: os[] required`);
|
|
45
|
+
if (typeof inst.package_manager !== 'string') errors.push(`installers[${i}]: package_manager required`);
|
|
46
|
+
if (typeof inst.package_coordinate !== 'string' || inst.package_coordinate === '') {
|
|
47
|
+
errors.push(`installers[${i}]: fixed package_coordinate required`);
|
|
48
|
+
}
|
|
49
|
+
if (typeof inst.min_version !== 'string') errors.push(`installers[${i}]: min_version required`);
|
|
50
|
+
checkArgv(inst.argv, `installers[${i}].argv`, errors);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const probe of ['detect', 'version_check', 'auth_status']) {
|
|
55
|
+
if (!manifest[probe] || typeof manifest[probe] !== 'object') {
|
|
56
|
+
errors.push(`${probe} required`);
|
|
57
|
+
} else {
|
|
58
|
+
checkArgv(manifest[probe].argv, `${probe}.argv`, errors);
|
|
59
|
+
if (!manifest[probe].success) errors.push(`${probe}.success required`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!manifest.login || typeof manifest.login !== 'object') {
|
|
64
|
+
errors.push('login required');
|
|
65
|
+
} else {
|
|
66
|
+
checkArgv(manifest.login.argv, 'login.argv', errors);
|
|
67
|
+
if (manifest.login.interactive !== true) errors.push('login.interactive must be true (credential values are never piped)');
|
|
68
|
+
}
|
|
69
|
+
if (!manifest.logout || typeof manifest.logout !== 'object') {
|
|
70
|
+
errors.push('logout required');
|
|
71
|
+
} else {
|
|
72
|
+
checkArgv(manifest.logout.argv, 'logout.argv', errors);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const cs = manifest.credential_store;
|
|
76
|
+
if (!cs || typeof cs !== 'object') {
|
|
77
|
+
errors.push('credential_store required');
|
|
78
|
+
} else {
|
|
79
|
+
for (const f of ['secure_storage', 'plaintext_fallback', 'abort_auto_login_on_plaintext']) {
|
|
80
|
+
if (typeof cs[f] !== 'boolean') errors.push(`credential_store.${f} must be boolean`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!manifest.uninstall || typeof manifest.uninstall !== 'object' || Object.keys(manifest.uninstall).length === 0) {
|
|
85
|
+
errors.push('uninstall required');
|
|
86
|
+
} else {
|
|
87
|
+
for (const [pm, argv] of Object.entries(manifest.uninstall)) checkArgv(argv, `uninstall.${pm}`, errors);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!Array.isArray(manifest.side_effects)) errors.push('side_effects[] required');
|
|
91
|
+
|
|
92
|
+
const np = manifest.noninteractive_policy;
|
|
93
|
+
if (!np || typeof np !== 'object' || !['guided_manual', 'deny'].includes(np.login)) {
|
|
94
|
+
errors.push('noninteractive_policy.login must be guided_manual|deny');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { valid: errors.length === 0, errors };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function loadTier1Manifest(serviceId, env = process.env) {
|
|
101
|
+
const root = pluginRoot(env);
|
|
102
|
+
const capsFile = path.join(root, 'config', 'service-capabilities.json');
|
|
103
|
+
let manifestRel = null;
|
|
104
|
+
try {
|
|
105
|
+
const caps = JSON.parse(fs.readFileSync(capsFile, 'utf8'));
|
|
106
|
+
manifestRel = caps.services?.[serviceId]?.manifest ?? null;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
if (!manifestRel) return null;
|
|
111
|
+
try {
|
|
112
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'config', manifestRel), 'utf8'));
|
|
113
|
+
const { valid, errors } = validateManifest(manifest);
|
|
114
|
+
if (!valid) throw new Error(`tier 1 manifest invalid: ${errors.join('; ')}`);
|
|
115
|
+
return manifest;
|
|
116
|
+
} catch (err) {
|
|
117
|
+
if (String(err.message).startsWith('tier 1 manifest invalid')) throw err;
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Tier 2: build an ad-hoc manifest from researched official provenance.
|
|
123
|
+
// The engine refuses to execute it unless provenance.confirmed_by_user is true —
|
|
124
|
+
// the caller must have shown docs_url, package coordinate, and exact argv on screen.
|
|
125
|
+
export function buildAdHocManifest(input) {
|
|
126
|
+
if (!input || typeof input !== 'object') {
|
|
127
|
+
return { ok: false, reason: 'input required' };
|
|
128
|
+
}
|
|
129
|
+
const prov = input.provenance;
|
|
130
|
+
if (!prov || typeof prov.docs_url !== 'string' || !prov.docs_url.startsWith('https://')) {
|
|
131
|
+
return { ok: false, reason: 'official provenance (https docs_url) required — guided manual로 낮추세요' };
|
|
132
|
+
}
|
|
133
|
+
let host;
|
|
134
|
+
try {
|
|
135
|
+
host = new URL(prov.docs_url).host;
|
|
136
|
+
} catch {
|
|
137
|
+
host = '';
|
|
138
|
+
}
|
|
139
|
+
if (!host) {
|
|
140
|
+
return { ok: false, reason: 'provenance docs_url host unresolvable — guided manual로 낮추세요' };
|
|
141
|
+
}
|
|
142
|
+
const manifest = { ...input.manifest, tier: 2, docs_url: prov.docs_url };
|
|
143
|
+
const { valid, errors } = validateManifest(manifest);
|
|
144
|
+
if (!valid) return { ok: false, reason: `ad-hoc manifest invalid: ${errors.join('; ')}` };
|
|
145
|
+
if (prov.confirmed_by_user !== true) {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
reason: 'user has not confirmed the provenance evidence — 근거(문서 URL·coordinate·argv)를 화면에 제시하고 승인받으세요',
|
|
149
|
+
manifest,
|
|
150
|
+
requiresUserApproval: true,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return { ok: true, manifest, requiresUserApproval: true };
|
|
154
|
+
}
|