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.
- package/CLAUDE.md +121 -0
- package/ORCHESTRATOR-PROMPT.md +295 -0
- package/cli.js +117 -0
- package/lib/analyze-session.js +92 -0
- package/lib/evolution-writer.js +27 -0
- package/lib/permissions.js +311 -0
- package/lib/playbook-tracker.js +85 -0
- package/lib/resilience.js +458 -0
- package/lib/ring-buffer.js +125 -0
- package/lib/safe-file-writer.js +51 -0
- package/lib/scheduler.js +212 -0
- package/lib/settings-gen.js +159 -0
- package/lib/sse.js +103 -0
- package/lib/status-detect.js +229 -0
- package/lib/task-dag.js +547 -0
- package/lib/tool-rater.js +63 -0
- package/orchestrator/evolution-log.md +33 -0
- package/orchestrator/identity.md +60 -0
- package/orchestrator/metrics/.gitkeep +0 -0
- package/orchestrator/metrics/raw/.gitkeep +0 -0
- package/orchestrator/metrics/session-2026-03-23-setup.md +54 -0
- package/orchestrator/metrics/session-2026-03-24-appcast-build.md +55 -0
- package/orchestrator/playbooks.md +71 -0
- package/orchestrator/security-protocol.md +69 -0
- package/orchestrator/tool-registry.md +96 -0
- package/package.json +46 -0
- package/public/app.js +860 -0
- package/public/index.html +60 -0
- package/public/style.css +678 -0
- package/server.js +695 -0
|
@@ -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 };
|