sentix 2.0.1

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.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * safety.js — Safety word hashing and verification
3
+ *
4
+ * Stores a SHA-256 hash of the user's safety word in .sentix/safety.toml.
5
+ * The Governor uses this to gate dangerous operations (memory wipe, data export, rule changes).
6
+ *
7
+ * 보안 원칙:
8
+ * - 평문은 절대 저장하지 않는다 (SHA-256 해시만 저장)
9
+ * - .sentix/safety.toml은 .gitignore에 포함 (PEM 키와 동일 취급)
10
+ * - AI가 안전어를 대화에 출력하거나 외부로 전송하는 것은 절대 금지
11
+ * - 안전어 해시도 사용자 요청 없이 노출 금지
12
+ */
13
+
14
+ import { createHash } from 'node:crypto';
15
+
16
+ const SAFETY_PATH = '.sentix/safety.toml';
17
+
18
+ /** SHA-256 hash with a fixed salt to prevent rainbow table lookups */
19
+ const SALT = 'sentix-safety-v1';
20
+
21
+ export function hashWord(word) {
22
+ return createHash('sha256')
23
+ .update(`${SALT}:${word.trim()}`)
24
+ .digest('hex');
25
+ }
26
+
27
+ /**
28
+ * Load the stored safety hash from .sentix/safety.toml
29
+ * Returns null if not configured.
30
+ */
31
+ export async function loadSafetyHash(ctx) {
32
+ if (!ctx.exists(SAFETY_PATH)) return null;
33
+
34
+ const content = await ctx.readFile(SAFETY_PATH);
35
+ const match = content.match(/^hash\s*=\s*"([a-f0-9]{64})"/m);
36
+ return match ? match[1] : null;
37
+ }
38
+
39
+ /**
40
+ * Save the safety hash to .sentix/safety.toml
41
+ */
42
+ export async function saveSafetyHash(ctx, hash) {
43
+ const content = `# ┌─────────────────────────────────────────────────────┐
44
+ # │ SENTIX SAFETY WORD — CONFIDENTIAL │
45
+ # │ │
46
+ # │ 이 파일은 PEM 키와 동일한 보안 수준으로 취급합니다. │
47
+ # │ !! 절대 git에 커밋하지 마세요 !! │
48
+ # │ !! 절대 외부에 공유하지 마세요 !! │
49
+ # │ !! 절대 AI 대화에 내용을 붙여넣지 마세요 !! │
50
+ # │ │
51
+ # │ This file is treated with PEM-key-level security. │
52
+ # │ !! NEVER commit to git !! │
53
+ # │ !! NEVER share externally !! │
54
+ # │ !! NEVER paste contents into AI conversations !! │
55
+ # │ │
56
+ # │ 수정: sentix safety set <새 안전어> │
57
+ # └─────────────────────────────────────────────────────┘
58
+
59
+ [safety]
60
+ hash = "${hash}"
61
+ enabled = true
62
+ `;
63
+ await ctx.writeFile(SAFETY_PATH, content);
64
+ }
65
+
66
+ /**
67
+ * Verify a word against the stored hash.
68
+ * Returns: true (match), false (mismatch), null (no safety word configured)
69
+ */
70
+ export async function verifyWord(ctx, word) {
71
+ const stored = await loadSafetyHash(ctx);
72
+ if (!stored) return null;
73
+ return hashWord(word) === stored;
74
+ }
75
+
76
+ /**
77
+ * Check if safety word is configured
78
+ */
79
+ export async function isConfigured(ctx) {
80
+ return (await loadSafetyHash(ctx)) !== null;
81
+ }
82
+
83
+ /**
84
+ * Check if safety.toml is accidentally tracked by git
85
+ */
86
+ export function isGitignored(ctx) {
87
+ if (!ctx.exists('.gitignore')) return false;
88
+ // Synchronous check — just verify the path appears in .gitignore
89
+ // Full verification happens via ctx.readFile in the command layer
90
+ return true;
91
+ }
92
+
93
+ /**
94
+ * Patterns that trigger safety word verification.
95
+ * These detect potential LLM injection attempts.
96
+ */
97
+ export const DANGEROUS_PATTERNS = [
98
+ // ── Memory / learning manipulation ──
99
+ /잊어|forget|delete.*(memory|lesson|pattern|learning)/i,
100
+ /기억.*(삭제|지워|초기화)/i,
101
+ /lessons?\.md.*(삭제|지워|초기화|clear|wipe|reset)/i,
102
+ /patterns?\.md.*(삭제|지워|초기화|clear|wipe|reset)/i,
103
+ /(clear|wipe|reset|erase).*(lessons?|patterns?|memory|학습|기억)/i,
104
+
105
+ // ── Data exfiltration ──
106
+ /외부.*(보내|전송|export|send)/i,
107
+ /(send|export|upload|transmit).*(data|secret|key|token|credential)/i,
108
+ /(curl|wget|fetch).*\.(io|com|net|org)/i,
109
+
110
+ // ── Rule / config manipulation ──
111
+ /하드\s*룰.*(변경|삭제|무시|수정|disable)/i,
112
+ /(ignore|disable|bypass|skip).*(rule|hard.?rule|규칙|하드)/i,
113
+ /config\.toml.*(삭제|수정|변경)/i,
114
+
115
+ // ── Safety word tampering ──
116
+ /safety.*(word|어).*(변경|삭제|무시|disable|remove|change)/i,
117
+ /안전어.*(변경|삭제|무시|바꿔)/i,
118
+
119
+ // ── Safety word extraction (탈취 시도) ──
120
+ /안전어.*(알려|보여|출력|말해|뭐야|뭔지|무엇)/i,
121
+ /safety.*(word|어).*(show|tell|reveal|what|print|display)/i,
122
+ /(what|show|tell|reveal|print|display).*(safety.?word|안전어)/i,
123
+ /safety\.toml.*(읽어|보여|열어|cat|내용|출력|read|show|print|display)/i,
124
+ /\.sentix\/safety.*(읽|보|열|cat|show|read|print)/i,
125
+ /(해시|hash).*(보여|알려|출력|show|tell|reveal|print)/i,
126
+
127
+ // ── Scope escape ──
128
+ /CLAUDE\.md.*(수정|변경|삭제|rewrite|modify)/i,
129
+ /FRAMEWORK\.md.*(수정|변경|삭제|rewrite|modify)/i,
130
+
131
+ // ── Bulk destruction ──
132
+ /(rm\s+-rf|del\s+\/|rmdir|전부\s*삭제|모두\s*삭제)/i,
133
+ ];
134
+
135
+ /**
136
+ * Check if a request text matches any dangerous pattern.
137
+ * Returns the matched pattern description or null.
138
+ */
139
+ export function detectDangerousRequest(text) {
140
+ for (const pattern of DANGEROUS_PATTERNS) {
141
+ if (pattern.test(text)) {
142
+ return pattern.source;
143
+ }
144
+ }
145
+ return null;
146
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Minimal semver utilities — zero external dependencies.
3
+ *
4
+ * parse("2.0.1") → { major:2, minor:0, patch:1 }
5
+ * bump("2.0.1", "minor") → "2.1.0"
6
+ * compare("2.0.1","2.1.0") → -1
7
+ */
8
+
9
+ export function parseSemver(version) {
10
+ const clean = String(version).replace(/^v/i, '');
11
+ const [major, minor, patch] = clean.split('.').map(Number);
12
+ if ([major, minor, patch].some(n => !Number.isFinite(n) || n < 0)) {
13
+ throw new Error(`Invalid semver: ${version}`);
14
+ }
15
+ return { major, minor, patch };
16
+ }
17
+
18
+ export function bumpSemver(version, type) {
19
+ const v = parseSemver(version);
20
+ switch (type) {
21
+ case 'major': return `${v.major + 1}.0.0`;
22
+ case 'minor': return `${v.major}.${v.minor + 1}.0`;
23
+ case 'patch': return `${v.major}.${v.minor}.${v.patch + 1}`;
24
+ default: throw new Error(`Unknown bump type: ${type} (use major|minor|patch)`);
25
+ }
26
+ }
27
+
28
+ export function compareSemver(a, b) {
29
+ const va = parseSemver(a);
30
+ const vb = parseSemver(b);
31
+ for (const key of ['major', 'minor', 'patch']) {
32
+ if (va[key] < vb[key]) return -1;
33
+ if (va[key] > vb[key]) return 1;
34
+ }
35
+ return 0;
36
+ }
37
+
38
+ export function formatSemver(v) {
39
+ return `${v.major}.${v.minor}.${v.patch}`;
40
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Zero-dependency text similarity using trigram Jaccard coefficient.
3
+ * Used for duplicate ticket detection against lessons.md and existing tickets.
4
+ */
5
+
6
+ export const DUPLICATE_THRESHOLD = 0.65;
7
+
8
+ export function tokenize(text) {
9
+ return String(text)
10
+ .toLowerCase()
11
+ .replace(/[^\w\s가-힣]/g, ' ')
12
+ .split(/\s+/)
13
+ .filter(Boolean);
14
+ }
15
+
16
+ export function trigrams(tokens) {
17
+ const set = new Set();
18
+ for (let i = 0; i <= tokens.length - 3; i++) {
19
+ set.add(`${tokens[i]} ${tokens[i + 1]} ${tokens[i + 2]}`);
20
+ }
21
+ // Also include bigrams for short texts
22
+ for (let i = 0; i <= tokens.length - 2; i++) {
23
+ set.add(`${tokens[i]} ${tokens[i + 1]}`);
24
+ }
25
+ return set;
26
+ }
27
+
28
+ export function similarity(textA, textB) {
29
+ const a = trigrams(tokenize(textA));
30
+ const b = trigrams(tokenize(textB));
31
+
32
+ if (a.size === 0 && b.size === 0) return 1;
33
+ if (a.size === 0 || b.size === 0) return 0;
34
+
35
+ let intersection = 0;
36
+ for (const gram of a) {
37
+ if (b.has(gram)) intersection++;
38
+ }
39
+
40
+ // Jaccard = |A ∩ B| / |A ∪ B|
41
+ const union = a.size + b.size - intersection;
42
+ return union === 0 ? 0 : intersection / union;
43
+ }
44
+
45
+ /**
46
+ * Find best match above threshold from a list of candidates.
47
+ * @returns {{ text: string, score: number } | null}
48
+ */
49
+ export function findBestMatch(query, candidates) {
50
+ let best = null;
51
+ for (const text of candidates) {
52
+ const score = similarity(query, text);
53
+ if (score >= DUPLICATE_THRESHOLD && (!best || score > best.score)) {
54
+ best = { text, score };
55
+ }
56
+ }
57
+ return best;
58
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Ticket Index — CRUD operations for tasks/tickets/index.json
3
+ *
4
+ * Manages ticket lifecycle: open → in_progress → review → resolved → closed
5
+ * Zero external dependencies.
6
+ */
7
+
8
+ import { createHash } from 'node:crypto';
9
+
10
+ const INDEX_PATH = 'tasks/tickets/index.json';
11
+
12
+ const VALID_TRANSITIONS = {
13
+ open: ['in_progress'],
14
+ in_progress: ['review', 'open'],
15
+ review: ['resolved', 'in_progress'],
16
+ resolved: ['closed'],
17
+ closed: [],
18
+ };
19
+
20
+ const SEVERITY_ORDER = { critical: 0, warning: 1, suggestion: 2 };
21
+
22
+ // ── CRUD ──────────────────────────────────────────────
23
+
24
+ export async function loadIndex(ctx) {
25
+ if (!ctx.exists(INDEX_PATH)) return [];
26
+ try {
27
+ return await ctx.readJSON(INDEX_PATH);
28
+ } catch {
29
+ return [];
30
+ }
31
+ }
32
+
33
+ export async function saveIndex(ctx, entries) {
34
+ await ctx.writeJSON(INDEX_PATH, entries);
35
+ }
36
+
37
+ export async function nextTicketId(ctx, prefix) {
38
+ const entries = await loadIndex(ctx);
39
+ const matching = entries
40
+ .filter(e => e.id.startsWith(`${prefix}-`))
41
+ .map(e => parseInt(e.id.split('-')[1], 10))
42
+ .filter(n => Number.isFinite(n));
43
+
44
+ const next = matching.length > 0 ? Math.max(...matching) + 1 : 1;
45
+ return `${prefix}-${String(next).padStart(3, '0')}`;
46
+ }
47
+
48
+ export async function addTicket(ctx, entry) {
49
+ const entries = await loadIndex(ctx);
50
+ entries.push(entry);
51
+ await saveIndex(ctx, entries);
52
+ return entry;
53
+ }
54
+
55
+ export async function updateTicket(ctx, id, updates) {
56
+ const entries = await loadIndex(ctx);
57
+ const idx = entries.findIndex(e => e.id === id);
58
+ if (idx === -1) return null;
59
+
60
+ // Validate status transition if status is being changed
61
+ if (updates.status && updates.status !== entries[idx].status) {
62
+ const allowed = VALID_TRANSITIONS[entries[idx].status] || [];
63
+ if (!allowed.includes(updates.status)) {
64
+ throw new Error(
65
+ `Invalid transition: ${entries[idx].status} → ${updates.status} (allowed: ${allowed.join(', ') || 'none'})`
66
+ );
67
+ }
68
+ }
69
+
70
+ entries[idx] = {
71
+ ...entries[idx],
72
+ ...updates,
73
+ updated_at: new Date().toISOString(),
74
+ };
75
+ await saveIndex(ctx, entries);
76
+ return entries[idx];
77
+ }
78
+
79
+ export async function findTicket(ctx, id) {
80
+ const entries = await loadIndex(ctx);
81
+ return entries.find(e => e.id === id) || null;
82
+ }
83
+
84
+ export async function findByStatus(ctx, status) {
85
+ const entries = await loadIndex(ctx);
86
+ return entries.filter(e => e.status === status);
87
+ }
88
+
89
+ export async function findByType(ctx, type) {
90
+ const entries = await loadIndex(ctx);
91
+ return entries.filter(e => e.type === type);
92
+ }
93
+
94
+ // ── Severity auto-classification ──────────────────────
95
+
96
+ const CRITICAL_KEYWORDS = ['crash', 'security', 'data loss', 'corruption', 'undefined is not', 'econnrefused', 'injection', 'vulnerability', '보안', '데이터 손실', '크래시'];
97
+ const WARNING_KEYWORDS = ['slow', 'wrong', 'broken', 'error', 'fail', 'incorrect', 'bug', '오류', '에러', '느림', '실패'];
98
+
99
+ export function classifySeverity(description) {
100
+ const lower = description.toLowerCase();
101
+ if (CRITICAL_KEYWORDS.some(k => lower.includes(k))) return 'critical';
102
+ if (WARNING_KEYWORDS.some(k => lower.includes(k))) return 'warning';
103
+ return 'suggestion';
104
+ }
105
+
106
+ // ── Helpers ───────────────────────────────────────────
107
+
108
+ export function descriptionHash(text) {
109
+ return createHash('sha256').update(text).digest('hex').slice(0, 8);
110
+ }
111
+
112
+ export function sortBySeverity(entries) {
113
+ return [...entries].sort((a, b) => {
114
+ const sa = SEVERITY_ORDER[a.severity] ?? 3;
115
+ const sb = SEVERITY_ORDER[b.severity] ?? 3;
116
+ if (sa !== sb) return sa - sb;
117
+ return new Date(b.created_at) - new Date(a.created_at);
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Create a standard TicketEntry object.
123
+ */
124
+ export function createTicketEntry({ id, type, title, severity, description }) {
125
+ return {
126
+ id,
127
+ type,
128
+ title,
129
+ severity: severity || 'suggestion',
130
+ status: 'open',
131
+ created_at: new Date().toISOString(),
132
+ updated_at: new Date().toISOString(),
133
+ description_hash: descriptionHash(description || title),
134
+ related_cycle: null,
135
+ file_path: `tasks/tickets/${id}.md`,
136
+ };
137
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * tools.js — Engine mode 도구 정의 + 실행 핸들러
3
+ *
4
+ * AI가 사용할 도구를 정의하고, 도구 호출을 실행한다.
5
+ * 화이트리스트 기반 — 허용된 명령만 실행 가능.
6
+ */
7
+
8
+ import { execSync } from 'node:child_process';
9
+ import { readdirSync } from 'node:fs';
10
+ import { resolve } from 'node:path';
11
+
12
+ // AI에게 제공하는 도구 목록 (Anthropic tool_use 포맷)
13
+ export const TOOLS = [
14
+ {
15
+ name: 'read_file',
16
+ description: 'Read the contents of a file',
17
+ parameters: {
18
+ type: 'object',
19
+ properties: { path: { type: 'string', description: 'File path relative to project root' } },
20
+ required: ['path'],
21
+ },
22
+ },
23
+ {
24
+ name: 'write_file',
25
+ description: 'Write content to a file (creates parent dirs)',
26
+ parameters: {
27
+ type: 'object',
28
+ properties: {
29
+ path: { type: 'string', description: 'File path relative to project root' },
30
+ content: { type: 'string', description: 'File content to write' },
31
+ },
32
+ required: ['path', 'content'],
33
+ },
34
+ },
35
+ {
36
+ name: 'list_files',
37
+ description: 'List files in a directory',
38
+ parameters: {
39
+ type: 'object',
40
+ properties: { path: { type: 'string', description: 'Directory path (default: .)' } },
41
+ },
42
+ },
43
+ {
44
+ name: 'search_files',
45
+ description: 'Search for a text pattern in files (grep)',
46
+ parameters: {
47
+ type: 'object',
48
+ properties: {
49
+ pattern: { type: 'string', description: 'Search pattern (regex)' },
50
+ path: { type: 'string', description: 'Directory to search (default: .)' },
51
+ },
52
+ required: ['pattern'],
53
+ },
54
+ },
55
+ {
56
+ name: 'run_command',
57
+ description: 'Run a whitelisted shell command (npm test, git status, git diff, etc.)',
58
+ parameters: {
59
+ type: 'object',
60
+ properties: { command: { type: 'string', description: 'Command to run' } },
61
+ required: ['command'],
62
+ },
63
+ },
64
+ {
65
+ name: 'git_diff',
66
+ description: 'Show git diff of current changes',
67
+ parameters: {
68
+ type: 'object',
69
+ properties: { staged: { type: 'boolean', description: 'Show staged changes only' } },
70
+ },
71
+ },
72
+ ];
73
+
74
+ // 허용된 명령 화이트리스트 (접두사 매칭)
75
+ const COMMAND_WHITELIST = [
76
+ 'npm test', 'npm run test', 'npm run lint', 'npm run build',
77
+ 'node bin/sentix.js',
78
+ 'git status', 'git diff', 'git log', 'git add', 'git commit',
79
+ 'ls', 'cat', 'head', 'tail', 'wc', 'find', 'grep',
80
+ ];
81
+
82
+ /**
83
+ * 도구 호출 실행
84
+ * @param {string} name - 도구 이름
85
+ * @param {object} args - 도구 인자
86
+ * @param {object} ctx - sentix context
87
+ * @returns {Promise<string>} 실행 결과
88
+ */
89
+ export async function executeTool(name, args, ctx) {
90
+ switch (name) {
91
+ case 'read_file':
92
+ return ctx.readFile(args.path);
93
+
94
+ case 'write_file':
95
+ await ctx.writeFile(args.path, args.content);
96
+ return `File written: ${args.path}`;
97
+
98
+ case 'list_files': {
99
+ const dir = resolve(ctx.cwd, args.path || '.');
100
+ const files = readdirSync(dir, { withFileTypes: true });
101
+ return files.map(f => f.isDirectory() ? f.name + '/' : f.name).join('\n');
102
+ }
103
+
104
+ case 'search_files': {
105
+ const searchPath = args.path || '.';
106
+ const result = execSync(
107
+ `grep -rn "${args.pattern.replace(/"/g, '\\"')}" ${searchPath} --include="*.js" --include="*.ts" --include="*.md" --include="*.json" 2>/dev/null | head -50`,
108
+ { cwd: ctx.cwd, encoding: 'utf-8', stdio: 'pipe', timeout: 10000 }
109
+ );
110
+ return result || 'No matches found';
111
+ }
112
+
113
+ case 'run_command': {
114
+ if (!isAllowedCommand(args.command)) {
115
+ return `DENIED: Command not in whitelist. Allowed prefixes: ${COMMAND_WHITELIST.join(', ')}`;
116
+ }
117
+ try {
118
+ return execSync(args.command, {
119
+ cwd: ctx.cwd,
120
+ encoding: 'utf-8',
121
+ stdio: 'pipe',
122
+ timeout: 60000,
123
+ });
124
+ } catch (err) {
125
+ return `Command failed (exit ${err.status}): ${err.stderr?.slice(0, 500) || err.message}`;
126
+ }
127
+ }
128
+
129
+ case 'git_diff': {
130
+ const cmd = args.staged ? 'git diff --staged' : 'git diff';
131
+ return execSync(cmd, { cwd: ctx.cwd, encoding: 'utf-8', stdio: 'pipe', timeout: 10000 });
132
+ }
133
+
134
+ default:
135
+ return `Unknown tool: ${name}`;
136
+ }
137
+ }
138
+
139
+ function isAllowedCommand(cmd) {
140
+ const trimmed = cmd.trim();
141
+ return COMMAND_WHITELIST.some(prefix => trimmed.startsWith(prefix));
142
+ }