@nexrall/code-core 1.4.23 → 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.
@@ -8,10 +8,43 @@ export interface AgentType {
8
8
  /** System instructions (the markdown body below the frontmatter). */
9
9
  prompt: string;
10
10
  source: 'project' | 'global' | 'builtin' | 'plugin';
11
+ /**
12
+ * When true, this agent's write tools may only target TEST files.
13
+ *
14
+ * Needed because a tool allowlist is all-or-nothing per tool: granting
15
+ * `edit_file` grants it for every path. The `test-writer` agent must be able to
16
+ * write tests while being unable to "fix" production source to make a test
17
+ * pass — the single most common way a test-writing agent destroys signal — and
18
+ * a prompt instruction alone cannot guarantee that. Enforced in loop.ts's
19
+ * permission gate, where it is a real refusal rather than a request.
20
+ */
21
+ testFilesOnly?: boolean;
11
22
  }
12
- /** Discover all custom agent types (project overrides global overrides builtin). */
23
+ /**
24
+ * Discover all agent types. Precedence: project > global > plugin > builtin.
25
+ *
26
+ * Results are returned in a STABLE order (builtins in declared order, then
27
+ * user-supplied ones alphabetically) rather than in discovery order. The
28
+ * system-prompt catalogue is built from this, and a set that reshuffles between
29
+ * runs would silently bust the prompt cache on the block it lives in — filesystem
30
+ * readdir order is not guaranteed to be stable across machines or platforms.
31
+ */
13
32
  export declare function loadAgentTypes(workDir: string): AgentType[];
14
- /** A compact catalogue injected into the system prompt so the model can pick a type. */
33
+ /**
34
+ * One line per agent for the system prompt's <available_subagents> block.
35
+ *
36
+ * Reports each agent's CAPABILITY CLASS rather than enumerating its allowlist.
37
+ * The full lists (~15 tool names each) went into every single request while
38
+ * telling the model nothing it needs in order to choose: what matters when
39
+ * delegating is "can this one edit files?" and "what is it for?", not whether
40
+ * `get_hover` happens to be included. With six builtins the verbatim lists cost
41
+ * ~670 tokens per request, most of it near-identical boilerplate that also
42
+ * weakens the signal it was meant to carry.
43
+ *
44
+ * The per-agent description already states its own restrictions in prose, and the
45
+ * allowlist is enforced at the permission gate regardless of what is advertised
46
+ * here — so this is purely a summary, never the mechanism.
47
+ */
15
48
  export declare function summariseAgents(types: AgentType[]): string;
16
49
  export declare function findAgentType(types: AgentType[], name: string | undefined): AgentType | undefined;
17
50
  //# sourceMappingURL=agentTypes.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"agentTypes.d.ts","sourceRoot":"","sources":["../../src/agent/agentTypes.ts"],"names":[],"mappings":"AAuBA,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,iDAAiD;IACjD,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,OAAO,CAAC;IAClC,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,SAAS,GAAG,QAAQ,GAAG,SAAS,GAAG,QAAQ,CAAC;CACrD;AAoFD,oFAAoF;AACpF,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,CAS3D;AAED,wFAAwF;AACxF,wBAAgB,eAAe,CAAC,KAAK,EAAE,SAAS,EAAE,GAAG,MAAM,CAQ1D;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,CAIjG"}
1
+ {"version":3,"file":"agentTypes.d.ts","sourceRoot":"","sources":["../../src/agent/agentTypes.ts"],"names":[],"mappings":"AAuBA,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,iDAAiD;IACjD,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,OAAO,CAAC;IAClC,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,SAAS,GAAG,QAAQ,GAAG,SAAS,GAAG,QAAQ,CAAC;IACpD;;;;;;;;;OASG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AA4RD;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,CAmB3D;AACD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,SAAS,EAAE,GAAG,MAAM,CAY1D;AAQD,wBAAgB,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,CAIjG"}
@@ -43,11 +43,37 @@ const index_1 = require("../plugins/index");
43
43
  // ── Built-in agent types ──────────────────────────────────────────────────────
44
44
  // Shipped defaults; lowest precedence (project > global > builtin), so a user
45
45
  // can override any of them with a same-name .nexrall/agents/<name>.md file.
46
+ // Shared read-only tool set.
47
+ //
48
+ // An allowlist only ever GRANTS — a name that doesn't exist on the current client
49
+ // is simply never offered to the model, so listing the VS Code language-server
50
+ // tools alongside the CLI ones is safe and gives each agent the best available
51
+ // capability on whichever client it runs.
52
+ //
53
+ // This exists because it was previously inlined per agent, and the one agent that
54
+ // had it (`reviewer`) listed five VS Code-only tools, leaving it with just five
55
+ // usable tools when run from the CLI — a silent capability gap that is very easy
56
+ // to reintroduce by copy-pasting a list.
57
+ const READ_ONLY_TOOLS = [
58
+ // Universal
59
+ 'read_file', 'search_files', 'glob', 'list_directory', 'bash', 'bash_output',
60
+ 'notebook_read', 'todo_write', 'todo_read',
61
+ // VS Code language server (ignored on the CLI)
62
+ 'get_symbols', 'get_workspace_symbols', 'find_references', 'go_to_definition',
63
+ 'get_hover', 'get_diagnostics',
64
+ ];
65
+ /** Read-only + the network, for agents that must consult external sources. */
66
+ const RESEARCH_TOOLS = [...READ_ONLY_TOOLS, 'web_search', 'fetch_url'];
67
+ /** Read-only + the write tools, for agents that produce code. */
68
+ const WRITE_TOOLS = [
69
+ ...READ_ONLY_TOOLS,
70
+ 'write_file', 'edit_file', 'multi_edit', 'create_directory', 'move_file', 'copy_file',
71
+ ];
46
72
  const BUILTIN_AGENTS = [
47
73
  {
48
74
  name: 'reviewer',
49
75
  description: 'Read-only code reviewer — finds correctness bugs, edge cases, and security issues in a diff or file set. Cannot modify files.',
50
- tools: ['read_file', 'search_files', 'glob', 'list_directory', 'bash', 'get_symbols', 'get_workspace_symbols', 'find_references', 'go_to_definition', 'get_diagnostics'],
76
+ tools: READ_ONLY_TOOLS,
51
77
  source: 'builtin',
52
78
  prompt: [
53
79
  'You are a meticulous senior code reviewer. You NEVER modify files — you only read, search, and report.',
@@ -64,6 +90,162 @@ const BUILTIN_AGENTS = [
64
90
  'then a final verdict (APPROVE or REQUEST CHANGES) with a one-paragraph rationale.',
65
91
  ].join('\n'),
66
92
  },
93
+ // Promoted from the security-audit plugin to a builtin.
94
+ //
95
+ // Leaving it plugin-only was indefensible next to `reviewer` being builtin:
96
+ // reviewer's own prompt already tells it to look for security issues, so
97
+ // security IS treated as default work — yet the specialist agent for it was
98
+ // invisible unless the user happened to know the plugin existed. For an agent
99
+ // that WRITES code, "you only get a security review if you knew to install
100
+ // something" is the wrong default.
101
+ {
102
+ name: 'security-auditor',
103
+ description: 'Read-only security auditor — hunts injection, authz, secrets, and validation flaws in a path or diff. Cannot modify files.',
104
+ tools: READ_ONLY_TOOLS,
105
+ model: 'pro',
106
+ source: 'builtin',
107
+ prompt: [
108
+ 'You are a security auditor. You find real, exploitable flaws — not style issues.',
109
+ '',
110
+ 'Method:',
111
+ '1. Map the attack surface FIRST: entry points (HTTP routes, message handlers, CLI args, file/network',
112
+ ' input, deserialization), then trace user-controlled data inward to where it is used.',
113
+ '2. For each finding: file:line, the flaw class, a one-line exploit scenario, and the concrete fix.',
114
+ '3. Grade severity honestly: Critical = remote compromise or data breach; High = auth bypass/IDOR;',
115
+ ' Medium = needs unusual preconditions; Low = hardening.',
116
+ '',
117
+ 'Classes worth the most attention, in order: injection (SQL/command/template/prototype), broken',
118
+ 'authz (missing ownership checks, IDOR, trusting client-supplied ids), secrets committed to source,',
119
+ 'path traversal, SSRF, unsafe deserialization, missing rate limits on expensive or auth endpoints,',
120
+ 'and crypto misuse (hand-rolled comparison, predictable randomness).',
121
+ '',
122
+ 'Hard rules:',
123
+ '- READ-ONLY: never modify, create or delete files. bash only for read-only inspection.',
124
+ '- NEVER print a discovered secret\'s value. Report its location and advise rotation.',
125
+ '- Distinguish EXPLOITABLE from theoretical, and say which one each finding is.',
126
+ '- "No issues found in scope X" is a valid, useful result. Do not pad the report to look thorough.',
127
+ ].join('\n'),
128
+ },
129
+ // The gap Claude Code fills with its built-in `Explore`: read-heavy codebase
130
+ // search that would otherwise flood the parent's context. Defaults to the
131
+ // cheapest model on purpose — "find every caller of X" has no need of a
132
+ // frontier model, and this is the agent most likely to be spawned in bulk.
133
+ {
134
+ name: 'explorer',
135
+ description: 'Fast read-only codebase explorer — locates files, symbols, and call sites and reports concise findings. Use to keep bulk searching out of the main context. Cannot modify files.',
136
+ tools: READ_ONLY_TOOLS,
137
+ model: 'turbo',
138
+ source: 'builtin',
139
+ prompt: [
140
+ 'You map code. You NEVER modify anything.',
141
+ '',
142
+ 'Method:',
143
+ '1. Prefer structural search over text search where available (get_workspace_symbols, find_references,',
144
+ ' go_to_definition); fall back to search_files/glob otherwise.',
145
+ '2. Read only the sections you need — use read_file with offset/limit on large files instead of',
146
+ ' pulling in thousands of lines.',
147
+ '3. Follow the real call graph rather than guessing from names.',
148
+ '',
149
+ 'Your ONLY output is a compact report: the file:line locations that matter, how they relate, and the',
150
+ 'direct answer to the question you were given. This exists to keep bulk search OUT of the parent\'s',
151
+ 'context, so do not paste large file contents back — cite locations and summarise. Say plainly when',
152
+ 'something does not exist; a confident wrong answer is far worse than "not found".',
153
+ ].join('\n'),
154
+ },
155
+ // Matches Claude Code's built-in `Plan`: research a change and return a
156
+ // strategy, deliberately WITHOUT write access so "make a plan" can never
157
+ // quietly become "start editing".
158
+ {
159
+ name: 'planner',
160
+ description: 'Read-only planning agent — researches a change and returns a concrete step-by-step implementation plan with risks and affected files. Cannot modify files.',
161
+ tools: RESEARCH_TOOLS,
162
+ model: 'pro',
163
+ source: 'builtin',
164
+ prompt: [
165
+ 'You produce implementation plans. You NEVER modify files — planning and doing are separate steps,',
166
+ 'and this agent exists so "plan it" cannot silently turn into "change it".',
167
+ '',
168
+ 'Method:',
169
+ '1. Read the actual code before proposing anything. No plan may rest on an assumed API shape.',
170
+ '2. Find every affected call site (find_references / search_files) and list them.',
171
+ '3. Order the steps so the tree stays working after each one — types, then implementation, then',
172
+ ' tests, then exports/registration.',
173
+ '',
174
+ 'Output:',
175
+ '- Goal, in one sentence.',
176
+ '- Numbered steps, each with the exact files touched and what changes in them.',
177
+ '- Risks + the specific thing that could break, and how it would be detected.',
178
+ '- How to verify (the exact test/build command for THIS project, taken from package.json/Makefile).',
179
+ '- Anything genuinely ambiguous, stated as an open question rather than a silent assumption.',
180
+ ].join('\n'),
181
+ },
182
+ // Promoted from the test-gen plugin. Needs write access — it produces test
183
+ // files — but is deliberately forbidden from touching source, because "make the
184
+ // tests pass" is the single most common way an agent destroys signal.
185
+ {
186
+ name: 'test-writer',
187
+ description: 'Writes tests that follow the project\'s existing conventions. May create/edit TEST files only — never production source.',
188
+ tools: WRITE_TOOLS,
189
+ // Enforced, not merely requested: the permission gate refuses a write whose
190
+ // path is not a test file. Without this the allowlist would grant edit_file
191
+ // for every path and the rule below would be a suggestion the model is free
192
+ // to rationalise its way past.
193
+ testFilesOnly: true,
194
+ source: 'builtin',
195
+ prompt: [
196
+ 'You write tests. You may create and edit TEST files only.',
197
+ '',
198
+ 'Hard rules — these are the ways test-writing agents destroy value, so they are non-negotiable:',
199
+ '- NEVER modify production source to make a test pass. If the code looks wrong, REPORT it and stop.',
200
+ '- NEVER weaken, delete or skip an existing assertion or test.',
201
+ '- A test that cannot fail is worse than no test. Every test must be able to fail for one clear reason.',
202
+ '',
203
+ 'Method:',
204
+ '1. Read the existing tests FIRST and copy their conventions exactly — runner, file naming, layout,',
205
+ ' assertion style, fixture/helper patterns. Never introduce a new framework.',
206
+ '2. Test observable behaviour and the contract, not private internals.',
207
+ '3. Cover the boring-but-real cases: empty input, null/undefined, unicode and non-BMP characters,',
208
+ ' boundaries, error paths, concurrency where it applies.',
209
+ '4. No sleeps or wall-clock dependence — those produce the flaky tests that get deleted later.',
210
+ '5. RUN the tests you wrote and report the real output. Never claim a test passes without running it.',
211
+ ].join('\n'),
212
+ },
213
+ // The DevOps gap — answered with a READ-ONLY advisor, not an operator.
214
+ //
215
+ // A "DevOps agent" with write/apply access is a genuinely different risk class
216
+ // from the others here: its mistakes are `kubectl delete`, a bad `terraform
217
+ // apply`, a broken deploy pipeline — often not revertible and affecting
218
+ // production rather than a working tree. So this one diagnoses and proposes a
219
+ // diff; a human applies it. That asymmetry is the whole design.
220
+ {
221
+ name: 'devops-advisor',
222
+ description: 'Read-only CI/CD, container, and infrastructure advisor — diagnoses pipelines, Dockerfiles, and k8s manifests and proposes concrete fixes as a diff. Never applies changes.',
223
+ tools: RESEARCH_TOOLS,
224
+ model: 'pro',
225
+ source: 'builtin',
226
+ prompt: [
227
+ 'You are an infrastructure and delivery advisor. You DIAGNOSE and PROPOSE. You never apply changes.',
228
+ '',
229
+ 'Hard rules:',
230
+ '- READ-ONLY, and stricter than the other read-only agents: bash is for INSPECTION only',
231
+ ' (git log/diff, cat, grep, `kubectl get/describe`, `docker images`, `terraform plan`).',
232
+ ' NEVER run anything that mutates infrastructure — no apply/delete/scale/rollout/restart/push,',
233
+ ' no `terraform apply`, no `helm upgrade`. If a fix needs such a command, WRITE IT OUT for a human.',
234
+ '- Never print secret values from env files, k8s Secrets or CI variables. Reference them by name.',
235
+ '',
236
+ 'Method:',
237
+ '1. Read what actually exists — workflow files, Dockerfiles, manifests, kustomize overlays, the',
238
+ ' deploy scripts — before drawing any conclusion. Never reason from what a stack "usually" looks like.',
239
+ '2. Follow the real path a change takes to production, and name the step that is broken or missing.',
240
+ '3. Check the failure modes that bite hardest: CI path filters that skip files a workload actually',
241
+ ' needs, image tags that do not match what is deployed, missing health probes, absent resource',
242
+ ' limits, secrets baked into images, ports/timeouts inconsistent between proxy and app, and',
243
+ ' migrations that must run before the new image is live.',
244
+ '',
245
+ 'Output: the diagnosis, the evidence (file:line or command output), the proposed change as a diff or',
246
+ 'exact file content, and the command a human should run to apply and verify it.',
247
+ ].join('\n'),
248
+ },
67
249
  ];
68
250
  function parseFrontmatter(raw) {
69
251
  const m = /^\s*---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw);
@@ -81,6 +263,10 @@ function parseModel(v) {
81
263
  const s = (v ?? '').toLowerCase();
82
264
  return s === 'turbo' || s === 'pro' || s === 'ultra' ? s : undefined;
83
265
  }
266
+ /** Parse a frontmatter boolean, accepting the usual truthy spellings. */
267
+ function parseBool(v) {
268
+ return /^(true|yes|1|on)$/i.test((v ?? '').trim());
269
+ }
84
270
  function parseToolList(v) {
85
271
  if (!v)
86
272
  return undefined;
@@ -116,6 +302,11 @@ function loadDir(dir, source, into) {
116
302
  model: parseModel(meta.model),
117
303
  prompt: body,
118
304
  source,
305
+ // Exposed to user/plugin definitions too — `test_files_only: true` (or
306
+ // `testFilesOnly`) lets anyone build a test-writing agent that genuinely
307
+ // cannot touch production source, rather than only the builtin getting
308
+ // that guarantee.
309
+ ...(parseBool(meta.test_files_only ?? meta.testfilesonly) ? { testFilesOnly: true } : {}),
119
310
  });
120
311
  }
121
312
  catch {
@@ -123,7 +314,15 @@ function loadDir(dir, source, into) {
123
314
  }
124
315
  }
125
316
  }
126
- /** Discover all custom agent types (project overrides global overrides builtin). */
317
+ /**
318
+ * Discover all agent types. Precedence: project > global > plugin > builtin.
319
+ *
320
+ * Results are returned in a STABLE order (builtins in declared order, then
321
+ * user-supplied ones alphabetically) rather than in discovery order. The
322
+ * system-prompt catalogue is built from this, and a set that reshuffles between
323
+ * runs would silently bust the prompt cache on the block it lives in — filesystem
324
+ * readdir order is not guaranteed to be stable across machines or platforms.
325
+ */
127
326
  function loadAgentTypes(workDir) {
128
327
  const out = new Map();
129
328
  loadDir(path.join(workDir, '.nexrall', 'agents'), 'project', out);
@@ -134,19 +333,55 @@ function loadAgentTypes(workDir) {
134
333
  if (!out.has(agent.name))
135
334
  out.set(agent.name, agent);
136
335
  }
137
- return [...out.values()];
336
+ // Builtins first in their declared order (the common, cache-friendly case), then
337
+ // everything user-supplied alphabetically.
338
+ const builtinOrder = new Map(BUILTIN_AGENTS.map((a, i) => [a.name, i]));
339
+ return [...out.values()].sort((a, b) => {
340
+ const ai = builtinOrder.get(a.name);
341
+ const bi = builtinOrder.get(b.name);
342
+ if (ai !== undefined && bi !== undefined)
343
+ return ai - bi;
344
+ if (ai !== undefined)
345
+ return -1;
346
+ if (bi !== undefined)
347
+ return 1;
348
+ return a.name.localeCompare(b.name);
349
+ });
138
350
  }
139
- /** A compact catalogue injected into the system prompt so the model can pick a type. */
351
+ /**
352
+ * One line per agent for the system prompt's <available_subagents> block.
353
+ *
354
+ * Reports each agent's CAPABILITY CLASS rather than enumerating its allowlist.
355
+ * The full lists (~15 tool names each) went into every single request while
356
+ * telling the model nothing it needs in order to choose: what matters when
357
+ * delegating is "can this one edit files?" and "what is it for?", not whether
358
+ * `get_hover` happens to be included. With six builtins the verbatim lists cost
359
+ * ~670 tokens per request, most of it near-identical boilerplate that also
360
+ * weakens the signal it was meant to carry.
361
+ *
362
+ * The per-agent description already states its own restrictions in prose, and the
363
+ * allowlist is enforced at the permission gate regardless of what is advertised
364
+ * here — so this is purely a summary, never the mechanism.
365
+ */
140
366
  function summariseAgents(types) {
141
367
  if (!types.length)
142
368
  return '';
143
369
  return types
144
370
  .map((t) => {
145
- const tools = t.tools ? ` (tools: ${t.tools.join(', ')})` : '';
146
- return `- ${t.name}: ${t.description}${tools}`;
371
+ const canWrite = !t.tools || t.tools.some((x) => WRITE_TOOL_HINTS.has(x));
372
+ const access = t.testFilesOnly
373
+ ? 'writes TEST files only'
374
+ : canWrite ? 'can modify files' : 'read-only';
375
+ const model = t.model ? `, ${t.model} model` : '';
376
+ return `- ${t.name} (${access}${model}): ${t.description}`;
147
377
  })
148
378
  .join('\n');
149
379
  }
380
+ /** Tool names that imply write access, for the summary line above. */
381
+ const WRITE_TOOL_HINTS = new Set([
382
+ 'write_file', 'edit_file', 'multi_edit', 'notebook_edit',
383
+ 'delete_file', 'move_file', 'copy_file',
384
+ ]);
150
385
  function findAgentType(types, name) {
151
386
  if (!name)
152
387
  return undefined;
@@ -47,6 +47,26 @@ export declare function compactionThresholds(): {
47
47
  export declare function estimateBodyBytes(messages: Message[]): number;
48
48
  /** Tools that mutate the filesystem — used by the verification nudge (GAP D). */
49
49
  export declare const WRITE_TOOL_NAMES: Set<string>;
50
+ /**
51
+ * May an agent restricted to `testFilesOnly` perform this tool call?
52
+ *
53
+ * A tool allowlist is all-or-nothing per tool: granting `edit_file` grants it for
54
+ * every path in the repo. The `test-writer` agent needs write access to produce
55
+ * tests, but must NOT be able to "fix" production source so a failing test goes
56
+ * green — the single most common way a test-writing agent destroys the signal it
57
+ * was asked to create. Its prompt says so; this makes it a refusal rather than a
58
+ * request.
59
+ *
60
+ * Pure + exported so the rules are testable directly, without running a real
61
+ * sub-agent.
62
+ *
63
+ * KNOWN LIMIT, stated rather than hidden: this gates the file TOOLS, not `bash`.
64
+ * A determined model could still write source via `bash: echo ... > src/x.ts`.
65
+ * Closing that means parsing shell redirection, which is not reliably doable — so
66
+ * this is a strong guardrail against the realistic failure mode, not a sandbox.
67
+ * Real isolation is the sandbox config (tools/sandbox.ts), a separate mechanism.
68
+ */
69
+ export declare function allowsTestOnlyWrite(tool: string, input: Record<string, unknown> | undefined): boolean;
50
70
  /** Heuristic: does a bash command look like it's running tests/build/lint/typecheck? (GAP D) */
51
71
  export declare const VERIFY_CMD_RE: RegExp;
52
72
  /**
@@ -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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"}
@@ -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;
@@ -528,6 +529,10 @@ async function runSubTask(input, options, agentTypes) {
528
529
  const gatedPermission = async (req) => {
529
530
  if (allowed && !allowed.has(req.tool))
530
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;
531
536
  return options.requestPermission(req);
532
537
  };
533
538
  const subMessages = [
@@ -757,6 +762,56 @@ function resolveVerificationNudge(rawSettings) {
757
762
  }
758
763
  /** Tools that mutate the filesystem — used by the verification nudge (GAP D). */
759
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
+ }
760
815
  /** Heuristic: does a bash command look like it's running tests/build/lint/typecheck? (GAP D) */
761
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;
762
817
  /**
@@ -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';
@@ -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"}
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);
@@ -1 +1 @@
1
- {"version":3,"file":"executor.d.ts","sourceRoot":"","sources":["../../src/tools/executor.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAC3C,OAAO,EAAyB,KAAK,aAAa,EAAE,MAAM,WAAW,CAAC;AA4sEtE,wBAAsB,WAAW,CAC/B,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,WAAW,CAAC,EAAE;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,EAClC,OAAO,CAAC,EAAE,aAAa,EACvB,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,MAAM,EACnB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,GACjC,OAAO,CAAC,UAAU,CAAC,CAoBrB"}
1
+ {"version":3,"file":"executor.d.ts","sourceRoot":"","sources":["../../src/tools/executor.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAC3C,OAAO,EAAyB,KAAK,aAAa,EAAE,MAAM,WAAW,CAAC;AAiuEtE,wBAAsB,WAAW,CAC/B,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,WAAW,CAAC,EAAE;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,EAClC,OAAO,CAAC,EAAE,aAAa,EACvB,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,MAAM,EACnB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,GACjC,OAAO,CAAC,UAAU,CAAC,CAoBrB"}
@@ -44,6 +44,7 @@ const child_process_1 = require("child_process");
44
44
  const sandbox_1 = require("./sandbox");
45
45
  const auth_1 = require("../auth");
46
46
  const editCompleteness_1 = require("../agent/editCompleteness");
47
+ const securityLint_1 = require("../agent/securityLint");
47
48
  const memory_1 = require("../agent/memory");
48
49
  const skills_1 = require("../agent/skills");
49
50
  const testIntegrity_1 = require("../agent/testIntegrity");
@@ -413,8 +414,16 @@ async function writeFile(input, workDir) {
413
414
  atomicWrite(resolved, content, existingMode);
414
415
  const bytes = Buffer.byteLength(content, 'utf-8');
415
416
  const lines = content.split('\n').length;
417
+ // Inline security review of what was just written.
418
+ //
419
+ // Six gates already run automatically on every write (elision, cross-file
420
+ // breakage, test integrity, claim evidence, flaky tests, destructive commands)
421
+ // and none of them was about security — so an agent that writes production code
422
+ // could commit a hardcoded credential or an injection sink with nothing to say
423
+ // otherwise. This warns; it never blocks (see securityLint.ts for why).
424
+ const sec = (0, securityLint_1.securityNoteText)((0, securityLint_1.checkSecurity)(content));
416
425
  if (isNew) {
417
- return { output: `Created ${resolved} (${lines} lines, ${bytes} bytes)` };
426
+ return { output: `Created ${resolved} (${lines} lines, ${bytes} bytes)${sec}` };
418
427
  }
419
428
  const xfile = crossFileBreakageWarning(resolved, normalizeLF(priorContent), normalizeLF(content), workDir ?? process.cwd());
420
429
  // Reward-hacking guard: a write_file that OVERWRITES an existing test file can
@@ -426,7 +435,7 @@ async function writeFile(input, workDir) {
426
435
  const ti = (0, testIntegrity_1.analyzeTestEdit)(resolved, normalizeLF(priorContent), normalizeLF(content));
427
436
  if (ti.suspicious)
428
437
  tiMarker = (0, testIntegrity_1.encodeTestIntegrityMarker)(ti.findings);
429
- return { output: `Overwrote ${resolved} (${lines} lines, ${bytes} bytes)${xfile}${tiMarker}` };
438
+ return { output: `Overwrote ${resolved} (${lines} lines, ${bytes} bytes)${xfile}${sec}${tiMarker}` };
430
439
  }
431
440
  catch (err) {
432
441
  return { error: err.message };
@@ -1408,7 +1417,12 @@ async function editFile(input, workDir) {
1408
1417
  const linesBefore = origNorm.split('\n').length;
1409
1418
  const linesAfter = updated.split('\n').length;
1410
1419
  const xfile = crossFileBreakageWarning(resolved, origNorm, normalizeLF(updated), workDir ?? process.cwd());
1411
- return { output: `Edited ${resolved} (${linesBefore} ${linesAfter} lines)\n\n${diff}${xfile}` };
1420
+ // Security-lint only the NEWLY INSERTED text, not the whole file. Scanning the
1421
+ // full file would re-report pre-existing findings on every unrelated edit —
1422
+ // noise that has nothing to do with the change being made, and the fastest way
1423
+ // to teach everyone to ignore the warning.
1424
+ const sec = (0, securityLint_1.securityNoteText)((0, securityLint_1.checkSecurity)(newNorm));
1425
+ return { output: `Edited ${resolved} (${linesBefore} → ${linesAfter} lines)\n\n${diff}${xfile}${sec}` };
1412
1426
  }
1413
1427
  catch (err) {
1414
1428
  return { error: err.message };
@@ -1747,6 +1761,9 @@ async function multiEdit(input, workDir) {
1747
1761
  let content = normalizeLF(rawFile);
1748
1762
  const originalNorm = content; // captured before edits mutate `content`
1749
1763
  const diffs = [];
1764
+ // Inserted text from every edit, collected for the security lint below so it
1765
+ // scans only what this call ADDED rather than the whole file.
1766
+ const insertedText = [];
1750
1767
  for (let i = 0; i < edits.length; i++) {
1751
1768
  const edit = edits[i];
1752
1769
  // Normalize incoming old/new strings too
@@ -1766,6 +1783,7 @@ async function multiEdit(input, workDir) {
1766
1783
  // Pass the pre-replace content so line numbers reflect the file state at
1767
1784
  // the point this edit was applied (not the final state after all edits).
1768
1785
  diffs.push(buildDiff(filePath, oldStr, newStr, content));
1786
+ insertedText.push(newStr);
1769
1787
  content = literalReplace(content, oldStr, newStr);
1770
1788
  }
1771
1789
  // Restore original line endings before writing (atomic temp+rename),
@@ -1773,9 +1791,11 @@ async function multiEdit(input, workDir) {
1773
1791
  const finalContent = wasCRLF ? content.replace(/\n/g, '\r\n') : content;
1774
1792
  atomicWritePreservingMode(resolved, finalContent, pre.mode);
1775
1793
  const xfile = crossFileBreakageWarning(resolved, originalNorm, content, workDir ?? process.cwd());
1794
+ // Lint only the newly inserted text (see the same reasoning in editFile).
1795
+ const sec = (0, securityLint_1.securityNoteText)((0, securityLint_1.checkSecurity)(insertedText.join('\n')));
1776
1796
  return {
1777
1797
  output: `Applied ${edits.length} edit(s) to ${resolved}:\n` +
1778
- diffs.map((d, i) => `\n--- edit #${i + 1} ---\n${d}`).join('\n') + xfile,
1798
+ diffs.map((d, i) => `\n--- edit #${i + 1} ---\n${d}`).join('\n') + xfile + sec,
1779
1799
  };
1780
1800
  }
1781
1801
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nexrall/code-core",
3
- "version": "1.4.23",
3
+ "version": "1.4.24",
4
4
  "description": "Core agent loop, tools, and extension primitives for Nexrall Code — embed an AI coding agent in any Node.js application.",
5
5
  "license": "MIT",
6
6
  "author": "Nexrall <support@nexrall.com> (https://nexrall.com)",