@nexrall/code-core 1.4.22 → 1.4.24

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.
@@ -35,9 +35,14 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.VERIFY_CMD_RE = exports.WRITE_TOOL_NAMES = void 0;
37
37
  exports.resolveMaxIterations = resolveMaxIterations;
38
+ exports.createLimiter = createLimiter;
39
+ exports.extractSubTaskText = extractSubTaskText;
40
+ exports.capSubTaskText = capSubTaskText;
41
+ exports.summariseSubTaskProgress = summariseSubTaskProgress;
38
42
  exports.contextWindowFor = contextWindowFor;
39
43
  exports.compactionThresholds = compactionThresholds;
40
44
  exports.estimateBodyBytes = estimateBodyBytes;
45
+ exports.allowsTestOnlyWrite = allowsTestOnlyWrite;
41
46
  exports.findSafeCutIndex = findSafeCutIndex;
42
47
  exports.transcriptOf = transcriptOf;
43
48
  exports.createLedger = createLedger;
@@ -241,6 +246,58 @@ async function withFileLock(absPath, fn) {
241
246
  _fileLocks.delete(absPath);
242
247
  }
243
248
  }
249
+ // ─── Sub-agent fan-out limiter ────────────────────────────────────────────────
250
+ //
251
+ // Tool calls in one turn run via Promise.all with no ceiling. For ordinary tools
252
+ // that is right — they're cheap and mostly I/O — but a `task` call spawns a WHOLE
253
+ // nested agent loop: its own model stream, its own tool executions, its own
254
+ // sub-process spawns. A model that emits ten `task` blocks in one turn therefore
255
+ // starts ten concurrent agents, each billing tokens and competing for the same
256
+ // CPU, file handles and API rate limit. The practical symptoms are the ones users
257
+ // report as "it got slow and then stalled": every sub-agent's stream slows, some
258
+ // trip their own stall watchdog, and one shared rate limit is spread across ten
259
+ // callers.
260
+ //
261
+ // Anthropic hit the same wall and capped Claude Code's concurrent subagents at 20
262
+ // (v2.1.217, July 2026); community guidance settles far lower, around 3-5, because
263
+ // past that the synthesis overhead cancels the parallelism. We default to 4:
264
+ // enough for genuine fan-out (the case sub-agents exist for), low enough that a
265
+ // runaway `task` burst degrades into a queue instead of a thundering herd.
266
+ //
267
+ // This is a QUEUE, not a rejection: every sub-task still runs, just at most N at a
268
+ // time. Failing the excess would be worse than serialising it.
269
+ const MAX_CONCURRENT_SUBTASKS = (() => {
270
+ const raw = Number(process.env.NEXRALL_MAX_CONCURRENT_SUBTASKS);
271
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 4;
272
+ })();
273
+ /**
274
+ * Minimal concurrency gate. Hand-rolled rather than pulling in `p-limit` because
275
+ * the CLI ships as a single esbuild bundle with no node_modules, and this is a
276
+ * dozen lines.
277
+ */
278
+ function createLimiter(max) {
279
+ let active = 0;
280
+ const queue = [];
281
+ const release = () => {
282
+ active--;
283
+ queue.shift()?.();
284
+ };
285
+ return async (fn) => {
286
+ if (active >= max)
287
+ await new Promise((resolve) => queue.push(resolve));
288
+ active++;
289
+ try {
290
+ return await fn();
291
+ }
292
+ finally {
293
+ release();
294
+ }
295
+ };
296
+ }
297
+ // Process-wide, deliberately: the limit exists to protect shared resources (CPU,
298
+ // the API rate limit, file handles), and those are shared across every concurrent
299
+ // turn in this process, not just the tool calls of one message.
300
+ const _subTaskLimit = createLimiter(MAX_CONCURRENT_SUBTASKS);
244
301
  // ─── Human-readable tool descriptions ────────────────────────────────────────
245
302
  function humanDescription(name, input) {
246
303
  switch (name) {
@@ -326,7 +383,15 @@ function humanDescription(name, input) {
326
383
  }
327
384
  }
328
385
  // ─── Sub-task runner ──────────────────────────────────────────────────────────
329
- const MAX_TASK_DEPTH = 2; // main agent (0) can spawn sub-agents (1) sub-agents cannot spawn further
386
+ // Deepest nesting level allowed to spawn a sub-agent. The main agent runs at
387
+ // depth 0 and the sub-agents it spawns at depth 1.
388
+ //
389
+ // This is 1, not 2. With 2 the guard below (`depth >= MAX_TASK_DEPTH`) let a
390
+ // depth-1 sub-agent spawn depth-2 grandchildren — contradicting both the constant's
391
+ // own comment and the system prompt's promise that "sub-agents cannot spawn further
392
+ // sub-agents", and quietly making the worst-case fan-out quadratic. The value and
393
+ // the documented behaviour now agree.
394
+ const MAX_TASK_DEPTH = 1;
330
395
  let _subTaskCounter = 0; // unique per-process id → per-sub-agent todo scope
331
396
  // A sub-agent that stalls (hung tool, model provider stuck, infinite tool-call
332
397
  // loop bypassing the iteration budget somehow) used to have NO ceiling of its
@@ -338,13 +403,112 @@ let _subTaskCounter = 0; // unique per-process id → per-sub-agent todo scope
338
403
  const SUBTASK_TIMEOUT_MS = Number(process.env.NEXRALL_SUBTASK_TIMEOUT_MS) > 0
339
404
  ? Number(process.env.NEXRALL_SUBTASK_TIMEOUT_MS)
340
405
  : 10 * 60 * 1000; // 10 minutes, matching the general industry norm for a stalled-agent cutoff
406
+ /** Cap on the text a sub-task hands back, so one verbose sub-agent can't blow up
407
+ * the PARENT's context in a single tool_result. */
408
+ const SUBTASK_MAX = 48000; // chars (~12k tokens)
409
+ /**
410
+ * Slice `s` to at most `max` UTF-16 units without splitting a surrogate pair.
411
+ *
412
+ * A bare `slice()` can cut between the high and low half of a non-BMP character
413
+ * (emoji, many CJK extension glyphs), producing a lone surrogate — invalid UTF-16
414
+ * that the Anthropic API rejects outright with "no low surrogate in string". That
415
+ * exact failure has already been shipped and fixed once in this codebase; every
416
+ * new cap on model-facing text has to be surrogate-aware from the start.
417
+ */
418
+ function sliceSafeEnd(s, max) {
419
+ if (s.length <= max)
420
+ return s;
421
+ let end = max;
422
+ const code = s.charCodeAt(end - 1);
423
+ if (code >= 0xd800 && code <= 0xdbff)
424
+ end--; // trailing high surrogate — drop it
425
+ return s.slice(0, end);
426
+ }
427
+ /** Mirror of sliceSafeEnd for a tail slice: never START on a low surrogate. */
428
+ function sliceSafeStart(s, from) {
429
+ if (from <= 0)
430
+ return s;
431
+ let start = from;
432
+ const code = s.charCodeAt(start);
433
+ if (code >= 0xdc00 && code <= 0xdfff)
434
+ start++; // leading low surrogate — drop it
435
+ return s.slice(start);
436
+ }
437
+ /**
438
+ * Reduce a sub-agent's message history to the text its parent should receive.
439
+ *
440
+ * Pure + exported so the salvage rules can be tested without running a real
441
+ * sub-agent (which needs a live model stream and, for the timeout path, ten
442
+ * minutes of wall clock).
443
+ *
444
+ * `preferLast` — the normal completion path — returns the final assistant
445
+ * message, which is the sub-agent's actual answer.
446
+ *
447
+ * `preferLast: false` is the SALVAGE path, used when the sub-agent was cut off.
448
+ * A stopped sub-agent usually has no closing summary at all (it was killed
449
+ * mid-tool-round), so the last assistant message is frequently empty or a
450
+ * fragment. Concatenating what it did produce is far more useful to the parent
451
+ * model than nothing: it can build on the work instead of redoing it.
452
+ */
453
+ function extractSubTaskText(messages, preferLast = true) {
454
+ const assistants = messages.filter((m) => m.role === 'assistant');
455
+ const textOf = (m) => (m?.content ?? [])
456
+ .filter((b) => b.type === 'text' && typeof b.text === 'string')
457
+ .map((b) => b.text)
458
+ .join('')
459
+ .trim();
460
+ if (preferLast)
461
+ return textOf(assistants[assistants.length - 1]);
462
+ return assistants.map(textOf).filter(Boolean).join('\n\n').trim();
463
+ }
464
+ /** Apply the parent-context cap to a sub-task's text, keeping head + tail. */
465
+ function capSubTaskText(text, max = SUBTASK_MAX) {
466
+ if (text.length <= max)
467
+ return text;
468
+ const head = sliceSafeEnd(text, Math.floor(max * 0.6));
469
+ const tail = sliceSafeStart(text, text.length - Math.floor(max * 0.4));
470
+ return `${head}\n\n[… sub-task output truncated (${text.length} chars) — kept the beginning and end …]\n\n${tail}`;
471
+ }
472
+ /**
473
+ * Summarise what a cut-short sub-agent actually accomplished, so the parent model
474
+ * can continue from it rather than starting over.
475
+ *
476
+ * This is the whole point of the salvage path. Previously a timed-out sub-agent
477
+ * returned ONLY an error string: ten minutes of work, dozens of tool calls and
478
+ * any files it wrote were invisible to the parent, which typically responded by
479
+ * re-running the same work from scratch — while the tokens for the discarded run
480
+ * had already been billed in full.
481
+ */
482
+ function summariseSubTaskProgress(messages) {
483
+ const toolNames = [];
484
+ for (const m of messages) {
485
+ if (m.role !== 'assistant' || !Array.isArray(m.content))
486
+ continue;
487
+ for (const b of m.content) {
488
+ if (b?.type === 'tool_use' && typeof b.name === 'string')
489
+ toolNames.push(b.name);
490
+ }
491
+ }
492
+ if (toolNames.length === 0)
493
+ return '';
494
+ // Collapse to "name ×N" so a 40-call run reads as a short inventory rather
495
+ // than forty repeated lines of the same tool name.
496
+ const counts = new Map();
497
+ for (const n of toolNames)
498
+ counts.set(n, (counts.get(n) ?? 0) + 1);
499
+ const inventory = [...counts.entries()]
500
+ .sort((a, b) => b[1] - a[1])
501
+ .map(([name, n]) => (n > 1 ? `${name} ×${n}` : name))
502
+ .join(', ');
503
+ return `Tool calls completed before it was stopped (${toolNames.length} total): ${inventory}.`;
504
+ }
341
505
  async function runSubTask(input, options, agentTypes) {
342
506
  const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
343
507
  if (!prompt)
344
508
  return { error: 'task tool requires a non-empty prompt' };
345
509
  const depth = options._depth ?? 0;
346
510
  if (depth >= MAX_TASK_DEPTH) {
347
- return { error: `Sub-task depth limit (${MAX_TASK_DEPTH}) reached — sub-agents cannot spawn further sub-agents.` };
511
+ return { error: 'Sub-agents cannot spawn further sub-agents. Do this work directly, or report back so the main agent can delegate it.' };
348
512
  }
349
513
  // Resolve an optional custom agent type (subagent_type).
350
514
  const requestedType = typeof input.subagent_type === 'string' ? input.subagent_type : '';
@@ -365,6 +529,10 @@ async function runSubTask(input, options, agentTypes) {
365
529
  const gatedPermission = async (req) => {
366
530
  if (allowed && !allowed.has(req.tool))
367
531
  return false;
532
+ // Path-scoped write restriction (agent.testFilesOnly) — see
533
+ // allowsTestOnlyWrite for the reasoning and its known limit.
534
+ if (agent?.testFilesOnly && !allowsTestOnlyWrite(req.tool, req.input))
535
+ return false;
368
536
  return options.requestPermission(req);
369
537
  };
370
538
  const subMessages = [
@@ -423,32 +591,57 @@ async function runSubTask(input, options, agentTypes) {
423
591
  onThinkingDelta: (text) => options.onThinkingDelta?.(text),
424
592
  onThinkingProgress: (tok) => options.onThinkingProgress?.(tok),
425
593
  });
426
- // The sub-agent stopped because it hit the stall timeout (not because it
427
- // finished or the parent aborted) — report this distinctly so the model
428
- // (and the user, via the tool_result) understands WHY it got cut short,
429
- // instead of silently receiving whatever partial text happened to exist.
594
+ // ── Stall timeout: SALVAGE, don't discard ────────────────────────────────
595
+ //
596
+ // The sub-agent hit its wall-clock cap (rather than finishing, or the parent
597
+ // aborting). This used to return ONLY an error string — throwing away
598
+ // everything the sub-agent had produced in up to ten minutes of work. The
599
+ // tokens were billed in full either way, and the parent model, told merely
600
+ // that "it stalled", would routinely re-run the identical work from scratch.
601
+ //
602
+ // The timeout still has to be reported unmistakably (the parent must not
603
+ // mistake a truncated run for a complete answer), but it is reported ALONGSIDE
604
+ // whatever was actually accomplished, not instead of it. `preferLast: false`
605
+ // because a killed sub-agent rarely has a closing summary — its useful output
606
+ // is spread across the assistant turns it did manage to produce.
430
607
  if (subAbort.aborted && !options.abortSignal?.aborted) {
431
- return { error: `Sub-task stalled and was stopped after ${Math.round(SUBTASK_TIMEOUT_MS / 60000)} minutes with no completion. Consider breaking it into smaller sub-tasks or investigating what it may have been stuck on (a hung command, an unresponsive MCP/tool call, or a very large scope).` };
432
- }
433
- // Extract final assistant text as the task output
434
- const lastAssistant = [...result].reverse().find((m) => m.role === 'assistant');
435
- let text = (lastAssistant?.content ?? [])
436
- .filter((b) => b.type === 'text')
437
- .map((b) => b.text)
438
- .join('')
439
- .trim();
440
- // Cap the sub-agent's returned text so a verbose sub-task (e.g. "list every
441
- // usage") can't blow up the PARENT's context in a single tool_result. Keep
442
- // the head + tail the conclusion is usually at the end.
443
- const SUBTASK_MAX = 48000; // chars (~12k tokens)
444
- if (text.length > SUBTASK_MAX) {
445
- const head = text.slice(0, Math.floor(SUBTASK_MAX * 0.6));
446
- const tail = text.slice(text.length - Math.floor(SUBTASK_MAX * 0.4));
447
- text = `${head}\n\n[… sub-task output truncated (${text.length} chars) — kept the beginning and end …]\n\n${tail}`;
608
+ const mins = Math.round(SUBTASK_TIMEOUT_MS / 60000);
609
+ const partial = capSubTaskText(extractSubTaskText(result, false));
610
+ const progress = summariseSubTaskProgress(result);
611
+ const sections = [
612
+ `Sub-task STOPPED after ${mins} minutes without completing — treat the following as PARTIAL, unverified work, not a finished answer.`,
613
+ progress,
614
+ partial ? `Partial output before it was stopped:\n\n${partial}` : '',
615
+ 'Do NOT simply re-run the same sub-task: build on what is above, or split the remaining work into smaller, more focused sub-tasks.',
616
+ ].filter(Boolean);
617
+ // Returned as `error` (not `output`) on purpose: the loop's ledger counts an
618
+ // errored call as a non-effect, which is right nothing here is verified —
619
+ // and the STALL_LIMIT runaway guard must still see repeated timeouts as
620
+ // failures so a permanently stuck sub-task can't loop forever.
621
+ return { error: sections.join('\n\n') };
448
622
  }
623
+ // Normal completion: the final assistant message is the sub-agent's answer.
624
+ const text = capSubTaskText(extractSubTaskText(result, true));
449
625
  return { output: text || '(sub-task completed with no text output)' };
450
626
  }
451
627
  catch (err) {
628
+ // Same salvage rule as the timeout path above, for the other way a sub-agent
629
+ // dies: runAgentLoop throws AgentTurnError when its stream fails, and that
630
+ // error CARRIES the history completed up to the failure precisely so callers
631
+ // don't lose it (see types.ts). Discarding it here — as this catch used to —
632
+ // reproduced the exact waste that class was written to prevent, one level down.
633
+ const salvaged = (0, types_1.salvageHistory)(err);
634
+ if (salvaged) {
635
+ const partial = capSubTaskText(extractSubTaskText(salvaged, false));
636
+ const progress = summariseSubTaskProgress(salvaged);
637
+ const sections = [
638
+ `Sub-task FAILED before completing: ${err.message}`,
639
+ progress,
640
+ partial ? `Partial output before the failure:\n\n${partial}` : '',
641
+ 'Treat the above as PARTIAL, unverified work. Build on it rather than re-running the whole sub-task.',
642
+ ].filter(Boolean);
643
+ return { error: sections.join('\n\n') };
644
+ }
452
645
  return { error: `Sub-task failed: ${err.message}` };
453
646
  }
454
647
  finally {
@@ -569,6 +762,56 @@ function resolveVerificationNudge(rawSettings) {
569
762
  }
570
763
  /** Tools that mutate the filesystem — used by the verification nudge (GAP D). */
571
764
  exports.WRITE_TOOL_NAMES = new Set(['write_file', 'edit_file', 'multi_edit', 'delete_file', 'move_file', 'copy_file', 'notebook_edit']);
765
+ /**
766
+ * May an agent restricted to `testFilesOnly` perform this tool call?
767
+ *
768
+ * A tool allowlist is all-or-nothing per tool: granting `edit_file` grants it for
769
+ * every path in the repo. The `test-writer` agent needs write access to produce
770
+ * tests, but must NOT be able to "fix" production source so a failing test goes
771
+ * green — the single most common way a test-writing agent destroys the signal it
772
+ * was asked to create. Its prompt says so; this makes it a refusal rather than a
773
+ * request.
774
+ *
775
+ * Pure + exported so the rules are testable directly, without running a real
776
+ * sub-agent.
777
+ *
778
+ * KNOWN LIMIT, stated rather than hidden: this gates the file TOOLS, not `bash`.
779
+ * A determined model could still write source via `bash: echo ... > src/x.ts`.
780
+ * Closing that means parsing shell redirection, which is not reliably doable — so
781
+ * this is a strong guardrail against the realistic failure mode, not a sandbox.
782
+ * Real isolation is the sandbox config (tools/sandbox.ts), a separate mechanism.
783
+ */
784
+ function allowsTestOnlyWrite(tool, input) {
785
+ // Non-write tools are unaffected: reading, searching and running tests are all
786
+ // essential to writing a test.
787
+ //
788
+ // WRITE_TOOL_NAMES deliberately excludes `create_directory`: isTestFile matches
789
+ // FILE paths, so a legitimate `create_directory('test/helpers')` would be
790
+ // refused and the agent could not scaffold the tree it needs — while an empty
791
+ // directory cannot damage production code, and files placed in it are still
792
+ // checked individually.
793
+ if (!exports.WRITE_TOOL_NAMES.has(tool))
794
+ return true;
795
+ // EVERY path the call could affect must be a test file, not just `path`:
796
+ // move_file takes {source, dest} and copy_file {source, destination}, so
797
+ // checking `path` alone would let `move_file src/index.ts -> /tmp/x` through and
798
+ // remove production code by relocating it.
799
+ //
800
+ // `source` is skipped for notebook_edit specifically, where it is the CELL
801
+ // CONTENT rather than a path — treating a blob of code as a path would refuse
802
+ // every legitimate notebook edit.
803
+ const pathKeys = tool === 'notebook_edit'
804
+ ? ['path']
805
+ : ['path', 'source', 'dest', 'destination'];
806
+ const candidates = pathKeys
807
+ .map((k) => input?.[k])
808
+ .filter((v) => typeof v === 'string' && v.length > 0);
809
+ // An unrecognised write shape (no path-like argument at all) is refused rather
810
+ // than allowed through, so a future tool cannot silently become a hole here.
811
+ if (candidates.length === 0)
812
+ return false;
813
+ return candidates.every((p) => (0, testIntegrity_1.isTestFile)(p));
814
+ }
572
815
  /** Heuristic: does a bash command look like it's running tests/build/lint/typecheck? (GAP D) */
573
816
  exports.VERIFY_CMD_RE = /\b(npm|yarn|pnpm)\s+(run\s+)?(test|build|lint|typecheck|tsc)\b|\bpytest\b|\bgo\s+(test|vet|build)\b|\btsc\b|\beslint\b|\bcargo\s+(test|build|check)\b/i;
574
817
  /**
@@ -1515,7 +1758,26 @@ async function runAgentLoop(initialMessages, options) {
1515
1758
  result = { error: 'Permission denied by user' };
1516
1759
  }
1517
1760
  else if (name === 'task') {
1518
- result = await runSubTask(input, options, agentTypes);
1761
+ // Gated so a burst of `task` blocks in one message becomes a QUEUE
1762
+ // rather than N concurrent agent loops (see MAX_CONCURRENT_SUBTASKS).
1763
+ //
1764
+ // The gate wraps the CALL, not the inside of runSubTask, and that
1765
+ // placement is the whole trick: runSubTask arms its own 10-minute stall
1766
+ // timeout as its first act, so acquiring the slot inside it would start
1767
+ // that clock while the sub-task was still sitting in the queue. A
1768
+ // sub-task queued behind three long-running siblings could then be
1769
+ // "stopped for stalling" having never executed a single step.
1770
+ //
1771
+ // Only TOP-LEVEL fan-out is gated (`depth === 0`) — a deliberate
1772
+ // deadlock guard, kept even though MAX_TASK_DEPTH currently makes a
1773
+ // nested spawn impossible anyway. A nested spawn would request a slot
1774
+ // while its parent still holds one; if every slot were held by a parent
1775
+ // waiting on a child that can never be scheduled, the run would wedge
1776
+ // permanently. Keeping the limiter acyclic (a holder never re-enters it)
1777
+ // means raising MAX_TASK_DEPTH later can't silently reintroduce that.
1778
+ result = depth === 0
1779
+ ? await _subTaskLimit(() => runSubTask(input, options, agentTypes))
1780
+ : await runSubTask(input, options, agentTypes);
1519
1781
  }
1520
1782
  else {
1521
1783
  const pre = runToolHooks(hooks.PreToolUse, 'PreToolUse', name, input, options.workDir);
@@ -0,0 +1,27 @@
1
+ export interface SecurityFinding {
2
+ /** Machine-readable class, e.g. 'hardcoded-secret'. */
3
+ kind: string;
4
+ /** One-line explanation aimed at the model, phrased as what to do. */
5
+ message: string;
6
+ /** 1-based line number within the written content. */
7
+ line: number;
8
+ /** The offending line, truncated and with any secret value redacted. */
9
+ sample: string;
10
+ }
11
+ /**
12
+ * Scan file content for flagrant security problems.
13
+ *
14
+ * Returns at most `max` findings (default 5) — enough to be useful, few enough
15
+ * that the note appended to a tool result stays readable and cheap.
16
+ */
17
+ export declare function checkSecurity(content: string, max?: number): SecurityFinding[];
18
+ /**
19
+ * Render findings as a note to append to a successful write's tool result.
20
+ *
21
+ * Phrased as a review comment rather than an error: the write HAS happened, and
22
+ * the model is being asked to look again. Deliberately explicit that it may be a
23
+ * false positive — otherwise the model tends to "fix" flagged-but-correct code,
24
+ * which is its own kind of damage.
25
+ */
26
+ export declare function securityNoteText(findings: SecurityFinding[]): string;
27
+ //# sourceMappingURL=securityLint.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"securityLint.d.ts","sourceRoot":"","sources":["../../src/agent/securityLint.ts"],"names":[],"mappings":"AAuBA,MAAM,WAAW,eAAe;IAC9B,uDAAuD;IACvD,IAAI,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;IAChB,sDAAsD;IACtD,IAAI,EAAE,MAAM,CAAC;IACb,wEAAwE;IACxE,MAAM,EAAE,MAAM,CAAC;CAChB;AA6GD;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,SAAI,GAAG,eAAe,EAAE,CA6CzE;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,eAAe,EAAE,GAAG,MAAM,CAepE"}
@@ -0,0 +1,195 @@
1
+ "use strict";
2
+ // ─── Inline security lint ─────────────────────────────────────────────────────
3
+ //
4
+ // The gap this closes: six checks already run automatically on every write —
5
+ // editCompleteness, crossFile, testIntegrity, claimEvidence, flaky, destructive —
6
+ // and not one of them is about security. For an agent that WRITES production code,
7
+ // nothing stopped it committing a hardcoded credential, an `eval` over
8
+ // user-controlled input, or a SQL string built by concatenation. The only security
9
+ // review available was an agent the user had to know to ask for.
10
+ //
11
+ // DESIGN: WARN, NEVER BLOCK.
12
+ //
13
+ // This runs on every single write, so a false positive is expensive — it would
14
+ // train the model (and the user) to ignore the channel, or worse, stall a
15
+ // legitimate edit. `editCompleteness` can afford to refuse outright because
16
+ // "// ... rest unchanged" is unambiguous; "this looks like SQL injection" is not.
17
+ // So findings are appended to the tool RESULT as a note the model sees and can act
18
+ // on, and the write still succeeds.
19
+ //
20
+ // Consequently every pattern here is tuned for PRECISION over recall. A check that
21
+ // fires on ordinary code was removed rather than loosened. Deep analysis is the
22
+ // `security-auditor` agent's job; this only catches the flagrant cases at the
23
+ // moment they are written.
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.checkSecurity = checkSecurity;
26
+ exports.securityNoteText = securityNoteText;
27
+ /** Max characters of the offending line to echo back. */
28
+ const SAMPLE_MAX = 160;
29
+ /**
30
+ * Redact anything that looks like a literal secret value before echoing a line.
31
+ *
32
+ * The whole point of flagging a hardcoded credential is to get it removed — so
33
+ * this must not copy the value into the transcript (and from there into logs, or
34
+ * the next request's context) on the way to reporting it.
35
+ */
36
+ function redact(line) {
37
+ const masked = line
38
+ // key = "value" / key: 'value' → keep the key, mask the value
39
+ .replace(/(['"`]?[\w.-]*(?:secret|password|passwd|token|api[_-]?key|apikey|auth|credential|private[_-]?key)[\w.-]*['"`]?\s*[:=]\s*)(['"`])([^'"`]{4,})\2/gi, (_m, head, q) => `${head}${q}[REDACTED]${q}`)
40
+ // Bare high-entropy provider tokens appearing anywhere on the line
41
+ .replace(/\b(sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,})\b/g, '[REDACTED]');
42
+ return masked.length > SAMPLE_MAX ? masked.slice(0, SAMPLE_MAX) + '…' : masked;
43
+ }
44
+ /** Strip string/comment noise that causes false positives, keeping length stable. */
45
+ function isLikelyCommentLine(line) {
46
+ return /^\s*(\/\/|\*|#|--|<!--)/.test(line);
47
+ }
48
+ const RULES = [
49
+ // ── Hardcoded credentials ───────────────────────────────────────────────────
50
+ // Provider-prefixed tokens are near-zero false positive: the prefixes are
51
+ // registered formats, not something that occurs naturally in source. Checked
52
+ // inside comments too — a key commented out is still a committed key.
53
+ {
54
+ kind: 'hardcoded-secret',
55
+ re: /\b(sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,})\b/,
56
+ message: 'Looks like a real API key/token committed to source. Move it to an environment variable and rotate the exposed key.',
57
+ includeComments: true,
58
+ },
59
+ // NOTE: the `private-key` check is NOT here — it needs to span multiple lines
60
+ // (BEGIN header on one, base64 body on the next), which this per-line loop
61
+ // cannot express. It runs separately in checkSecurity below.
62
+ {
63
+ kind: 'hardcoded-password',
64
+ // An ASSIGNMENT of a credential-ish name to a non-trivial literal.
65
+ //
66
+ // Tightened after measuring against the real backend, where the looser version
67
+ // fired on `missingSecret:'FIREBASE_TOKEN'` — code that NAMES a secret in an
68
+ // error message, the opposite of leaking one. So a value that is itself just a
69
+ // SCREAMING_SNAKE identifier (an env-var name) is excluded, along with the
70
+ // usual placeholder vocabulary. The value must also look like actual secret
71
+ // material: mixed case or digits, not a lone lowercase word.
72
+ re: /(?:password|passwd|secret|api[_-]?key|apikey|access[_-]?token)['"`]?\s*[:=]\s*['"`](?![A-Z0-9_]+['"`])(?!.*(?:\$\{|process\.env|os\.environ|example|changeme|placeholder|redacted|xxx|test|dummy|fake|sample|your[_-]?|<|\*{3}))(?=[^'"`]*[0-9A-Z])[^'"`\s]{10,}['"`]/,
73
+ message: 'Hardcoded credential literal. Read it from the environment/secret store instead, and rotate the exposed value.',
74
+ includeComments: true,
75
+ },
76
+ // ── Injection ───────────────────────────────────────────────────────────────
77
+ {
78
+ kind: 'dynamic-eval',
79
+ // The negative lookbehind for `.` is what makes this usable: `redisClient.eval`
80
+ // (a Redis Lua script), `page.eval` (Playwright), `vm.eval` and friends are
81
+ // METHOD calls on an object and have nothing to do with JavaScript's global
82
+ // eval. Without it, the real backend's Redis idempotency script was flagged.
83
+ // Only a bare `eval(` / `new Function(` with a non-literal argument counts.
84
+ re: /(?<![.\w$])(?:eval|new\s+Function)\s*\(\s*(?!['"`][^'"`]*['"`]\s*\))[^)]*[a-zA-Z_$][\w$]*/,
85
+ message: 'eval / new Function on a non-literal value executes arbitrary code if that value is ever user-controlled. Use an explicit parser or a lookup table.',
86
+ },
87
+ {
88
+ kind: 'sql-injection',
89
+ // Only fires when the interpolated expression is plausibly REQUEST-DERIVED.
90
+ //
91
+ // The obvious pattern — any `${...}` inside a SQL string — was measured
92
+ // against the real backend and flagged 15 of 183 files, essentially all of
93
+ // them safe and idiomatic: `${sets.join(', ')}` for a dynamic UPDATE, `${field}`
94
+ // for a server-chosen column, `${CONSUMPTION}` for a module constant. At that
95
+ // hit rate the warning is pure noise, and noise is worse than silence because
96
+ // it teaches everyone to skip the channel.
97
+ //
98
+ // So the interpolation must name something that plausibly came from the
99
+ // outside: req/request/params/query/body/input/user/args, or a bare
100
+ // `'...' + ident`. This trades recall for precision on purpose — thorough SQL
101
+ // review is the security-auditor agent's job, not an inline regex's.
102
+ re: /\b(?:SELECT|INSERT\s+INTO|UPDATE|DELETE\s+FROM)\b[^;'"`]{0,160}(?:\$\{\s*(?:req|request|params?|query|body|input|user|args|ctx)\b|['"`]\s*\+\s*(?:req|request|params?|query|body|input|user|args|ctx)\b|%\s*\(\s*(?:request|params?|query|body|input|user)\b)/i,
103
+ message: 'SQL built by interpolating a request-derived value. Use a parameterised query ($1 / ? placeholders) — this is the classic injection sink.',
104
+ },
105
+ {
106
+ kind: 'command-injection',
107
+ // Shell execution with an interpolated or concatenated argument.
108
+ re: /\b(?:exec|execSync|spawnSync?|system|popen|os\.system|subprocess\.(?:call|run|Popen))\s*\(\s*(?:[`'"][^`'"]*(?:\$\{|['"]\s*\+)|[a-zA-Z_$][\w$]*\s*\+)/,
109
+ message: 'Shell command built from a variable. Pass arguments as an array (no shell), or validate against an allowlist — a value containing ; or $() becomes command execution.',
110
+ },
111
+ // ── Transport / verification ────────────────────────────────────────────────
112
+ {
113
+ kind: 'tls-verification-disabled',
114
+ re: /(?:rejectUnauthorized\s*:\s*false|NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['"]?0|verify\s*=\s*False|InsecureSkipVerify\s*:\s*true)/,
115
+ message: 'TLS certificate verification is disabled, which removes protection against man-in-the-middle attacks. Trust a specific CA instead if the cert is self-signed.',
116
+ },
117
+ ];
118
+ /**
119
+ * Scan file content for flagrant security problems.
120
+ *
121
+ * Returns at most `max` findings (default 5) — enough to be useful, few enough
122
+ * that the note appended to a tool result stays readable and cheap.
123
+ */
124
+ function checkSecurity(content, max = 5) {
125
+ if (!content)
126
+ return [];
127
+ const findings = [];
128
+ const lines = content.split(/\r?\n/);
129
+ // Private keys are matched across lines, unlike every other rule.
130
+ //
131
+ // A real PEM block is inherently multi-line: the BEGIN header sits on one line
132
+ // and the base64 body on the next. Requiring both on ONE line (which the
133
+ // line-by-line loop below does) meant the single highest-severity finding here
134
+ // only fired for keys embedded in a "\n"-escaped string literal, and missed the
135
+ // far more common case of a key pasted in verbatim.
136
+ const pemIdx = lines.findIndex((l) => /-----BEGIN\s+(?:RSA|EC|DSA|OPENSSH|PGP)?\s*PRIVATE KEY-----/.test(l));
137
+ if (pemIdx !== -1) {
138
+ // Require actual key material nearby, so a bare header — placeholder text in a
139
+ // config UI, documentation, a PEM parser — does not trip it.
140
+ const following = lines.slice(pemIdx, pemIdx + 4).join('\n');
141
+ if (/[A-Za-z0-9+/]{40,}/.test(following.replace(/-----[^-]+-----/g, ''))) {
142
+ findings.push({
143
+ kind: 'private-key',
144
+ message: 'A private key with real key material is being written into source. Store it outside the repo (secret manager / env var) and rotate it.',
145
+ line: pemIdx + 1,
146
+ sample: '-----BEGIN PRIVATE KEY----- [REDACTED]',
147
+ });
148
+ }
149
+ }
150
+ // One finding per (kind, line) at most, and one finding per kind overall —
151
+ // a file with fifty interpolated queries should say "SQL injection" once, not
152
+ // fill the model's context with fifty copies of the same advice.
153
+ const seenKinds = new Set();
154
+ for (let i = 0; i < lines.length && findings.length < max; i++) {
155
+ const line = lines[i];
156
+ if (!line || line.length > 2000)
157
+ continue; // minified/bundled — not hand-written source
158
+ const commentish = isLikelyCommentLine(line);
159
+ for (const rule of RULES) {
160
+ if (seenKinds.has(rule.kind))
161
+ continue;
162
+ if (commentish && !rule.includeComments)
163
+ continue;
164
+ if (!rule.re.test(line))
165
+ continue;
166
+ seenKinds.add(rule.kind);
167
+ findings.push({ kind: rule.kind, message: rule.message, line: i + 1, sample: redact(line.trim()) });
168
+ break; // at most one rule per line
169
+ }
170
+ }
171
+ return findings;
172
+ }
173
+ /**
174
+ * Render findings as a note to append to a successful write's tool result.
175
+ *
176
+ * Phrased as a review comment rather than an error: the write HAS happened, and
177
+ * the model is being asked to look again. Deliberately explicit that it may be a
178
+ * false positive — otherwise the model tends to "fix" flagged-but-correct code,
179
+ * which is its own kind of damage.
180
+ */
181
+ function securityNoteText(findings) {
182
+ // Opt-out, mirroring NEXRALL_ALLOW_ELIDED_WRITE. Someone working in a codebase
183
+ // that trips a rule constantly (a SQL-builder library, a crypto implementation,
184
+ // a test-fixture directory full of fake keys) needs a way to silence this
185
+ // without disabling the write path itself.
186
+ if (process.env.NEXRALL_SECURITY_LINT === 'off')
187
+ return '';
188
+ if (!findings.length)
189
+ return '';
190
+ const lines = findings.map((f) => ` • line ${f.line} [${f.kind}]: ${f.message}\n ${f.sample}`);
191
+ return (`\n\n⚠ SECURITY REVIEW (${findings.length} finding${findings.length > 1 ? 's' : ''}) — the write succeeded; check these before moving on:\n` +
192
+ lines.join('\n') +
193
+ `\nIf a finding is a false positive (test fixture, placeholder, intentionally dynamic), say so and continue — do NOT rewrite correct code to silence it.`);
194
+ }
195
+ //# sourceMappingURL=securityLint.js.map
@@ -76,6 +76,18 @@ export declare function streamChat(messages: Message[], options: StreamChatOptio
76
76
  * bounds the cost if this request never lands.
77
77
  */
78
78
  export declare function cancelTurn(turnId: string): Promise<void>;
79
+ /**
80
+ * Revoke the stored refresh token server-side.
81
+ *
82
+ * Deleting the local config alone is NOT a logout once refresh tokens exist: the
83
+ * token stays valid for its full 60-day life and can keep minting access tokens
84
+ * for anyone who recovered it (a synced dotfile, a backup, a shared machine).
85
+ * The backend already exposes /api/auth/logout for exactly this.
86
+ *
87
+ * Best-effort and never throws — a failed revoke must not stop the local
88
+ * credentials from being cleared, which is the part the user can see.
89
+ */
90
+ export declare function revokeRefreshToken(): Promise<void>;
79
91
  export declare function getBalance(): Promise<number>;
80
92
  export declare function exchangeVscodeCode(code: string): Promise<AuthConfig>;
81
93
  export declare function login(email: string, password: string): Promise<AuthConfig>;
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,UAAU,EAA8B,UAAU,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAiErH,eAAO,MAAM,QAAQ,QAAmB,CAAC;AAIzC;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,SAAS;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,EAC5D,OAAO,EAAE,CAAC,EAAE,EACZ,UAAU,EAAE,KAAK,CAAC;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,IAAI,GAAG,SAAS,GACtD,CAAC,EAAE,CAeL;AAeD,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB,aAAa,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC;IACnC,oFAAoF;IACpF,UAAU,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IACjG,6EAA6E;IAC7E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6IAA6I;IAC7I,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;;;;;;;;OAeG;IACH,uBAAuB,CAAC,EAAE,OAAO,CAAC;CACnC;AAiDD,wBAAsB,UAAU,CAC9B,QAAQ,EAAE,OAAO,EAAE,EACnB,OAAO,EAAE,iBAAiB,EAC1B,OAAO,EAAE,CAAC,CAAC,EAAE,QAAQ,KAAK,IAAI,GAC7B,OAAO,CAAC,OAAO,CAAC,CAu7BlB;AAID;;;;;;;;;;;GAWG;AACH,wBAAsB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAU9D;AAID,wBAAsB,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC,CAalD;AAID,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAgB1E;AAID,wBAAsB,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAkBhF"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,UAAU,EAA8B,UAAU,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAiErH,eAAO,MAAM,QAAQ,QAAmB,CAAC;AAIzC;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,SAAS;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,EAC5D,OAAO,EAAE,CAAC,EAAE,EACZ,UAAU,EAAE,KAAK,CAAC;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,IAAI,GAAG,SAAS,GACtD,CAAC,EAAE,CAeL;AAiGD,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB,aAAa,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC;IACnC,oFAAoF;IACpF,UAAU,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IACjG,6EAA6E;IAC7E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6IAA6I;IAC7I,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;;;;;;;;OAeG;IACH,uBAAuB,CAAC,EAAE,OAAO,CAAC;CACnC;AAiDD,wBAAsB,UAAU,CAC9B,QAAQ,EAAE,OAAO,EAAE,EACnB,OAAO,EAAE,iBAAiB,EAC1B,OAAO,EAAE,CAAC,CAAC,EAAE,QAAQ,KAAK,IAAI,GAC7B,OAAO,CAAC,OAAO,CAAC,CA6jClB;AAID;;;;;;;;;;;GAWG;AACH,wBAAsB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAU9D;AAID;;;;;;;;;;GAUG;AACH,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC,CAYxD;AAID,wBAAsB,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC,CA0BlD;AAID,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CA+B1E;AAID,wBAAsB,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CA0BhF"}