tsunami-code 3.12.0 → 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.
Files changed (3) hide show
  1. package/index.js +58 -5
  2. package/lib/tools.js +544 -96
  3. package/package.json +1 -1
package/index.js CHANGED
@@ -25,8 +25,9 @@ import {
25
25
  getSessionContext
26
26
  } from './lib/memory.js';
27
27
  import { listMemories, readMemory, saveMemory, deleteMemory, getMemdirPath } from './lib/memdir.js';
28
+ import { execSync, spawn } from 'child_process';
28
29
 
29
- const VERSION = '3.12.0';
30
+ const VERSION = '3.12.2';
30
31
  const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
31
32
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
32
33
  const DEFAULT_SERVER = 'https://radiometric-reita-amuck.ngrok-free.dev';
@@ -1398,7 +1399,59 @@ Output ONLY the TSUNAMI.md content, starting with "# Project: <name>"`;
1398
1399
  });
1399
1400
  }
1400
1401
 
1401
- run().catch(e => {
1402
- console.error(chalk.red(`Fatal: ${e.message}`));
1403
- process.exit(1);
1404
- });
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
+
1416
+ async function whaleWatcher() {
1417
+ if (!process.stdin.isTTY) return; // skip in --print / pipe mode
1418
+ const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
1419
+ let fi = 0;
1420
+ const interval = setInterval(() => {
1421
+ process.stdout.write(`\r 🐋 ${chalk.bold('Whale Watcher')} ${chalk.dim(frames[fi++ % frames.length] + ' checking for updates...')} `);
1422
+ }, 80);
1423
+ const clear = () => process.stdout.write('\r' + ' '.repeat(70) + '\r');
1424
+ try {
1425
+ const res = await fetch('https://registry.npmjs.org/tsunami-code/latest',
1426
+ { signal: AbortSignal.timeout(5000) });
1427
+ const { version: latest } = await res.json();
1428
+ clearInterval(interval); clear();
1429
+ if (latest && latest !== VERSION) {
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'));
1434
+ try {
1435
+ execSync(`npm install -g tsunami-code@${latest}`, { stdio: 'pipe' });
1436
+ process.stdout.write(chalk.green(` ✓ Updated to v${latest} — restarting...\n\n`));
1437
+ const child = spawn(process.execPath, process.argv.slice(1), { stdio: 'inherit' });
1438
+ child.on('exit', code => process.exit(code || 0));
1439
+ await new Promise(() => {}); // wait for child
1440
+ } catch {
1441
+ process.stdout.write(chalk.yellow(` ⚠ Auto-update failed — run: npm install -g tsunami-code\n\n`));
1442
+ }
1443
+ } else {
1444
+ process.stdout.write(` 🐋 ${chalk.bold('Whale Watcher')} ${chalk.dim(`no updates found v${VERSION}`)}\n\n`);
1445
+ }
1446
+ } catch {
1447
+ clearInterval(interval); clear(); // network error — silent
1448
+ }
1449
+ }
1450
+
1451
+ (async () => {
1452
+ await whaleWatcher();
1453
+ run().catch(e => {
1454
+ console.error(chalk.red(`Fatal: ${e.message}`));
1455
+ process.exit(1);
1456
+ });
1457
+ })();
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
- // Mirrors CC's multi-file undo: each turn's file changes grouped together.
17
- // /undo restores ALL files changed in the last turn at once.
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 (set by index.js at startup) ──────────────────────────────
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
- const out = stdout || '';
102
- const err = stderr ? `\nSTDERR: ${stderr}` : '';
103
- return (out + err).trim() || '(no output)';
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
- const slice = lines.slice(offset, offset + limit);
134
- return slice.map((l, i) => `${offset + i + 1}\t${l}`).join('\n');
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
- ? content.split(old_string).join(new_string)
198
- : content.replace(old_string, new_string);
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
- type: ['string', 'null'],
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/2.9)' }
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(/&nbsp;/g, ' ')
402
- .replace(/&amp;/g, '&')
403
- .replace(/&lt;/g, '<')
404
- .replace(/&gt;/g, '>')
405
- .replace(/&quot;/g, '"')
406
- .replace(/\s{3,}/g, '\n\n')
407
- .trim();
408
- return text.slice(0, 50000) + (text.length > 50000 ? '\n\n[truncated]' : '');
893
+ .replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/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
- // DuckDuckGo uses redirect URLs extract the actual URL
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsunami-code",
3
- "version": "3.12.0",
3
+ "version": "3.12.2",
4
4
  "description": "Tsunami Code CLI — AI coding agent by Keystone World Management Navy Seal Unit XI3",
5
5
  "type": "module",
6
6
  "bin": {