context-mode 0.9.17 → 0.9.18
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/.claude-plugin/hooks/hooks.json +38 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -2
- package/LICENSE +94 -21
- package/README.md +127 -3
- package/build/cli.js +54 -1
- package/build/executor.js +46 -16
- package/build/runtime.d.ts +1 -1
- package/build/runtime.js +39 -21
- package/build/security.d.ts +120 -0
- package/build/security.js +466 -0
- package/build/server.js +169 -14
- package/build/store.d.ts +8 -0
- package/build/store.js +316 -109
- package/hooks/hooks.json +38 -0
- package/hooks/pretooluse.mjs +259 -134
- package/hooks/routing-block.mjs +47 -0
- package/hooks/sessionstart.mjs +30 -0
- package/package.json +2 -2
- package/server.bundle.mjs +145 -81
- package/skills/context-mode/SKILL.md +20 -1
package/build/server.js
CHANGED
|
@@ -5,8 +5,9 @@ import { createRequire } from "node:module";
|
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { PolyglotExecutor } from "./executor.js";
|
|
7
7
|
import { ContentStore, cleanupStaleDBs } from "./store.js";
|
|
8
|
+
import { readBashPolicies, evaluateCommandDenyOnly, extractShellCommands, readToolDenyPatterns, evaluateFilePath, } from "./security.js";
|
|
8
9
|
import { detectRuntimes, getRuntimeSummary, getAvailableLanguages, hasBunRuntime, } from "./runtime.js";
|
|
9
|
-
const VERSION = "0.
|
|
10
|
+
const VERSION = "0.9.18";
|
|
10
11
|
const runtimes = detectRuntimes();
|
|
11
12
|
const available = getAvailableLanguages(runtimes);
|
|
12
13
|
const server = new McpServer({
|
|
@@ -44,6 +45,83 @@ function trackResponse(toolName, response) {
|
|
|
44
45
|
function trackIndexed(bytes) {
|
|
45
46
|
sessionStats.bytesIndexed += bytes;
|
|
46
47
|
}
|
|
48
|
+
// ==============================================================================
|
|
49
|
+
// Security: server-side deny firewall
|
|
50
|
+
// ==============================================================================
|
|
51
|
+
/**
|
|
52
|
+
* Check a shell command against Bash deny patterns.
|
|
53
|
+
* Returns an error ToolResult if denied, or null if allowed.
|
|
54
|
+
*/
|
|
55
|
+
function checkDenyPolicy(command, toolName) {
|
|
56
|
+
try {
|
|
57
|
+
const policies = readBashPolicies(process.env.CLAUDE_PROJECT_DIR);
|
|
58
|
+
const result = evaluateCommandDenyOnly(command, policies);
|
|
59
|
+
if (result.decision === "deny") {
|
|
60
|
+
return trackResponse(toolName, {
|
|
61
|
+
content: [{
|
|
62
|
+
type: "text",
|
|
63
|
+
text: `Command blocked by security policy: matches deny pattern ${result.matchedPattern}`,
|
|
64
|
+
}],
|
|
65
|
+
isError: true,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Security check failed — allow through (fail-open for server,
|
|
71
|
+
// hooks are the primary enforcement layer)
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Check non-shell code for shell-escape calls against deny patterns.
|
|
77
|
+
*/
|
|
78
|
+
function checkNonShellDenyPolicy(code, language, toolName) {
|
|
79
|
+
try {
|
|
80
|
+
const commands = extractShellCommands(code, language);
|
|
81
|
+
if (commands.length === 0)
|
|
82
|
+
return null;
|
|
83
|
+
const policies = readBashPolicies(process.env.CLAUDE_PROJECT_DIR);
|
|
84
|
+
for (const cmd of commands) {
|
|
85
|
+
const result = evaluateCommandDenyOnly(cmd, policies);
|
|
86
|
+
if (result.decision === "deny") {
|
|
87
|
+
return trackResponse(toolName, {
|
|
88
|
+
content: [{
|
|
89
|
+
type: "text",
|
|
90
|
+
text: `Command blocked by security policy: embedded shell command "${cmd}" matches deny pattern ${result.matchedPattern}`,
|
|
91
|
+
}],
|
|
92
|
+
isError: true,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Fail-open
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Check a file path against Read deny patterns.
|
|
104
|
+
* Returns an error ToolResult if denied, or null if allowed.
|
|
105
|
+
*/
|
|
106
|
+
function checkFilePathDenyPolicy(filePath, toolName) {
|
|
107
|
+
try {
|
|
108
|
+
const denyGlobs = readToolDenyPatterns("Read", process.env.CLAUDE_PROJECT_DIR);
|
|
109
|
+
const result = evaluateFilePath(filePath, denyGlobs);
|
|
110
|
+
if (result.denied) {
|
|
111
|
+
return trackResponse(toolName, {
|
|
112
|
+
content: [{
|
|
113
|
+
type: "text",
|
|
114
|
+
text: `File access blocked by security policy: path matches Read deny pattern ${result.matchedPattern}`,
|
|
115
|
+
}],
|
|
116
|
+
isError: true,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Fail-open
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
47
125
|
// Build description dynamically based on detected runtimes
|
|
48
126
|
const langList = available.join(", ");
|
|
49
127
|
const bunNote = hasBunRuntime()
|
|
@@ -156,7 +234,7 @@ export function extractSnippet(content, query, maxLen = 1500, highlighted) {
|
|
|
156
234
|
// ─────────────────────────────────────────────────────────
|
|
157
235
|
server.registerTool("execute", {
|
|
158
236
|
title: "Execute Code",
|
|
159
|
-
description: `Execute code in a sandboxed subprocess. Only stdout enters context — raw data stays in the subprocess
|
|
237
|
+
description: `MANDATORY: Use for any command where output exceeds 20 lines. Execute code in a sandboxed subprocess. Only stdout enters context — raw data stays in the subprocess.${bunNote} Available: ${langList}.\n\nPREFER THIS OVER BASH for: API calls (gh, curl, aws), test runners (npm test, pytest), git queries (git log, git diff), data processing, and ANY CLI command that may produce large output. Bash should only be used for file mutations, git writes, and navigation.`,
|
|
160
238
|
inputSchema: z.object({
|
|
161
239
|
language: z
|
|
162
240
|
.enum([
|
|
@@ -190,6 +268,17 @@ server.registerTool("execute", {
|
|
|
190
268
|
"\n\nTIP: Use specific technical terms, not just concepts. Check 'Searchable terms' in the response for available vocabulary."),
|
|
191
269
|
}),
|
|
192
270
|
}, async ({ language, code, timeout, intent }) => {
|
|
271
|
+
// Security: deny-only firewall
|
|
272
|
+
if (language === "shell") {
|
|
273
|
+
const denied = checkDenyPolicy(code, "execute");
|
|
274
|
+
if (denied)
|
|
275
|
+
return denied;
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
const denied = checkNonShellDenyPolicy(code, language, "execute");
|
|
279
|
+
if (denied)
|
|
280
|
+
return denied;
|
|
281
|
+
}
|
|
193
282
|
try {
|
|
194
283
|
// For JS/TS: wrap in async IIFE with fetch interceptor to track network bytes
|
|
195
284
|
let instrumentedCode = code;
|
|
@@ -370,6 +459,21 @@ server.registerTool("execute_file", {
|
|
|
370
459
|
"returns only matching sections via BM25 search instead of truncated output."),
|
|
371
460
|
}),
|
|
372
461
|
}, async ({ path, language, code, timeout, intent }) => {
|
|
462
|
+
// Security: check file path against Read deny patterns
|
|
463
|
+
const pathDenied = checkFilePathDenyPolicy(path, "execute_file");
|
|
464
|
+
if (pathDenied)
|
|
465
|
+
return pathDenied;
|
|
466
|
+
// Security: check code parameter against Bash deny patterns
|
|
467
|
+
if (language === "shell") {
|
|
468
|
+
const codeDenied = checkDenyPolicy(code, "execute_file");
|
|
469
|
+
if (codeDenied)
|
|
470
|
+
return codeDenied;
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
const codeDenied = checkNonShellDenyPolicy(code, language, "execute_file");
|
|
474
|
+
if (codeDenied)
|
|
475
|
+
return codeDenied;
|
|
476
|
+
}
|
|
373
477
|
try {
|
|
374
478
|
const result = await executor.executeFile({
|
|
375
479
|
path,
|
|
@@ -650,6 +754,9 @@ function resolveGfmPluginPath() {
|
|
|
650
754
|
// ─────────────────────────────────────────────────────────
|
|
651
755
|
// Tool: fetch_and_index
|
|
652
756
|
// ─────────────────────────────────────────────────────────
|
|
757
|
+
// Subprocess code that fetches a URL, detects Content-Type, and outputs a
|
|
758
|
+
// __CM_CT__:<type> marker on the first line so the handler can route to the
|
|
759
|
+
// appropriate indexing strategy. HTML is converted to markdown via Turndown.
|
|
653
760
|
function buildFetchCode(url) {
|
|
654
761
|
const turndownPath = JSON.stringify(resolveTurndownPath());
|
|
655
762
|
const gfmPath = JSON.stringify(resolveGfmPluginPath());
|
|
@@ -661,12 +768,38 @@ const url = ${JSON.stringify(url)};
|
|
|
661
768
|
async function main() {
|
|
662
769
|
const resp = await fetch(url);
|
|
663
770
|
if (!resp.ok) { console.error("HTTP " + resp.status); process.exit(1); }
|
|
664
|
-
const
|
|
771
|
+
const contentType = resp.headers.get('content-type') || '';
|
|
772
|
+
|
|
773
|
+
// --- JSON responses ---
|
|
774
|
+
if (contentType.includes('application/json') || contentType.includes('+json')) {
|
|
775
|
+
const text = await resp.text();
|
|
776
|
+
try {
|
|
777
|
+
const pretty = JSON.stringify(JSON.parse(text), null, 2);
|
|
778
|
+
console.log('__CM_CT__:json');
|
|
779
|
+
console.log(pretty);
|
|
780
|
+
} catch {
|
|
781
|
+
// Unparseable "JSON" — fall back to plain text
|
|
782
|
+
console.log('__CM_CT__:text');
|
|
783
|
+
console.log(text);
|
|
784
|
+
}
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// --- HTML responses (default for text/html, application/xhtml+xml) ---
|
|
789
|
+
if (contentType.includes('text/html') || contentType.includes('application/xhtml')) {
|
|
790
|
+
const html = await resp.text();
|
|
791
|
+
const td = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
|
|
792
|
+
td.use(gfm);
|
|
793
|
+
td.remove(['script', 'style', 'nav', 'header', 'footer', 'noscript']);
|
|
794
|
+
console.log('__CM_CT__:html');
|
|
795
|
+
console.log(td.turndown(html));
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
665
798
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
console.log(
|
|
799
|
+
// --- Everything else: plain text, CSV, XML, etc. ---
|
|
800
|
+
const text = await resp.text();
|
|
801
|
+
console.log('__CM_CT__:text');
|
|
802
|
+
console.log(text);
|
|
670
803
|
}
|
|
671
804
|
main();
|
|
672
805
|
`;
|
|
@@ -675,7 +808,8 @@ server.registerTool("fetch_and_index", {
|
|
|
675
808
|
title: "Fetch & Index URL",
|
|
676
809
|
description: "Fetches URL content, converts HTML to markdown, indexes into searchable knowledge base, " +
|
|
677
810
|
"and returns a ~3KB preview. Full content stays in sandbox — use search() for deeper lookups.\n\n" +
|
|
678
|
-
"Better than WebFetch: preview is immediate, full content is searchable, raw HTML never enters context
|
|
811
|
+
"Better than WebFetch: preview is immediate, full content is searchable, raw HTML never enters context.\n\n" +
|
|
812
|
+
"Content-type aware: HTML is converted to markdown, JSON is chunked by key paths, plain text is indexed directly.",
|
|
679
813
|
inputSchema: z.object({
|
|
680
814
|
url: z.string().describe("The URL to fetch and index"),
|
|
681
815
|
source: z
|
|
@@ -703,22 +837,37 @@ server.registerTool("fetch_and_index", {
|
|
|
703
837
|
isError: true,
|
|
704
838
|
});
|
|
705
839
|
}
|
|
706
|
-
|
|
840
|
+
// Parse content-type marker from subprocess output
|
|
841
|
+
const store = getStore();
|
|
842
|
+
const rawOutput = (result.stdout || "").trim();
|
|
843
|
+
const firstNewline = rawOutput.indexOf("\n");
|
|
844
|
+
const header = firstNewline >= 0 ? rawOutput.slice(0, firstNewline) : "";
|
|
845
|
+
const content = firstNewline >= 0 ? rawOutput.slice(firstNewline + 1) : rawOutput;
|
|
846
|
+
const markdown = content.trim();
|
|
847
|
+
if (markdown.length === 0) {
|
|
707
848
|
return trackResponse("fetch_and_index", {
|
|
708
849
|
content: [
|
|
709
850
|
{
|
|
710
851
|
type: "text",
|
|
711
|
-
text: `Fetched ${url} but got empty content
|
|
852
|
+
text: `Fetched ${url} but got empty content`,
|
|
712
853
|
},
|
|
713
854
|
],
|
|
714
855
|
isError: true,
|
|
715
856
|
});
|
|
716
857
|
}
|
|
717
|
-
// Index the markdown into FTS5
|
|
718
|
-
const store = getStore();
|
|
719
|
-
const markdown = result.stdout.trim();
|
|
720
858
|
trackIndexed(Buffer.byteLength(markdown));
|
|
721
|
-
|
|
859
|
+
// Route to the appropriate indexing strategy based on Content-Type
|
|
860
|
+
let indexed;
|
|
861
|
+
if (header === "__CM_CT__:json") {
|
|
862
|
+
indexed = store.indexJSON(markdown, source ?? url);
|
|
863
|
+
}
|
|
864
|
+
else if (header === "__CM_CT__:text") {
|
|
865
|
+
indexed = store.indexPlainText(markdown, source ?? url);
|
|
866
|
+
}
|
|
867
|
+
else {
|
|
868
|
+
// HTML (default) — content is already converted to markdown
|
|
869
|
+
indexed = store.index({ content: markdown, source: source ?? url });
|
|
870
|
+
}
|
|
722
871
|
// Build preview — first ~3KB of markdown for immediate use
|
|
723
872
|
const PREVIEW_LIMIT = 3072;
|
|
724
873
|
const preview = markdown.length > PREVIEW_LIMIT
|
|
@@ -782,6 +931,12 @@ server.registerTool("batch_execute", {
|
|
|
782
931
|
.describe("Max execution time in ms (default: 60s)"),
|
|
783
932
|
}),
|
|
784
933
|
}, async ({ commands, queries, timeout }) => {
|
|
934
|
+
// Security: check each command against deny patterns
|
|
935
|
+
for (const cmd of commands) {
|
|
936
|
+
const denied = checkDenyPolicy(cmd.command, "batch_execute");
|
|
937
|
+
if (denied)
|
|
938
|
+
return denied;
|
|
939
|
+
}
|
|
785
940
|
try {
|
|
786
941
|
// Build batch script with markdown section headers for proper chunking
|
|
787
942
|
const script = commands
|
package/build/store.d.ts
CHANGED
|
@@ -47,6 +47,14 @@ export declare class ContentStore {
|
|
|
47
47
|
* look for headings — it chunks by line count with overlap.
|
|
48
48
|
*/
|
|
49
49
|
indexPlainText(content: string, source: string, linesPerChunk?: number): IndexResult;
|
|
50
|
+
/**
|
|
51
|
+
* Index JSON content by walking the object tree and using key paths
|
|
52
|
+
* as chunk titles (analogous to heading hierarchy in markdown). Objects
|
|
53
|
+
* recurse by key; arrays batch items by size.
|
|
54
|
+
*
|
|
55
|
+
* Falls back to `indexPlainText` if the content is not valid JSON.
|
|
56
|
+
*/
|
|
57
|
+
indexJSON(content: string, source: string, maxChunkBytes?: number): IndexResult;
|
|
50
58
|
search(query: string, limit?: number, source?: string): SearchResult[];
|
|
51
59
|
searchTrigram(query: string, limit?: number, source?: string): SearchResult[];
|
|
52
60
|
fuzzyCorrect(query: string): string | null;
|