@gmickel/gno 2.7.0 → 2.7.1
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 +25 -30
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.7.0.zip → gno-browser-clipper-v2.7.1.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.7.1.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -1
- package/spec/output-schemas/collection-list.schema.json +2 -2
- package/spec/output-schemas/status.schema.json +12 -16
- package/src/cli/commands/ask.ts +31 -12
- package/src/cli/commands/ls.ts +6 -1
- package/src/core/host-paths.ts +23 -5
- package/src/core/request-receipts.ts +63 -9
- package/src/core/windows-private-path.ts +136 -1
- package/src/embed/backlog.ts +22 -10
- package/src/embed/variant-backlog.ts +34 -9
- package/src/llm/inference-scope.ts +18 -0
- package/src/mcp/tools/status.ts +19 -6
- package/src/serve/embed-scheduler.ts +2 -2
- package/src/serve/host-path-redaction.ts +51 -14
- package/src/serve/public/components/BootstrapStatus.tsx +5 -3
- package/src/serve/public/components/CaptureModal.tsx +1 -1
- package/src/serve/public/components/CollectionModelDialog.tsx +16 -13
- package/src/serve/public/components/CollectionsEmptyState.tsx +5 -3
- package/src/serve/public/components/FirstRunWizard.tsx +4 -2
- package/src/serve/public/components/sessions/SourcesPanel.tsx +77 -60
- package/src/serve/public/pages/Collections.tsx +16 -13
- package/src/serve/public/pages/Connectors.tsx +7 -4
- package/src/serve/public/pages/Dashboard.tsx +8 -6
- package/src/serve/resident-runtime.ts +7 -0
- package/src/serve/routes/sessions.ts +61 -5
- package/src/serve/server.ts +5 -3
- package/src/serve/status-model.ts +10 -6
- package/src/serve/status.ts +2 -7
- package/src/store/sqlite/adapter.ts +2 -2
- package/src/store/sqlite/change-journal-store.ts +1 -1
- package/src/store/sqlite/legacy-vector-ownership.ts +2 -1
- package/browser-extension/artifacts/gno-browser-clipper-v2.7.0.zip.sha256 +0 -1
package/README.md
CHANGED
|
@@ -139,7 +139,7 @@ See the [guide](docs/COMPILED-CONTEXT.md).
|
|
|
139
139
|
|
|
140
140
|
<!-- public-truth:current-version -->
|
|
141
141
|
|
|
142
|
-
> Current source version: **v2.7.
|
|
142
|
+
> Current source version: **v2.7.1**. See [CHANGELOG.md](./CHANGELOG.md).
|
|
143
143
|
|
|
144
144
|
<!-- /public-truth -->
|
|
145
145
|
|
|
@@ -184,9 +184,8 @@ See the [guide](docs/COMPILED-CONTEXT.md).
|
|
|
184
184
|
explicit read-only retrieval smoke from Connectors.
|
|
185
185
|
- **One resident gateway**: `gno serve` and `gno daemon` now host stateful
|
|
186
186
|
Streamable HTTP MCP at `/mcp` from the same long-lived runtime as their
|
|
187
|
-
watcher, jobs, stores, and models.
|
|
188
|
-
|
|
189
|
-
and shutdown.
|
|
187
|
+
watcher, jobs, stores, and models. Several MCP clients can share one
|
|
188
|
+
resident process and get the same results as stdio MCP.
|
|
190
189
|
- **Knowledge Delta**: `gno changes`, `gno diff`, and `gno impact` expose
|
|
191
190
|
bounded metadata-only history, structural change summaries, and explainable
|
|
192
191
|
dependency paths across CLI, REST, MCP, and SDK.
|
|
@@ -215,17 +214,13 @@ See the [guide](docs/COMPILED-CONTEXT.md).
|
|
|
215
214
|
`local_only`, `lan`, or `remote` boundary that follows mixed and derived
|
|
216
215
|
evidence through resident serving, inference, publishing, exports, Capsules,
|
|
217
216
|
and traces. Authentication never overrides policy.
|
|
218
|
-
- **Source availability (`any` | `local`)**: opt-in `local`
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
universal provider support. On the controlled 5,000-file all-local corpus,
|
|
226
|
-
production `any` regressed -1.1280% and hierarchical `local` added 1.1841%
|
|
227
|
-
median traversal overhead (2 warmups, 9 samples per lane; same corpus and
|
|
228
|
-
interleaved pre-implementation/current production walkers).
|
|
217
|
+
- **Source availability (`any` | `local`)**: opt-in `local` indexes only files
|
|
218
|
+
already on disk and never makes a cloud provider download one. Supported on
|
|
219
|
+
macOS with Google Drive, iCloud Drive, and OneDrive SharePoint library roots.
|
|
220
|
+
Cloud-only files are skipped and reported, not treated as conversion errors,
|
|
221
|
+
and documents under a cloud-only folder stay indexed. Other platforms fail
|
|
222
|
+
with an error. On a 5,000-file all-local collection, `local` scans about 1%
|
|
223
|
+
slower than `any`.
|
|
229
224
|
- **Read-only integrity audits**: `gno audit` and MCP `gno_audit` inspect local
|
|
230
225
|
links, declared provenance, and source/index freshness offline. Stable,
|
|
231
226
|
bounded findings distinguish complete, partial, unavailable, and changing
|
|
@@ -240,7 +235,7 @@ See the [guide](docs/COMPILED-CONTEXT.md).
|
|
|
240
235
|
<!-- public-truth:default-embed-model -->
|
|
241
236
|
- **Default Embed Model**: all four built-in presets use `Qwen3-Embedding-0.6B-GGUF`; see the dated, fixture-scoped evidence below
|
|
242
237
|
<!-- /public-truth -->
|
|
243
|
-
- **Regression Fixes**: tightened phrase/negation/hyphen/underscore BM25 behavior, cleaned non-TTY hyperlink output, improved `gno doctor` chunking and embedding fingerprint visibility
|
|
238
|
+
- **Regression Fixes**: tightened phrase/negation/hyphen/underscore BM25 behavior, cleaned non-TTY hyperlink output, and improved `gno doctor` chunking and embedding fingerprint visibility
|
|
244
239
|
|
|
245
240
|
### Upgrading Existing Collections
|
|
246
241
|
|
|
@@ -1318,10 +1313,10 @@ Current product stance:
|
|
|
1318
1313
|
|
|
1319
1314
|
Why Qwen is the current default:
|
|
1320
1315
|
|
|
1321
|
-
-
|
|
1322
|
-
-
|
|
1323
|
-
-
|
|
1324
|
-
-
|
|
1316
|
+
- ties `bge-m3` on the small [canonical code benchmark](./evals/fixtures/code-embedding-benchmark/canonical.md) (vector nDCG@10 `0.95` each)
|
|
1317
|
+
- beats `bge-m3` on [GNO's own web server code](./evals/fixtures/code-embedding-benchmark/repo-serve.md) (`0.8102` vs `0.1003`)
|
|
1318
|
+
- beats `bge-m3` on [pinned open-source code slices](./evals/fixtures/code-embedding-benchmark/oss-slices.md) (`1.0` vs `0.6116`)
|
|
1319
|
+
- beats `bge-m3` on the multilingual prose/docs benchmark below
|
|
1325
1320
|
|
|
1326
1321
|
Current trade-off:
|
|
1327
1322
|
|
|
@@ -1340,7 +1335,7 @@ bun run bench:general-embeddings --candidate qwen3-embedding-0.6b --write
|
|
|
1340
1335
|
|
|
1341
1336
|
<!-- public-truth:general-embedding-benchmark -->
|
|
1342
1337
|
|
|
1343
|
-
The
|
|
1338
|
+
The April 2026 FastAPI-docs run used 15 documents in five corpus
|
|
1344
1339
|
languages (`en`, `de`, `fr`, `es`, `zh`) and 13 queries:
|
|
1345
1340
|
|
|
1346
1341
|
- [bge-m3 incumbent](./evals/fixtures/general-embedding-benchmark/2026-04-06-bge-m3-incumbent.md): vector nDCG@10 `0.3503`, hybrid nDCG@10 `0.642`
|
|
@@ -1362,23 +1357,23 @@ languages.
|
|
|
1362
1357
|
|
|
1363
1358
|
<!-- public-truth:cjk-lexical-benchmark -->
|
|
1364
1359
|
|
|
1365
|
-
Lexical fallback has separate evidence. The
|
|
1360
|
+
Lexical fallback has separate evidence. The
|
|
1366
1361
|
[July 22, 2026 CJK result](./evals/fixtures/cjk-lexical-benchmark/2026-07-22.md)
|
|
1367
1362
|
uses 21 synthetic documents and 25 same-language queries across Chinese,
|
|
1368
|
-
Japanese, and Korean. Production BM25 lexical results and
|
|
1363
|
+
Japanese, and Korean. Production BM25 lexical results and the promotion floors:
|
|
1369
1364
|
|
|
1370
1365
|
- Chinese: baseline Recall@10 `0.2222`, nDCG@10 `0.1481`, zero-result `0.7778`; promotion Recall@10 `0.4722`, nDCG@10 `0.3981`, maximum zero-result `0.5278`
|
|
1371
1366
|
- Japanese: baseline Recall@10 `0.125`, nDCG@10 `0.125`, zero-result `0.875`; promotion Recall@10 `0.375`, nDCG@10 `0.375`, maximum zero-result `0.625`
|
|
1372
1367
|
- Korean: baseline Recall@10 `0.5`, nDCG@10 `0.5`, zero-result `0.5`; promotion Recall@10 `0.75`, nDCG@10 `0.75`, maximum zero-result `0.25`
|
|
1373
1368
|
|
|
1374
|
-
The
|
|
1369
|
+
The "promotion" values are the floors a CJK-aware lexical analyzer must reach
|
|
1370
|
+
before GNO ships one.
|
|
1375
1371
|
[promotion-gates.md](./evals/fixtures/cjk-lexical-benchmark/promotion-gates.md)
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
nDCG measures placement but not distinctions among positive gain
|
|
1380
|
-
|
|
1381
|
-
`fn-109`.
|
|
1372
|
+
lists them along with MRR, non-regression, and cost requirements. No analyzer
|
|
1373
|
+
has met them, so production BM25 tokenization is unchanged. These lexical
|
|
1374
|
+
numbers do not measure semantic retrieval. All positive qrels use relevance
|
|
1375
|
+
`3`, so nDCG measures placement but not distinctions among positive gain
|
|
1376
|
+
grades.
|
|
1382
1377
|
|
|
1383
1378
|
<!-- /public-truth -->
|
|
1384
1379
|
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0566f9eb5eb5db6a1632063e63f37dab01a9d2a0aa3dde5658356926a53c395e gno-browser-clipper-v2.7.1.zip
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gmickel/gno",
|
|
3
|
-
"version": "2.7.
|
|
3
|
+
"version": "2.7.1",
|
|
4
4
|
"description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"embeddings",
|
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
"smoke:serve-shutdown": "bun scripts/serve-shutdown-smoke.ts",
|
|
90
90
|
"test:watcher:filesystem": "bun test test/serve/watch-service-filesystem.test.ts test/serve/resident-runtime.test.ts",
|
|
91
91
|
"bench:watcher-reconciliation": "bun scripts/watcher-reconciliation-benchmark.ts",
|
|
92
|
+
"bench:session-import": "bun scripts/session-import-benchmark.ts",
|
|
92
93
|
"smoke:watcher-reconciliation": "bun scripts/watcher-reconciliation-smoke.ts",
|
|
93
94
|
"bench:code-embeddings": "bun scripts/code-embedding-benchmark.ts",
|
|
94
95
|
"bench:code-embeddings:write": "bun scripts/code-embedding-benchmark.ts --write",
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"type": "array",
|
|
7
7
|
"items": {
|
|
8
8
|
"type": "object",
|
|
9
|
-
"required": ["name", "
|
|
9
|
+
"required": ["name", "pattern", "include", "exclude"],
|
|
10
10
|
"properties": {
|
|
11
11
|
"name": {
|
|
12
12
|
"type": "string",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"path": {
|
|
17
17
|
"type": "string",
|
|
18
|
-
"description": "Absolute path to collection root directory"
|
|
18
|
+
"description": "Absolute path to collection root directory. Same-host callers only: omitted for a remote REST caller."
|
|
19
19
|
},
|
|
20
20
|
"pattern": {
|
|
21
21
|
"type": "string",
|
|
@@ -90,24 +90,18 @@
|
|
|
90
90
|
},
|
|
91
91
|
"configPath": {
|
|
92
92
|
"type": "string",
|
|
93
|
-
"description": "Path to config file"
|
|
93
|
+
"description": "Path to config file. Same-host callers only: omitted for a remote REST or HTTP MCP caller."
|
|
94
94
|
},
|
|
95
95
|
"dbPath": {
|
|
96
96
|
"type": "string",
|
|
97
|
-
"description": "Path to SQLite database"
|
|
97
|
+
"description": "Path to SQLite database. Same-host callers only: omitted for a remote REST or HTTP MCP caller."
|
|
98
98
|
},
|
|
99
99
|
"collections": {
|
|
100
100
|
"type": "array",
|
|
101
101
|
"description": "Collection statistics",
|
|
102
102
|
"items": {
|
|
103
103
|
"type": "object",
|
|
104
|
-
"required": [
|
|
105
|
-
"name",
|
|
106
|
-
"path",
|
|
107
|
-
"documentCount",
|
|
108
|
-
"chunkCount",
|
|
109
|
-
"embeddedCount"
|
|
110
|
-
],
|
|
104
|
+
"required": ["name", "documentCount", "chunkCount", "embeddedCount"],
|
|
111
105
|
"properties": {
|
|
112
106
|
"name": {
|
|
113
107
|
"type": "string",
|
|
@@ -115,7 +109,7 @@
|
|
|
115
109
|
},
|
|
116
110
|
"path": {
|
|
117
111
|
"type": "string",
|
|
118
|
-
"description": "Collection root path"
|
|
112
|
+
"description": "Collection root path. Same-host callers only: omitted for a remote REST or HTTP MCP caller."
|
|
119
113
|
},
|
|
120
114
|
"documentCount": {
|
|
121
115
|
"type": "integer",
|
|
@@ -595,13 +589,14 @@
|
|
|
595
589
|
"type": "array",
|
|
596
590
|
"items": {
|
|
597
591
|
"type": "object",
|
|
598
|
-
"required": ["label", "
|
|
592
|
+
"required": ["label", "reason"],
|
|
599
593
|
"properties": {
|
|
600
594
|
"label": {
|
|
601
595
|
"type": "string"
|
|
602
596
|
},
|
|
603
597
|
"path": {
|
|
604
|
-
"type": "string"
|
|
598
|
+
"type": "string",
|
|
599
|
+
"description": "Suggested folder path. Same-host callers only: omitted for a remote REST or HTTP MCP caller."
|
|
605
600
|
},
|
|
606
601
|
"reason": {
|
|
607
602
|
"type": "string"
|
|
@@ -883,10 +878,11 @@
|
|
|
883
878
|
},
|
|
884
879
|
"cache": {
|
|
885
880
|
"type": "object",
|
|
886
|
-
"required": ["
|
|
881
|
+
"required": ["totalSizeBytes", "totalSizeLabel"],
|
|
887
882
|
"properties": {
|
|
888
883
|
"path": {
|
|
889
|
-
"type": "string"
|
|
884
|
+
"type": "string",
|
|
885
|
+
"description": "Model cache directory. Same-host callers only: omitted for a remote REST or HTTP MCP caller."
|
|
890
886
|
},
|
|
891
887
|
"totalSizeBytes": {
|
|
892
888
|
"type": "integer",
|
|
@@ -941,7 +937,6 @@
|
|
|
941
937
|
"role",
|
|
942
938
|
"uri",
|
|
943
939
|
"cached",
|
|
944
|
-
"path",
|
|
945
940
|
"sizeBytes",
|
|
946
941
|
"statusLabel"
|
|
947
942
|
],
|
|
@@ -957,7 +952,8 @@
|
|
|
957
952
|
"type": "boolean"
|
|
958
953
|
},
|
|
959
954
|
"path": {
|
|
960
|
-
"type": ["string", "null"]
|
|
955
|
+
"type": ["string", "null"],
|
|
956
|
+
"description": "Cached model file, or null when not cached. Same-host callers only: omitted for a remote REST or HTTP MCP caller."
|
|
961
957
|
},
|
|
962
958
|
"sizeBytes": {
|
|
963
959
|
"type": ["integer", "null"],
|
package/src/cli/commands/ask.ts
CHANGED
|
@@ -81,6 +81,36 @@ export type AskCommandResult =
|
|
|
81
81
|
}
|
|
82
82
|
| { success: false; error: string };
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Give every document URI `?index=` on a named index, like search and
|
|
86
|
+
* Capsule results: results, citations and the answer-context selection.
|
|
87
|
+
*/
|
|
88
|
+
export function decorateAskUris(
|
|
89
|
+
result: AskResult,
|
|
90
|
+
indexName: string | undefined
|
|
91
|
+
): AskResult {
|
|
92
|
+
const decorate = <T extends { uri: string }>(item: T): T => ({
|
|
93
|
+
...item,
|
|
94
|
+
uri: decorateUriForIndex(item.uri, indexName),
|
|
95
|
+
});
|
|
96
|
+
const answerContext = result.meta.answerContext;
|
|
97
|
+
return {
|
|
98
|
+
...result,
|
|
99
|
+
results: result.results.map(decorate),
|
|
100
|
+
citations: result.citations?.map(decorate),
|
|
101
|
+
meta: answerContext
|
|
102
|
+
? {
|
|
103
|
+
...result.meta,
|
|
104
|
+
answerContext: {
|
|
105
|
+
...answerContext,
|
|
106
|
+
selected: answerContext.selected.map(decorate),
|
|
107
|
+
dropped: answerContext.dropped.map(decorate),
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
: result.meta,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
84
114
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
85
115
|
// Command Implementation
|
|
86
116
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -435,20 +465,9 @@ export async function ask(
|
|
|
435
465
|
return { success: false, error: finalized.error.message };
|
|
436
466
|
}
|
|
437
467
|
}
|
|
438
|
-
// URIs carry `?index=` like search and Capsule results on a named index.
|
|
439
468
|
return {
|
|
440
469
|
success: true,
|
|
441
|
-
data:
|
|
442
|
-
...askResult,
|
|
443
|
-
results: askResult.results.map((result) => ({
|
|
444
|
-
...result,
|
|
445
|
-
uri: decorateUriForIndex(result.uri, globals.index),
|
|
446
|
-
})),
|
|
447
|
-
citations: askResult.citations?.map((citation) => ({
|
|
448
|
-
...citation,
|
|
449
|
-
uri: decorateUriForIndex(citation.uri, globals.index),
|
|
450
|
-
})),
|
|
451
|
-
},
|
|
470
|
+
data: decorateAskUris(askResult, globals.index),
|
|
452
471
|
metadata: traceSession?.metadata(),
|
|
453
472
|
};
|
|
454
473
|
} catch (cause) {
|
package/src/cli/commands/ls.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { DocumentRow, StorePort, StoreResult } from "../../store/types";
|
|
9
9
|
|
|
10
|
+
import { decorateUriForIndex } from "../../app/constants";
|
|
10
11
|
import { initStore } from "./shared";
|
|
11
12
|
|
|
12
13
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -145,7 +146,11 @@ export async function ls(
|
|
|
145
146
|
// Apply offset and limit
|
|
146
147
|
const offset = options.offset ?? 0;
|
|
147
148
|
const limit = options.limit ?? 20;
|
|
148
|
-
|
|
149
|
+
// Sorted on canonical URIs; the output URIs carry `?index=` like search.
|
|
150
|
+
const paged = allActive.slice(offset, offset + limit).map((doc) => ({
|
|
151
|
+
...doc,
|
|
152
|
+
uri: decorateUriForIndex(doc.uri, options.indexName),
|
|
153
|
+
}));
|
|
149
154
|
|
|
150
155
|
return {
|
|
151
156
|
success: true,
|
package/src/core/host-paths.ts
CHANGED
|
@@ -6,26 +6,44 @@
|
|
|
6
6
|
* peek receipts). A remote caller identifies documents by `uri` and
|
|
7
7
|
* collection-relative `relPath` instead, so every `absPath` key is removed
|
|
8
8
|
* before a payload leaves a remote-reachable surface.
|
|
9
|
+
*
|
|
10
|
+
* Status, collection, and connector payloads also name owner configuration
|
|
11
|
+
* locations: `configPath`, `dbPath`, and `path` (collection roots, suggested
|
|
12
|
+
* folders, model cache and model files, connector targets). A remote caller
|
|
13
|
+
* identifies a collection by `name`, so those keys are removed from those
|
|
14
|
+
* payloads too.
|
|
9
15
|
*/
|
|
10
16
|
|
|
11
|
-
|
|
17
|
+
/** Document host path key of result payloads. */
|
|
18
|
+
export const HOST_PATH_FIELDS: ReadonlySet<string> = new Set(["absPath"]);
|
|
19
|
+
|
|
20
|
+
/** Owner configuration path keys of status, collection, and connector payloads. */
|
|
21
|
+
export const OWNER_CONFIG_PATH_FIELDS: ReadonlySet<string> = new Set([
|
|
22
|
+
"configPath",
|
|
23
|
+
"dbPath",
|
|
24
|
+
"path",
|
|
25
|
+
]);
|
|
12
26
|
|
|
13
27
|
const isPlainObject = (value: object): boolean => {
|
|
14
28
|
const proto = Object.getPrototypeOf(value) as object | null;
|
|
15
29
|
return proto === Object.prototype || proto === null;
|
|
16
30
|
};
|
|
17
31
|
|
|
18
|
-
/** Deep copy of a JSON-shaped value with every `
|
|
19
|
-
export function
|
|
32
|
+
/** Deep copy of a JSON-shaped value with every key in `fields` removed. */
|
|
33
|
+
export function withoutFields<T>(value: T, fields: ReadonlySet<string>): T {
|
|
20
34
|
if (Array.isArray(value)) {
|
|
21
|
-
return value.map((item: unknown) =>
|
|
35
|
+
return value.map((item: unknown) => withoutFields(item, fields)) as T;
|
|
22
36
|
}
|
|
23
37
|
if (value === null || typeof value !== "object" || !isPlainObject(value)) {
|
|
24
38
|
return value;
|
|
25
39
|
}
|
|
26
40
|
const copy: Record<string, unknown> = {};
|
|
27
41
|
for (const [key, entry] of Object.entries(value)) {
|
|
28
|
-
if (key
|
|
42
|
+
if (!fields.has(key)) copy[key] = withoutFields(entry, fields);
|
|
29
43
|
}
|
|
30
44
|
return copy as T;
|
|
31
45
|
}
|
|
46
|
+
|
|
47
|
+
/** Deep copy of a JSON-shaped value with every `absPath` key removed. */
|
|
48
|
+
export const withoutHostPaths = <T>(value: T): T =>
|
|
49
|
+
withoutFields(value, HOST_PATH_FIELDS);
|
|
@@ -11,14 +11,18 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { Database } from "bun:sqlite";
|
|
14
|
-
// node:fs/promises chmod/mkdir: filesystem structure ops, no Bun equivalent
|
|
15
|
-
import { chmod, mkdir } from "node:fs/promises";
|
|
14
|
+
// node:fs/promises chmod/mkdir/lstat/readdir: filesystem structure ops, no Bun equivalent
|
|
15
|
+
import { chmod, lstat, mkdir, readdir } from "node:fs/promises";
|
|
16
16
|
// node:path has no Bun path utilities
|
|
17
17
|
import { basename, dirname, join } from "node:path";
|
|
18
18
|
|
|
19
19
|
import { MCP_ERRORS } from "./errors";
|
|
20
20
|
import { withWriteLock } from "./file-lock";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
isOwnerOnlyDescriptor,
|
|
23
|
+
windowsDirectoryDescriptor,
|
|
24
|
+
windowsPrivatePath,
|
|
25
|
+
} from "./windows-private-path";
|
|
22
26
|
import { writeLeasePath } from "./write-lease";
|
|
23
27
|
|
|
24
28
|
/** Committed receipts keep their full outcome this long, then become tombstones. */
|
|
@@ -221,25 +225,75 @@ CREATE TABLE IF NOT EXISTS request_receipts (
|
|
|
221
225
|
/** Ledger directories whose owner-only Windows DACL this process verified. */
|
|
222
226
|
const privateLedgerDirs = new Set<string>();
|
|
223
227
|
|
|
228
|
+
/** Inside the ledger directory: what the last authoritative check verified. */
|
|
229
|
+
const LEDGER_DIR_MARKER = ".owner-only-verified";
|
|
230
|
+
|
|
231
|
+
export interface PrivateDirAcl {
|
|
232
|
+
/** Authoritative check (spawns PowerShell); `create` sets the owner-only DACL first. */
|
|
233
|
+
verify: (dir: string, create: boolean) => Promise<void>;
|
|
234
|
+
/** In-process owner and DACL bytes, or null when they cannot be read. */
|
|
235
|
+
descriptor: (dir: string) => Uint8Array | null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const WINDOWS_ACL: PrivateDirAcl = {
|
|
239
|
+
verify: windowsPrivatePath,
|
|
240
|
+
descriptor: windowsDirectoryDescriptor,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/** Directory identity plus descriptor digest; null unless owner-only. */
|
|
244
|
+
async function ledgerDirStamp(
|
|
245
|
+
dir: string,
|
|
246
|
+
acl: PrivateDirAcl
|
|
247
|
+
): Promise<string | null> {
|
|
248
|
+
const { dev, ino } = await lstat(dir, { bigint: true });
|
|
249
|
+
const descriptor = acl.descriptor(dir);
|
|
250
|
+
if (!descriptor || !isOwnerOnlyDescriptor(descriptor)) return null;
|
|
251
|
+
const digest = new Bun.CryptoHasher("sha256")
|
|
252
|
+
.update(descriptor)
|
|
253
|
+
.digest("hex");
|
|
254
|
+
return `${dev}:${ino}:${digest}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
224
257
|
/**
|
|
225
258
|
* Windows ignores POSIX modes: give a new ledger directory the current-user
|
|
226
259
|
* DACL before SQLite creates the database or its WAL/SHM (they inherit it),
|
|
227
260
|
* and refuse an existing one that grants another principal access.
|
|
261
|
+
*
|
|
262
|
+
* The PowerShell check costs a process start, so its success is recorded in a
|
|
263
|
+
* marker inside the directory. A later open skips it only when the directory
|
|
264
|
+
* is the same object and its owner and DACL are byte-identical to the verified
|
|
265
|
+
* ones and still owner-only; reading the marker at all requires access that
|
|
266
|
+
* owner-only DACL grants. Anything else re-runs the authoritative check.
|
|
267
|
+
*
|
|
268
|
+
* An existing but empty directory is secured like a new one: a first open
|
|
269
|
+
* interrupted before its DACL was set leaves exactly that, and nothing inside
|
|
270
|
+
* it could have been exposed.
|
|
228
271
|
*/
|
|
229
|
-
async function
|
|
272
|
+
export async function securePrivateLedgerDir(
|
|
230
273
|
dir: string,
|
|
231
|
-
created:
|
|
274
|
+
created: boolean,
|
|
275
|
+
acl: PrivateDirAcl = WINDOWS_ACL
|
|
232
276
|
): Promise<void> {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
277
|
+
const marker = Bun.file(join(dir, LEDGER_DIR_MARKER));
|
|
278
|
+
if (!created) {
|
|
279
|
+
const stamp = await ledgerDirStamp(dir, acl);
|
|
280
|
+
if (stamp !== null && stamp === (await marker.text().catch(() => null)))
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const secure = created || (await readdir(dir)).length === 0;
|
|
284
|
+
await acl.verify(dir, secure);
|
|
285
|
+
const stamp = await ledgerDirStamp(dir, acl);
|
|
286
|
+
if (stamp !== null) await Bun.write(marker, stamp);
|
|
236
287
|
}
|
|
237
288
|
|
|
238
289
|
async function openLedger(path: string): Promise<Database> {
|
|
239
290
|
try {
|
|
240
291
|
const dir = dirname(path);
|
|
241
292
|
const created = await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
242
|
-
|
|
293
|
+
if (process.platform === "win32" && !privateLedgerDirs.has(dir)) {
|
|
294
|
+
await securePrivateLedgerDir(dir, created !== undefined);
|
|
295
|
+
privateLedgerDirs.add(dir);
|
|
296
|
+
}
|
|
243
297
|
const db = new Database(path, { create: true, strict: true });
|
|
244
298
|
try {
|
|
245
299
|
// POSIX: private before any journal file exists (they inherit this mode).
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/** Windows owner-only evidence storage. Paths are data, never interpolated script.
|
|
2
2
|
* Fresh Windows objects may use the token default Owner instead of its User.
|
|
3
3
|
* Only those exact owner SIDs are accepted; allowed DACL entries remain User-only. */
|
|
4
|
+
// bun:ffi — reading a security descriptor in-process needs advapi32; Bun has no ACL API
|
|
5
|
+
import { dlopen, FFIType } from "bun:ffi";
|
|
6
|
+
|
|
4
7
|
// Use framework APIs directly: module auto-discovery depends on profile paths
|
|
5
8
|
// deliberately absent from isolated native workers.
|
|
6
9
|
const ACL = `
|
|
@@ -65,7 +68,8 @@ export async function windowsPrivatePath(
|
|
|
65
68
|
// ACL checks return no data; do not allocate an unused stdout pipe.
|
|
66
69
|
stdout: "ignore",
|
|
67
70
|
stderr: "pipe",
|
|
68
|
-
|
|
71
|
+
// A cold PowerShell start on a loaded host can take over 10s.
|
|
72
|
+
timeout: 30000,
|
|
69
73
|
}
|
|
70
74
|
);
|
|
71
75
|
const reader = child.stderr.getReader();
|
|
@@ -94,3 +98,134 @@ export async function windowsPrivatePath(
|
|
|
94
98
|
await child.exited;
|
|
95
99
|
}
|
|
96
100
|
}
|
|
101
|
+
|
|
102
|
+
const OWNER_AND_DACL = 0x1 | 0x4; // OWNER_ | DACL_SECURITY_INFORMATION
|
|
103
|
+
const FILE_ATTRIBUTE_DIRECTORY = 0x10;
|
|
104
|
+
const FILE_ATTRIBUTE_REPARSE_POINT = 0x4_00;
|
|
105
|
+
const INVALID_FILE_ATTRIBUTES = 0xff_ff_ff_ff;
|
|
106
|
+
const SECURITY_DESCRIPTOR_MAX_BYTES = 65_536;
|
|
107
|
+
|
|
108
|
+
interface Win32Security {
|
|
109
|
+
getFileAttributes: (path: Uint8Array) => number;
|
|
110
|
+
getFileSecurity: (
|
|
111
|
+
path: Uint8Array,
|
|
112
|
+
info: number,
|
|
113
|
+
descriptor: Uint8Array,
|
|
114
|
+
length: number,
|
|
115
|
+
needed: Uint32Array
|
|
116
|
+
) => number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let win32Security: Win32Security | null | undefined;
|
|
120
|
+
|
|
121
|
+
function loadWin32Security(): Win32Security | null {
|
|
122
|
+
if (win32Security !== undefined) return win32Security;
|
|
123
|
+
try {
|
|
124
|
+
const kernel32 = dlopen("kernel32.dll", {
|
|
125
|
+
GetFileAttributesW: { args: [FFIType.ptr], returns: FFIType.u32 },
|
|
126
|
+
});
|
|
127
|
+
const advapi32 = dlopen("advapi32.dll", {
|
|
128
|
+
GetFileSecurityW: {
|
|
129
|
+
args: [FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr],
|
|
130
|
+
returns: FFIType.i32,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
win32Security = {
|
|
134
|
+
getFileAttributes: kernel32.symbols.GetFileAttributesW,
|
|
135
|
+
getFileSecurity: advapi32.symbols.GetFileSecurityW,
|
|
136
|
+
};
|
|
137
|
+
} catch {
|
|
138
|
+
win32Security = null;
|
|
139
|
+
}
|
|
140
|
+
return win32Security;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Owner and DACL of a directory that is not a reparse point, read in-process
|
|
145
|
+
* (no PowerShell). Null when unavailable: callers treat that as unverified.
|
|
146
|
+
*/
|
|
147
|
+
export function windowsDirectoryDescriptor(path: string): Uint8Array | null {
|
|
148
|
+
if (process.platform !== "win32") return null;
|
|
149
|
+
const win32 = loadWin32Security();
|
|
150
|
+
if (!win32) return null;
|
|
151
|
+
const widePath = Buffer.from(`${path}\0`, "utf16le");
|
|
152
|
+
const attributes = win32.getFileAttributes(widePath);
|
|
153
|
+
if (
|
|
154
|
+
attributes === INVALID_FILE_ATTRIBUTES ||
|
|
155
|
+
(attributes & FILE_ATTRIBUTE_DIRECTORY) === 0 ||
|
|
156
|
+
(attributes & FILE_ATTRIBUTE_REPARSE_POINT) !== 0
|
|
157
|
+
) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const descriptor = new Uint8Array(SECURITY_DESCRIPTOR_MAX_BYTES);
|
|
161
|
+
const needed = new Uint32Array(1);
|
|
162
|
+
const ok = win32.getFileSecurity(
|
|
163
|
+
widePath,
|
|
164
|
+
OWNER_AND_DACL,
|
|
165
|
+
descriptor,
|
|
166
|
+
descriptor.length,
|
|
167
|
+
needed
|
|
168
|
+
);
|
|
169
|
+
const length = needed[0] ?? 0;
|
|
170
|
+
if (ok === 0 || length === 0 || length > descriptor.length) return null;
|
|
171
|
+
return descriptor.slice(0, length);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const SE_DACL_PRESENT = 0x4;
|
|
175
|
+
const SE_SELF_RELATIVE = 0x80_00;
|
|
176
|
+
const ACCESS_ALLOWED_ACE_TYPE = 0;
|
|
177
|
+
const ACCESS_DENIED_ACE_TYPE = 1;
|
|
178
|
+
const FILE_ALL_ACCESS = 0x1f_01_ff;
|
|
179
|
+
|
|
180
|
+
function sidAt(view: DataView, offset: number): Uint8Array | null {
|
|
181
|
+
if (offset === 0 || offset + 8 > view.byteLength) return null;
|
|
182
|
+
const end = offset + 8 + 4 * view.getUint8(offset + 1);
|
|
183
|
+
if (end > view.byteLength) return null;
|
|
184
|
+
return new Uint8Array(view.buffer, view.byteOffset + offset, end - offset);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
|
|
188
|
+
return left.length === right.length && left.every((b, i) => b === right[i]);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Self-relative descriptor whose owner is the only principal any allow entry
|
|
193
|
+
* names, with full control among them. Stricter than the PowerShell policy
|
|
194
|
+
* (which also accepts a token default owner): a false here only means the
|
|
195
|
+
* authoritative check runs.
|
|
196
|
+
*/
|
|
197
|
+
export function isOwnerOnlyDescriptor(descriptor: Uint8Array): boolean {
|
|
198
|
+
const view = new DataView(
|
|
199
|
+
descriptor.buffer,
|
|
200
|
+
descriptor.byteOffset,
|
|
201
|
+
descriptor.byteLength
|
|
202
|
+
);
|
|
203
|
+
if (view.byteLength < 20 || view.getUint8(0) !== 1) return false;
|
|
204
|
+
const control = view.getUint16(2, true);
|
|
205
|
+
if ((control & SE_SELF_RELATIVE) === 0 || (control & SE_DACL_PRESENT) === 0)
|
|
206
|
+
return false;
|
|
207
|
+
const owner = sidAt(view, view.getUint32(4, true));
|
|
208
|
+
const dacl = view.getUint32(16, true);
|
|
209
|
+
if (!owner || dacl === 0 || dacl + 8 > view.byteLength) return false;
|
|
210
|
+
const aclEnd = dacl + view.getUint16(dacl + 2, true);
|
|
211
|
+
if (aclEnd > view.byteLength) return false;
|
|
212
|
+
let offset = dacl + 8;
|
|
213
|
+
let fullControl = false;
|
|
214
|
+
for (let ace = view.getUint16(dacl + 4, true); ace > 0; ace--) {
|
|
215
|
+
if (offset + 8 > aclEnd) return false;
|
|
216
|
+
const type = view.getUint8(offset);
|
|
217
|
+
const size = view.getUint16(offset + 2, true);
|
|
218
|
+
if (size < 8 || offset + size > aclEnd) return false;
|
|
219
|
+
if (type === ACCESS_ALLOWED_ACE_TYPE) {
|
|
220
|
+
const sid = sidAt(view, offset + 8);
|
|
221
|
+
if (!sid || offset + 8 + sid.length > offset + size) return false;
|
|
222
|
+
if (!sameBytes(sid, owner)) return false;
|
|
223
|
+
const mask = view.getUint32(offset + 4, true);
|
|
224
|
+
if ((mask & FILE_ALL_ACCESS) === FILE_ALL_ACCESS) fullControl = true;
|
|
225
|
+
} else if (type !== ACCESS_DENIED_ACE_TYPE) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
offset += size;
|
|
229
|
+
}
|
|
230
|
+
return fullControl;
|
|
231
|
+
}
|