tsunami-code 3.12.1 → 3.12.2
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/index.js +22 -6
- package/lib/tools.js +544 -96
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
import { listMemories, readMemory, saveMemory, deleteMemory, getMemdirPath } from './lib/memdir.js';
|
|
28
28
|
import { execSync, spawn } from 'child_process';
|
|
29
29
|
|
|
30
|
-
const VERSION = '3.12.
|
|
30
|
+
const VERSION = '3.12.2';
|
|
31
31
|
const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
|
|
32
32
|
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
33
33
|
const DEFAULT_SERVER = 'https://radiometric-reita-amuck.ngrok-free.dev';
|
|
@@ -1400,22 +1400,37 @@ Output ONLY the TSUNAMI.md content, starting with "# Project: <name>"`;
|
|
|
1400
1400
|
}
|
|
1401
1401
|
|
|
1402
1402
|
// ── Whale Watcher — auto-update check on every startup ───────────────────────
|
|
1403
|
+
function waitForKeypress() {
|
|
1404
|
+
return new Promise(resolve => {
|
|
1405
|
+
process.stdin.setRawMode(true);
|
|
1406
|
+
process.stdin.resume();
|
|
1407
|
+
process.stdin.once('data', key => {
|
|
1408
|
+
if (key[0] === 3) { process.stdout.write('\n'); process.exit(0); } // Ctrl+C
|
|
1409
|
+
process.stdin.setRawMode(false);
|
|
1410
|
+
process.stdin.pause();
|
|
1411
|
+
resolve();
|
|
1412
|
+
});
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1403
1416
|
async function whaleWatcher() {
|
|
1404
1417
|
if (!process.stdin.isTTY) return; // skip in --print / pipe mode
|
|
1405
1418
|
const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
|
|
1406
1419
|
let fi = 0;
|
|
1407
1420
|
const interval = setInterval(() => {
|
|
1408
|
-
process.stdout.write(`\r 🐋 ${chalk.bold('Whale Watcher')} ${chalk.dim(frames[fi++ % frames.length]+' checking for updates...')} `);
|
|
1421
|
+
process.stdout.write(`\r 🐋 ${chalk.bold('Whale Watcher')} ${chalk.dim(frames[fi++ % frames.length] + ' checking for updates...')} `);
|
|
1409
1422
|
}, 80);
|
|
1410
|
-
const clear = () => process.stdout.write('\r' + ' '.repeat(
|
|
1423
|
+
const clear = () => process.stdout.write('\r' + ' '.repeat(70) + '\r');
|
|
1411
1424
|
try {
|
|
1412
1425
|
const res = await fetch('https://registry.npmjs.org/tsunami-code/latest',
|
|
1413
1426
|
{ signal: AbortSignal.timeout(5000) });
|
|
1414
1427
|
const { version: latest } = await res.json();
|
|
1415
1428
|
clearInterval(interval); clear();
|
|
1416
1429
|
if (latest && latest !== VERSION) {
|
|
1417
|
-
process.stdout.write(` 🐋 ${chalk.bold('Whale Watcher')}
|
|
1418
|
-
process.stdout.write(chalk.dim('
|
|
1430
|
+
process.stdout.write(` 🐋 ${chalk.bold('Whale Watcher')} ${chalk.cyan('update available')} ${chalk.dim(`v${VERSION} → v${latest}`)}\n`);
|
|
1431
|
+
process.stdout.write(chalk.dim(' Press any key to download and install...\n'));
|
|
1432
|
+
await waitForKeypress();
|
|
1433
|
+
process.stdout.write(chalk.dim('\n Downloading...\n'));
|
|
1419
1434
|
try {
|
|
1420
1435
|
execSync(`npm install -g tsunami-code@${latest}`, { stdio: 'pipe' });
|
|
1421
1436
|
process.stdout.write(chalk.green(` ✓ Updated to v${latest} — restarting...\n\n`));
|
|
@@ -1425,8 +1440,9 @@ async function whaleWatcher() {
|
|
|
1425
1440
|
} catch {
|
|
1426
1441
|
process.stdout.write(chalk.yellow(` ⚠ Auto-update failed — run: npm install -g tsunami-code\n\n`));
|
|
1427
1442
|
}
|
|
1443
|
+
} else {
|
|
1444
|
+
process.stdout.write(` 🐋 ${chalk.bold('Whale Watcher')} ${chalk.dim(`no updates found v${VERSION}`)}\n\n`);
|
|
1428
1445
|
}
|
|
1429
|
-
// up to date — silent, proceed immediately
|
|
1430
1446
|
} catch {
|
|
1431
1447
|
clearInterval(interval); clear(); // network error — silent
|
|
1432
1448
|
}
|
package/lib/tools.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { execSync, exec } from 'child_process';
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, realpathSync, appendFileSync, lstatSync, readlinkSync, statSync } from 'fs';
|
|
3
3
|
import { glob } from 'glob';
|
|
4
4
|
import { promisify } from 'util';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import readline from 'readline';
|
|
5
8
|
import { getRgPath } from './preflight.js';
|
|
6
9
|
import { addFileNote, updateContext, appendDecision } from './memory.js';
|
|
7
10
|
import fetch from 'node-fetch';
|
|
@@ -9,17 +12,13 @@ import fetch from 'node-fetch';
|
|
|
9
12
|
const execAsync = promisify(exec);
|
|
10
13
|
|
|
11
14
|
// ── Lines Changed Tracking ────────────────────────────────────────────────────
|
|
12
|
-
// Mirrors cost-tracker.ts addToTotalLinesChanged pattern
|
|
13
15
|
export const linesChanged = { added: 0, removed: 0 };
|
|
14
16
|
|
|
15
17
|
// ── Undo Stack (per-turn batching) ───────────────────────────────────────────
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const _undoTurns = []; // Array of turns: each is [{ filePath, content }]
|
|
19
|
-
let _currentTurnFiles = []; // Accumulates file snapshots for the active turn
|
|
18
|
+
const _undoTurns = [];
|
|
19
|
+
let _currentTurnFiles = [];
|
|
20
20
|
const MAX_UNDO_TURNS = 10;
|
|
21
21
|
|
|
22
|
-
/** Call before each agent turn starts to open a new undo group. */
|
|
23
22
|
export function beginUndoTurn() {
|
|
24
23
|
if (_currentTurnFiles.length > 0) {
|
|
25
24
|
_undoTurns.push([..._currentTurnFiles]);
|
|
@@ -29,7 +28,6 @@ export function beginUndoTurn() {
|
|
|
29
28
|
}
|
|
30
29
|
|
|
31
30
|
function _pushFileSnapshot(filePath) {
|
|
32
|
-
// Only snapshot once per file per turn (first state = what to restore to)
|
|
33
31
|
if (_currentTurnFiles.some(f => f.filePath === filePath)) return;
|
|
34
32
|
try {
|
|
35
33
|
const content = existsSync(filePath) ? readFileSync(filePath, 'utf8') : null;
|
|
@@ -37,9 +35,7 @@ function _pushFileSnapshot(filePath) {
|
|
|
37
35
|
} catch {}
|
|
38
36
|
}
|
|
39
37
|
|
|
40
|
-
/** Undo all file changes from the most recent turn. Returns list of restored paths. */
|
|
41
38
|
export function undo() {
|
|
42
|
-
// Commit current turn if it has anything
|
|
43
39
|
if (_currentTurnFiles.length > 0) {
|
|
44
40
|
_undoTurns.push([..._currentTurnFiles]);
|
|
45
41
|
_currentTurnFiles = [];
|
|
@@ -50,7 +46,6 @@ export function undo() {
|
|
|
50
46
|
for (const { filePath, content } of turn.reverse()) {
|
|
51
47
|
try {
|
|
52
48
|
if (content === null) {
|
|
53
|
-
// File was created this turn — remove it
|
|
54
49
|
import('fs').then(({ unlinkSync }) => { try { unlinkSync(filePath); } catch {} });
|
|
55
50
|
} else {
|
|
56
51
|
writeFileSync(filePath, content, 'utf8');
|
|
@@ -62,7 +57,7 @@ export function undo() {
|
|
|
62
57
|
}
|
|
63
58
|
export function undoStackSize() { return _undoTurns.length + (_currentTurnFiles.length > 0 ? 1 : 0); }
|
|
64
59
|
|
|
65
|
-
// ── Session Context
|
|
60
|
+
// ── Session Context ───────────────────────────────────────────────────────────
|
|
66
61
|
let _sessionDir = null;
|
|
67
62
|
let _cwd = null;
|
|
68
63
|
|
|
@@ -71,6 +66,357 @@ export function setSession({ sessionDir, cwd }) {
|
|
|
71
66
|
_cwd = cwd;
|
|
72
67
|
}
|
|
73
68
|
|
|
69
|
+
// ── TSUNAMI SECURITY LAYER ───────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
// Permanently blocked — never executed regardless of context
|
|
72
|
+
const BLOCKED_PATTERNS = [
|
|
73
|
+
/rm\s+-[^\s]*r[^\s]*\s+\/(?!\w)/, // rm -rf / (wipe root)
|
|
74
|
+
/:\s*\(\s*\)\s*\{.*\|.*&.*\}/, // fork bomb
|
|
75
|
+
/dd\s+if=.*of=\/dev\/(sd|nvme|hd)/, // disk wipe
|
|
76
|
+
/mkfs\./, // format filesystem
|
|
77
|
+
/>\s*\/dev\/(sd|nvme|hd)/, // write to raw disk
|
|
78
|
+
/chmod\s+-R\s+777\s+\//, // chmod 777 root
|
|
79
|
+
/curl[^|]*\|\s*(ba|z|da)?sh/, // curl pipe to shell
|
|
80
|
+
/wget[^|]*\|\s*(ba|z|da)?sh/, // wget pipe to shell
|
|
81
|
+
/\/dev\/tcp\//, // bash TCP reverse shell
|
|
82
|
+
/\/dev\/udp\//, // bash UDP reverse shell
|
|
83
|
+
/\bnc\b.*-e\s+\/bin\/(ba)?sh/, // netcat reverse shell
|
|
84
|
+
/\bncat\b.*-e\s+\/bin\/(ba)?sh/, // ncat reverse shell
|
|
85
|
+
/\bsocat\b.*exec:/i, // socat reverse shell
|
|
86
|
+
/\bsudo\b/, // privilege escalation
|
|
87
|
+
/chmod\s+\S*\+s\b/, // SUID/SGID symbolic (u+s, g+s, a+s, etc.)
|
|
88
|
+
/chmod\s+[0-7]?[2-7][0-7]{3}\b/, // SUID/SGID octal (4755, 2755, 6755)
|
|
89
|
+
/\b(useradd|usermod|userdel|adduser|deluser)\b/, // user account manipulation
|
|
90
|
+
/\bpasswd\b(?!\s+--status)/, // password change
|
|
91
|
+
/history\s+-[cw]/, // clearing/writing bash history
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
// Destructive — require interactive confirmation
|
|
95
|
+
const DESTRUCTIVE_PATTERNS = [
|
|
96
|
+
{ pattern: /\brm\s+(-[^\s]*\s+)*-[^\s]*r/i, label: 'recursive delete (rm -r)' },
|
|
97
|
+
{ pattern: /git\s+reset\s+--hard/, label: 'git reset --hard' },
|
|
98
|
+
{ pattern: /git\s+push\s+.*--force/, label: 'force push' },
|
|
99
|
+
{ pattern: /git\s+push\s+-f\b/, label: 'force push (-f)' },
|
|
100
|
+
{ pattern: /git\s+checkout\s+--\s+\./, label: 'git checkout -- . (discard all changes)' },
|
|
101
|
+
{ pattern: /git\s+clean\s+-[^\s]*f/, label: 'git clean -f' },
|
|
102
|
+
{ pattern: /git\s+branch\s+-D/, label: 'force delete branch' },
|
|
103
|
+
{ pattern: /DROP\s+TABLE/i, label: 'DROP TABLE' },
|
|
104
|
+
{ pattern: /DROP\s+DATABASE/i, label: 'DROP DATABASE' },
|
|
105
|
+
{ pattern: /TRUNCATE\s+TABLE/i, label: 'TRUNCATE TABLE' },
|
|
106
|
+
{ pattern: /pkill|killall/, label: 'kill processes' },
|
|
107
|
+
{ pattern: /shutdown|reboot|halt/, label: 'system shutdown/reboot' },
|
|
108
|
+
{ pattern: /^\s*(env|printenv|export\s+-p|set)\s*$/, label: 'environment variable dump' },
|
|
109
|
+
{ pattern: /\bnohup\b/, label: 'background process (nohup)' },
|
|
110
|
+
{ pattern: /\bdisown\b/, label: 'process disown' },
|
|
111
|
+
{ pattern: /(?<![&])[&]\s*$/, label: 'background process (&)' },
|
|
112
|
+
{ pattern: /\bcrontab\s+-[^l]/, label: 'crontab modification (-e/-r/-u)' },
|
|
113
|
+
{ pattern: /\b(at|batch)\s+/, label: 'scheduled command execution (at/batch)' },
|
|
114
|
+
{ pattern: /\b(iptables|ip6tables|ufw|firewall-cmd)\b/, label: 'firewall rule modification' },
|
|
115
|
+
{ pattern: /\b(strace|ltrace|gdb|lldb)\b/, label: 'process tracing/debugging' },
|
|
116
|
+
{ pattern: /\b(scp|rsync)\b.*@[^:]+:/, label: 'outbound file transfer (scp/rsync to remote host)' },
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
// Paths always off-limits for read and write
|
|
120
|
+
const BLOCKED_PATHS = [
|
|
121
|
+
'/etc/passwd', '/etc/shadow', '/etc/sudoers', '/etc/ssh',
|
|
122
|
+
'/root', '/proc', '/sys',
|
|
123
|
+
os.homedir() + '/.ssh',
|
|
124
|
+
];
|
|
125
|
+
const BLOCKED_EXTENSIONS = ['.pem', '.key', '.p12', '.pfx', '.cert', '.crt', '.sqlite', '.sqlite3', '.db'];
|
|
126
|
+
const BLOCKED_FILENAMES = new Set([
|
|
127
|
+
'.env', '.npmrc', '.netrc', '.pgpass',
|
|
128
|
+
'id_rsa', 'id_ed25519', 'id_dsa', 'id_ecdsa',
|
|
129
|
+
'id_rsa.pub', 'id_ed25519.pub', 'id_dsa.pub', 'id_ecdsa.pub',
|
|
130
|
+
'authorized_keys', 'known_hosts',
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
const WRITE_ROOT = process.cwd();
|
|
134
|
+
const PROTECTED_DIRS = ['.git', '.vscode', '.husky', '.claude'];
|
|
135
|
+
const AGENT_DIR = path.resolve(path.dirname(new URL(import.meta.url).pathname));
|
|
136
|
+
|
|
137
|
+
const CREDENTIAL_PATTERNS = [
|
|
138
|
+
{ pattern: /-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/i, label: 'private key' },
|
|
139
|
+
{ pattern: /AKIA[0-9A-Z]{16}/, label: 'AWS access key' },
|
|
140
|
+
{ pattern: /ghp_[a-zA-Z0-9]{36}/, label: 'GitHub personal access token' },
|
|
141
|
+
{ pattern: /ghs_[a-zA-Z0-9]{36}/, label: 'GitHub Actions token' },
|
|
142
|
+
{ pattern: /sk-[a-zA-Z0-9]{48}/, label: 'OpenAI API key (legacy)' },
|
|
143
|
+
{ pattern: /sk-proj-[a-zA-Z0-9\-_]{80,}/, label: 'OpenAI project API key' },
|
|
144
|
+
{ pattern: /xox[baprs]-[a-zA-Z0-9\-]{10,}/, label: 'Slack token' },
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
const NETWORK_ALLOWLIST = [
|
|
148
|
+
/^https?:\/\/([a-z0-9-]+\.)*npmjs\.(com|org)/,
|
|
149
|
+
/^https?:\/\/([a-z0-9-]+\.)*github\.com/,
|
|
150
|
+
/^https?:\/\/([a-z0-9-]+\.)*githubusercontent\.com/,
|
|
151
|
+
/^https?:\/\/([a-z0-9-]+\.)*registry\.npmjs\.org/,
|
|
152
|
+
/^https?:\/\/([a-z0-9-]+\.)*pypi\.org/,
|
|
153
|
+
/^https?:\/\/([a-z0-9-]+\.)*python\.org/,
|
|
154
|
+
/^https?:\/\/localhost(:\d+)?(\/|$)/,
|
|
155
|
+
/^https?:\/\/127\.0\.0\.1(:\d+)?(\/|$)/,
|
|
156
|
+
/^https?:\/\/0\.0\.0\.0(:\d+)?(\/|$)/,
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
const RATE_LIMITS = {
|
|
160
|
+
Bash: 120, Write: 60, Edit: 60, Read: 200, Glob: 100, Grep: 100,
|
|
161
|
+
WebFetch: 20, WebSearch: 20, Agent: 10, Note: 100, Checkpoint: 100,
|
|
162
|
+
TodoWrite: 200, AskUser: 30, Kairos: 30,
|
|
163
|
+
};
|
|
164
|
+
const toolCallLog = new Map();
|
|
165
|
+
|
|
166
|
+
const MAX_READ_BYTES = 10 * 1024 * 1024; // 10MB
|
|
167
|
+
|
|
168
|
+
// ── Permission Mode ───────────────────────────────────────────────────────────
|
|
169
|
+
// auto — default: hard blocks + destructive prompts only
|
|
170
|
+
// accept-edits — auto-approve Write/Edit, destructive Bash still prompts
|
|
171
|
+
// confirm-writes — prompt before every Write/Edit
|
|
172
|
+
// confirm-all — prompt before every Bash AND every Write/Edit
|
|
173
|
+
// bypass — skip ALL prompts; hard blocks still enforced
|
|
174
|
+
// readonly — Bash runs, Write/Edit blocked
|
|
175
|
+
// plan — Read/Glob/Grep only; Bash + Write/Edit blocked
|
|
176
|
+
const TSUNAMI_MODE = (() => {
|
|
177
|
+
const m = (process.env.TSUNAMI_MODE || 'auto').toLowerCase();
|
|
178
|
+
const valid = ['auto', 'accept-edits', 'confirm-writes', 'confirm-all', 'bypass', 'readonly', 'plan'];
|
|
179
|
+
if (!valid.includes(m)) {
|
|
180
|
+
console.error(`[Tsunami Security] Unknown TSUNAMI_MODE "${m}" — defaulting to "auto". Valid: ${valid.join(', ')}`);
|
|
181
|
+
return 'auto';
|
|
182
|
+
}
|
|
183
|
+
if (m === 'bypass') console.warn('[Tsunami Security] ⚠️ BYPASS MODE — confirmations disabled. Hard blocks enforced.');
|
|
184
|
+
return m;
|
|
185
|
+
})();
|
|
186
|
+
|
|
187
|
+
// Commands that skip destructive confirmation: TSUNAMI_ALLOWED_COMMANDS=npm test,git log,git diff*
|
|
188
|
+
const ALLOWED_COMMANDS = (process.env.TSUNAMI_ALLOWED_COMMANDS || '')
|
|
189
|
+
.split(',').map(s => s.trim()).filter(Boolean);
|
|
190
|
+
|
|
191
|
+
function isCommandAllowed(command) {
|
|
192
|
+
if (ALLOWED_COMMANDS.length === 0) return false;
|
|
193
|
+
const cmd = command.trim();
|
|
194
|
+
return ALLOWED_COMMANDS.some(allowed => {
|
|
195
|
+
if (allowed.includes('*')) {
|
|
196
|
+
const re = new RegExp('^' + allowed.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$');
|
|
197
|
+
return re.test(cmd);
|
|
198
|
+
}
|
|
199
|
+
return cmd === allowed || cmd.startsWith(allowed + ' ') || cmd.startsWith(allowed + '\n');
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Restrict reads to specific roots: TSUNAMI_ALLOWED_PATHS=/path/one:/path/two
|
|
204
|
+
const ALLOWED_READ_PATHS = process.env.TSUNAMI_ALLOWED_PATHS
|
|
205
|
+
? process.env.TSUNAMI_ALLOWED_PATHS.split(':').map(p => path.resolve(p.trim())).filter(Boolean)
|
|
206
|
+
: null;
|
|
207
|
+
|
|
208
|
+
// Disable tools entirely: TSUNAMI_DISABLED_TOOLS=Bash,Write
|
|
209
|
+
const DISABLED_TOOLS = new Set(
|
|
210
|
+
(process.env.TSUNAMI_DISABLED_TOOLS || '').split(',').map(s => s.trim()).filter(Boolean)
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// Circuit breaker — block Bash after N consecutive errors
|
|
214
|
+
let consecutiveBashErrors = 0;
|
|
215
|
+
const MAX_CONSECUTIVE_ERRORS = 5;
|
|
216
|
+
|
|
217
|
+
// Audit log
|
|
218
|
+
const AUDIT_LOG = path.join(os.homedir(), '.tsunami_audit.log');
|
|
219
|
+
function audit(event, detail) {
|
|
220
|
+
try { appendFileSync(AUDIT_LOG, `[${new Date().toISOString()}] ${event}: ${String(detail).slice(0, 300)}\n`, 'utf8'); } catch {}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Session block counter
|
|
224
|
+
let sessionBlockCount = 0;
|
|
225
|
+
const SESSION_BLOCK_THRESHOLD = 3;
|
|
226
|
+
function recordBlock(message) {
|
|
227
|
+
sessionBlockCount++;
|
|
228
|
+
audit('BLOCKED', message);
|
|
229
|
+
if (sessionBlockCount >= SESSION_BLOCK_THRESHOLD) {
|
|
230
|
+
console.error(`[Tsunami Security] ⚠️ ALERT: ${sessionBlockCount} blocks this session. Review: ${AUDIT_LOG}`);
|
|
231
|
+
}
|
|
232
|
+
return message;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Session write manifest
|
|
236
|
+
const SESSION_MANIFEST = new Map();
|
|
237
|
+
function recordWriteAudit(filePath, action, detail) {
|
|
238
|
+
const resolved = safeResolve(filePath);
|
|
239
|
+
SESSION_MANIFEST.set(resolved, { action, detail, timestamp: new Date().toISOString() });
|
|
240
|
+
audit('WRITE', `${action}: ${resolved}`);
|
|
241
|
+
}
|
|
242
|
+
export function getSessionManifest() {
|
|
243
|
+
if (SESSION_MANIFEST.size === 0) return 'No files written this session.';
|
|
244
|
+
const entries = [...SESSION_MANIFEST.entries()].map(
|
|
245
|
+
([p, { action, detail, timestamp }]) => ` ${timestamp} [${action}] ${p} — ${detail}`
|
|
246
|
+
);
|
|
247
|
+
return `Session write manifest (${SESSION_MANIFEST.size} file(s)):\n${entries.join('\n')}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Rate limiter
|
|
251
|
+
function checkRateLimit(toolName) {
|
|
252
|
+
const now = Date.now();
|
|
253
|
+
const log = toolCallLog.get(toolName) || [];
|
|
254
|
+
const recent = log.filter(t => now - t < 60_000);
|
|
255
|
+
recent.push(now);
|
|
256
|
+
toolCallLog.set(toolName, recent);
|
|
257
|
+
const limit = RATE_LIMITS[toolName];
|
|
258
|
+
if (limit && recent.length > limit) {
|
|
259
|
+
return recordBlock(`[Tsunami Security] BLOCKED: Rate limit exceeded for ${toolName} — ${recent.length}/${limit} calls/60s.`);
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Symlink-safe path resolution
|
|
265
|
+
function safeResolve(filePath) {
|
|
266
|
+
const abs = path.resolve(filePath);
|
|
267
|
+
try { return realpathSync(abs); } catch {}
|
|
268
|
+
try {
|
|
269
|
+
const s = lstatSync(abs);
|
|
270
|
+
if (s.isSymbolicLink()) return path.resolve(path.dirname(abs), readlinkSync(abs));
|
|
271
|
+
} catch {}
|
|
272
|
+
try { return path.join(realpathSync(path.dirname(abs)), path.basename(abs)); } catch {}
|
|
273
|
+
return abs;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function isBlockedPath(filePath) {
|
|
277
|
+
const resolved = safeResolve(filePath);
|
|
278
|
+
if (BLOCKED_PATHS.some(bp => resolved === bp || resolved.startsWith(bp + '/'))) return true;
|
|
279
|
+
if (BLOCKED_EXTENSIONS.some(ext => resolved.endsWith(ext))) return true;
|
|
280
|
+
if (BLOCKED_FILENAMES.has(path.basename(resolved))) return true;
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function isOutsideWorkdir(filePath) {
|
|
285
|
+
const resolved = safeResolve(filePath);
|
|
286
|
+
return !resolved.startsWith(WRITE_ROOT + path.sep) && resolved !== WRITE_ROOT;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function isProtectedDir(filePath) {
|
|
290
|
+
const resolved = safeResolve(filePath);
|
|
291
|
+
const parts = path.relative(WRITE_ROOT, resolved).split(path.sep);
|
|
292
|
+
return PROTECTED_DIRS.some(d => parts[0] === d);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function isSelfModification(filePath) {
|
|
296
|
+
const resolved = safeResolve(filePath);
|
|
297
|
+
return resolved.startsWith(AGENT_DIR + path.sep) || resolved === AGENT_DIR;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function isSearchPathBlocked(searchPath) {
|
|
301
|
+
if (!searchPath) return null;
|
|
302
|
+
const resolved = safeResolve(searchPath);
|
|
303
|
+
if (resolved === '/') return 'filesystem root scan is not permitted';
|
|
304
|
+
if (BLOCKED_PATHS.some(bp => resolved === bp || resolved.startsWith(bp + '/')))
|
|
305
|
+
return `search path "${resolved}" is in a blocked directory`;
|
|
306
|
+
if (path.isAbsolute(searchPath) && !resolved.startsWith(WRITE_ROOT))
|
|
307
|
+
return `search path "${resolved}" is outside the working directory (${WRITE_ROOT})`;
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isReadPathAllowed(filePath) {
|
|
312
|
+
if (!ALLOWED_READ_PATHS) return true;
|
|
313
|
+
const resolved = safeResolve(filePath);
|
|
314
|
+
return ALLOWED_READ_PATHS.some(a => resolved === a || resolved.startsWith(a + path.sep));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function checkDisabled(toolName) {
|
|
318
|
+
if (DISABLED_TOOLS.has(toolName)) return recordBlock(`[Tsunami Security] BLOCKED: "${toolName}" is disabled via TSUNAMI_DISABLED_TOOLS.`);
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Redirect safety
|
|
323
|
+
const SAFE_REDIRECT_TARGETS = ['/dev/null', '/dev/stderr', '/dev/stdout', '/dev/stdin'];
|
|
324
|
+
function expandTilde(s) { return s.replace(/(^|(?<=[^a-zA-Z0-9]))~(?=\/|$)/g, os.homedir()); }
|
|
325
|
+
|
|
326
|
+
function hasDangerousRedirect(command) {
|
|
327
|
+
const expanded = expandTilde(command);
|
|
328
|
+
for (const match of expanded.matchAll(/>>?\s*(\/[^\s"';&|)]+)/g)) {
|
|
329
|
+
const target = match[1];
|
|
330
|
+
if (SAFE_REDIRECT_TARGETS.includes(target)) continue;
|
|
331
|
+
if (!target.startsWith(WRITE_ROOT + '/') && target !== WRITE_ROOT) return target;
|
|
332
|
+
}
|
|
333
|
+
for (const match of expanded.matchAll(/\|\s*tee\s+(.*)/g)) {
|
|
334
|
+
const pathArg = match[1].trim().split(/\s+/).find(a => a.startsWith('/'));
|
|
335
|
+
if (!pathArg || SAFE_REDIRECT_TARGETS.includes(pathArg)) continue;
|
|
336
|
+
if (!pathArg.startsWith(WRITE_ROOT + '/') && pathArg !== WRITE_ROOT) return pathArg;
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function hasBlockedNetworkCall(command) {
|
|
342
|
+
for (const [url] of command.matchAll(/https?:\/\/[^\s"';&|)\]]+/g)) {
|
|
343
|
+
if (!NETWORK_ALLOWLIST.some(p => p.test(url))) return url;
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function hasNullByte(str) { return str.includes('\0'); }
|
|
349
|
+
|
|
350
|
+
function deobfuscate(command) {
|
|
351
|
+
let decoded = command;
|
|
352
|
+
const b64 = decoded.match(/base64\s+-d\s*(?:<<<?\s*["']?([A-Za-z0-9+/=]{10,})["']?)/);
|
|
353
|
+
if (b64?.[1]) { try { decoded = Buffer.from(b64[1], 'base64').toString('utf8'); } catch {} }
|
|
354
|
+
decoded = decoded.replace(/\\x([0-9a-fA-F]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
|
355
|
+
decoded = decoded.replace(/([a-zA-Z])'{2}([a-zA-Z])/g, '$1$2').replace(/([a-zA-Z])"{2}([a-zA-Z])/g, '$1$2');
|
|
356
|
+
return decoded;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function extractNestedCommands(command) {
|
|
360
|
+
const nested = [];
|
|
361
|
+
for (const m of command.matchAll(/\b(?:bash|sh|zsh|dash)\s+-c\s+(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')/gs)) {
|
|
362
|
+
const inner = (m[1] || m[2] || '').replace(/\\(.)/g, '$1');
|
|
363
|
+
if (inner) { nested.push(inner); nested.push(deobfuscate(inner)); }
|
|
364
|
+
}
|
|
365
|
+
for (const m of command.matchAll(/\b(?:python3?|node|ruby|perl)\s+-[ce]\s+(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')/gs)) {
|
|
366
|
+
const inner = (m[1] || m[2] || '').replace(/\\(.)/g, '$1');
|
|
367
|
+
if (inner) nested.push(inner);
|
|
368
|
+
}
|
|
369
|
+
return nested;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const INJECTION_PATTERNS = [
|
|
373
|
+
/ignore\s+(all\s+)?(previous|above|prior)\s+instructions/i,
|
|
374
|
+
/disregard\s+(your\s+)?system\s+prompt/i,
|
|
375
|
+
/you\s+are\s+now\s+(a\s+)?different/i,
|
|
376
|
+
/jailbreak/i,
|
|
377
|
+
/<\s*system\s*>/i,
|
|
378
|
+
];
|
|
379
|
+
function checkPromptInjection(text) { return INJECTION_PATTERNS.some(p => p.test(text)); }
|
|
380
|
+
|
|
381
|
+
function scrubSecrets(output) {
|
|
382
|
+
let s = output;
|
|
383
|
+
for (const { pattern, label } of CREDENTIAL_PATTERNS) {
|
|
384
|
+
const gp = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
|
|
385
|
+
s = s.replace(gp, `[REDACTED:${label}]`);
|
|
386
|
+
}
|
|
387
|
+
return s;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function confirmDestructive(label, command) {
|
|
391
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
392
|
+
return new Promise(resolve => {
|
|
393
|
+
rl.question(
|
|
394
|
+
`\n[Tsunami Security] DESTRUCTIVE OPERATION DETECTED\nType: ${label}\nCommand: ${command.slice(0, 150)}\nAllow? (yes/no): `,
|
|
395
|
+
answer => {
|
|
396
|
+
rl.close();
|
|
397
|
+
const allowed = answer.trim().toLowerCase() === 'yes';
|
|
398
|
+
audit(allowed ? 'DESTRUCTIVE_CONFIRMED' : 'DESTRUCTIVE_DENIED', `${label} | ${command.slice(0, 200)}`);
|
|
399
|
+
resolve(allowed);
|
|
400
|
+
}
|
|
401
|
+
);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function confirmAction(toolName, detail) {
|
|
406
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
407
|
+
return new Promise(resolve => {
|
|
408
|
+
rl.question(
|
|
409
|
+
`\n[Tsunami Security] Confirmation required (TSUNAMI_MODE=${TSUNAMI_MODE})\nTool: ${toolName}\nDetail: ${detail.slice(0, 150)}\nAllow? (yes/no): `,
|
|
410
|
+
answer => {
|
|
411
|
+
rl.close();
|
|
412
|
+
const allowed = answer.trim().toLowerCase() === 'yes';
|
|
413
|
+
audit(allowed ? 'CONFIRM_ALLOWED' : 'CONFIRM_DENIED', `${toolName} | ${detail.slice(0, 200)}`);
|
|
414
|
+
resolve(allowed);
|
|
415
|
+
}
|
|
416
|
+
);
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
74
420
|
// ── BASH ──────────────────────────────────────────────────────────────────────
|
|
75
421
|
export const BashTool = {
|
|
76
422
|
name: 'Bash',
|
|
@@ -91,6 +437,75 @@ IMPORTANT: Never use for grep/find/cat/head/tail/sed/awk — use Read, Grep, Glo
|
|
|
91
437
|
required: ['command']
|
|
92
438
|
},
|
|
93
439
|
async run({ command, timeout = 120000 }) {
|
|
440
|
+
const rateLimitError = checkRateLimit('Bash');
|
|
441
|
+
if (rateLimitError) return rateLimitError;
|
|
442
|
+
|
|
443
|
+
const disabledError = checkDisabled('Bash');
|
|
444
|
+
if (disabledError) return disabledError;
|
|
445
|
+
|
|
446
|
+
if (TSUNAMI_MODE === 'plan') return recordBlock(`[Tsunami Security] BLOCKED: Bash is disabled in plan mode. Use Read/Glob/Grep only.`);
|
|
447
|
+
|
|
448
|
+
if (consecutiveBashErrors >= MAX_CONSECUTIVE_ERRORS) {
|
|
449
|
+
return recordBlock(`[Tsunami Security] BLOCKED: Circuit breaker tripped after ${consecutiveBashErrors} consecutive errors. Possible attack loop. Restart session to reset.`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const MAX_CMD_LENGTH = 10 * 1024;
|
|
453
|
+
if (command.length > MAX_CMD_LENGTH) return recordBlock(`[Tsunami Security] BLOCKED: Command too long (${command.length} chars). Possible injection payload.`);
|
|
454
|
+
if (hasNullByte(command)) return recordBlock(`[Tsunami Security] BLOCKED: Null bytes in command — possible injection attempt.`);
|
|
455
|
+
|
|
456
|
+
const decoded = deobfuscate(command);
|
|
457
|
+
const isObfuscated = decoded !== command;
|
|
458
|
+
const tildeExpanded = expandTilde(command);
|
|
459
|
+
const nested = extractNestedCommands(command);
|
|
460
|
+
const variants = [...new Set([command, decoded, tildeExpanded, ...nested])].filter(Boolean);
|
|
461
|
+
|
|
462
|
+
// Pass 1: hard blocks — always rejected, no prompts
|
|
463
|
+
for (const variant of variants) {
|
|
464
|
+
for (const pattern of BLOCKED_PATTERNS) {
|
|
465
|
+
if (pattern.test(variant)) {
|
|
466
|
+
return recordBlock(`[Tsunami Security] BLOCKED: ${isObfuscated ? 'Obfuscated ' : ''}blocked command pattern.\nCommand: ${command.slice(0, 200)}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (checkPromptInjection(variant)) {
|
|
470
|
+
return recordBlock(`[Tsunami Security] BLOCKED: Prompt injection detected${isObfuscated ? ' after deobfuscation' : ''}.`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// confirm-all: prompt once before destructive checks
|
|
475
|
+
const skipAllConfirms = TSUNAMI_MODE === 'bypass';
|
|
476
|
+
const skipDestructiveConfirm = skipAllConfirms || isCommandAllowed(command);
|
|
477
|
+
let alreadyConfirmed = skipAllConfirms;
|
|
478
|
+
|
|
479
|
+
if (!alreadyConfirmed && TSUNAMI_MODE === 'confirm-all') {
|
|
480
|
+
const allowed = await confirmAction('Bash', command);
|
|
481
|
+
if (!allowed) return recordBlock(`[Tsunami Security] Cancelled by user (confirm-all mode).`);
|
|
482
|
+
alreadyConfirmed = true;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Pass 2: destructive checks — prompt once across all variants
|
|
486
|
+
for (const variant of variants) {
|
|
487
|
+
for (const { pattern, label } of DESTRUCTIVE_PATTERNS) {
|
|
488
|
+
if (pattern.test(variant)) {
|
|
489
|
+
if (!alreadyConfirmed && !skipDestructiveConfirm) {
|
|
490
|
+
const allowed = await confirmDestructive(isObfuscated ? `${label} (detected after deobfuscation)` : label, command);
|
|
491
|
+
if (!allowed) return recordBlock(`[Tsunami Security] Cancelled by user: ${label}`);
|
|
492
|
+
}
|
|
493
|
+
alreadyConfirmed = true;
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const dangerousTarget = hasDangerousRedirect(command);
|
|
500
|
+
if (dangerousTarget) {
|
|
501
|
+
return recordBlock(`[Tsunami Security] BLOCKED: Redirect to path outside working directory.\nTarget: ${dangerousTarget}\nWorkdir: ${WRITE_ROOT}`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const blockedUrl = hasBlockedNetworkCall(command);
|
|
505
|
+
if (blockedUrl) {
|
|
506
|
+
return recordBlock(`[Tsunami Security] BLOCKED: Network call to non-allowlisted domain.\nURL: ${blockedUrl}`);
|
|
507
|
+
}
|
|
508
|
+
|
|
94
509
|
try {
|
|
95
510
|
const shell = process.platform === 'win32' ? undefined : '/bin/bash';
|
|
96
511
|
const { stdout, stderr } = await execAsync(command, {
|
|
@@ -98,10 +513,15 @@ IMPORTANT: Never use for grep/find/cat/head/tail/sed/awk — use Read, Grep, Glo
|
|
|
98
513
|
maxBuffer: 10 * 1024 * 1024,
|
|
99
514
|
shell
|
|
100
515
|
});
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
516
|
+
let result = ((stdout || '') + (stderr ? `\nSTDERR: ${stderr}` : '')).trim() || '(no output)';
|
|
517
|
+
result = scrubSecrets(result);
|
|
518
|
+
const MAX_OUTPUT = 50 * 1024;
|
|
519
|
+
if (result.length > MAX_OUTPUT) result = result.slice(0, MAX_OUTPUT) + `\n\n[Tsunami Security] Output truncated at 50KB`;
|
|
520
|
+
if (checkPromptInjection(result)) return `[Tsunami Security Warning] Output may contain prompt injection.\n\n${result}`;
|
|
521
|
+
consecutiveBashErrors = 0;
|
|
522
|
+
return result;
|
|
104
523
|
} catch (e) {
|
|
524
|
+
consecutiveBashErrors++;
|
|
105
525
|
if (e.killed) return `Error: Command timed out after ${timeout}ms`;
|
|
106
526
|
return `Error (exit ${e.code}): ${e.message}\n${e.stderr || ''}`.trim();
|
|
107
527
|
}
|
|
@@ -126,12 +546,31 @@ export const ReadTool = {
|
|
|
126
546
|
required: ['file_path']
|
|
127
547
|
},
|
|
128
548
|
async run({ file_path, offset = 0, limit = 2000 }) {
|
|
549
|
+
const rateLimitError = checkRateLimit('Read');
|
|
550
|
+
if (rateLimitError) return rateLimitError;
|
|
551
|
+
|
|
552
|
+
const disabledError = checkDisabled('Read');
|
|
553
|
+
if (disabledError) return disabledError;
|
|
554
|
+
|
|
555
|
+
if (isBlockedPath(file_path)) return recordBlock(`[Tsunami Security] BLOCKED: Access to "${file_path}" is not permitted.`);
|
|
556
|
+
if (!isReadPathAllowed(file_path)) return recordBlock(`[Tsunami Security] BLOCKED: "${safeResolve(file_path)}" is outside allowed read paths.`);
|
|
129
557
|
if (!existsSync(file_path)) return `Error: File not found: ${file_path}`;
|
|
558
|
+
|
|
559
|
+
try {
|
|
560
|
+
const fileStat = statSync(file_path);
|
|
561
|
+
if (fileStat.size > MAX_READ_BYTES) {
|
|
562
|
+
return `Error: File too large (${(fileStat.size / 1024 / 1024).toFixed(1)}MB, limit 10MB). Use offset/limit or Grep.`;
|
|
563
|
+
}
|
|
564
|
+
} catch {}
|
|
565
|
+
|
|
130
566
|
try {
|
|
131
567
|
const content = readFileSync(file_path, 'utf8');
|
|
568
|
+
if (checkPromptInjection(content)) {
|
|
569
|
+
console.error(`[Tsunami Security Warning] File "${file_path}" may contain prompt injection content.`);
|
|
570
|
+
}
|
|
132
571
|
const lines = content.split('\n');
|
|
133
|
-
|
|
134
|
-
return
|
|
572
|
+
let output = lines.slice(offset, offset + limit).map((l, i) => `${offset + i + 1}\t${l}`).join('\n');
|
|
573
|
+
return scrubSecrets(output);
|
|
135
574
|
} catch (e) {
|
|
136
575
|
return `Error reading file: ${e.message}`;
|
|
137
576
|
}
|
|
@@ -151,6 +590,26 @@ export const WriteTool = {
|
|
|
151
590
|
required: ['file_path', 'content']
|
|
152
591
|
},
|
|
153
592
|
async run({ file_path, content }) {
|
|
593
|
+
const rateLimitError = checkRateLimit('Write');
|
|
594
|
+
if (rateLimitError) return rateLimitError;
|
|
595
|
+
|
|
596
|
+
const disabledError = checkDisabled('Write');
|
|
597
|
+
if (disabledError) return disabledError;
|
|
598
|
+
|
|
599
|
+
if (TSUNAMI_MODE === 'readonly' || TSUNAMI_MODE === 'plan') return recordBlock(`[Tsunami Security] BLOCKED: Write operations are disabled (TSUNAMI_MODE=${TSUNAMI_MODE}).`);
|
|
600
|
+
if (isSelfModification(file_path)) return recordBlock(`[Tsunami Security] BLOCKED: Writing to the agent security layer is not permitted.\nPath: ${safeResolve(file_path)}`);
|
|
601
|
+
if (isBlockedPath(file_path)) return recordBlock(`[Tsunami Security] BLOCKED: Writing to "${file_path}" is not permitted.`);
|
|
602
|
+
if (isOutsideWorkdir(file_path)) return recordBlock(`[Tsunami Security] BLOCKED: Writing outside working directory.\nPath: ${safeResolve(file_path)}\nWorkdir: ${WRITE_ROOT}`);
|
|
603
|
+
if (isProtectedDir(file_path)) return recordBlock(`[Tsunami Security] BLOCKED: Writing to protected directory.\nPath: ${safeResolve(file_path)}`);
|
|
604
|
+
for (const { pattern, label } of CREDENTIAL_PATTERNS) {
|
|
605
|
+
if (pattern.test(content)) return recordBlock(`[Tsunami Security] BLOCKED: Content contains a ${label}. Writing credentials to disk is not permitted.`);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if ((TSUNAMI_MODE === 'confirm-writes' || TSUNAMI_MODE === 'confirm-all') && TSUNAMI_MODE !== 'bypass') {
|
|
609
|
+
const allowed = await confirmAction('Write', file_path);
|
|
610
|
+
if (!allowed) return recordBlock(`[Tsunami Security] Cancelled by user (${TSUNAMI_MODE} mode).`);
|
|
611
|
+
}
|
|
612
|
+
|
|
154
613
|
try {
|
|
155
614
|
_pushFileSnapshot(file_path);
|
|
156
615
|
const newLineCount = content.split('\n').length;
|
|
@@ -158,6 +617,7 @@ export const WriteTool = {
|
|
|
158
617
|
writeFileSync(file_path, content, 'utf8');
|
|
159
618
|
linesChanged.added += Math.max(0, newLineCount - oldLineCount);
|
|
160
619
|
linesChanged.removed += Math.max(0, oldLineCount - newLineCount);
|
|
620
|
+
recordWriteAudit(file_path, 'Write', `${newLineCount} lines`);
|
|
161
621
|
return `Written ${newLineCount} lines to ${file_path}`;
|
|
162
622
|
} catch (e) {
|
|
163
623
|
return `Error writing file: ${e.message}`;
|
|
@@ -184,7 +644,19 @@ export const EditTool = {
|
|
|
184
644
|
required: ['file_path', 'old_string', 'new_string']
|
|
185
645
|
},
|
|
186
646
|
async run({ file_path, old_string, new_string, replace_all = false }) {
|
|
647
|
+
const rateLimitError = checkRateLimit('Edit');
|
|
648
|
+
if (rateLimitError) return rateLimitError;
|
|
649
|
+
|
|
650
|
+
const disabledError = checkDisabled('Edit');
|
|
651
|
+
if (disabledError) return disabledError;
|
|
652
|
+
|
|
653
|
+
if (TSUNAMI_MODE === 'readonly' || TSUNAMI_MODE === 'plan') return recordBlock(`[Tsunami Security] BLOCKED: Edit operations are disabled (TSUNAMI_MODE=${TSUNAMI_MODE}).`);
|
|
654
|
+
if (isSelfModification(file_path)) return recordBlock(`[Tsunami Security] BLOCKED: Editing the agent security layer is not permitted.\nPath: ${safeResolve(file_path)}`);
|
|
655
|
+
if (isBlockedPath(file_path)) return recordBlock(`[Tsunami Security] BLOCKED: Editing "${file_path}" is not permitted.`);
|
|
656
|
+
if (isOutsideWorkdir(file_path)) return recordBlock(`[Tsunami Security] BLOCKED: Editing outside working directory.\nPath: ${safeResolve(file_path)}\nWorkdir: ${WRITE_ROOT}`);
|
|
657
|
+
if (isProtectedDir(file_path)) return recordBlock(`[Tsunami Security] BLOCKED: Editing protected directory.\nPath: ${safeResolve(file_path)}`);
|
|
187
658
|
if (!existsSync(file_path)) return `Error: File not found: ${file_path}`;
|
|
659
|
+
|
|
188
660
|
try {
|
|
189
661
|
let content = readFileSync(file_path, 'utf8');
|
|
190
662
|
_pushFileSnapshot(file_path);
|
|
@@ -193,13 +665,17 @@ export const EditTool = {
|
|
|
193
665
|
const count = content.split(old_string).length - 1;
|
|
194
666
|
if (count > 1) return `Error: old_string appears ${count} times — use replace_all or add more context to make it unique`;
|
|
195
667
|
}
|
|
196
|
-
const updated = replace_all
|
|
197
|
-
|
|
198
|
-
|
|
668
|
+
const updated = replace_all ? content.split(old_string).join(new_string) : content.replace(old_string, new_string);
|
|
669
|
+
|
|
670
|
+
if ((TSUNAMI_MODE === 'confirm-writes' || TSUNAMI_MODE === 'confirm-all') && TSUNAMI_MODE !== 'bypass') {
|
|
671
|
+
const allowed = await confirmAction('Edit', file_path);
|
|
672
|
+
if (!allowed) return recordBlock(`[Tsunami Security] Cancelled by user (${TSUNAMI_MODE} mode).`);
|
|
673
|
+
}
|
|
674
|
+
|
|
199
675
|
writeFileSync(file_path, updated, 'utf8');
|
|
200
|
-
// Track lines changed: old_string lines removed, new_string lines added
|
|
201
676
|
linesChanged.added += new_string.split('\n').length;
|
|
202
677
|
linesChanged.removed += old_string.split('\n').length;
|
|
678
|
+
recordWriteAudit(file_path, 'Edit', replace_all ? 'replace_all' : '1 replacement');
|
|
203
679
|
return `Edited ${file_path}`;
|
|
204
680
|
} catch (e) {
|
|
205
681
|
return `Error editing file: ${e.message}`;
|
|
@@ -223,6 +699,17 @@ export const GlobTool = {
|
|
|
223
699
|
required: ['pattern']
|
|
224
700
|
},
|
|
225
701
|
async run({ pattern, path: searchPath }) {
|
|
702
|
+
const rateLimitError = checkRateLimit('Glob');
|
|
703
|
+
if (rateLimitError) return rateLimitError;
|
|
704
|
+
|
|
705
|
+
const disabledError = checkDisabled('Glob');
|
|
706
|
+
if (disabledError) return disabledError;
|
|
707
|
+
|
|
708
|
+
if (searchPath && !isReadPathAllowed(searchPath)) return recordBlock(`[Tsunami Security] BLOCKED: Glob path "${safeResolve(searchPath)}" is outside allowed read paths.`);
|
|
709
|
+
|
|
710
|
+
const pathBlock = isSearchPathBlocked(searchPath);
|
|
711
|
+
if (pathBlock) return recordBlock(`[Tsunami Security] BLOCKED: Glob search blocked — ${pathBlock}`);
|
|
712
|
+
|
|
226
713
|
try {
|
|
227
714
|
const cwd = searchPath || process.cwd();
|
|
228
715
|
const matches = await glob(pattern, { cwd, absolute: true });
|
|
@@ -255,18 +742,31 @@ export const GrepTool = {
|
|
|
255
742
|
required: ['pattern']
|
|
256
743
|
},
|
|
257
744
|
async run({ pattern, path: searchPath = '.', glob: globFilter, output_mode = 'files_with_matches', '-i': caseInsensitive, '-C': context, head_limit = 250 }) {
|
|
745
|
+
const rateLimitError = checkRateLimit('Grep');
|
|
746
|
+
if (rateLimitError) return rateLimitError;
|
|
747
|
+
|
|
748
|
+
const disabledError = checkDisabled('Grep');
|
|
749
|
+
if (disabledError) return disabledError;
|
|
750
|
+
|
|
751
|
+
if (searchPath !== '.' && !isReadPathAllowed(searchPath)) return recordBlock(`[Tsunami Security] BLOCKED: Grep path "${safeResolve(searchPath)}" is outside allowed read paths.`);
|
|
752
|
+
|
|
753
|
+
const pathBlock = isSearchPathBlocked(searchPath === '.' ? null : searchPath);
|
|
754
|
+
if (pathBlock) return recordBlock(`[Tsunami Security] BLOCKED: Grep search blocked — ${pathBlock}`);
|
|
755
|
+
|
|
756
|
+
if (hasNullByte(pattern)) return recordBlock(`[Tsunami Security] BLOCKED: Grep pattern contains null bytes.`);
|
|
757
|
+
|
|
258
758
|
const rgPath = getRgPath();
|
|
259
759
|
if (!rgPath) return 'Error: ripgrep not available — install with: brew install ripgrep or apt install ripgrep';
|
|
260
760
|
try {
|
|
261
761
|
const rgBin = JSON.stringify(rgPath);
|
|
262
762
|
let cmd = rgBin;
|
|
263
763
|
if (caseInsensitive) cmd += ' -i';
|
|
264
|
-
if (context) cmd += ` -C ${context}`;
|
|
764
|
+
if (context) cmd += ` -C ${Number(context)}`;
|
|
265
765
|
if (globFilter) cmd += ` --glob ${JSON.stringify(globFilter)}`;
|
|
266
766
|
if (output_mode === 'files_with_matches') cmd += ' -l';
|
|
267
767
|
else if (output_mode === 'count') cmd += ' --count';
|
|
268
768
|
else cmd += ' -n';
|
|
269
|
-
cmd += ` ${JSON.stringify(pattern)} ${JSON.stringify(searchPath)}`;
|
|
769
|
+
cmd += ` -- ${JSON.stringify(pattern)} ${JSON.stringify(searchPath)}`;
|
|
270
770
|
const { stdout } = await execAsync(cmd, { maxBuffer: 10 * 1024 * 1024 });
|
|
271
771
|
const lines = stdout.trim().split('\n').filter(Boolean);
|
|
272
772
|
return lines.slice(0, head_limit).join('\n') || 'No matches found';
|
|
@@ -299,21 +799,14 @@ Set file_path to null for project-wide notes (written to CODEBASE.md).`,
|
|
|
299
799
|
input_schema: {
|
|
300
800
|
type: 'object',
|
|
301
801
|
properties: {
|
|
302
|
-
file_path: {
|
|
303
|
-
|
|
304
|
-
description: 'Absolute path to the file this note is about. null for project-wide notes.'
|
|
305
|
-
},
|
|
306
|
-
note: {
|
|
307
|
-
type: 'string',
|
|
308
|
-
description: 'The note content. Be specific and concrete — future sessions depend on this.'
|
|
309
|
-
}
|
|
802
|
+
file_path: { type: ['string', 'null'], description: 'Absolute path to the file this note is about. null for project-wide notes.' },
|
|
803
|
+
note: { type: 'string', description: 'The note content. Be specific and concrete — future sessions depend on this.' }
|
|
310
804
|
},
|
|
311
805
|
required: ['note']
|
|
312
806
|
},
|
|
313
807
|
async run({ file_path, note }) {
|
|
314
808
|
try {
|
|
315
809
|
if (!_cwd) return 'Note saved (no project memory initialized yet)';
|
|
316
|
-
// Normalize: model sometimes passes "null" or "undefined" as a string
|
|
317
810
|
const fp = (!file_path || file_path === 'null' || file_path === 'undefined') ? null : file_path;
|
|
318
811
|
addFileNote(_cwd, fp, note);
|
|
319
812
|
return `Note saved to project memory${file_path ? ` for ${file_path}` : ' (CODEBASE.md)'}.`;
|
|
@@ -347,10 +840,7 @@ EXAMPLE:
|
|
|
347
840
|
input_schema: {
|
|
348
841
|
type: 'object',
|
|
349
842
|
properties: {
|
|
350
|
-
content: {
|
|
351
|
-
type: 'string',
|
|
352
|
-
description: 'Task progress summary: what was done, what is next, key context.'
|
|
353
|
-
}
|
|
843
|
+
content: { type: 'string', description: 'Task progress summary: what was done, what is next, key context.' }
|
|
354
844
|
},
|
|
355
845
|
required: ['content']
|
|
356
846
|
},
|
|
@@ -383,29 +873,26 @@ export const WebFetchTool = {
|
|
|
383
873
|
required: ['url']
|
|
384
874
|
},
|
|
385
875
|
async run({ url, prompt: _prompt }) {
|
|
876
|
+
const rateLimitError = checkRateLimit('WebFetch');
|
|
877
|
+
if (rateLimitError) return rateLimitError;
|
|
878
|
+
|
|
386
879
|
try {
|
|
387
880
|
const controller = new AbortController();
|
|
388
881
|
const timer = setTimeout(() => controller.abort(), 15000);
|
|
389
882
|
const res = await fetch(url, {
|
|
390
883
|
signal: controller.signal,
|
|
391
|
-
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; TsunamiCode/
|
|
884
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; TsunamiCode/3.11)' }
|
|
392
885
|
});
|
|
393
886
|
clearTimeout(timer);
|
|
394
887
|
if (!res.ok) return `Error: HTTP ${res.status} ${res.statusText}`;
|
|
395
888
|
const raw = await res.text();
|
|
396
|
-
// Strip HTML tags, collapse whitespace
|
|
397
889
|
const text = raw
|
|
398
890
|
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
399
891
|
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
400
892
|
.replace(/<[^>]+>/g, ' ')
|
|
401
|
-
.replace(/ /g, ' ')
|
|
402
|
-
.replace(
|
|
403
|
-
|
|
404
|
-
.replace(/>/g, '>')
|
|
405
|
-
.replace(/"/g, '"')
|
|
406
|
-
.replace(/\s{3,}/g, '\n\n')
|
|
407
|
-
.trim();
|
|
408
|
-
return text.slice(0, 50000) + (text.length > 50000 ? '\n\n[truncated]' : '');
|
|
893
|
+
.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
894
|
+
.replace(/\s{3,}/g, '\n\n').trim();
|
|
895
|
+
return scrubSecrets(text.slice(0, 50000) + (text.length > 50000 ? '\n\n[truncated]' : ''));
|
|
409
896
|
} catch (e) {
|
|
410
897
|
if (e.name === 'AbortError') return 'Error: Request timed out after 15s';
|
|
411
898
|
return `Error fetching URL: ${e.message}`;
|
|
@@ -414,7 +901,6 @@ export const WebFetchTool = {
|
|
|
414
901
|
};
|
|
415
902
|
|
|
416
903
|
// ── TODO WRITE ────────────────────────────────────────────────────────────────
|
|
417
|
-
// In-memory todo list — persists for the session, visible to the model
|
|
418
904
|
const _todos = [];
|
|
419
905
|
let _todoId = 0;
|
|
420
906
|
|
|
@@ -458,7 +944,6 @@ The list is shown to the user after every update.`,
|
|
|
458
944
|
if (idx === -1) return `Error: todo #${id} not found`;
|
|
459
945
|
_todos.splice(idx, 1);
|
|
460
946
|
}
|
|
461
|
-
// Always return current list
|
|
462
947
|
if (_todos.length === 0) return 'Todo list is empty.';
|
|
463
948
|
const icons = { pending: '○', in_progress: '◉', done: '✓' };
|
|
464
949
|
return _todos.map(t => `${icons[t.status] || '○'} [${t.id}] ${t.text}`).join('\n');
|
|
@@ -466,7 +951,6 @@ The list is shown to the user after every update.`,
|
|
|
466
951
|
};
|
|
467
952
|
|
|
468
953
|
// ── ASK USER ──────────────────────────────────────────────────────────────────
|
|
469
|
-
// This tool is a signal — the agent loop in index.js intercepts it and prompts the user
|
|
470
954
|
export const AskUserTool = {
|
|
471
955
|
name: 'AskUser',
|
|
472
956
|
description: `Ask the user a clarifying question and wait for their answer. Use this when you are genuinely blocked and need input that cannot be inferred.
|
|
@@ -488,8 +972,6 @@ Do NOT use for:
|
|
|
488
972
|
required: ['question']
|
|
489
973
|
},
|
|
490
974
|
async run({ question }) {
|
|
491
|
-
// The agent loop intercepts this and handles the actual prompt.
|
|
492
|
-
// Return value here is fallback only.
|
|
493
975
|
return `[AskUser] ${question}`;
|
|
494
976
|
}
|
|
495
977
|
};
|
|
@@ -509,6 +991,9 @@ Returns titles, URLs, and snippets for the top results. Follow up with WebFetch
|
|
|
509
991
|
required: ['query']
|
|
510
992
|
},
|
|
511
993
|
async run({ query, num_results = 8 }) {
|
|
994
|
+
const rateLimitError = checkRateLimit('WebSearch');
|
|
995
|
+
if (rateLimitError) return rateLimitError;
|
|
996
|
+
|
|
512
997
|
try {
|
|
513
998
|
const n = Math.min(num_results, 20);
|
|
514
999
|
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
|
@@ -516,37 +1001,23 @@ Returns titles, URLs, and snippets for the top results. Follow up with WebFetch
|
|
|
516
1001
|
const timer = setTimeout(() => controller.abort(), 12000);
|
|
517
1002
|
const res = await fetch(url, {
|
|
518
1003
|
signal: controller.signal,
|
|
519
|
-
headers: {
|
|
520
|
-
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
|
|
521
|
-
'Accept': 'text/html'
|
|
522
|
-
}
|
|
1004
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36', 'Accept': 'text/html' }
|
|
523
1005
|
});
|
|
524
1006
|
clearTimeout(timer);
|
|
525
1007
|
if (!res.ok) return `Error: HTTP ${res.status}`;
|
|
526
1008
|
const html = await res.text();
|
|
527
|
-
|
|
528
|
-
// Parse results from DuckDuckGo HTML
|
|
529
1009
|
const results = [];
|
|
530
1010
|
const resultBlocks = html.match(/<div class="result[^"]*"[\s\S]*?<\/div>\s*<\/div>/g) || [];
|
|
531
1011
|
for (const block of resultBlocks.slice(0, n)) {
|
|
532
1012
|
const titleMatch = block.match(/<a[^>]+class="result__a"[^>]*>([\s\S]*?)<\/a>/);
|
|
533
1013
|
const urlMatch = block.match(/href="([^"]+)"/);
|
|
534
1014
|
const snippetMatch = block.match(/<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/);
|
|
535
|
-
|
|
536
1015
|
const title = titleMatch ? titleMatch[1].replace(/<[^>]+>/g, '').trim() : '';
|
|
537
1016
|
const href = urlMatch ? urlMatch[1] : '';
|
|
538
1017
|
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
const actualUrl = href.includes('uddg=')
|
|
542
|
-
? decodeURIComponent(href.match(/uddg=([^&]+)/)?.[1] || href)
|
|
543
|
-
: href;
|
|
544
|
-
|
|
545
|
-
if (title && actualUrl) {
|
|
546
|
-
results.push(`${results.length + 1}. ${title}\n ${actualUrl}\n ${snippet}`);
|
|
547
|
-
}
|
|
1018
|
+
const actualUrl = href.includes('uddg=') ? decodeURIComponent(href.match(/uddg=([^&]+)/)?.[1] || href) : href;
|
|
1019
|
+
if (title && actualUrl) results.push(`${results.length + 1}. ${title}\n ${actualUrl}\n ${snippet}`);
|
|
548
1020
|
}
|
|
549
|
-
|
|
550
1021
|
if (results.length === 0) return `No results found for: ${query}`;
|
|
551
1022
|
return `Search results for "${query}":\n\n${results.join('\n\n')}`;
|
|
552
1023
|
} catch (e) {
|
|
@@ -557,7 +1028,6 @@ Returns titles, URLs, and snippets for the top results. Follow up with WebFetch
|
|
|
557
1028
|
};
|
|
558
1029
|
|
|
559
1030
|
// ── AGENT TOOL ────────────────────────────────────────────────────────────────
|
|
560
|
-
// Circular import prevention — agentLoop is injected at runtime by loop.js
|
|
561
1031
|
let _agentLoopRef = null;
|
|
562
1032
|
export function injectAgentLoop(fn) { _agentLoopRef = fn; }
|
|
563
1033
|
|
|
@@ -582,6 +1052,9 @@ IMPORTANT: You can call Agent multiple times in one response to run tasks truly
|
|
|
582
1052
|
required: ['task']
|
|
583
1053
|
},
|
|
584
1054
|
async run({ task, serverUrl }) {
|
|
1055
|
+
const rateLimitError = checkRateLimit('Agent');
|
|
1056
|
+
if (rateLimitError) return rateLimitError;
|
|
1057
|
+
|
|
585
1058
|
if (!_agentLoopRef) return 'Error: AgentTool not initialized (no agent loop reference)';
|
|
586
1059
|
if (!_currentServerUrl) return 'Error: AgentTool not initialized (no server URL)';
|
|
587
1060
|
|
|
@@ -592,30 +1065,18 @@ IMPORTANT: You can call Agent multiple times in one response to run tasks truly
|
|
|
592
1065
|
];
|
|
593
1066
|
|
|
594
1067
|
const outputTokens = [];
|
|
595
|
-
let done = false;
|
|
596
1068
|
try {
|
|
597
|
-
await _agentLoopRef(
|
|
598
|
-
url,
|
|
599
|
-
subMessages,
|
|
600
|
-
(token) => { outputTokens.push(token); },
|
|
601
|
-
() => {}, // tool call display — silent in sub-agent
|
|
602
|
-
null, // no session info for sub-agents
|
|
603
|
-
null, // no confirm callback
|
|
604
|
-
10 // max iterations
|
|
605
|
-
);
|
|
606
|
-
done = true;
|
|
1069
|
+
await _agentLoopRef(url, subMessages, (token) => { outputTokens.push(token); }, () => {}, null, null, 10);
|
|
607
1070
|
} catch (e) {
|
|
608
1071
|
return `Sub-agent error: ${e.message}`;
|
|
609
1072
|
}
|
|
610
1073
|
|
|
611
|
-
// Find the last assistant message as the result
|
|
612
1074
|
const lastAssistant = [...subMessages].reverse().find(m => m.role === 'assistant');
|
|
613
1075
|
const result = lastAssistant?.content || outputTokens.join('');
|
|
614
1076
|
return `[Sub-agent result]\n${String(result).slice(0, 6000)}`;
|
|
615
1077
|
}
|
|
616
1078
|
};
|
|
617
1079
|
|
|
618
|
-
// Server URL + system prompt injected by loop.js at startup
|
|
619
1080
|
let _currentServerUrl = null;
|
|
620
1081
|
let _agentSystemPrompt = null;
|
|
621
1082
|
export function injectServerContext(serverUrl, systemPrompt) {
|
|
@@ -624,8 +1085,6 @@ export function injectServerContext(serverUrl, systemPrompt) {
|
|
|
624
1085
|
}
|
|
625
1086
|
|
|
626
1087
|
// ── SNIP TOOL ─────────────────────────────────────────────────────────────────
|
|
627
|
-
// The model calls this to surgically remove specific turns from context
|
|
628
|
-
// loop.js handles the actual splice — this is a signal tool
|
|
629
1088
|
export const SnipTool = {
|
|
630
1089
|
name: 'Snip',
|
|
631
1090
|
description: `Remove specific conversation turns from context to free up space, without losing the whole conversation like /compact does.
|
|
@@ -640,17 +1099,12 @@ Use /status to see current message count, then pick which to snip.`,
|
|
|
640
1099
|
input_schema: {
|
|
641
1100
|
type: 'object',
|
|
642
1101
|
properties: {
|
|
643
|
-
indices: {
|
|
644
|
-
type: 'array',
|
|
645
|
-
items: { type: 'number' },
|
|
646
|
-
description: 'Array of 0-based message indices to remove from context'
|
|
647
|
-
},
|
|
1102
|
+
indices: { type: 'array', items: { type: 'number' }, description: 'Array of 0-based message indices to remove from context' },
|
|
648
1103
|
reason: { type: 'string', description: 'Why you are snipping these (logged for transparency)' }
|
|
649
1104
|
},
|
|
650
1105
|
required: ['indices']
|
|
651
1106
|
},
|
|
652
1107
|
async run({ indices, reason }) {
|
|
653
|
-
// Actual snipping happens in agentLoop — this signals intent
|
|
654
1108
|
return JSON.stringify({ __snip__: true, indices, reason: reason || 'context management' });
|
|
655
1109
|
}
|
|
656
1110
|
};
|
|
@@ -674,7 +1128,6 @@ Example:
|
|
|
674
1128
|
async run({ content }) {
|
|
675
1129
|
try {
|
|
676
1130
|
if (!_sessionDir) return 'Brief recorded (no session initialized)';
|
|
677
|
-
// Reuse checkpoint mechanism — both go to session context
|
|
678
1131
|
updateContext(_sessionDir, `[BRIEF]\n${content}`);
|
|
679
1132
|
return 'Brief saved to working memory.';
|
|
680
1133
|
} catch (e) {
|
|
@@ -722,13 +1175,8 @@ export const ALL_TOOLS = [
|
|
|
722
1175
|
KairosTool
|
|
723
1176
|
];
|
|
724
1177
|
|
|
725
|
-
/**
|
|
726
|
-
* Register MCP tool objects into the live tool list.
|
|
727
|
-
* Called after MCP servers connect so the agent loop sees them immediately.
|
|
728
|
-
*/
|
|
729
1178
|
export function registerMcpTools(mcpToolObjects) {
|
|
730
1179
|
for (const tool of mcpToolObjects) {
|
|
731
|
-
// Don't double-register if reconnecting
|
|
732
1180
|
if (!ALL_TOOLS.find(t => t.name === tool.name)) {
|
|
733
1181
|
ALL_TOOLS.push(tool);
|
|
734
1182
|
}
|