@nexrall/code-core 1.4.23 → 1.4.25

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.
@@ -1 +1 @@
1
- {"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"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"}
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.VERIFY_CMD_RE = exports.WRITE_TOOL_NAMES = void 0;
36
+ exports.VERIFY_CMD_RE = exports.WRITE_TOOL_NAMES = exports.ToolNotAllowedError = void 0;
37
37
  exports.resolveMaxIterations = resolveMaxIterations;
38
38
  exports.createLimiter = createLimiter;
39
39
  exports.extractSubTaskText = extractSubTaskText;
@@ -42,6 +42,7 @@ exports.summariseSubTaskProgress = summariseSubTaskProgress;
42
42
  exports.contextWindowFor = contextWindowFor;
43
43
  exports.compactionThresholds = compactionThresholds;
44
44
  exports.estimateBodyBytes = estimateBodyBytes;
45
+ exports.allowsTestOnlyWrite = allowsTestOnlyWrite;
45
46
  exports.findSafeCutIndex = findSafeCutIndex;
46
47
  exports.transcriptOf = transcriptOf;
47
48
  exports.createLedger = createLedger;
@@ -405,6 +406,24 @@ const SUBTASK_TIMEOUT_MS = Number(process.env.NEXRALL_SUBTASK_TIMEOUT_MS) > 0
405
406
  /** Cap on the text a sub-task hands back, so one verbose sub-agent can't blow up
406
407
  * the PARENT's context in a single tool_result. */
407
408
  const SUBTASK_MAX = 48000; // chars (~12k tokens)
409
+ /**
410
+ * Thrown by a sub-agent's permission gate when the AGENT DEFINITION forbids a
411
+ * tool — as opposed to the user declining it.
412
+ *
413
+ * The distinction matters to the model, which is why this is an exception rather
414
+ * than a `false`: both used to collapse into "Permission denied by user", so an
415
+ * agent blocked by its own allowlist (very often a mistyped tool name) was told
416
+ * the human had refused. The rational response to that is to ask again, which
417
+ * can never succeed. Carrying a reason lets the tool_result say what is actually
418
+ * true and what to do instead.
419
+ */
420
+ class ToolNotAllowedError extends Error {
421
+ constructor(message) {
422
+ super(message);
423
+ this.name = 'ToolNotAllowedError';
424
+ }
425
+ }
426
+ exports.ToolNotAllowedError = ToolNotAllowedError;
408
427
  /**
409
428
  * Slice `s` to at most `max` UTF-16 units without splitting a surrogate pair.
410
429
  *
@@ -510,11 +529,32 @@ async function runSubTask(input, options, agentTypes) {
510
529
  return { error: 'Sub-agents cannot spawn further sub-agents. Do this work directly, or report back so the main agent can delegate it.' };
511
530
  }
512
531
  // Resolve an optional custom agent type (subagent_type).
532
+ //
533
+ // `agentTypes` is a snapshot taken once at the top of runAgentLoop, before the
534
+ // model said anything. That made "write .nexrall/agents/x.md, then use it"
535
+ // impossible within a single turn: the file existed on disk, but this lookup
536
+ // consulted a list captured before it was written, and the model was told the
537
+ // agent did not exist — which reads as "creating it failed".
538
+ //
539
+ // So on a MISS ONLY, re-read from disk before giving up. The hit path (every
540
+ // normal call) still costs zero syscalls, and the miss path costs ~4 mostly-
541
+ // ENOENT stats — against a sub-agent that is about to run for seconds to
542
+ // minutes. Note runAgentLoop re-reads for the sub-agent anyway, so the old
543
+ // behaviour was already inconsistent: fresh for the child, stale for the lookup.
513
544
  const requestedType = typeof input.subagent_type === 'string' ? input.subagent_type : '';
514
- const agent = (0, agentTypes_1.findAgentType)(agentTypes, requestedType);
545
+ let agent = (0, agentTypes_1.findAgentType)(agentTypes, requestedType);
546
+ let knownTypes = agentTypes;
515
547
  if (requestedType && !agent) {
516
- const known = agentTypes.map((a) => a.name).join(', ') || '(none defined)';
517
- return { error: `Unknown subagent_type "${requestedType}". Available types: ${known}.` };
548
+ knownTypes = (0, agentTypes_1.loadAgentTypes)(options.workDir);
549
+ agent = (0, agentTypes_1.findAgentType)(knownTypes, requestedType);
550
+ }
551
+ if (requestedType && !agent) {
552
+ const known = knownTypes.map((a) => a.name).join(', ') || '(none defined)';
553
+ return {
554
+ error: `Unknown subagent_type "${requestedType}". Available types: ${known}.\n` +
555
+ 'If you just created .nexrall/agents/' + requestedType + '.md, make sure the write finished in an ' +
556
+ 'EARLIER tool call than this one — a file written in the same batch may not be on disk yet.',
557
+ };
518
558
  }
519
559
  // A custom agent's persona is delivered through the project-instructions
520
560
  // channel (authoritative in the system prompt), layered above the project's
@@ -524,10 +564,24 @@ async function runSubTask(input, options, agentTypes) {
524
564
  (options.nexrallMd ? `\n\n---\n\n${options.nexrallMd}` : '')
525
565
  : options.nexrallMd;
526
566
  // Optional tool allowlist — deny anything outside it for this sub-agent.
567
+ //
568
+ // A refusal here is reported through `deniedReason` rather than the generic
569
+ // "Permission denied by user", which was actively misleading: the user denied
570
+ // nothing, and a model told that will re-ask for approval instead of noticing
571
+ // that the agent's own allowlist (often a typo'd tool name) is what stopped it.
527
572
  const allowed = agent?.tools ? new Set(agent.tools) : null;
528
573
  const gatedPermission = async (req) => {
529
- if (allowed && !allowed.has(req.tool))
530
- return false;
574
+ if (allowed && !allowed.has(req.tool)) {
575
+ throw new ToolNotAllowedError(`The "${agent.name}" sub-agent is not allowed to use \`${req.tool}\` — it is not in that agent's ` +
576
+ 'tool allowlist. This is a restriction of the agent definition, NOT a user decision: do not ask ' +
577
+ 'for approval, use one of the tools you do have, or report back that the task needs a different agent.');
578
+ }
579
+ // Path-scoped write restriction (agent.testFilesOnly) — see
580
+ // allowsTestOnlyWrite for the reasoning and its known limit.
581
+ if (agent?.testFilesOnly && !allowsTestOnlyWrite(req.tool, req.input)) {
582
+ throw new ToolNotAllowedError(`The "${agent.name}" sub-agent may only write to TEST files, so \`${req.tool}\` was refused for this ` +
583
+ 'path. Do not try to work around it: if production code must change, say so in your report instead.');
584
+ }
531
585
  return options.requestPermission(req);
532
586
  };
533
587
  const subMessages = [
@@ -757,6 +811,56 @@ function resolveVerificationNudge(rawSettings) {
757
811
  }
758
812
  /** Tools that mutate the filesystem — used by the verification nudge (GAP D). */
759
813
  exports.WRITE_TOOL_NAMES = new Set(['write_file', 'edit_file', 'multi_edit', 'delete_file', 'move_file', 'copy_file', 'notebook_edit']);
814
+ /**
815
+ * May an agent restricted to `testFilesOnly` perform this tool call?
816
+ *
817
+ * A tool allowlist is all-or-nothing per tool: granting `edit_file` grants it for
818
+ * every path in the repo. The `test-writer` agent needs write access to produce
819
+ * tests, but must NOT be able to "fix" production source so a failing test goes
820
+ * green — the single most common way a test-writing agent destroys the signal it
821
+ * was asked to create. Its prompt says so; this makes it a refusal rather than a
822
+ * request.
823
+ *
824
+ * Pure + exported so the rules are testable directly, without running a real
825
+ * sub-agent.
826
+ *
827
+ * KNOWN LIMIT, stated rather than hidden: this gates the file TOOLS, not `bash`.
828
+ * A determined model could still write source via `bash: echo ... > src/x.ts`.
829
+ * Closing that means parsing shell redirection, which is not reliably doable — so
830
+ * this is a strong guardrail against the realistic failure mode, not a sandbox.
831
+ * Real isolation is the sandbox config (tools/sandbox.ts), a separate mechanism.
832
+ */
833
+ function allowsTestOnlyWrite(tool, input) {
834
+ // Non-write tools are unaffected: reading, searching and running tests are all
835
+ // essential to writing a test.
836
+ //
837
+ // WRITE_TOOL_NAMES deliberately excludes `create_directory`: isTestFile matches
838
+ // FILE paths, so a legitimate `create_directory('test/helpers')` would be
839
+ // refused and the agent could not scaffold the tree it needs — while an empty
840
+ // directory cannot damage production code, and files placed in it are still
841
+ // checked individually.
842
+ if (!exports.WRITE_TOOL_NAMES.has(tool))
843
+ return true;
844
+ // EVERY path the call could affect must be a test file, not just `path`:
845
+ // move_file takes {source, dest} and copy_file {source, destination}, so
846
+ // checking `path` alone would let `move_file src/index.ts -> /tmp/x` through and
847
+ // remove production code by relocating it.
848
+ //
849
+ // `source` is skipped for notebook_edit specifically, where it is the CELL
850
+ // CONTENT rather than a path — treating a blob of code as a path would refuse
851
+ // every legitimate notebook edit.
852
+ const pathKeys = tool === 'notebook_edit'
853
+ ? ['path']
854
+ : ['path', 'source', 'dest', 'destination'];
855
+ const candidates = pathKeys
856
+ .map((k) => input?.[k])
857
+ .filter((v) => typeof v === 'string' && v.length > 0);
858
+ // An unrecognised write shape (no path-like argument at all) is refused rather
859
+ // than allowed through, so a future tool cannot silently become a hole here.
860
+ if (candidates.length === 0)
861
+ return false;
862
+ return candidates.every((p) => (0, testIntegrity_1.isTestFile)(p));
863
+ }
760
864
  /** Heuristic: does a bash command look like it's running tests/build/lint/typecheck? (GAP D) */
761
865
  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;
762
866
  /**
@@ -1693,14 +1797,19 @@ async function runAgentLoop(initialMessages, options) {
1693
1797
  // Request permission
1694
1798
  const description = humanDescription(name, input);
1695
1799
  let permitted;
1800
+ // A definition-level refusal carries its own explanation and must not be
1801
+ // flattened into the generic user-denial message below.
1802
+ let deniedReason = null;
1696
1803
  try {
1697
1804
  permitted = await options.requestPermission({ tool: name, input, description });
1698
1805
  }
1699
- catch {
1806
+ catch (err) {
1700
1807
  permitted = false;
1808
+ if (err instanceof ToolNotAllowedError)
1809
+ deniedReason = err.message;
1701
1810
  }
1702
1811
  if (!permitted) {
1703
- result = { error: 'Permission denied by user' };
1812
+ result = { error: deniedReason ?? 'Permission denied by user' };
1704
1813
  }
1705
1814
  else if (name === 'task') {
1706
1815
  // Gated so a burst of `task` blocks in one message becomes a QUEUE
@@ -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
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export * from './tools/executor';
5
5
  export * from './agent/loop';
6
6
  export * from './agent/testIntegrity';
7
7
  export * from './agent/editCompleteness';
8
+ export * from './agent/securityLint';
8
9
  export * from './agent/crossFile';
9
10
  export * from './agent/flaky';
10
11
  export * from './agent/claimEvidence';
@@ -20,4 +21,5 @@ export * from './permissions/rules';
20
21
  export * from './permissions/destructive';
21
22
  export * from './plugins/index';
22
23
  export * from './plugins/installer';
24
+ export * from './plugins/sources';
23
25
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,mBAAmB,CAAC;AAClC,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC;AAC9B,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,iBAAiB,CAAC;AAChC,cAAc,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC;AAC9B,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,iBAAiB,CAAC;AAChC,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC"}
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ __exportStar(require("./tools/executor"), exports);
21
21
  __exportStar(require("./agent/loop"), exports);
22
22
  __exportStar(require("./agent/testIntegrity"), exports);
23
23
  __exportStar(require("./agent/editCompleteness"), exports);
24
+ __exportStar(require("./agent/securityLint"), exports);
24
25
  __exportStar(require("./agent/crossFile"), exports);
25
26
  __exportStar(require("./agent/flaky"), exports);
26
27
  __exportStar(require("./agent/claimEvidence"), exports);
@@ -36,4 +37,5 @@ __exportStar(require("./permissions/rules"), exports);
36
37
  __exportStar(require("./permissions/destructive"), exports);
37
38
  __exportStar(require("./plugins/index"), exports);
38
39
  __exportStar(require("./plugins/installer"), exports);
40
+ __exportStar(require("./plugins/sources"), exports);
39
41
  //# sourceMappingURL=index.js.map
@@ -7,6 +7,26 @@ export interface PluginInfo {
7
7
  dir: string;
8
8
  scope: 'project' | 'global';
9
9
  }
10
+ /**
11
+ * Where each single-file component may live, in priority order.
12
+ *
13
+ * Our own layout is checked first so a plugin shipping both is unambiguous, and
14
+ * so this stays additive: nothing that worked before changes behaviour.
15
+ *
16
+ * SECURITY: `hooks` and `mcp` here are code-execution surfaces. Every reader
17
+ * below and `inspectPluginDir` in installer.ts MUST resolve through this same
18
+ * table — if the loader reads a path the inspector does not check, a plugin can
19
+ * run commands that the install-time warning never mentioned. Adding a candidate
20
+ * without updating the inspector is exactly that bug, so the two are pinned
21
+ * together by a test.
22
+ */
23
+ export declare const FILE_CANDIDATES: {
24
+ readonly manifest: readonly ["plugin.json", string];
25
+ readonly hooks: readonly ["hooks.json", string];
26
+ readonly mcp: readonly ["mcp.json", ".mcp.json"];
27
+ };
28
+ /** First existing candidate path for a component, or null. */
29
+ export declare function resolvePluginFile(dir: string, kind: keyof typeof FILE_CANDIDATES): string | null;
10
30
  /** Discover installed plugins (project scope shadows global on name clash). */
11
31
  export declare function loadPlugins(workDir: string): PluginInfo[];
12
32
  /** Subdirectories of every installed plugin that hold `kind` assets (existing only). */
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/plugins/index.ts"],"names":[],"mappings":"AAuBA,MAAM,WAAW,UAAU;IACzB,2DAA2D;IAC3D,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6CAA6C;IAC7C,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,SAAS,GAAG,QAAQ,CAAC;CAC7B;AAsCD,+EAA+E;AAC/E,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU,EAAE,CAKzD;AAED,wFAAwF;AACxF,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,EAAE,CAMjG;AAED,2FAA2F;AAC3F,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAetE;AAED,iGAAiG;AACjG,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAazE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/plugins/index.ts"],"names":[],"mappings":"AAsCA,MAAM,WAAW,UAAU;IACzB,2DAA2D;IAC3D,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6CAA6C;IAC7C,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,SAAS,GAAG,QAAQ,CAAC;CAC7B;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,eAAe;;;;CAIlB,CAAC;AAEX,8DAA8D;AAC9D,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,OAAO,eAAe,GAAG,MAAM,GAAG,IAAI,CAQhG;AAwCD,+EAA+E;AAC/E,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU,EAAE,CAKzD;AAED,wFAAwF;AACxF,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,EAAE,CAMjG;AAED,2FAA2F;AAC3F,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAiBtE;AAED,iGAAiG;AACjG,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAezE"}
@@ -33,6 +33,8 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.FILE_CANDIDATES = void 0;
37
+ exports.resolvePluginFile = resolvePluginFile;
36
38
  exports.loadPlugins = loadPlugins;
37
39
  exports.pluginAssetDirs = pluginAssetDirs;
38
40
  exports.pluginHooks = pluginHooks;
@@ -40,9 +42,42 @@ exports.pluginMcpServers = pluginMcpServers;
40
42
  const fs = __importStar(require("fs"));
41
43
  const path = __importStar(require("path"));
42
44
  const os = __importStar(require("os"));
45
+ /**
46
+ * Where each single-file component may live, in priority order.
47
+ *
48
+ * Our own layout is checked first so a plugin shipping both is unambiguous, and
49
+ * so this stays additive: nothing that worked before changes behaviour.
50
+ *
51
+ * SECURITY: `hooks` and `mcp` here are code-execution surfaces. Every reader
52
+ * below and `inspectPluginDir` in installer.ts MUST resolve through this same
53
+ * table — if the loader reads a path the inspector does not check, a plugin can
54
+ * run commands that the install-time warning never mentioned. Adding a candidate
55
+ * without updating the inspector is exactly that bug, so the two are pinned
56
+ * together by a test.
57
+ */
58
+ exports.FILE_CANDIDATES = {
59
+ manifest: ['plugin.json', path.join('.claude-plugin', 'plugin.json')],
60
+ hooks: ['hooks.json', path.join('hooks', 'hooks.json')],
61
+ mcp: ['mcp.json', '.mcp.json'],
62
+ };
63
+ /** First existing candidate path for a component, or null. */
64
+ function resolvePluginFile(dir, kind) {
65
+ for (const rel of exports.FILE_CANDIDATES[kind]) {
66
+ const full = path.join(dir, rel);
67
+ try {
68
+ if (fs.statSync(full).isFile())
69
+ return full;
70
+ }
71
+ catch { /* try the next candidate */ }
72
+ }
73
+ return null;
74
+ }
43
75
  function readMeta(dir) {
76
+ const file = resolvePluginFile(dir, 'manifest');
77
+ if (!file)
78
+ return {};
44
79
  try {
45
- const raw = fs.readFileSync(path.join(dir, 'plugin.json'), 'utf-8');
80
+ const raw = fs.readFileSync(file, 'utf-8');
46
81
  const j = JSON.parse(raw);
47
82
  return {
48
83
  name: typeof j.name === 'string' ? j.name : undefined,
@@ -104,8 +139,11 @@ function pluginAssetDirs(workDir, kind) {
104
139
  function pluginHooks(workDir) {
105
140
  const merged = {};
106
141
  for (const p of loadPlugins(workDir)) {
142
+ const file = resolvePluginFile(p.dir, 'hooks');
143
+ if (!file)
144
+ continue;
107
145
  try {
108
- const raw = fs.readFileSync(path.join(p.dir, 'hooks.json'), 'utf-8');
146
+ const raw = fs.readFileSync(file, 'utf-8');
109
147
  const j = JSON.parse(raw);
110
148
  // Accept either { hooks: {...} } or the bare hooks object.
111
149
  const hooks = (j.hooks ?? j);
@@ -123,8 +161,11 @@ function pluginHooks(workDir) {
123
161
  function pluginMcpServers(workDir) {
124
162
  const merged = {};
125
163
  for (const p of loadPlugins(workDir)) {
164
+ const file = resolvePluginFile(p.dir, 'mcp');
165
+ if (!file)
166
+ continue;
126
167
  try {
127
- const raw = fs.readFileSync(path.join(p.dir, 'mcp.json'), 'utf-8');
168
+ const raw = fs.readFileSync(file, 'utf-8');
128
169
  const j = JSON.parse(raw);
129
170
  const servers = (j.mcpServers ?? j);
130
171
  for (const [name, cfg] of Object.entries(servers)) {
@@ -26,12 +26,29 @@ export interface InstallReceipt {
26
26
  ref?: string;
27
27
  subdir?: string;
28
28
  installedAt: string;
29
+ /**
30
+ * The exact commit these files came from.
31
+ *
32
+ * Without it an install was not reproducible and, worse, not auditable: the
33
+ * receipt recorded `"owner/repo"`, so nobody — including `nex plugin update` —
34
+ * could say WHICH code had been reviewed and approved. Recording the resolved
35
+ * SHA lets an update report `abc1234 → def5678` instead of silently swapping
36
+ * the contents of a plugin the user already trusted.
37
+ *
38
+ * Optional because resolution needs a network call that must never be the
39
+ * reason an install fails (see resolveCommitSha).
40
+ */
41
+ sha?: string;
29
42
  }
30
43
  export interface InstallResult {
31
44
  name: string;
32
45
  dir: string;
33
46
  scope: 'project' | 'global';
34
47
  inspection: PluginInspection;
48
+ /** Commit installed, when known. */
49
+ sha?: string;
50
+ /** For an update: the commit that was previously installed, when known. */
51
+ previousSha?: string;
35
52
  }
36
53
  export interface RegistryPlugin {
37
54
  name: string;
@@ -57,7 +74,17 @@ export declare function getRegistryPlugin(name: string): Promise<RegistryPlugin
57
74
  export declare function reportInstall(name: string): void;
58
75
  /** Parse a user-supplied plugin spec into a structured source. Throws on junk. */
59
76
  export declare function parsePluginSource(spec: string): PluginSource;
60
- /** What does this plugin contain? Callers must warn on hasHooks/hasMcp. */
77
+ /**
78
+ * What does this plugin contain? Callers must warn on hasHooks/hasMcp.
79
+ *
80
+ * Resolves every single-file component through the SAME candidate table the
81
+ * loader uses (plugins/index.ts FILE_CANDIDATES). That shared lookup is a
82
+ * security requirement, not tidiness: this function produces the warning shown
83
+ * before install, so any path the loader would execute but the inspector does
84
+ * not check is a plugin that runs code the user was never told about. A Claude
85
+ * Code plugin shipping `.mcp.json` used to be exactly that — reported as
86
+ * containing no MCP servers.
87
+ */
61
88
  export declare function inspectPluginDir(dir: string): PluginInspection;
62
89
  export interface InstallOptions {
63
90
  /** 'global' (default) → ~/.nexrall/plugins; 'project' → <workDir>/.nexrall/plugins */
@@ -71,7 +98,18 @@ export interface InstallOptions {
71
98
  * Called after inspection, before finalising. Return false to abort.
72
99
  * Callers should surface hooks/MCP warnings here.
73
100
  */
74
- confirm?: (inspection: PluginInspection, name: string) => Promise<boolean> | boolean;
101
+ confirm?: (inspection: PluginInspection, name: string, ctx?: InstallContext) => Promise<boolean> | boolean;
102
+ /** Internal: SHA previously installed, so an update can be described as a change. */
103
+ _previousSha?: string;
104
+ }
105
+ /** Extra facts about what is being installed, for the confirmation prompt. */
106
+ export interface InstallContext {
107
+ /** Commit about to be installed, when it could be resolved. */
108
+ sha?: string;
109
+ /** Commit currently installed (updates only). */
110
+ previousSha?: string;
111
+ /** True when this replaces an existing install of the same name. */
112
+ isUpdate: boolean;
75
113
  }
76
114
  export declare function installPlugin(spec: string, opts?: InstallOptions): Promise<InstallResult>;
77
115
  export declare function readReceipt(pluginDir: string): InstallReceipt | null;