@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.
- package/dist/agent/agentTypes.d.ts +66 -2
- package/dist/agent/agentTypes.d.ts.map +1 -1
- package/dist/agent/agentTypes.js +415 -31
- package/dist/agent/loop.d.ts +34 -0
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +117 -8
- package/dist/agent/securityLint.d.ts +27 -0
- package/dist/agent/securityLint.d.ts.map +1 -0
- package/dist/agent/securityLint.js +195 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/plugins/index.d.ts +20 -0
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +44 -3
- package/dist/plugins/installer.d.ts +40 -2
- package/dist/plugins/installer.d.ts.map +1 -1
- package/dist/plugins/installer.js +85 -14
- package/dist/plugins/sources.d.ts +49 -0
- package/dist/plugins/sources.d.ts.map +1 -0
- package/dist/plugins/sources.js +197 -0
- package/dist/tools/executor.d.ts.map +1 -1
- package/dist/tools/executor.js +24 -4
- package/package.json +1 -1
package/dist/agent/loop.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,OAAO,EAMP,gBAAgB,EAChB,UAAU,EACX,MAAM,UAAU,CAAC;AAyKlB,wBAAgB,oBAAoB,CAClC,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,MAAM,CAWR;AAkED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAgBlF;
|
|
1
|
+
{"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,OAAO,EAMP,gBAAgB,EAChB,UAAU,EACX,MAAM,UAAU,CAAC;AAyKlB,wBAAgB,oBAAoB,CAClC,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,MAAM,CAWR;AAkED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAgBlF;AAuJD;;;;;;;;;;GAUG;AACH,qBAAa,mBAAoB,SAAQ,KAAK;gBAChC,OAAO,EAAE,MAAM;CAI5B;AA4BD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,UAAU,UAAO,GAAG,MAAM,CAYjF;AAED,8EAA8E;AAC9E,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,SAAc,GAAG,MAAM,CAKtE;AAED;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAoBpE;AA8ND,oGAAoG;AACpG,wBAAgB,gBAAgB,CAAC,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,OAAO,GAAG,MAAM,CAE1E;AA8BD,kHAAkH;AAClH,wBAAgB,oBAAoB,IAAI;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAEzE;AAuBD,+EAA+E;AAC/E,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAM7D;AAsBD,iFAAiF;AACjF,eAAO,MAAM,gBAAgB,aAA+G,CAAC;AAC7I;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAAG,OAAO,CA8BrG;AAED,gGAAgG;AAChG,eAAO,MAAM,aAAa,QAA2J,CAAC;AAEtL;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAK5E;AAUD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAwBxD;AAoBD,MAAM,WAAW,cAAc;IAC7B,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC3D,aAAa,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,OAAO,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAClE,qFAAqF;IACrF,aAAa,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvD;;;;OAIG;IACH,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAgB,YAAY,IAAI,cAAc,CAE7C;AAED,kFAAkF;AAClF,wBAAgB,YAAY,CAC1B,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC1C,EAAE,EAAE,OAAO,EACX,MAAM,CAAC,EAAE,MAAM,EACf,QAAQ,CAAC,EAAE,MAAM,GAChB,IAAI,CAoDN;AAED,kFAAkF;AAClF,wBAAgB,aAAa,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,CA6B5D;AAmBD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,eAAe,SAAI,GAAG,MAAM,CAgCpF;AAsKD,gFAAgF;AAChF,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAE/D;AAED;;;;;;;;;GASG;AACH,wBAAsB,wBAAwB,CAC5C,QAAQ,EAAE,OAAO,EAAE,EACnB,IAAI,EAAE;IACJ,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,OAAO,CAAC;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC,GACA,OAAO,CAAC,OAAO,CAAC,CAqElB;AAID,wBAAsB,YAAY,CAChC,eAAe,EAAE,OAAO,EAAE,EAC1B,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,OAAO,EAAE,CAAC,CAyuBpB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,OAAO,EAAE,CAqCtE"}
|
package/dist/agent/loop.js
CHANGED
|
@@ -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
|
-
|
|
545
|
+
let agent = (0, agentTypes_1.findAgentType)(agentTypes, requestedType);
|
|
546
|
+
let knownTypes = agentTypes;
|
|
515
547
|
if (requestedType && !agent) {
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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
|
package/dist/plugins/index.d.ts
CHANGED
|
@@ -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":"
|
|
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"}
|
package/dist/plugins/index.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
-
/**
|
|
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;
|