@dotdotgod/context 0.2.25 → 0.2.27

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/README.md CHANGED
@@ -10,7 +10,8 @@ The package exposes a local stdio MCP server and reusable core modules. Claude C
10
10
 
11
11
  - `execute`, `batch_execute`, `execute_file`
12
12
  - `index`, `search`, `fetch_and_index`
13
- - `stats`, `doctor`, `purge`
13
+ - `session_resume`, `ingestion_job_start`, `ingestion_job_status`, `ingestion_job_cancel`
14
+ - `context_heal`, `stats`, `doctor`, `purge`
14
15
  - `dotdotgod_project_load`, `dotdotgod_project_impact`, `dotdotgod_project_initialize`
15
16
 
16
17
  Run the stdio MCP server directly with:
@@ -36,6 +37,8 @@ Each command has one **10 MiB combined capture ceiling** shared by stdout and st
36
37
 
37
38
  `batch_execute` accepts up to 100 commands, uses concurrency from 1 through 8, and preserves input order. Execution and explicit shell mode remain write-capable operations subject to the host's approval model.
38
39
 
40
+ `execute`, `batch_execute`, and `execute_file` share an `inherit-filtered-v1` child-environment policy. It preserves compatibility-oriented inheritance while filtering `NODE_OPTIONS`, `PYTHONPATH`, `RUBYOPT`, `LD_PRELOAD`, and supported-platform `DYLD_*` variables. Callers may provide string overrides or remove inherited variables with `null`, but cannot restore reserved variables. Results report filtered names without values. This policy applies only to these dotdotgod tools and does not isolate ordinary inherited credentials.
41
+
39
42
  ## Local Storage And Retrieval
40
43
 
41
44
  Indexed content is stored in the project-local SQLite FTS5 database:
@@ -44,7 +47,7 @@ Indexed content is stored in the project-local SQLite FTS5 database:
44
47
  .dotdotgod/context/context.sqlite
45
48
  ```
46
49
 
47
- The store is local ignored state, not maintained project truth. Sources can use transient, session, or project scope and carry optional expiry metadata. Search applies scope, session, and source predicates before candidate fusion, then:
50
+ The store is local ignored state, not maintained project truth. Sources can use transient, session, or project scope and carry optional expiry metadata. Writable connections use WAL and a 1-second busy timeout. Source replacement, expiry, and purge update source and FTS rows transactionally. Recognized older schemas migrate transactionally through a version ledger; unknown, newer, incomplete, or corrupt schemas fail closed before explicit healing. Search applies scope, session, and source predicates before candidate fusion, then:
48
51
 
49
52
  1. retrieves bounded Porter-tokenized FTS5 candidates;
50
53
  2. retrieves bounded label/path candidates;
@@ -56,7 +59,17 @@ This is bounded indexing and retrieval, not LLM summarization. Search does not a
56
59
 
57
60
  ## Structural Ingestion
58
61
 
59
- Local `index` accepts one regular text file up to **25 MiB**. Directory ingestion is not supported.
62
+ Local `index` accepts one project-contained regular text file or directory. A file is limited to **25 MiB**. Directory defaults allow at most depth 16, 10,000 visited entries, 1,000 indexed files, 25 MiB per file, and 100 MiB in aggregate; callers may configure the traversal limits explicitly.
63
+
64
+ Directory ingestion:
65
+
66
+ - walks paths in deterministic lexical order and returns metadata-only indexed, skipped, and failed entries;
67
+ - accepts explicit `includeExtensions` and `excludePaths` filters but does not interpret `.gitignore` files;
68
+ - skips directory and file symlinks by default; optional file-symlink following remains limited to targets inside the project;
69
+ - rejects special files and verifies file identity through a bounded descriptor read before indexing;
70
+ - stops further traversal/indexing on cancellation while reporting already committed sources.
71
+
72
+ Content chunking then applies these rules:
60
73
 
61
74
  - Markdown is split around headings, prose blocks, and fenced blocks while retaining heading and fence metadata.
62
75
  - JSON is parsed into deterministic key-path chunks; invalid JSON falls back to bounded text chunks.
@@ -91,7 +104,7 @@ The application-level fetch policy:
91
104
  - supports identity, gzip, deflate, and Brotli encodings and rejects unsupported encodings;
92
105
  - accepts bounded text-oriented MIME types and fails closed for unsupported types or HTTP statuses.
93
106
 
94
- HTML is handled as bounded untrusted text; the package does not claim rich HTML extraction. These controls provide application-level URL and address validation, not a network namespace or process sandbox.
107
+ Accepted HTML is normalized by a bounded `html-v1` extractor. It validates supported UTF-8/US-ASCII declarations, removes scripts, styles, noscript and hidden/non-content elements, and preserves basic titles, headings, paragraphs, lists, tables, code, and link text. Link metadata is separately bounded. The extractor does not invoke a browser, execute JavaScript, load subresources, follow links, or provide standards-complete HTML rendering. HTML remains `external-untrusted`; normalization is not rendering sanitization or a prompt-injection guarantee. These controls provide application-level URL and address validation, not a network namespace or process sandbox.
95
108
 
96
109
  ## Diagnostics And Deletion
97
110
 
@@ -100,7 +113,7 @@ HTML is handled as bounded untrusted text; the package does not claim rich HTML
100
113
  - the minimum Node.js runtime;
101
114
  - project and storage-path readiness;
102
115
  - SQLite FTS5 with the Porter tokenizer;
103
- - compatibility of an existing context database schema;
116
+ - compatibility of an existing context database schema without migration or repair;
104
117
  - current store statistics when supplied;
105
118
  - the configured strict fetch policy.
106
119
 
@@ -114,6 +127,12 @@ The MCP server also exposes bounded project load, graph impact, and initializer
114
127
 
115
128
  Claude Code and Codex adapter hooks may tell the model to call these MCP tools and retry a denied operation. Hooks do not transform an ordinary host shell call into an MCP call. Pi retains its native project-memory lifecycle and calls the shared runtime directly.
116
129
 
130
+ ## Phase 3 Capabilities
131
+
132
+ Search includes a bounded typo-tolerant trigram lane while Porter FTS remains primary. Databases use transactional version-ledger migrations; explicit healing backs up first and accepts only recognized profiles. Opaque session IDs can be resumed without a history-listing API. Durable background ingestion has bounded queue, status, cancellation, and restart recovery.
133
+
134
+ Execution keeps compatibility-oriented filtered inheritance by default; this mode is not credential isolation. Opt-in `allowlist-v1` starts from an empty child environment and copies only explicit names, including caller overrides only when separately allowlisted, with names-only reporting. Strict fetch remains default. Browser rendering is available only through an injected renderer plus `browser: true`; this package bundles no browser, keeps rendered content `external-untrusted`, enforces limits/abort, and makes no sandbox claim.
135
+
117
136
  ## Learn More
118
137
 
119
138
  - [Behavior specification](https://github.com/dotdotgod/dotdotgod-kit/blob/main/docs/spec/CONTEXT_EXECUTION.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdotgod/context",
3
- "version": "0.2.25",
3
+ "version": "0.2.27",
4
4
  "description": "Local-first MCP execution, indexing, and retrieval runtime for dotdotgod adapters.",
5
5
  "type": "module",
6
6
  "license": "Elastic-2.0",
@@ -28,7 +28,7 @@
28
28
  "dependencies": {
29
29
  "@modelcontextprotocol/sdk": "^1.25.2",
30
30
  "zod": "^3.25.76",
31
- "@dotdotgod/cli": "0.2.25"
31
+ "@dotdotgod/cli": "0.2.27"
32
32
  },
33
33
  "engines": {
34
34
  "node": ">=22.5.0"
package/src/content.mjs CHANGED
@@ -1,41 +1,129 @@
1
1
  import { readFileSync, statSync } from 'node:fs';
2
+ import { readManifestFile, walkDirectoryManifest } from './directory-ingestion.mjs';
3
+ import { normalizeHtml } from './html-normalize.mjs';
2
4
  import { resolveExistingWithinRoot } from './paths.mjs';
3
5
  import { safeFetch } from './safe-fetch.mjs';
4
6
 
5
7
  const MAX_FILE_BYTES = 25 * 1024 * 1024;
6
8
  const MAX_FETCH_BYTES = 10 * 1024 * 1024;
7
9
 
8
- export function indexFile(store, input, sessionId) {
9
- const path = resolveExistingWithinRoot(input.root || process.cwd(), input.path);
10
- const stat = statSync(path);
11
- if (!stat.isFile()) throw new Error('Only regular files are supported in the first release.');
10
+ function indexRegularFile(store, input, sessionId, path, stat, label = input.source ?? path, bytes) {
12
11
  const maxBytes = Math.min(input.maxBytes ?? MAX_FILE_BYTES, MAX_FILE_BYTES);
13
12
  if (stat.size > maxBytes) throw new Error(`File exceeds maximum size: ${stat.size} bytes`);
13
+ const content = bytes ?? readFileSync(path);
14
+ if (content.length > maxBytes) throw new Error(`File exceeds maximum size: ${content.length} bytes`);
14
15
  return store.index({
15
16
  scope: input.scope ?? 'session',
16
17
  sessionId,
17
- label: input.source ?? path,
18
+ label,
18
19
  kind: 'file',
19
- text: readFileSync(path, 'utf8'),
20
+ text: content.toString('utf8'),
20
21
  metadata: { path, size: stat.size },
21
22
  ttlMs: input.ttlMs ?? null,
22
23
  });
23
24
  }
24
25
 
25
- export async function fetchAndIndex(store, input, sessionId, signal) {
26
+ function indexDirectory(store, input, sessionId, signal) {
27
+ const manifest = walkDirectoryManifest({
28
+ root: input.root || process.cwd(),
29
+ path: input.path,
30
+ includeExtensions: input.includeExtensions,
31
+ excludePaths: input.excludePaths,
32
+ followFileSymlinks: input.followFileSymlinks,
33
+ maxDepth: input.maxDepth,
34
+ maxVisitedEntries: input.maxVisitedEntries,
35
+ maxFiles: input.maxFiles,
36
+ maxFileBytes: Math.min(input.maxBytes ?? MAX_FILE_BYTES, MAX_FILE_BYTES),
37
+ maxAggregateBytes: input.maxAggregateBytes,
38
+ signal,
39
+ });
40
+ const indexed = [];
41
+ const failed = [...manifest.failed];
42
+ const aggregateLimit = input.maxAggregateBytes ?? 100 * 1024 * 1024;
43
+ let indexedBytes = 0;
44
+ for (const entry of manifest.files) {
45
+ if (signal?.aborted) break;
46
+ try {
47
+ const remaining = aggregateLimit - indexedBytes;
48
+ if (remaining < 0) throw new Error('Directory aggregate byte limit exceeded.');
49
+ const bytes = readManifestFile(entry, manifest.root, Math.min(input.maxBytes ?? MAX_FILE_BYTES, MAX_FILE_BYTES, remaining));
50
+ const label = input.source ? `${input.source}/${entry.path}` : entry.absolutePath;
51
+ indexed.push({ path: entry.path, ...indexRegularFile(store, input, sessionId, entry.absolutePath, entry, label, bytes) });
52
+ indexedBytes += bytes.length;
53
+ } catch (error) {
54
+ failed.push({ path: entry.path, reason: 'index-failed', error: error instanceof Error ? error.message : String(error) });
55
+ }
56
+ }
57
+ return {
58
+ directory: manifest.directory,
59
+ indexed,
60
+ skipped: manifest.skipped,
61
+ failed,
62
+ aborted: manifest.aborted || signal?.aborted === true,
63
+ truncated: manifest.truncated,
64
+ visitedEntries: manifest.visitedEntries,
65
+ aggregateBytes: indexedBytes,
66
+ };
67
+ }
68
+
69
+ export function indexFile(store, input, sessionId, signal) {
70
+ const path = resolveExistingWithinRoot(input.root || process.cwd(), input.path);
71
+ const stat = statSync(path);
72
+ if (stat.isDirectory()) return indexDirectory(store, input, sessionId, signal);
73
+ if (!stat.isFile()) throw new Error('Only regular files and directories are supported.');
74
+ return indexRegularFile(store, input, sessionId, path, stat);
75
+ }
76
+
77
+ export async function fetchAndIndex(store, input, sessionId, signal, capabilities = {}) {
26
78
  const maxBytes = Math.min(input.maxBytes ?? MAX_FETCH_BYTES, MAX_FETCH_BYTES);
27
- const fetched = await safeFetch(input.url, {
79
+ let fetched;
80
+ if (input.browser === true) {
81
+ if (typeof capabilities.renderer !== 'function') throw new Error('Browser rendering capability is unavailable; strict fetch remains the default.');
82
+ const url = new URL(input.url);
83
+ if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP(S) browser rendering is supported.');
84
+ const timeoutMs = Math.min(input.timeoutMs ?? 10_000, 120_000);
85
+ const controller = new AbortController();
86
+ let rejectAbort;
87
+ const aborted = new Promise((_, reject) => { rejectAbort = reject; });
88
+ const abort = () => { const reason = signal?.reason instanceof Error ? signal.reason : new Error('Browser rendering aborted.'); controller.abort(reason); rejectAbort(reason); };
89
+ if (signal?.aborted) abort(); else signal?.addEventListener('abort', abort, { once: true });
90
+ let timer;
91
+ const timeout = new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(new Error('Browser rendering timed out.')); reject(new Error(`Browser rendering timed out after ${timeoutMs}ms.`)); }, timeoutMs); timer.unref?.(); });
92
+ let rendered;
93
+ try { rendered = await Promise.race([capabilities.renderer({ url: url.href, signal: controller.signal, timeoutMs, maxBytes }), timeout, aborted]); }
94
+ finally { clearTimeout(timer); signal?.removeEventListener('abort', abort); }
95
+ if (!rendered || typeof rendered !== 'object' || Array.isArray(rendered)) throw new Error('Browser renderer returned an invalid result.');
96
+ if (rendered.body !== undefined && !Buffer.isBuffer(rendered.body)) throw new Error('Browser renderer body must be a Buffer.');
97
+ if (rendered.text !== undefined && typeof rendered.text !== 'string') throw new Error('Browser renderer text must be a string.');
98
+ if (rendered.body === undefined && rendered.text === undefined) throw new Error('Browser renderer must return body or text.');
99
+ const body = rendered.body ?? Buffer.from(rendered.text, 'utf8');
100
+ if (body.length > maxBytes) throw new Error(`Rendered content exceeds maximum size: ${body.length} bytes`);
101
+ const finalUrl = String(rendered.url ?? url.href);
102
+ if (finalUrl.length > 4096 || !['http:', 'https:'].includes(new URL(finalUrl).protocol)) throw new Error('Browser renderer returned an invalid final URL.');
103
+ const contentType = String(rendered.contentType ?? 'text/html; charset=utf-8');
104
+ if (contentType.length > 512 || /[\r\n]/u.test(contentType)) throw new Error('Browser renderer returned invalid content type metadata.');
105
+ const status = rendered.status ?? 200;
106
+ if (!Number.isInteger(status) || status < 100 || status > 599) throw new Error('Browser renderer returned an invalid HTTP status.');
107
+ fetched = { body, text: body.toString('utf8'), url: finalUrl, originalUrl: url.href, redirects: [], contentType, contentEncoding: 'identity', status, bytes: body.length, wireLength: body.length };
108
+ } else fetched = await safeFetch(input.url, {
28
109
  signal,
29
110
  timeoutMs: input.timeoutMs,
30
111
  maxBytes,
31
112
  maxWireBytes: maxBytes,
32
113
  });
114
+ const isHtml = /^(?:text\/html|application\/xhtml\+xml)(?:;|$)/iu.test(fetched.contentType);
115
+ const normalized = isHtml ? normalizeHtml(fetched.body, {
116
+ contentType: fetched.contentType,
117
+ maxInputBytes: maxBytes,
118
+ maxOutputBytes: maxBytes,
119
+ }) : null;
120
+ const text = normalized?.text ?? fetched.text;
33
121
  return store.index({
34
122
  scope: input.scope ?? 'project',
35
123
  sessionId,
36
- label: input.source ?? fetched.url,
124
+ label: input.source ?? normalized?.title ?? fetched.url,
37
125
  kind: 'url',
38
- text: fetched.text,
126
+ text,
39
127
  metadata: {
40
128
  url: fetched.url,
41
129
  originalUrl: fetched.originalUrl,
@@ -46,8 +134,21 @@ export async function fetchAndIndex(store, input, sessionId, signal) {
46
134
  status: fetched.status,
47
135
  bytes: fetched.bytes,
48
136
  wireLength: fetched.wireLength,
137
+ ...(normalized ? {
138
+ htmlTitle: normalized.title,
139
+ htmlInputBytes: normalized.inputBytes,
140
+ htmlOutputBytes: normalized.outputBytes,
141
+ htmlTruncated: normalized.truncated,
142
+ htmlFallbackReason: normalized.fallbackReason,
143
+ } : {}),
144
+ acquisitionMode: input.browser === true ? 'browser-renderer' : 'strict-fetch',
145
+ },
146
+ provenance: {
147
+ sourceType: 'fetched-url',
148
+ origin: fetched.url,
149
+ contentType: normalized ? 'text/markdown' : fetched.contentType,
150
+ extractor: normalized?.extractor,
49
151
  },
50
- provenance: { sourceType: 'fetched-url', origin: fetched.url, contentType: fetched.contentType },
51
152
  ttlMs: input.ttlMs ?? 24 * 60 * 60 * 1000,
52
153
  });
53
154
  }
@@ -0,0 +1,182 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
3
+
4
+ const DEFAULT_LIMITS = Object.freeze({
5
+ maxDepth: 16,
6
+ maxVisitedEntries: 10_000,
7
+ maxFiles: 1_000,
8
+ maxFileBytes: 25 * 1024 * 1024,
9
+ maxAggregateBytes: 100 * 1024 * 1024,
10
+ });
11
+
12
+ function inside(base, target) {
13
+ const rel = relative(base, target);
14
+ return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
15
+ }
16
+
17
+ function relativePath(base, target) {
18
+ return relative(base, target).split(sep).join('/');
19
+ }
20
+
21
+ function boundedInteger(value, fallback, name) {
22
+ const selected = value ?? fallback;
23
+ if (!Number.isSafeInteger(selected) || selected < 0) throw new TypeError(`${name} must be a non-negative safe integer`);
24
+ return selected;
25
+ }
26
+
27
+ function normalizeOptions(options) {
28
+ const includeExtensions = options.includeExtensions?.map((value) => {
29
+ const extension = String(value).toLowerCase();
30
+ return extension.startsWith('.') ? extension : `.${extension}`;
31
+ });
32
+ const excludePaths = (options.excludePaths ?? []).map((value) => {
33
+ const normalized = String(value).replaceAll('\\', '/').replace(/^\.\//, '').replace(/\/$/, '');
34
+ if (!normalized || normalized === '..' || normalized.startsWith('../') || normalized.includes('/../')) {
35
+ throw new TypeError(`Invalid excluded path: ${value}`);
36
+ }
37
+ return normalized;
38
+ });
39
+ return {
40
+ includeExtensions: includeExtensions ? new Set(includeExtensions) : null,
41
+ excludePaths,
42
+ followFileSymlinks: options.followFileSymlinks === true,
43
+ maxDepth: boundedInteger(options.maxDepth, DEFAULT_LIMITS.maxDepth, 'maxDepth'),
44
+ maxVisitedEntries: boundedInteger(options.maxVisitedEntries, DEFAULT_LIMITS.maxVisitedEntries, 'maxVisitedEntries'),
45
+ maxFiles: boundedInteger(options.maxFiles, DEFAULT_LIMITS.maxFiles, 'maxFiles'),
46
+ maxFileBytes: boundedInteger(options.maxFileBytes, DEFAULT_LIMITS.maxFileBytes, 'maxFileBytes'),
47
+ maxAggregateBytes: boundedInteger(options.maxAggregateBytes, DEFAULT_LIMITS.maxAggregateBytes, 'maxAggregateBytes'),
48
+ };
49
+ }
50
+
51
+ function excluded(path, prefixes) {
52
+ return prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
53
+ }
54
+
55
+ function extensionAllowed(path, extensions) {
56
+ if (!extensions) return true;
57
+ const slash = path.lastIndexOf('/');
58
+ const dot = path.lastIndexOf('.');
59
+ return dot > slash && extensions.has(path.slice(dot).toLowerCase());
60
+ }
61
+
62
+ function snapshot(path, target, stat, symlink) {
63
+ return {
64
+ path,
65
+ absolutePath: target,
66
+ size: stat.size,
67
+ identity: { dev: stat.dev, ino: stat.ino, size: stat.size, mtimeMs: stat.mtimeMs },
68
+ symlink,
69
+ };
70
+ }
71
+
72
+ export function walkDirectoryManifest(options = {}) {
73
+ const root = realpathSync(resolve(options.root || process.cwd()));
74
+ const requested = resolve(root, options.path ?? '.');
75
+ if (!inside(root, requested)) throw new Error(`Directory escapes project root: ${options.path}`);
76
+ if (lstatSync(requested).isSymbolicLink()) throw new Error(`Directory symlinks are not followed: ${options.path ?? '.'}`);
77
+ const directory = realpathSync(requested);
78
+ if (!inside(root, directory)) throw new Error(`Directory escapes project root: ${options.path}`);
79
+ if (!statSync(directory).isDirectory()) throw new Error(`Not a directory: ${options.path ?? '.'}`);
80
+
81
+ const policy = normalizeOptions(options);
82
+ const files = [];
83
+ const skipped = [];
84
+ const failed = [];
85
+ let visitedEntries = 0;
86
+ let aggregateBytes = 0;
87
+ let aborted = false;
88
+ let truncated = false;
89
+
90
+ function skip(path, reason) { skipped.push({ path, reason }); }
91
+ function fail(path, reason) { failed.push({ path, reason }); }
92
+
93
+ function visit(current, depth) {
94
+ if (aborted || options.signal?.aborted) { aborted = true; return; }
95
+ let names;
96
+ try { names = readdirSync(current).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); }
97
+ catch { fail(relativePath(root, current), 'directory-read-failed'); return; }
98
+
99
+ for (const name of names) {
100
+ if (options.signal?.aborted) { aborted = true; return; }
101
+ const absolutePath = resolve(current, name);
102
+ const path = relativePath(root, absolutePath);
103
+ if (visitedEntries >= policy.maxVisitedEntries) { skip(path, 'visited-entry-limit'); truncated = true; return; }
104
+ visitedEntries += 1;
105
+ if (excluded(path, policy.excludePaths)) { skip(path, 'excluded-path'); continue; }
106
+
107
+ let entry;
108
+ try { entry = lstatSync(absolutePath); }
109
+ catch { fail(path, 'entry-stat-failed'); continue; }
110
+
111
+ if (entry.isSymbolicLink()) {
112
+ let target;
113
+ let targetStat;
114
+ try {
115
+ target = realpathSync(absolutePath);
116
+ if (!inside(root, target)) { skip(path, 'symlink-outside-root'); continue; }
117
+ targetStat = statSync(target);
118
+ } catch { skip(path, 'symlink-unresolvable'); continue; }
119
+ if (targetStat.isDirectory()) { skip(path, 'directory-symlink'); continue; }
120
+ if (!policy.followFileSymlinks) { skip(path, 'file-symlink'); continue; }
121
+ if (!targetStat.isFile()) { skip(path, 'special-file'); continue; }
122
+ addFile(path, target, targetStat, true);
123
+ continue;
124
+ }
125
+
126
+ if (entry.isDirectory()) {
127
+ if (depth >= policy.maxDepth) { skip(path, 'depth-limit'); continue; }
128
+ visit(absolutePath, depth + 1);
129
+ if (aborted || truncated) return;
130
+ continue;
131
+ }
132
+ if (!entry.isFile()) { skip(path, 'special-file'); continue; }
133
+ addFile(path, absolutePath, entry, false);
134
+ }
135
+ }
136
+
137
+ function addFile(path, absolutePath, stat, symlink) {
138
+ if (!extensionAllowed(path, policy.includeExtensions)) { skip(path, 'extension-filter'); return; }
139
+ if (stat.size > policy.maxFileBytes) { skip(path, 'file-byte-limit'); return; }
140
+ if (files.length >= policy.maxFiles) { skip(path, 'file-count-limit'); return; }
141
+ if (aggregateBytes + stat.size > policy.maxAggregateBytes) { skip(path, 'aggregate-byte-limit'); return; }
142
+ files.push(snapshot(path, absolutePath, stat, symlink));
143
+ aggregateBytes += stat.size;
144
+ }
145
+
146
+ visit(directory, 0);
147
+ return { root, directory, files, skipped, failed, aborted, truncated, visitedEntries, aggregateBytes };
148
+ }
149
+
150
+ function matchesIdentity(stat, identity) {
151
+ return stat.dev === identity.dev && stat.ino === identity.ino && stat.size === identity.size && stat.mtimeMs === identity.mtimeMs;
152
+ }
153
+
154
+ export function verifyManifestFile(entry, root) {
155
+ const base = realpathSync(resolve(root));
156
+ const target = realpathSync(entry.absolutePath);
157
+ if (!inside(base, target)) throw new Error(`Manifest file escapes project root: ${entry.path}`);
158
+ const stat = statSync(target);
159
+ if (!stat.isFile()) throw new Error(`Manifest entry is not a regular file: ${entry.path}`);
160
+ if (!matchesIdentity(stat, entry.identity ?? {})) throw new Error(`Manifest file changed before indexing: ${entry.path}`);
161
+ return target;
162
+ }
163
+
164
+ export function readManifestFile(entry, root, maxBytes = DEFAULT_LIMITS.maxFileBytes) {
165
+ const target = verifyManifestFile(entry, root);
166
+ const noFollow = constants.O_NOFOLLOW ?? 0;
167
+ const descriptor = openSync(target, constants.O_RDONLY | noFollow);
168
+ try {
169
+ const before = fstatSync(descriptor);
170
+ if (!before.isFile() || !matchesIdentity(before, entry.identity ?? {})) throw new Error(`Manifest file changed before indexing: ${entry.path}`);
171
+ if (before.size > maxBytes) throw new Error(`Manifest file exceeds maximum size: ${entry.path}`);
172
+ const bytes = readFileSync(descriptor);
173
+ const after = fstatSync(descriptor);
174
+ if (!matchesIdentity(after, entry.identity ?? {}) || bytes.length !== before.size) throw new Error(`Manifest file changed while indexing: ${entry.path}`);
175
+ if (bytes.length > maxBytes) throw new Error(`Manifest file exceeds maximum size: ${entry.path}`);
176
+ return bytes;
177
+ } finally {
178
+ closeSync(descriptor);
179
+ }
180
+ }
181
+
182
+ export { DEFAULT_LIMITS as DIRECTORY_INGESTION_LIMITS };
package/src/doctor.mjs CHANGED
@@ -92,11 +92,20 @@ function schemaCheck(dbPath) {
92
92
  const names = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table', 'view')").all().map((row) => row.name));
93
93
  const missing = [...EXPECTED_TABLES].filter((name) => !names.has(name));
94
94
  if (missing.length > 0) return check('schema-compatibility', 'FAIL', 'Context database is missing required tables.', { dbPath, missing });
95
- const sourceColumns = new Set(db.prepare('PRAGMA table_info(sources)').all().map((row) => row.name));
96
- const requiredColumns = ['id', 'scope', 'session_id', 'label', 'kind', 'metadata', 'created_at', 'expires_at'];
97
- const missingColumns = requiredColumns.filter((name) => !sourceColumns.has(name));
98
- if (missingColumns.length > 0) return check('schema-compatibility', 'FAIL', 'Sources table is incompatible.', { dbPath, missingColumns });
99
- return check('schema-compatibility', 'OK', 'Existing context database has the required schema.', { dbPath });
95
+ const sourceRows = db.prepare('PRAGMA table_info(sources)').all();
96
+ const chunkRows = db.prepare('PRAGMA table_info(chunks)').all();
97
+ const requiredSources = ['id', 'scope', 'session_id', 'label', 'kind', 'metadata', 'created_at', 'expires_at'];
98
+ const requiredChunks = ['source_id', 'scope', 'session_id', 'ordinal', 'body'];
99
+ const missingColumns = [...requiredSources.filter((name) => !sourceRows.some((row) => row.name === name)), ...requiredChunks.filter((name) => !chunkRows.some((row) => row.name === name))];
100
+ const sourceShape = sourceRows.length === requiredSources.length && sourceRows.every((row, index) => row.name === requiredSources[index])
101
+ && sourceRows[0].pk === 1
102
+ && sourceRows.every((row) => ['scope', 'label', 'kind', 'metadata', 'created_at'].includes(row.name) ? row.notnull === 1 : true);
103
+ const chunkShape = chunkRows.length === requiredChunks.length && chunkRows.every((row, index) => row.name === requiredChunks[index]);
104
+ const chunkSql = String(db.prepare("SELECT sql FROM sqlite_master WHERE name = 'chunks'").get()?.sql ?? '').toLowerCase();
105
+ if (missingColumns.length > 0 || !sourceShape || !chunkShape || !chunkSql.includes('using fts5') || !chunkSql.includes("tokenize='porter unicode61'")) {
106
+ return check('schema-compatibility', 'FAIL', 'Context database schema is incompatible.', { dbPath, schemaProfile: 'unknown', missingColumns });
107
+ }
108
+ return check('schema-compatibility', 'OK', 'Existing context database matches compatibility profile 1.', { dbPath, schemaProfile: 1 });
100
109
  } catch (error) {
101
110
  return check('schema-compatibility', 'FAIL', 'Context database could not be inspected read-only.', { dbPath, error: error.message });
102
111
  } finally {
@@ -0,0 +1,85 @@
1
+ const COMMON_FILTERED_NAMES = new Set(['NODE_OPTIONS', 'PYTHONPATH', 'RUBYOPT']);
2
+ const VALID_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
3
+
4
+ function platformFamily(platform) {
5
+ if (platform === 'win32') return 'win32';
6
+ if (platform === 'darwin') return 'darwin';
7
+ return 'posix';
8
+ }
9
+
10
+ function canonicalName(name, family) {
11
+ return family === 'win32' ? name.toUpperCase() : name;
12
+ }
13
+
14
+ function validateName(name) {
15
+ if (!VALID_NAME.test(name)) throw new TypeError(`Invalid environment variable name: ${JSON.stringify(name)}`);
16
+ }
17
+
18
+ function isFilteredName(name, family) {
19
+ const comparable = family === 'win32' ? name.toUpperCase() : name;
20
+ if (COMMON_FILTERED_NAMES.has(comparable)) return true;
21
+ if (family === 'darwin') return comparable === 'LD_PRELOAD' || comparable.startsWith('DYLD_');
22
+ if (family === 'posix') return comparable === 'LD_PRELOAD';
23
+ return false;
24
+ }
25
+
26
+ function entriesOf(value, label) {
27
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
28
+ throw new TypeError(`${label} must be an object`);
29
+ }
30
+ return Object.entries(value).sort(([left], [right]) => left.localeCompare(right, 'en'));
31
+ }
32
+
33
+ /**
34
+ * Compose a child-process environment without exposing filtered values.
35
+ * Override values may be strings or null; null explicitly deletes a variable.
36
+ */
37
+ export function composeEnvironment({ inherited = {}, overrides = {}, platform = process.platform, mode = 'inherit-filtered-v1', allow = [] } = {}) {
38
+ const family = platformFamily(platform);
39
+ if (!['inherit-filtered-v1', 'allowlist-v1'].includes(mode)) throw new TypeError(`Unsupported environment policy mode: ${mode}`);
40
+ if (!Array.isArray(allow)) throw new TypeError('allow must be an array');
41
+ const allowed = new Set(allow.map((name) => { validateName(name); return canonicalName(name, family); }));
42
+ const env = {};
43
+ const filtered = new Set();
44
+
45
+ for (const [name, value] of entriesOf(inherited, 'inherited')) {
46
+ validateName(name);
47
+ if (typeof value !== 'string' && value !== undefined) {
48
+ throw new TypeError(`Inherited environment variable ${name} must be a string or undefined`);
49
+ }
50
+ if (value === undefined) continue;
51
+ const outputName = canonicalName(name, family);
52
+ if (isFilteredName(outputName, family) || (mode === 'allowlist-v1' && !allowed.has(outputName))) {
53
+ filtered.add(outputName);
54
+ continue;
55
+ }
56
+ env[outputName] = value;
57
+ }
58
+
59
+ for (const [name, value] of entriesOf(overrides, 'overrides')) {
60
+ validateName(name);
61
+ const outputName = canonicalName(name, family);
62
+ if (isFilteredName(outputName, family)) {
63
+ throw new TypeError(`Environment variable ${name} is reserved by the execution policy`);
64
+ }
65
+ if (mode === 'allowlist-v1' && !allowed.has(outputName)) throw new TypeError(`Environment variable ${name} is not in the explicit allowlist`);
66
+ if (value === null) {
67
+ delete env[outputName];
68
+ continue;
69
+ }
70
+ if (typeof value !== 'string') {
71
+ throw new TypeError(`Environment override ${name} must be a string or null`);
72
+ }
73
+ env[outputName] = value;
74
+ }
75
+
76
+ return {
77
+ env,
78
+ policy: {
79
+ mode,
80
+ platform: family,
81
+ ...(mode === 'allowlist-v1' ? { allowedNames: [...allowed].sort((left, right) => left.localeCompare(right, 'en')) } : {}),
82
+ filteredNames: [...filtered].sort((left, right) => left.localeCompare(right, 'en')),
83
+ },
84
+ };
85
+ }
package/src/execute.mjs CHANGED
@@ -3,7 +3,9 @@ import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { Transform } from 'node:stream';
6
+ import { finished } from 'node:stream/promises';
6
7
  import { resolveWithinRoot } from './paths.mjs';
8
+ import { composeEnvironment } from './environment-policy.mjs';
7
9
 
8
10
  const DIRECT_LIMIT = 12_000;
9
11
  const MAX_DIRECT_RETURN = 1024 * 1024;
@@ -32,6 +34,7 @@ function killProcess(child) {
32
34
  }
33
35
 
34
36
  export async function executeCommand(input, options = {}) {
37
+ if (options.signal?.aborted) throw options.signal.reason ?? new Error('Execution aborted.');
35
38
  const spec = commandSpec(input);
36
39
  const cwd = resolveWithinRoot(options.root || process.cwd(), input.cwd || '.');
37
40
  const timeoutMs = Math.min(Math.max(1, input.timeoutMs ?? 120_000), 30 * 60_000);
@@ -45,6 +48,12 @@ export async function executeCommand(input, options = {}) {
45
48
  let aborted = false;
46
49
  let captureLimitExceeded = false;
47
50
  const captureLimitBytes = Math.min(HARD_LIMIT, Math.max(1024, options.captureLimitBytes ?? HARD_LIMIT));
51
+ const environment = composeEnvironment({
52
+ inherited: options.env ?? process.env,
53
+ overrides: input.env ?? {},
54
+ mode: input.environmentMode ?? 'inherit-filtered-v1',
55
+ allow: input.allowedEnv ?? [],
56
+ });
48
57
 
49
58
  try {
50
59
  const result = await new Promise((resolvePromise, reject) => {
@@ -53,7 +62,7 @@ export async function executeCommand(input, options = {}) {
53
62
  shell: spec.shell,
54
63
  detached: process.platform !== 'win32',
55
64
  stdio: ['ignore', 'pipe', 'pipe'],
56
- env: options.env ?? process.env,
65
+ env: environment.env,
57
66
  });
58
67
  let capturedBytes = 0;
59
68
  const boundedCapture = () => new Transform({
@@ -74,14 +83,22 @@ export async function executeCommand(input, options = {}) {
74
83
  const timer = setTimeout(() => { timedOut = true; killProcess(child); }, timeoutMs);
75
84
  const onAbort = () => { aborted = true; killProcess(child); };
76
85
  options.signal?.addEventListener('abort', onAbort, { once: true });
77
- child.once('error', (error) => { clearTimeout(timer); reject(error); });
86
+ let spawnError = false;
87
+ child.once('error', (error) => {
88
+ spawnError = true;
89
+ clearTimeout(timer);
90
+ options.signal?.removeEventListener('abort', onAbort);
91
+ stdout.end(() => stderr.end(() => reject(error)));
92
+ });
78
93
  child.once('close', (code, signal) => {
94
+ if (spawnError) return;
79
95
  clearTimeout(timer);
80
96
  options.signal?.removeEventListener('abort', onAbort);
81
97
  stdout.end(() => stderr.end(() => resolvePromise({ code, signal })));
82
98
  });
83
99
  });
84
100
 
101
+ await Promise.all([finished(stdout), finished(stderr)]);
85
102
  const outputLimit = Math.min(MAX_DIRECT_RETURN, Math.max(1, input.outputLimit ?? DIRECT_LIMIT));
86
103
  const directLimit = Math.min(HARD_LIMIT, Math.max(1, input.directLimit ?? DIRECT_LIMIT));
87
104
  const stdoutResult = readBounded(stdoutPath, outputLimit);
@@ -99,6 +116,7 @@ export async function executeCommand(input, options = {}) {
99
116
  durationMs: Date.now() - startedAt,
100
117
  stdoutBytes: stdoutResult.bytes,
101
118
  stderrBytes: stderrResult.bytes,
119
+ environmentPolicy: environment.policy,
102
120
  };
103
121
  const mode = input.outputMode ?? 'auto';
104
122
  const shouldIndex = mode === 'indexed' || (mode === 'auto' && totalBytes > directLimit);
@@ -122,12 +140,13 @@ export async function executeCommand(input, options = {}) {
122
140
  ...(mode === 'discard' ? {} : shouldIndex ? { indexed } : { stdout: stdoutResult.text, stderr: stderrResult.text, truncated: stdoutResult.truncated || stderrResult.truncated }),
123
141
  };
124
142
  } finally {
125
- rmSync(dir, { recursive: true, force: true });
143
+ rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 10 });
126
144
  }
127
145
  }
128
146
 
129
147
  export async function executeFile(input, options = {}) {
130
148
  const path = resolveWithinRoot(options.root || process.cwd(), input.path);
149
+ const selectorEnvironment = composeEnvironment({ inherited: options.env ?? process.env, overrides: input.env ?? {}, mode: input.environmentMode ?? 'inherit-filtered-v1', allow: input.allowedEnv ?? [] }).env;
131
150
  const dir = mkdtempSync(join(tmpdir(), 'dotdotgod-context-file-'));
132
151
  try {
133
152
  let executable;
@@ -135,12 +154,12 @@ export async function executeFile(input, options = {}) {
135
154
  if (input.language === 'python') {
136
155
  const wrapper = join(dir, 'run.py');
137
156
  writeFileSync(wrapper, `from pathlib import Path\nFILE_CONTENT = Path(${JSON.stringify(path)}).read_text(encoding='utf-8', errors='replace')\n${input.code}\n`);
138
- executable = process.env.PYTHON || 'python3';
157
+ executable = selectorEnvironment.PYTHON || 'python3';
139
158
  args = [wrapper];
140
159
  } else if (input.language === 'shell') {
141
160
  const wrapper = join(dir, 'run.sh');
142
161
  writeFileSync(wrapper, `FILE_PATH=${JSON.stringify(path)}\nexport FILE_PATH\n${input.code}\n`);
143
- executable = process.env.SHELL || '/bin/sh';
162
+ executable = selectorEnvironment.SHELL || '/bin/sh';
144
163
  args = [wrapper];
145
164
  } else {
146
165
  const wrapper = join(dir, 'run.mjs');
@@ -150,7 +169,7 @@ export async function executeFile(input, options = {}) {
150
169
  }
151
170
  return await executeCommand({ ...input, executable, args, command: undefined, shell: false, cwd: input.cwd ?? options.root }, options);
152
171
  } finally {
153
- rmSync(dir, { recursive: true, force: true });
172
+ rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 10 });
154
173
  }
155
174
  }
156
175