ucn 5.0.3 → 5.0.5

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.
@@ -30,12 +30,14 @@ The release board independently cross-checks overlapping stable-handle answers f
30
30
  Publish-blocking semantic gates also compare stratified samples from pinned real repositories with independent language-native oracles: ts-morph, Pyright, gopls, rust-analyzer, JDT LS, clangd, and Roslyn. This validates the measured static evidence classes; it does not turn dynamic dispatch, generated code, reflection, or external dependencies into complete runtime knowledge.
31
31
 
32
32
  MCP keeps its process and project index warm across calls. CLI and MCP share
33
- the same text budget: targeted commands default to 10K output characters,
34
- broad commands to 3K, with a 100K hard ceiling. Use CLI `--max-chars`, MCP
35
- `max_chars`, a narrower file/directory scope, or a smaller section projection
36
- when necessary. Truncation must retain the accounting/contract lines needed to
37
- interpret the answer, and the requested limit includes both the notice and the
38
- preserved metadata.
33
+ the same text budget: broad sweep commands (`repo`, `entrypoints`,
34
+ `endpoints`, `deadcode`, `deps`, `check`, `audit-async`) default to 3K output
35
+ characters, all other commands to 10K, with a 100K hard ceiling. Use CLI
36
+ `--max-chars`, MCP `max_chars`, a narrower file/directory scope, or a smaller
37
+ section projection when necessary. Truncated output keeps the head of the
38
+ answer, a notice stating the full size and limit, and the accounting/contract
39
+ lines needed to interpret what remains; the requested limit includes all
40
+ three. The text block is the whole response on every surface.
39
41
 
40
42
  Persistent indexes live in a per-user, project-keyed cache rather than the analyzed repository. Set `UCN_CACHE_DIR` to override the cache root; CLI `--no-cache` bypasses persistence and `--clear-cache` removes the current project's cache. Legacy `<project>/.ucn-cache` directories are migrated on first use.
41
43
 
package/core/registry.js CHANGED
@@ -153,10 +153,12 @@ const FLAG_APPLICABILITY = {
153
153
  auditAsync: ['file', 'exclude', 'limit'],
154
154
  };
155
155
 
156
- // Commands whose output is project-wide — truncation means you need a filter, not more text.
157
- // Used by MCP server for tighter default output limits.
156
+ // Project/change-wide sweep commands with no required symbol argument —
157
+ // truncation means you need a filter, not more text. Drives the shared
158
+ // CLI/MCP output budget: broad commands default to 3K chars, everything
159
+ // else (symbol-targeted commands like usages/tests included) to 10K.
158
160
  const BROAD_COMMANDS = new Set([
159
- 'repo', 'entrypoints', 'endpoints', 'tests', 'deadcode', 'usages',
161
+ 'repo', 'entrypoints', 'endpoints', 'deadcode',
160
162
  'deps', 'check', 'auditAsync',
161
163
  ]);
162
164
 
package/core/shared.js CHANGED
@@ -2,9 +2,37 @@
2
2
  * core/shared.js - Shared utility functions used by both CLI and MCP server
3
3
  */
4
4
 
5
+ const fs = require('fs');
6
+ const path = require('path');
5
7
  const { isTestFile } = require('./discovery');
6
8
  const { detectLanguage } = require('./parser');
7
9
 
10
+ /**
11
+ * Security containment check (fix #285): is `candidate` inside `root`?
12
+ *
13
+ * UCN only ever reads files it discovered under the project root. Any code
14
+ * path that resolves a caller-supplied path string (stack traces, handles)
15
+ * must gate the read on this before touching disk, or a crafted input like
16
+ * `File "/etc/passwd"` or `../../secret` exfiltrates arbitrary files through
17
+ * the tool surface (CLI and, more dangerously, the MCP tool agents call).
18
+ *
19
+ * Symlinks are followed via realpath so a link INSIDE the project cannot
20
+ * resolve to a target outside it. Non-existent candidates fall back to a
21
+ * lexical resolve — the caller still gates the read on existence.
22
+ * Comparison is boundary-correct: a sibling directory whose name merely
23
+ * shares the root's prefix (`/a/b` vs `/a/bee`) is not "inside".
24
+ */
25
+ function isPathInsideRoot(root, candidate) {
26
+ if (!root || !candidate) return false;
27
+ let realRoot;
28
+ try { realRoot = fs.realpathSync(root); } catch { realRoot = path.resolve(root); }
29
+ let realCandidate;
30
+ try { realCandidate = fs.realpathSync(candidate); } catch { realCandidate = path.resolve(candidate); }
31
+ const rel = path.relative(realRoot, realCandidate);
32
+ return rel === '' ||
33
+ (rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel));
34
+ }
35
+
8
36
  /**
9
37
  * Code-unit string comparison (rule 11 / fix #227): output ordering is part
10
38
  * of the public contract and must be byte-identical across machines —
@@ -453,4 +481,5 @@ module.exports = {
453
481
  isOverrideMarked,
454
482
  hasTextBlindspots,
455
483
  countTextBlindspots,
484
+ isPathInsideRoot,
456
485
  };
@@ -7,6 +7,7 @@
7
7
 
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
+ const { isPathInsideRoot } = require('./shared');
10
11
 
11
12
  /**
12
13
  * Calculate path similarity score between two file paths
@@ -85,9 +86,14 @@ function findBestMatchingFile(index, filePath, funcName, lineNum) {
85
86
  }
86
87
 
87
88
  if (candidates.length === 0) {
88
- // Try absolute path
89
- const absPath = path.isAbsolute(filePath) ? filePath : path.join(index.root, filePath);
90
- if (fs.existsSync(absPath)) {
89
+ // A frame that matched no indexed file may still point at a real file
90
+ // inside the project the walker skipped (a build artifact, an ignored
91
+ // path). Resolve it and show it — but ONLY inside the project root.
92
+ // The frame path is caller-supplied and untrusted; without this gate
93
+ // `File "/etc/passwd"` or `../../secret` would exfiltrate arbitrary
94
+ // files through the tool surface (fix #285 — GHSA/issue #4).
95
+ const absPath = path.resolve(index.root, filePath);
96
+ if (isPathInsideRoot(index.root, absPath) && fs.existsSync(absPath)) {
91
97
  return {
92
98
  path: absPath,
93
99
  relativePath: path.relative(index.root, absPath),
@@ -160,6 +166,16 @@ function createStackFrame(index, filePath, lineNum, funcName, col, rawLine) {
160
166
  // Find the best matching file using improved algorithm
161
167
  const match = findBestMatchingFile(index, filePath, funcName, lineNum);
162
168
 
169
+ if (match && !isPathInsideRoot(index.root, match.path)) {
170
+ // Defense in depth: never read outside the project root even if a
171
+ // future matcher change returns such a path (fix #285). The frame is
172
+ // reported as located-but-unreadable rather than leaking content.
173
+ frame.matchedFile = match.relativePath;
174
+ frame.confidence = match.confidence;
175
+ frame.error = 'resolved outside project root; not read';
176
+ return frame;
177
+ }
178
+
163
179
  if (match) {
164
180
  const resolvedPath = match.path;
165
181
  frame.found = true;
package/mcp/server.js CHANGED
@@ -133,17 +133,12 @@ function toolResult(text, command, maxChars, suffixNote, params = {}) {
133
133
  surface: 'mcp',
134
134
  params,
135
135
  });
136
- const response = { content: [{ type: 'text', text: budget.text + suffix }] };
137
- if (budget.truncated) {
138
- response.structuredContent = {
139
- truncated: true,
140
- fullChars: budget.fullChars,
141
- requestedLimit: budget.requestedLimit,
142
- contractMetadata: budget.contractMetadata,
143
- contractMetadataComplete: budget.contractMetadataComplete,
144
- };
145
- }
146
- return response;
136
+ // The text block is the ONLY payload channel. Truncation facts live in the
137
+ // text itself (the OUTPUT TRUNCATED notice carries full size, limit, and
138
+ // narrowing hints; preserved contract lines follow). A structuredContent
139
+ // side-channel is rendered INSTEAD of content by MCP clients that prefer
140
+ // structured results, which discards the entire answer (fix #284).
141
+ return { content: [{ type: 'text', text: budget.text + suffix }] };
147
142
  }
148
143
 
149
144
  function toolError(message) {
@@ -324,7 +319,7 @@ server.registerTool(
324
319
  line: z.number().int().positive().optional().describe('Definition line pin. Resolves the symbol defined at this exact line (the middle component of a file:line:name handle). Disambiguates same-file same-name definitions.'),
325
320
  limit: z.number().int().positive().max(1000000).optional().describe('Max results to return (default: 500). Caps find, usages, search, deadcode, api, and repo files. Must be a positive integer.'),
326
321
  max_files: z.number().int().positive().max(10000000).optional().describe('Max files to index (default: 10000). Use for very large codebases. Must be a positive integer.'),
327
- max_chars: z.number().int().positive().max(100000).optional().describe('Max output chars before truncation. Targeted commands default to 10K; broad commands default to 3K. Maximum: 100K. all=true lifts formatter caps but keeps the 100K transport ceiling.'),
322
+ max_chars: z.number().int().positive().max(100000).optional().describe('Max output chars before truncation. Broad sweep commands (repo, entrypoints, endpoints, deadcode, deps, check, audit_async) default to 3K; all other commands default to 10K. Maximum: 100K. all=true lifts formatter caps but keeps the 100K transport ceiling.'),
328
323
  // Structural search flags (search command)
329
324
  type: z.string().optional().describe('Symbol type filter for structural search: function, class, call, method, type, state, field, constant, macro. Triggers index-based search.'),
330
325
  param: z.string().optional().describe('Filter by parameter name or type (structural search). E.g. "Request", "ctx".'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "5.0.3",
3
+ "version": "5.0.5",
4
4
  "mcpName": "io.github.mleoca/ucn",
5
5
  "description": "Auditable AST code intelligence for AI agents: 18 task-oriented commands through one MCP tool, CLI, or agent skill. Supports JS/TS, Python, Go, Rust, Java, C, C++, C#, and HTML.",
6
6
  "main": "index.js",