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/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.8.1";
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. Use instead of bash/cat when output would exceed 20 lines.${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.`,
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 html = await resp.text();
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
- const td = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
667
- td.use(gfm);
668
- td.remove(['script', 'style', 'nav', 'header', 'footer', 'noscript']);
669
- console.log(td.turndown(html));
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
- if (!result.stdout || result.stdout.trim().length === 0) {
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 after HTML conversion`,
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
- const indexed = store.index({ content: markdown, source: source ?? url });
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;