ninja-terminals 2.0.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.
@@ -0,0 +1,311 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Pattern matching helpers
7
+ // ---------------------------------------------------------------------------
8
+
9
+ /**
10
+ * Convert a simple glob pattern to a RegExp.
11
+ * Supports `*` (any chars) and `?` (single char). Everything else is literal.
12
+ * @param {string} pattern
13
+ * @returns {RegExp}
14
+ */
15
+ function globToRegex(pattern) {
16
+ const escaped = pattern
17
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
18
+ .replace(/\*/g, '.*')
19
+ .replace(/\?/g, '.');
20
+ return new RegExp(`^${escaped}$`, 'i');
21
+ }
22
+
23
+ /**
24
+ * Build a matchable string from a tool invocation.
25
+ * Format: `ToolName(argument_summary)`
26
+ * @param {string} toolName
27
+ * @param {Object} toolInput
28
+ * @returns {string}
29
+ */
30
+ function buildMatchString(toolName, toolInput) {
31
+ if (!toolInput || typeof toolInput !== 'object') return toolName;
32
+
33
+ // For Bash, the "command" field is the primary matchable content
34
+ if (toolName === 'Bash' && toolInput.command) {
35
+ return `Bash(${toolInput.command})`;
36
+ }
37
+
38
+ // For file-oriented tools, use the file path
39
+ const filePath = toolInput.file_path || toolInput.path || toolInput.pattern || '';
40
+ if (filePath) {
41
+ return `${toolName}(${filePath})`;
42
+ }
43
+
44
+ // Fallback: serialize all values
45
+ const vals = Object.values(toolInput).join(' ');
46
+ return `${toolName}(${vals})`;
47
+ }
48
+
49
+ /**
50
+ * Test whether a match string satisfies a pattern.
51
+ * Pattern formats:
52
+ * - `ToolName` — matches any invocation of that tool
53
+ * - `ToolName(glob)` — matches tool + argument glob
54
+ * - `*` — matches everything
55
+ *
56
+ * @param {string} matchStr - e.g. "Bash(rm -rf /tmp)"
57
+ * @param {string} pattern - e.g. "Bash(rm -rf *)"
58
+ * @returns {boolean}
59
+ */
60
+ function matchesPattern(matchStr, pattern) {
61
+ // Wildcard — matches everything
62
+ if (pattern === '*') return true;
63
+
64
+ // Pattern with arguments: "Tool(glob)"
65
+ const parenIdx = pattern.indexOf('(');
66
+ if (parenIdx !== -1 && pattern.endsWith(')')) {
67
+ const patTool = pattern.slice(0, parenIdx);
68
+ const patArgs = pattern.slice(parenIdx + 1, -1);
69
+
70
+ const matchParenIdx = matchStr.indexOf('(');
71
+ if (matchParenIdx === -1) return false;
72
+
73
+ const matchTool = matchStr.slice(0, matchParenIdx);
74
+ const matchArgs = matchStr.slice(matchParenIdx + 1, -1);
75
+
76
+ // Tool name must match (case-insensitive)
77
+ if (patTool.toLowerCase() !== matchTool.toLowerCase()) return false;
78
+
79
+ // Args glob match
80
+ return globToRegex(patArgs).test(matchArgs);
81
+ }
82
+
83
+ // Simple tool-name-only pattern
84
+ const matchTool = matchStr.split('(')[0];
85
+ return pattern.toLowerCase() === matchTool.toLowerCase();
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Permission evaluation
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /**
93
+ * @typedef {Object} PermissionRules
94
+ * @property {string[]} allow - Patterns to allow
95
+ * @property {string[]} deny - Patterns to deny
96
+ * @property {string[]} ask - Patterns to prompt the user
97
+ */
98
+
99
+ /**
100
+ * @typedef {Object} PermissionDecision
101
+ * @property {'allow'|'deny'|'ask'} decision
102
+ * @property {string} [reason]
103
+ */
104
+
105
+ /**
106
+ * Evaluate whether a tool invocation should be allowed, denied, or require approval.
107
+ *
108
+ * Evaluation order: deny -> allow -> ask -> default 'ask'.
109
+ *
110
+ * @param {string} terminalId - Terminal identifier
111
+ * @param {string} toolName - Name of the tool being invoked
112
+ * @param {Object} toolInput - Tool input/arguments
113
+ * @param {PermissionRules} rules - Permission rules to evaluate against
114
+ * @returns {PermissionDecision}
115
+ */
116
+ function evaluatePermission(terminalId, toolName, toolInput, rules) {
117
+ const matchStr = buildMatchString(toolName, toolInput);
118
+
119
+ // 1. Deny rules checked first (most restrictive wins)
120
+ if (rules.deny) {
121
+ for (const pattern of rules.deny) {
122
+ if (matchesPattern(matchStr, pattern)) {
123
+ return {
124
+ decision: 'deny',
125
+ reason: `Denied by rule: ${pattern}`,
126
+ };
127
+ }
128
+ }
129
+ }
130
+
131
+ // 2. Allow rules
132
+ if (rules.allow) {
133
+ for (const pattern of rules.allow) {
134
+ if (matchesPattern(matchStr, pattern)) {
135
+ return {
136
+ decision: 'allow',
137
+ reason: `Allowed by rule: ${pattern}`,
138
+ };
139
+ }
140
+ }
141
+ }
142
+
143
+ // 3. Ask rules
144
+ if (rules.ask) {
145
+ for (const pattern of rules.ask) {
146
+ if (matchesPattern(matchStr, pattern)) {
147
+ return {
148
+ decision: 'ask',
149
+ reason: `Requires approval per rule: ${pattern}`,
150
+ };
151
+ }
152
+ }
153
+ }
154
+
155
+ // 4. Default: ask
156
+ return {
157
+ decision: 'ask',
158
+ reason: `No matching rule for ${toolName}; defaulting to ask`,
159
+ };
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Default rules generator
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /** Safe bash commands that are read-only or low-risk. */
167
+ const SAFE_BASH_PREFIXES = [
168
+ 'ls', 'cat', 'head', 'tail', 'wc', 'echo', 'pwd', 'which', 'whoami',
169
+ 'date', 'env', 'printenv', 'node -e', 'node -p',
170
+ 'npm test', 'npm run test', 'npm run lint', 'npm run build',
171
+ 'npx jest', 'npx tsc', 'npx eslint',
172
+ 'git status', 'git diff', 'git log', 'git show', 'git branch',
173
+ 'git stash list', 'git remote -v',
174
+ ];
175
+
176
+ /**
177
+ * Generate sensible default permission rules scoped to a directory.
178
+ *
179
+ * @param {string} scope - Absolute or relative directory path (e.g. "/src/api/")
180
+ * @returns {PermissionRules}
181
+ */
182
+ function getDefaultRules(scope) {
183
+ const normalizedScope = scope.endsWith('/') ? scope : scope + '/';
184
+
185
+ const allow = [
186
+ // Read-only tools are always safe
187
+ 'Read',
188
+ 'Glob',
189
+ 'Grep',
190
+ // File modifications within scope
191
+ `Edit(${normalizedScope}*)`,
192
+ `Write(${normalizedScope}*)`,
193
+ ];
194
+
195
+ // Safe bash commands
196
+ for (const cmd of SAFE_BASH_PREFIXES) {
197
+ allow.push(`Bash(${cmd}*)`);
198
+ }
199
+
200
+ const deny = [
201
+ // Destructive filesystem operations
202
+ 'Bash(rm -rf *)',
203
+ 'Bash(rm -r /*)',
204
+ 'Bash(sudo *)',
205
+ 'Bash(chmod 777 *)',
206
+
207
+ // Destructive git operations
208
+ 'Bash(git push --force*)',
209
+ 'Bash(git push -f *)',
210
+ 'Bash(git reset --hard*)',
211
+ 'Bash(git clean -f*)',
212
+
213
+ // Sensitive files and directories
214
+ 'Read(*.env)',
215
+ 'Read(*.env.*)',
216
+ 'Edit(*.env)',
217
+ 'Edit(*.env.*)',
218
+ 'Write(*.env)',
219
+ 'Write(*.env.*)',
220
+ `Read(${path.join(process.env.HOME || '~', '.ssh')}*)`,
221
+ `Read(${path.join(process.env.HOME || '~', '.aws')}*)`,
222
+ `Edit(${path.join(process.env.HOME || '~', '.ssh')}*)`,
223
+ `Edit(${path.join(process.env.HOME || '~', '.aws')}*)`,
224
+ `Write(${path.join(process.env.HOME || '~', '.ssh')}*)`,
225
+ `Write(${path.join(process.env.HOME || '~', '.aws')}*)`,
226
+ ];
227
+
228
+ const ask = [
229
+ // Anything outside scope that modifies files
230
+ 'Edit',
231
+ 'Write',
232
+ // Any bash command not explicitly allowed
233
+ 'Bash',
234
+ ];
235
+
236
+ return { allow, deny, ask };
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Express middleware factory
241
+ // ---------------------------------------------------------------------------
242
+
243
+ /**
244
+ * Create an Express middleware that evaluates Claude Code PreToolUse hook requests.
245
+ *
246
+ * The middleware handles `POST /api/terminals/:id/evaluate` and returns
247
+ * a decision in the Claude Code hook response format.
248
+ *
249
+ * @param {(terminalId: string) => PermissionRules} getTerminalRules
250
+ * Callback that returns the permission rules for a given terminal ID.
251
+ * @returns {import('express').RequestHandler}
252
+ */
253
+ function createEvaluateMiddleware(getTerminalRules) {
254
+ return function evaluateMiddleware(req, res) {
255
+ try {
256
+ const terminalId = req.params.id;
257
+
258
+ if (!terminalId) {
259
+ res.status(400).json({ error: 'Missing terminal id' });
260
+ return;
261
+ }
262
+
263
+ const { tool_name, tool_input } = req.body || {};
264
+
265
+ if (!tool_name) {
266
+ res.status(400).json({ error: 'Missing tool_name in request body' });
267
+ return;
268
+ }
269
+
270
+ const rules = getTerminalRules(terminalId);
271
+
272
+ if (!rules) {
273
+ // No rules configured for this terminal — default allow
274
+ res.json({
275
+ hookSpecificOutput: {
276
+ hookEventName: 'PreToolUse',
277
+ permissionDecision: 'ask',
278
+ permissionDecisionReason: `No rules configured for terminal ${terminalId}`,
279
+ },
280
+ });
281
+ return;
282
+ }
283
+
284
+ const { decision, reason } = evaluatePermission(
285
+ terminalId,
286
+ tool_name,
287
+ tool_input || {},
288
+ rules
289
+ );
290
+
291
+ res.json({
292
+ hookSpecificOutput: {
293
+ hookEventName: 'PreToolUse',
294
+ permissionDecision: decision,
295
+ permissionDecisionReason: reason || '',
296
+ },
297
+ });
298
+ } catch (err) {
299
+ res.status(500).json({
300
+ error: 'Permission evaluation failed',
301
+ detail: err.message,
302
+ });
303
+ }
304
+ };
305
+ }
306
+
307
+ module.exports = {
308
+ evaluatePermission,
309
+ getDefaultRules,
310
+ createEvaluateMiddleware,
311
+ };
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const { SUMMARIES_PATH } = require('./analyze-session');
5
+
6
+ /**
7
+ * Parse playbooks.md into structured entries.
8
+ * Looks for ### headings and **Status:** lines.
9
+ * @param {string} playbooksPath
10
+ * @returns {Array<{ name: string, status: string, section: string }>}
11
+ */
12
+ function parsePlaybooks(playbooksPath) {
13
+ if (!fs.existsSync(playbooksPath)) return [];
14
+
15
+ const content = fs.readFileSync(playbooksPath, 'utf8');
16
+ const entries = [];
17
+ const sections = content.split(/^### /m).slice(1); // split on ### headings
18
+
19
+ for (const section of sections) {
20
+ const lines = section.split('\n');
21
+ const name = lines[0].trim();
22
+ const statusMatch = section.match(/\*\*Status:\*\*\s*(.+)/i);
23
+ const status = statusMatch
24
+ ? statusMatch[1].trim().toLowerCase().replace(/[^a-z_-]/g, '')
25
+ : 'unknown';
26
+
27
+ entries.push({ name, status, section: '### ' + section.trim() });
28
+ }
29
+
30
+ return entries;
31
+ }
32
+
33
+ /**
34
+ * Count how many sessions used each playbook (from PLAYBOOK: markers).
35
+ * @param {string} summariesPath
36
+ * @returns {Map<string, number>} playbook name -> session count
37
+ */
38
+ function getPlaybookUsage(summariesPath) {
39
+ const usage = new Map();
40
+ const filePath = summariesPath || SUMMARIES_PATH;
41
+
42
+ if (!fs.existsSync(filePath)) return usage;
43
+
44
+ const lines = fs.readFileSync(filePath, 'utf8').trim().split('\n').filter(Boolean);
45
+ for (const line of lines) {
46
+ try {
47
+ const s = JSON.parse(line);
48
+ if (s.playbook_used) {
49
+ usage.set(s.playbook_used, (usage.get(s.playbook_used) || 0) + 1);
50
+ }
51
+ } catch { /* skip */ }
52
+ }
53
+
54
+ return usage;
55
+ }
56
+
57
+ /**
58
+ * Generate promotion recommendations for playbooks based on usage data.
59
+ * Promotes hypothesis/testing -> validated after 3+ successful sessions.
60
+ * @param {string} playbooksPath
61
+ * @param {string} summariesPath
62
+ * @returns {Array<{ name: string, current_status: string, recommended: string, sessions: number, evidence: string }>}
63
+ */
64
+ function promotePlaybooks(playbooksPath, summariesPath) {
65
+ const entries = parsePlaybooks(playbooksPath);
66
+ const usage = getPlaybookUsage(summariesPath);
67
+ const promotions = [];
68
+
69
+ for (const entry of entries) {
70
+ const sessions = usage.get(entry.name) || 0;
71
+ if ((entry.status === 'hypothesis' || entry.status.startsWith('testing')) && sessions >= 3) {
72
+ promotions.push({
73
+ name: entry.name,
74
+ current_status: entry.status,
75
+ recommended: 'validated',
76
+ sessions,
77
+ evidence: `Used in ${sessions} sessions`,
78
+ });
79
+ }
80
+ }
81
+
82
+ return promotions;
83
+ }
84
+
85
+ module.exports = { parsePlaybooks, getPlaybookUsage, promotePlaybooks };