@oomkapwn/enquire-mcp 3.6.0 → 3.6.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/CHANGELOG.md +98 -0
- package/README.md +4 -4
- package/assets/social-preview.png +0 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -6
- package/dist/cli.js.map +1 -1
- package/dist/embed-db.d.ts +34 -0
- package/dist/embed-db.d.ts.map +1 -1
- package/dist/embed-db.js +56 -0
- package/dist/embed-db.js.map +1 -1
- package/dist/embeddings.d.ts +1 -1
- package/dist/embeddings.d.ts.map +1 -1
- package/dist/embeddings.js +8 -1
- package/dist/embeddings.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +22 -13
- package/dist/server.js.map +1 -1
- package/docs/audits/findings/L1-code-quality.md +213 -0
- package/docs/audits/findings/L2-architecture.md +245 -0
- package/docs/audits/findings/L3-tests.md +339 -0
- package/docs/audits/findings/L4-cicd.md +290 -0
- package/docs/audits/findings/L5-security.md +350 -0
- package/docs/audits/findings/L6-documentation.md +347 -0
- package/docs/audits/findings/L7-operational.md +50 -0
- package/docs/audits/findings/L8-reproducibility.md +64 -0
- package/docs/audits/findings/L9-process.md +84 -0
- package/docs/audits/findings/baseline.json +19 -0
- package/docs/audits/v3.6.0-external-anonymous-audit.md +163 -0
- package/docs/audits/v3.6.0-final-audit.md +171 -0
- package/package.json +2 -2
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# L4 — CI/CD pipeline audit
|
|
2
|
+
|
|
3
|
+
**Date**: 2026-05-15
|
|
4
|
+
**Auditor**: Sub-agent C4 (background)
|
|
5
|
+
**Scope**: `.github/workflows/*.yml`, branch protection vs ruleset alignment, GH Pages enablement, deprecation hygiene
|
|
6
|
+
**Repo**: `oomkapwn/enquire-mcp` (despite the local `obsidian-mcp` working directory name)
|
|
7
|
+
|
|
8
|
+
## TL;DR — 2 HIGH, 1 MEDIUM, 2 LOW, 1 INFO
|
|
9
|
+
|
|
10
|
+
| Check | Status |
|
|
11
|
+
|---|---|
|
|
12
|
+
| CI workflow triggers + permissions | ✅ |
|
|
13
|
+
| CI Node matrix matches engines + reality | ⚠️ — Medium drift (engines `>=20`, CI runs `[22,24]`) |
|
|
14
|
+
| CI action versions current (`@v6`/`@v7` floor) | ✅ |
|
|
15
|
+
| CI required jobs exist + names match branch protection | ✅ — 7/7 |
|
|
16
|
+
| Release.yml SHA-on-main verification functional | ✅ |
|
|
17
|
+
| Release.yml REQUIRED contexts regex matches reality | ✅ |
|
|
18
|
+
| Release.yml npm publish uses `--provenance --access public --tag` | ✅ — provenance attestation present on `3.6.0` |
|
|
19
|
+
| Release.yml dist-tag regex handles all 3 prerelease patterns | ✅ — `-rc.N`, `-beta.N`, `-alpha.N` all routed correctly |
|
|
20
|
+
| publish-docs.yml permissions minimal | ✅ |
|
|
21
|
+
| publish-docs.yml action versions current | ✅ |
|
|
22
|
+
| publish-docs.yml concurrency: serialize but don't cancel | ✅ |
|
|
23
|
+
| **publish-docs.yml: has it run successfully on main yet?** | ❌ **HIGH — 0 of 2 runs succeeded** |
|
|
24
|
+
| dist-tag-cleanup.yml manual-only + idempotent | ✅ |
|
|
25
|
+
| Branch protection (classic) vs ruleset (modern): same 7 checks | ⚠️ **HIGH — both list 7 same checks but BOTH are configured, drift risk** |
|
|
26
|
+
| Recent CI runs: no `set-output` / `save-state` / `node12` deprecations | ✅ |
|
|
27
|
+
| npm-side deprecations in install logs | ℹ️ informational (`prebuild-install`, `boolean`) |
|
|
28
|
+
|
|
29
|
+
## Detailed findings
|
|
30
|
+
|
|
31
|
+
### L4-01 (HIGH) — `publish-docs.yml` has failed every run since rc.4 introduction
|
|
32
|
+
|
|
33
|
+
**Class**: workflow committed but prerequisite repo-level config not enabled. Workflow has run twice on main since rc.4 shipped GH Pages auto-publish (PR #68 merge + PR #69 merge) and failed both times. The TypeDoc-generated site at https://oomkapwn.github.io/enquire-mcp/ that README + audit plan reference does not exist.
|
|
34
|
+
|
|
35
|
+
**Evidence**:
|
|
36
|
+
- `gh run list --workflow=publish-docs.yml` → 2 runs, both `failure`:
|
|
37
|
+
- `25917950064` (rc.4 merge, 2026-05-15T12:32:36Z): failed at `actions/configure-pages@v6`
|
|
38
|
+
- `25918407027` (v3.6.0 stable merge, 2026-05-15T12:43:23Z): failed at `actions/configure-pages@v6`
|
|
39
|
+
- Failure message (run 25918407027, build job, line `Run actions/configure-pages@v6`):
|
|
40
|
+
> `##[error]Get Pages site failed. Please verify that the repository has Pages enabled and configured to build using GitHub Actions, or consider exploring the `enablement` parameter for this action. Error: Not Found`
|
|
41
|
+
- `gh api repos/oomkapwn/enquire-mcp/pages` → HTTP 404 (`Not Found`)
|
|
42
|
+
- `gh api repos/oomkapwn/enquire-mcp | jq .has_pages` → `false`
|
|
43
|
+
- Workflow body (`.github/workflows/publish-docs.yml:42`): `- uses: actions/configure-pages@v6` — no `with: enablement: true`, so it expects Pages to already be on.
|
|
44
|
+
|
|
45
|
+
**Impact**:
|
|
46
|
+
- The `Publish API docs` job has shown red ✘ next to every main-branch run since rc.4. Anyone visiting the Actions tab sees this as "CI is broken on main" even though the 7 required jobs all pass.
|
|
47
|
+
- The README and `v3.6.0-system-audit-plan.md` both reference TypeDoc pages at `oomkapwn.github.io/enquire-mcp/` that don't exist.
|
|
48
|
+
- The rc.4 CHANGELOG (line 4 of `publish-docs.yml`) advertises auto-publish of API reference — currently a no-op.
|
|
49
|
+
|
|
50
|
+
**Cross-cutting check**:
|
|
51
|
+
- Is GH Pages mentioned elsewhere as available?
|
|
52
|
+
- `README.md` — check needed in L6 audit (TypeDoc badge / link)
|
|
53
|
+
- `docs/audits/v3.6.0-system-audit-plan.md` line 5–10 references `github.io` site
|
|
54
|
+
- `package.json` doesn't add a `homepage` pointing to pages (it points to GitHub repo `#readme`)
|
|
55
|
+
- The `publish-docs.yml` workflow header comment (line 6) says "lives at https://oomkapwn.github.io/enquire-mcp/" — not yet true.
|
|
56
|
+
|
|
57
|
+
**Suggested class fix** (one of):
|
|
58
|
+
1. **Enable Pages once**: `gh api -X POST repos/oomkapwn/enquire-mcp/pages -f source.branch=main -f build_type=workflow` then re-run the workflow via `gh workflow run publish-docs.yml`. After first successful deploy, future runs will work.
|
|
59
|
+
2. **Set `enablement: true` on configure-pages**: `actions/configure-pages@v6` accepts a `with: enablement: true` input that auto-enables Pages on first run. Requires the `pages: write` permission already present.
|
|
60
|
+
3. **Don't merge this workflow until Pages is enabled** (already too late, but flag in pre-merge checklist for future workflows that depend on repo features).
|
|
61
|
+
|
|
62
|
+
**Per-instance backfill**: enable Pages + manually rerun `publish-docs.yml` workflow_dispatch → confirms first green run → README/audit-plan claims become true.
|
|
63
|
+
|
|
64
|
+
**Severity rationale**: HIGH because (a) workflow is shipping red status checks on every main push, polluting the dashboard; (b) public-facing claim (TypeDoc site live) is false; (c) trivial fix (one API call or one YAML line).
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
### L4-02 (HIGH) — Branch protection: both legacy "branch protection" AND modern "ruleset" are configured, duplicate state
|
|
69
|
+
|
|
70
|
+
**Class**: GitHub has two ways to require status checks — `branches/main/protection` (legacy, classic) and `rulesets/15878550` (modern). Both endpoints return the same 7 required checks today, but having both configured means any future change must be made in two places or they will drift.
|
|
71
|
+
|
|
72
|
+
**Evidence**:
|
|
73
|
+
- `gh api repos/oomkapwn/enquire-mcp/branches/main/protection`:
|
|
74
|
+
```
|
|
75
|
+
contexts: ["lint","test (22)","test (24)","smoke","audit","coverage","version-consistency"]
|
|
76
|
+
```
|
|
77
|
+
- `gh api repos/oomkapwn/enquire-mcp/rulesets/15878550`:
|
|
78
|
+
```
|
|
79
|
+
required_status_checks: [lint, test (22), test (24), smoke, audit, coverage, version-consistency]
|
|
80
|
+
enforcement: active
|
|
81
|
+
bypass_actors: [{actor_id:5, actor_type:RepositoryRole, bypass_mode:pull_request}]
|
|
82
|
+
```
|
|
83
|
+
- Both APIs return the same 7 contexts. No drift today.
|
|
84
|
+
- Ruleset was last `updated_at: 2026-05-13T14:59:26` (the v3.5.11 Node-20 drop) — recent maintenance shows the maintainer remembers to update it.
|
|
85
|
+
- The legacy branch-protection API ALSO has dismiss-stale-reviews / restrictions that the ruleset doesn't seem to mirror. Suggests both are independently active.
|
|
86
|
+
|
|
87
|
+
**Impact**:
|
|
88
|
+
- Low impact today (both lists agree).
|
|
89
|
+
- Class risk: when the next CI job is added or renamed, the maintainer needs to update BOTH places. The audit plan only mentions checking the ruleset URL (`rulesets/15878550`) — would miss drift on the legacy `branches/main/protection` side.
|
|
90
|
+
- The release.yml regex `lint|test \(22\)|test \(24\)|smoke|audit|coverage|version-consistency` (release.yml:56) is implicitly the third source of truth — three places must stay synchronized.
|
|
91
|
+
|
|
92
|
+
**Cross-cutting check**:
|
|
93
|
+
- `release.yml:56` `REQUIRED="lint|test \(22\)|test \(24\)|smoke|audit|coverage|version-consistency"` — matches today.
|
|
94
|
+
- `release.yml:66` `REQ_COUNT=7` — matches today.
|
|
95
|
+
- `README.md` line referencing "7 required" — matches today (confirmed earlier).
|
|
96
|
+
- Total: 4 sources of truth (branch-protection contexts, ruleset required_status_checks, release.yml REQUIRED regex, release.yml REQ_COUNT, README badge text). All currently agree.
|
|
97
|
+
|
|
98
|
+
**Suggested class fix** (one of):
|
|
99
|
+
1. **Pick one**: GitHub recommends migrating off legacy branch protection to rulesets. Delete the legacy protection (`gh api -X DELETE repos/oomkapwn/enquire-mcp/branches/main/protection`) and rely on the ruleset alone. After deletion, only 1 GitHub-side source of truth.
|
|
100
|
+
2. **Document the dual-config in CLAUDE.md**: add a "when adding a CI job" checklist that mentions BOTH endpoints + release.yml regex + REQ_COUNT.
|
|
101
|
+
3. **Lint at audit time**: simple shell script that diffs the 4 sources of truth — could live in `scripts/check-required-checks-consistency.mjs` and become a 5th invariant gate. Class fix in the spirit of L-1 from prior audits.
|
|
102
|
+
|
|
103
|
+
**Per-instance backfill**: no drift today → no backfill needed. Just close the class.
|
|
104
|
+
|
|
105
|
+
**Severity rationale**: HIGH because the class is real (4 sources of truth that the maintainer must keep in sync by hand) and the audit plan in section "Branch protection vs ruleset alignment" explicitly flagged this as a check. Currently green; staying green requires either consolidation or an automated invariant.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
### L4-03 (MEDIUM) — `package.json#engines.node` says `>=20` but CI dropped Node 20 in v3.5.11
|
|
110
|
+
|
|
111
|
+
**Class**: `engines` field in package.json drifts from what's actually CI-verified. Users on Node 20 may install successfully but hit untested code paths.
|
|
112
|
+
|
|
113
|
+
**Evidence**:
|
|
114
|
+
- `package.json:149-151`:
|
|
115
|
+
```json
|
|
116
|
+
"engines": {
|
|
117
|
+
"node": ">=20"
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
- `.github/workflows/ci.yml:45`: `matrix.node-version: [22, 24]` — Node 20 dropped.
|
|
121
|
+
- `CHANGELOG.md` v3.5.11 entry (lines 483, 501) explicitly says this is INTENTIONAL:
|
|
122
|
+
> "Engines `>=20` UNCHANGED for non-PDF users on prebuilt dist."
|
|
123
|
+
> "end users on Node 20 installing from the npm registry get the prebuilt `dist/` (no local tsc) and the PDF feature simply degrades to 'not available'"
|
|
124
|
+
|
|
125
|
+
**Impact**:
|
|
126
|
+
- Documented and intentional decision, NOT a bug per the CHANGELOG.
|
|
127
|
+
- But: users on Node 20 are running prebuilt code that was never CI-tested against Node 20. Drift risk: anything that landed after v3.5.11 (rc.1..stable) may use a Node 22+ API and silently break on Node 20 with no CI gate to catch it.
|
|
128
|
+
- Specifically `engines` doesn't have `engine-strict`, so npm won't refuse install on Node 20.
|
|
129
|
+
|
|
130
|
+
**Cross-cutting check**:
|
|
131
|
+
- `README.md` Node requirement section: check needed in L6 audit (does the README clearly say "Node 22+ required for PDF feature, Node 20 supported for non-PDF"?).
|
|
132
|
+
- `STABILITY.md` — would need to be checked for an explicit "Node 20 support tier" stability claim.
|
|
133
|
+
- `docs/QUICKSTART.md` — does it mention Node 22 requirement?
|
|
134
|
+
|
|
135
|
+
**Suggested class fix** (one of):
|
|
136
|
+
1. **Bump engines to `>=22`** to match reality. Aligns with EOL of Node 20 (2026-04). One-line change.
|
|
137
|
+
2. **Add a periodic Node 20 advisory job** (mirroring the existing test-macos pattern: `continue-on-error: true`, not required by branch protection, but catches regressions). Cheapest if maintaining Node 20 support is genuinely valuable.
|
|
138
|
+
3. **Document the tier explicitly in README** ("Node 22+ required for full feature set; Node 20 prebuilt-binary install path supported best-effort, not CI-tested").
|
|
139
|
+
|
|
140
|
+
**Per-instance backfill**: not blocking. Most users are already on Node ≥22 (per the npm distribution data). The risk is hypothetical until a real Node 20-incompatible API gets used.
|
|
141
|
+
|
|
142
|
+
**Severity rationale**: MEDIUM because it's a documented, intentional decision but adds technical debt (drift surface) every release. The CHANGELOG comment in v3.5.11 admits this is a deferred decision.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
### L4-04 (LOW) — `dist-tag-cleanup.yml` is a one-shot that has not run
|
|
147
|
+
|
|
148
|
+
**Class**: workflow committed for a one-time cleanup but never executed; lives on as orphan code.
|
|
149
|
+
|
|
150
|
+
**Evidence**:
|
|
151
|
+
- `.github/workflows/dist-tag-cleanup.yml:1-48`: one-shot cleanup to remove stale `alpha` + `beta` dist-tags pointing at v2.0 prerelease versions.
|
|
152
|
+
- `npm view @oomkapwn/enquire-mcp dist-tags` → `{'latest': '3.6.0', 'rc': '3.6.0-rc.4'}` — only `latest` and `rc` exist today. No stale `alpha` or `beta`.
|
|
153
|
+
- Either (a) the cleanup ran via `workflow_dispatch` outside the audit window, or (b) the tags self-cleared somehow, or (c) the tags were never set on this scope after the package rename — checking via `npm view @oomkapwn/enquire-mcp@beta` would clarify but it's not critical.
|
|
154
|
+
|
|
155
|
+
**Impact**:
|
|
156
|
+
- Zero runtime impact (file just sits there).
|
|
157
|
+
- Repository hygiene: dead workflow file. If the cleanup already ran, the file should be deleted.
|
|
158
|
+
- `permissions: id-token: write` (line 22) is requested for OIDC but the actual cleanup commands don't need it (`npm dist-tag rm` uses `NPM_TOKEN`). Slightly over-broad permission.
|
|
159
|
+
|
|
160
|
+
**Cross-cutting check**:
|
|
161
|
+
- No other one-shot workflows in `.github/workflows/`.
|
|
162
|
+
- The file has the right safety pattern (`confirm: REMOVE` input) so even if accidentally triggered, it's gated.
|
|
163
|
+
|
|
164
|
+
**Suggested class fix**:
|
|
165
|
+
1. **Verify cleanup state**: `npm view @oomkapwn/enquire-mcp@beta version` — if 404, cleanup is done.
|
|
166
|
+
2. **Remove the workflow file** if cleanup is done. One commit, audit trail in CHANGELOG.
|
|
167
|
+
3. **OR if kept "in case"**: drop `id-token: write` to `contents: read` only — the file doesn't use OIDC.
|
|
168
|
+
|
|
169
|
+
**Per-instance backfill**: trivial cleanup or no-op decision. Low severity, low priority.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
### L4-05 (LOW) — `coverage` job in `ci.yml` is NOT in `needs:` chain of `smoke`/`audit` (already by design, but worth a note)
|
|
174
|
+
|
|
175
|
+
**Class**: parallel job dependency graph — `coverage` runs after `test` (line 80 `needs: test`), but `audit` (line 120) and `version-consistency` (line 139) have no `needs:` so they run in parallel with everything else.
|
|
176
|
+
|
|
177
|
+
**Evidence**:
|
|
178
|
+
- `ci.yml:80`: `coverage` has `needs: test`
|
|
179
|
+
- `ci.yml:102`: `smoke` has `needs: test`
|
|
180
|
+
- `ci.yml:120`: `audit` — no `needs`, runs in parallel
|
|
181
|
+
- `ci.yml:139`: `version-consistency` — no `needs`, runs in parallel
|
|
182
|
+
|
|
183
|
+
**Impact**:
|
|
184
|
+
- This is actually CORRECT for fast-fail behavior — `audit` (npm audit) and `version-consistency` (script check) don't depend on test results, so running them in parallel saves wall-clock time and surfaces unrelated regressions independently.
|
|
185
|
+
- No actual bug here.
|
|
186
|
+
|
|
187
|
+
**Cross-cutting check**: none needed.
|
|
188
|
+
|
|
189
|
+
**Suggested class fix**: none — current setup is optimal. Filed as informational.
|
|
190
|
+
|
|
191
|
+
**Severity rationale**: LOW (effectively INFO) — the audit plan asked to verify all required jobs exist + reference correct check names; both confirmed. The parallel dependency graph is intentional and good.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
### L4-06 (INFO) — npm-side deprecation noise in `npm ci` logs (not GH Actions deprecations)
|
|
196
|
+
|
|
197
|
+
**Class**: transitive deps emit `npm warn deprecated` lines in every CI install. Cosmetic, not blocking.
|
|
198
|
+
|
|
199
|
+
**Evidence** (from runs 25918411923, 25918407052, 25917953901, 25911650374):
|
|
200
|
+
```
|
|
201
|
+
npm warn deprecated prebuild-install@7.1.3: No longer maintained...
|
|
202
|
+
npm warn deprecated boolean@3.2.0: Package no longer supported...
|
|
203
|
+
```
|
|
204
|
+
- `prebuild-install` is a transitive dep of `better-sqlite3` / native modules. No newer version exists.
|
|
205
|
+
- `boolean` is a transitive dep (likely from one of the embeddings/HF tooling chains).
|
|
206
|
+
|
|
207
|
+
**No GH Actions deprecation warnings found**:
|
|
208
|
+
- Searched 4 recent CI runs (3 layers x 7 jobs each = 28 job logs) for `deprecat`. Only npm warnings. No `set-output`, `save-state`, `node12`, or other Action-runner deprecations.
|
|
209
|
+
- All actions are `@v6` or `@v7` floor — current major versions.
|
|
210
|
+
- `actions/checkout@v6` ← upstream latest `v6.0.2` ✓
|
|
211
|
+
- `actions/setup-node@v6` ← upstream latest `v6.4.0` ✓
|
|
212
|
+
- `actions/upload-artifact@v7` ← upstream latest `v7.0.1` ✓
|
|
213
|
+
- `actions/configure-pages@v6` ← upstream latest `v6.0.0` ✓
|
|
214
|
+
- `actions/upload-pages-artifact@v5` ← upstream latest `v5.0.0` ✓
|
|
215
|
+
- `actions/deploy-pages@v5` ← upstream latest `v5.0.0` ✓
|
|
216
|
+
|
|
217
|
+
**Impact**: cosmetic log noise only. Not a hygiene issue.
|
|
218
|
+
|
|
219
|
+
**Cross-cutting check**: none.
|
|
220
|
+
|
|
221
|
+
**Suggested class fix**: none — wait for upstream maintainers of `prebuild-install` / `boolean` to update their packages or for native deps to switch to a different prebuild tool. Out of our control.
|
|
222
|
+
|
|
223
|
+
**Severity rationale**: INFO — proactive note that the deprecation hygiene check passed cleanly. Recorded for traceability.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Workflow-by-workflow summary
|
|
228
|
+
|
|
229
|
+
### `.github/workflows/ci.yml` (148 lines)
|
|
230
|
+
|
|
231
|
+
| Aspect | Status | Notes |
|
|
232
|
+
|---|---|---|
|
|
233
|
+
| Trigger events (line 3–7) | ✅ | `push` + `pull_request` on `main`. Correct. |
|
|
234
|
+
| Permissions (line 9–10) | ✅ | `contents: read` only. Minimal. |
|
|
235
|
+
| Concurrency (line 12–14) | ✅ | `ci-${{github.ref}}`, cancel-in-progress. Standard. |
|
|
236
|
+
| Job: `lint` (line 17–27) | ✅ | Single-node 22, biome check, 5min timeout. |
|
|
237
|
+
| Job: `test` matrix (line 29–55) | ⚠️ | `[22, 24]` — matches v3.5.11 decision. Drift from `engines: >=20` (see L4-03). |
|
|
238
|
+
| Job: `test-macos` (line 57–75) | ✅ | Advisory only, `continue-on-error: true`. |
|
|
239
|
+
| Job: `coverage` (line 77–97) | ✅ | `needs: test`, includes `check:changelog-coverage` gate (line 92), uploads coverage artifact (`upload-artifact@v7`). |
|
|
240
|
+
| Job: `smoke` (line 99–118) | ✅ | `needs: test`, scan + FTS5 paths covered. |
|
|
241
|
+
| Job: `audit` (line 120–137) | ✅ | Prod `--audit-level=moderate`, dev `--audit-level=high`. |
|
|
242
|
+
| Job: `version-consistency` (line 139–147) | ✅ | Runs `scripts/check-version-consistency.mjs`. |
|
|
243
|
+
| Required-check names | ✅ | All 7 (`lint`, `test (22)`, `test (24)`, `smoke`, `audit`, `coverage`, `version-consistency`) appear in `check-runs` API. Match branch-protection list exactly. |
|
|
244
|
+
| Action versions | ✅ | All `@v6`/`@v7` floor. Match current upstream majors. |
|
|
245
|
+
|
|
246
|
+
### `.github/workflows/release.yml` (120 lines)
|
|
247
|
+
|
|
248
|
+
| Aspect | Status | Notes |
|
|
249
|
+
|---|---|---|
|
|
250
|
+
| Triggers (line 3–12) | ✅ | Tag push `v*` + `workflow_dispatch`. |
|
|
251
|
+
| Permissions (line 14–16) | ✅ | `contents: read` + `id-token: write` for OIDC provenance. |
|
|
252
|
+
| Checkout with `fetch-depth: 0` (line 28) | ✅ | Required for `git merge-base --is-ancestor`. |
|
|
253
|
+
| SHA-on-main verification (line 35–47) | ✅ | `git fetch origin main --depth=200` + `git merge-base --is-ancestor`. Tested green on v3.6.0 release. |
|
|
254
|
+
| Required-CI-checks verification (line 48–77) | ✅ | Regex `lint\|test \(22\)\|test \(24\)\|smoke\|audit\|coverage\|version-consistency` matches all 7 ruleset entries. `REQ_COUNT=7` matches. Polls up to 5min for in-flight CI. |
|
|
255
|
+
| Pre-publish gates (line 84–96) | ✅ | `npm ci`, lint, build, test, version-consistency, audit, smoke (scan + FTS5). Triple-redundant with the SHA-on-main verification — belt + suspenders. |
|
|
256
|
+
| Dist-tag derivation (line 97–115) | ✅ | Regex `/^\d+\.\d+\.\d+-([0-9A-Za-z-]+)/` correctly routes: `3.6.0-rc.4 → rc`, `2.0.0-beta.4 → beta`, `2.0.0-alpha.0 → alpha`, `3.6.0 → latest`. All 3 prerelease patterns we've used handled. Edge cases (build metadata `+`, no `.N` suffix) also handled per the v2.0.0-beta.2 P0 comment. |
|
|
257
|
+
| Publish step (line 116–119) | ✅ | `npm publish --provenance --access public --tag "${tag}"`. Verified: `npm view @oomkapwn/enquire-mcp@3.6.0` has `dist.attestations.provenance` populated with `predicateType: https://slsa.dev/provenance/v1`. SLSA-3 claim valid. |
|
|
258
|
+
|
|
259
|
+
### `.github/workflows/publish-docs.yml` (57 lines)
|
|
260
|
+
|
|
261
|
+
| Aspect | Status | Notes |
|
|
262
|
+
|---|---|---|
|
|
263
|
+
| Triggers (line 9–14) | ✅ | `push: branches: [main]` + `workflow_dispatch`. |
|
|
264
|
+
| Permissions (line 19–22) | ✅ | `contents: read` + `pages: write` + `id-token: write`. Minimum for GH Pages OIDC deploy. No over-broad scope. |
|
|
265
|
+
| Concurrency (line 26–28) | ✅ | `group: pages`, `cancel-in-progress: false`. Serializes deploys (good — aborted upload would corrupt site state). |
|
|
266
|
+
| Build job (line 30–45) | ✅ structure / ❌ runtime | YAML correct; fails at runtime because Pages is not enabled (see L4-01). |
|
|
267
|
+
| Deploy job (line 47–57) | ❌ runtime | Cannot run; depends on failed build job. |
|
|
268
|
+
| Action versions | ✅ | `actions/checkout@v6`, `actions/setup-node@v6`, `actions/configure-pages@v6`, `actions/upload-pages-artifact@v5`, `actions/deploy-pages@v5`. All match latest upstream majors. |
|
|
269
|
+
|
|
270
|
+
### `.github/workflows/dist-tag-cleanup.yml` (48 lines)
|
|
271
|
+
|
|
272
|
+
| Aspect | Status | Notes |
|
|
273
|
+
|---|---|---|
|
|
274
|
+
| Triggers (line 12–18) | ✅ | `workflow_dispatch` only, requires confirm input `REMOVE`. Cannot fire accidentally. |
|
|
275
|
+
| Permissions (line 20–22) | ⚠️ | `contents: read` + `id-token: write`. The `id-token` is unused — `npm dist-tag rm` authenticates via `NPM_TOKEN` env var. Slightly over-broad. |
|
|
276
|
+
| Guard (line 28) | ✅ | `if: ${{ inputs.confirm == 'REMOVE' }}`. |
|
|
277
|
+
| Idempotency (line 42, 46) | ✅ | `|| true` after each `npm dist-tag rm` — re-running on already-removed tag is safe. |
|
|
278
|
+
| Worth keeping? | ⚠️ | One-shot purpose served (no stale tags exist today). See L4-04. |
|
|
279
|
+
|
|
280
|
+
## Sign-off
|
|
281
|
+
|
|
282
|
+
L4 verdict: **YELLOW (2 HIGH for v3.6.1 patch)**.
|
|
283
|
+
|
|
284
|
+
- **L4-01** (HIGH, publish-docs not enabled): trivial fix, prevents red status on every main push, makes README claim true. Recommend fixing as part of v3.6.1.
|
|
285
|
+
- **L4-02** (HIGH, dual branch-protection state): no drift today, but class fix (consolidate to ruleset OR add invariant script) prevents future silent drift. Recommend v3.6.1 or v3.6.2.
|
|
286
|
+
- **L4-03** (MEDIUM, engines drift): defer to v3.7 or document tier explicitly.
|
|
287
|
+
- **L4-04**, **L4-05** (LOW): housekeeping, defer to v3.7.
|
|
288
|
+
- **L4-06** (INFO): clean bill of health on Actions-runner deprecations.
|
|
289
|
+
|
|
290
|
+
Pipeline is structurally sound. Release path is multi-gated (SHA-on-main + check-run verification + in-job re-run of lint/test/audit/smoke + provenance). The one shipping bug is GH Pages, which is repo-level config rather than workflow YAML.
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# L5 — Security (v3.6.0 audit)
|
|
2
|
+
|
|
3
|
+
**Scope**: CodeQL, Dependabot, npm audit, SLSA-3 provenance, bearer auth, path traversal, privacy filters, cache permissions.
|
|
4
|
+
**Auditor**: sub-agent C5.
|
|
5
|
+
**Date**: 2026-05-15.
|
|
6
|
+
**Repo**: `oomkapwn/enquire-mcp` (local folder name is `obsidian-mcp`, project name is `enquire-mcp`).
|
|
7
|
+
**Branch**: `v3.6.0/post-stable-audit`.
|
|
8
|
+
**Baseline package**: `@oomkapwn/enquire-mcp@3.6.0` (published 2026-05-15).
|
|
9
|
+
|
|
10
|
+
## Summary
|
|
11
|
+
|
|
12
|
+
The security posture is strong. Every external check is clean: 0 open CodeQL alerts, 0 open Dependabot alerts, 0 npm-audit findings at every audit level (`--audit-level=low/moderate/high` × dev/prod). SLSA-3 provenance attestation IS emitted for v3.6.0. Bearer auth uses `crypto.timingSafeEqual` after hashing both sides, no naive `===` comparison. CORS implementation explicitly defends against the `js/cors-misconfiguration-for-credentials` class. Path traversal goes through `vault.resolveSafePath()` with `fs.realpath` checks on every read/write; symlink-escape rejected at both parent-dir and leaf-target. Privacy filters (`--exclude-glob` / `--read-paths`) applied at 11+ surfaces: listMarkdown, listFilesByExtension, resolveSafePath, writeNote, renameFile, watcher (chokidar `ignored` predicate), text search post-filter, chunk resource gate, FTS5 hybrid post-filter, embeddings post-filter, replace_in_notes folder check. Cache files (.embed.db, .fts5.db, persistent-note-cache) consistently chmod 0600 with 0700 parent dirs.
|
|
13
|
+
|
|
14
|
+
The findings below cluster into 3 classes, all defense-in-depth (no exploitable issues):
|
|
15
|
+
|
|
16
|
+
1. **L5-01 (Medium)** — HNSW persistence files (`.hnsw.bin` + `.hnsw.meta.json`) are written without explicit 0600 chmod, defaulting to 0644 (umask-modified). The `.meta.json` contains note path + text-preview snippets. Parent dir is implicitly 0700 (shared with `.embed.db`'s open path), but the files themselves break the pattern set by `embed-db.ts`, `fts5.ts`, and `vault.ts`.
|
|
17
|
+
2. **L5-02 (Medium)** — `enquire-mcp setup` and `enquire-mcp index` CLI commands instantiate `Vault` WITHOUT `excludeGlobs` / `readPaths`, while `serve` and `build-embeddings` accept those flags. A user who runs `setup --vault foo` then later runs `serve --exclude-glob` ends up with FTS5 chunks for excluded paths persisted on disk in the `.fts5.db`. Runtime search filters those out via `vault.isExcluded()`, so an LLM never receives them — but at-rest content of excluded paths lives in the index file, contrary to the SECURITY.md "denylist" expectation.
|
|
18
|
+
3. **L5-03 (Info)** — 5 dismissed CodeQL alerts (`js/polynomial-redos` #5, #6, #8, #9, #10) all share the same dismissed_comment template. Code at the cited lines is unchanged since dismissal (2026-05-13); inline reasoning still holds (anchored `$` regex on single char class, strictly linear). No action needed; called out only for traceability.
|
|
19
|
+
|
|
20
|
+
No Critical / High findings. No exploitable issues. Both Mediums are defense-in-depth — they don't expose excluded content over the wire, they just leave artifacts on disk with weaker permissions or in caches the user expected to be empty.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Scope-item-by-scope-item verification
|
|
25
|
+
|
|
26
|
+
### 1. CodeQL alerts
|
|
27
|
+
|
|
28
|
+
`gh api repos/oomkapwn/enquire-mcp/code-scanning/alerts --jq '[.[] | select(.state == "open")] | length'` → **0**.
|
|
29
|
+
|
|
30
|
+
5 dismissed alerts, all `js/polynomial-redos`, all with the same template comment:
|
|
31
|
+
> Anchored-$ regex on single char class — strictly linear (O(n) worst case, no backtracking branch). Same class as v3.5.8 chunker.ts/bases.ts dismissals. See inline comments in src for per-site reasoning.
|
|
32
|
+
|
|
33
|
+
| # | Path | Line | Pattern | Dismissed at | Inline comment still present? |
|
|
34
|
+
|---|------|------|---------|--------------|-------------------------------|
|
|
35
|
+
| 5 | `src/embed-db.ts` | 407 | `/\/+$/` (folder-prefix trim) | 2026-05-13 | Yes — `src/embed-db.ts:403-406` |
|
|
36
|
+
| 6 | `src/fts5.ts` | 377 | `/\/+$/` (folder-prefix trim) | 2026-05-13 | Yes — `src/fts5.ts:373-376` |
|
|
37
|
+
| 8 | `src/fts5.ts` | 596 | `/^(#{1,6})\s+(.+)$/` (heading parse) | 2026-05-13 | Yes — `src/fts5.ts:591-595` |
|
|
38
|
+
| 9 | `src/fts5.ts` | 599 | `/\s+$/` (heading trim) | 2026-05-13 | Yes — `src/fts5.ts:591-595` |
|
|
39
|
+
| 10 | `src/fts5.ts` | 599 | `/#+$/` (heading trim) | 2026-05-13 | Yes — `src/fts5.ts:591-595` |
|
|
40
|
+
|
|
41
|
+
`git log --since="2026-05-13" -- src/embed-db.ts src/fts5.ts` returns no commits. Dismissed reasoning is still accurate.
|
|
42
|
+
|
|
43
|
+
### 2. Dependabot alerts
|
|
44
|
+
|
|
45
|
+
`gh api repos/oomkapwn/enquire-mcp/dependabot/alerts --jq '[.[] | select(.state == "open")] | length'` → **0**.
|
|
46
|
+
|
|
47
|
+
Upgrade policy in `.github/dependabot.yml`:
|
|
48
|
+
- Weekly cadence, Monday 06:00 Moscow time.
|
|
49
|
+
- Open-PR limit: 5 (npm) + 3 (gh-actions).
|
|
50
|
+
- Groups dev-deps (minor/patch) and runtime-patches separately — no auto-merge configured (PRs require human review).
|
|
51
|
+
- Production major bumps land as individual PRs (not grouped), so risky upgrades get individual scrutiny.
|
|
52
|
+
|
|
53
|
+
Upgrade policy is reasonable; no auto-merge means the human-review gate is intact.
|
|
54
|
+
|
|
55
|
+
### 3. npm audit
|
|
56
|
+
|
|
57
|
+
| Command | Result |
|
|
58
|
+
|---------|--------|
|
|
59
|
+
| `npm audit --omit=dev --audit-level=moderate` | `found 0 vulnerabilities` |
|
|
60
|
+
| `npm audit --include=dev --audit-level=high` | `found 0 vulnerabilities` |
|
|
61
|
+
| `npm audit --include=dev` (low) | `found 0 vulnerabilities` |
|
|
62
|
+
|
|
63
|
+
Zero findings at every level — the dependency tree is clean.
|
|
64
|
+
|
|
65
|
+
### 4. SLSA-3 provenance
|
|
66
|
+
|
|
67
|
+
`npm view @oomkapwn/enquire-mcp@latest --json | jq '.dist'` returns:
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"attestations": {
|
|
71
|
+
"url": "https://registry.npmjs.org/-/npm/v1/attestations/@oomkapwn%2fenquire-mcp@3.6.0",
|
|
72
|
+
"provenance": { "predicateType": "https://slsa.dev/provenance/v1" }
|
|
73
|
+
},
|
|
74
|
+
"signatures": [
|
|
75
|
+
{ "keyid": "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U", ... }
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`.attestations.provenance.predicateType` is `slsa.dev/provenance/v1` — full SLSA-3 provenance attached. Same for `@oomkapwn/enquire-mcp@3.6.0` exact version.
|
|
81
|
+
|
|
82
|
+
Release workflow (`.github/workflows/release.yml:14-16`) declares `id-token: write` permission; publish step (line 117) uses `npm publish --provenance --access public`. Confirmed.
|
|
83
|
+
|
|
84
|
+
### 5. Bearer auth — constant-time comparison
|
|
85
|
+
|
|
86
|
+
`grep -n 'timingSafeEqual\|=== bearerToken\|===.*token' src/http-transport.ts`:
|
|
87
|
+
|
|
88
|
+
- Line 31: `import { createHash, randomBytes, timingSafeEqual } from "node:crypto";`
|
|
89
|
+
- Line 161: `const expectedHash = createHash("sha256").update(expectedToken).digest();`
|
|
90
|
+
- Line 162: `const presentedHash = createHash("sha256").update(presented).digest();`
|
|
91
|
+
- Line 163: `if (!timingSafeEqual(expectedHash, presentedHash)) return null;`
|
|
92
|
+
|
|
93
|
+
`verifyBearer()` (`src/http-transport.ts:154-167`) hashes both sides to fixed-length SHA-256 buffers before `timingSafeEqual`, defeating length-leak side channels. Token validation is constant-time. No naive `===` comparison anywhere in the file (only `=== null` for the verifyBearer return value, line 358).
|
|
94
|
+
|
|
95
|
+
Generation: `generateBearerToken()` (line 582) → `randomBytes(32).toString("base64url")` → ~43 char base64url, CSPRNG. Startup gate (line 591): rejects `bearerToken < 16 chars`.
|
|
96
|
+
|
|
97
|
+
### 6. Path traversal — realpath checks
|
|
98
|
+
|
|
99
|
+
Every Vault file operation routes through one of two entry points:
|
|
100
|
+
|
|
101
|
+
- **`Vault.resolveInside(p)`** (`src/vault.ts:292-299`) — pure lexical check (`path.relative` rejects `..` or absolute escapes). Used for non-existent-file paths (writes to new files).
|
|
102
|
+
- **`Vault.resolveSafePath(relOrAbs)`** (`src/vault.ts:573-610`) — realpath-after-resolve, rejects if resolved path escapes vault root; also enforces `isExcluded`.
|
|
103
|
+
|
|
104
|
+
Direct `fs.readFile` / `fs.writeFile` outside `vault.ts`:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
src/eval.ts:145 fs.readFile(file, "utf8") # user-supplied JSONL, CLI-only diagnostic tool
|
|
108
|
+
src/hnsw.ts:309 fs.writeFile(metaFile, ...) # HNSW persistence (see L5-01)
|
|
109
|
+
src/hnsw.ts:337 fs.readFile(metaFile, "utf8") # HNSW load (own cache file)
|
|
110
|
+
src/periodic.ts:64 fs.readFile(dailyJsonPath) # .obsidian/daily-notes.json — gated by isExcluded
|
|
111
|
+
src/periodic.ts:84 fs.readFile(periodicJsonPath) # .obsidian/plugins/periodic-notes/data.json — gated
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`eval.ts:145` — CLI-only `enquire eval` diagnostic; reads a user-supplied JSONL path. Not user-controllable via MCP. Not a concern.
|
|
115
|
+
`hnsw.ts:309/337` — owns the HNSW sidecar files at known-good paths (`<embedDir>/<vaultname>.hnsw.bin` + `.meta.json`); not influenced by note content. See L5-01 for chmod.
|
|
116
|
+
`periodic.ts:64/84` — both calls gated by an `isExcluded` predicate (`src/periodic.ts:62`, `src/periodic.ts:80`); both target fixed `.obsidian/...` paths that can't be redirected.
|
|
117
|
+
|
|
118
|
+
Symlink-escape protection: `Vault.assertParentInsideVault` (`src/vault.ts:446-459`) walks parent chain with `fs.realpath`, refuses writes if any parent resolves outside. Leaf-target symlinks rejected explicitly (`src/vault.ts:431-434`). Walker `followSymlinks: false`.
|
|
119
|
+
|
|
120
|
+
Path traversal class fully defended.
|
|
121
|
+
|
|
122
|
+
### 7. Privacy filters
|
|
123
|
+
|
|
124
|
+
`--exclude-glob` and `--read-paths` are applied at the following surfaces (file:line citations):
|
|
125
|
+
|
|
126
|
+
| Surface | Function | File:line | Notes |
|
|
127
|
+
|---|---|---|---|
|
|
128
|
+
| 1 | Vault `listMarkdown` | `src/vault.ts:321-323` | Post-walk filter. Also gates folder-arg via `isExcluded(rel)` on line 313 |
|
|
129
|
+
| 2 | Vault `listFilesByExtension` | `src/vault.ts:344-346` | Same pattern as listMarkdown, also gates folder-arg (line 340) |
|
|
130
|
+
| 3 | Vault `resolveSafePath` (read path) | `src/vault.ts:598-604` | Refuses with allowlist-vs-denylist distinction in error message |
|
|
131
|
+
| 4 | Vault `writeNote` | `src/vault.ts:412-418` | Pre-write enforcement (P0 fix from v2.0.0-beta.1); both allowlist + denylist |
|
|
132
|
+
| 5 | Vault `renameFile` (target) | `src/vault.ts:481-485` | Same predicate as writeNote |
|
|
133
|
+
| 6 | `VaultWatcher.start` (chokidar predicate) | `src/watcher.ts:54-58` | Watcher never sees writes to excluded files (no FTS5 reindex trigger, no cache invalidation reveal) |
|
|
134
|
+
| 7 | `tool-registry.ts` text-search results | `src/tool-registry.ts:104` | Post-filter on `searchText`-style hits |
|
|
135
|
+
| 8 | `tool-registry.ts` chunk resource | `src/tool-registry.ts:1188` | "Chunk not found" framing matches the not-found branch — attacker can't distinguish |
|
|
136
|
+
| 9 | `tools/search.ts` `embeddingsSearch` | `src/tools/search.ts:908` | Post-filter on embed-cosine hits + HNSW results |
|
|
137
|
+
| 10 | `tools/search.ts` `searchHybrid` FTS5 leg | `src/tools/search.ts:1151` | Filters BM25 hits before RRF fusion — stale entries from pre-flag indexes blocked |
|
|
138
|
+
| 11 | `tools/write.ts` `replace_in_notes` folder | `src/tools/write.ts:577-582` | Tests both `<folder>` and `<folder>/_probe.md` to handle `**`-glob semantics |
|
|
139
|
+
| 12 | `periodic.ts` config loader | `src/periodic.ts:62, 80` | `.obsidian/daily-notes.json` and `.obsidian/plugins/periodic-notes/data.json` both gated |
|
|
140
|
+
|
|
141
|
+
**Trace 1 — FTS5 indexing**: `syncFtsIndex` (`src/server.ts:678`) calls `vault.listMarkdown()` which filters via `isExcluded` at `src/vault.ts:322`. Indexed entries never include excluded paths (at build time, with privacy flags wired through the relevant Vault constructor).
|
|
142
|
+
|
|
143
|
+
**Trace 2 — Embeddings build**: `syncEmbedDb` (`src/server.ts:567`) calls `vault.listMarkdown()` — same filter point. Chunker (`chunkContent` in `src/fts5.ts:502`) receives only already-vetted content; no separate filter needed.
|
|
144
|
+
|
|
145
|
+
**Trace 3 — Hybrid search at query time**: `searchHybrid` (`src/tools/search.ts`) calls FTS5 + TF-IDF + embeddings; each leg filters via `vault.isExcluded()` (line 1151 + line 908 + via TF-IDF's `buildTfidfIndex` which uses `vault.listMarkdown`). Plus, even if `.fts5.db` contained stale excluded entries from a pre-flag setup, the runtime filter strips them before RRF fusion. Defense-in-depth holds.
|
|
146
|
+
|
|
147
|
+
**Trace 4 — TF-IDF**: `buildTfidfIndex` (`src/tools/search.ts:484-487`) uses `vault.listMarkdown()` directly; index built only from non-excluded files. Per-query post-filter not needed because the index never contained them.
|
|
148
|
+
|
|
149
|
+
**Trace 5 — Tool resources (chunk URI)**: `tool-registry.ts:1188` blocks `enquire://chunk/...` URIs for excluded paths, even if those URIs were issued earlier in the session when no exclude flag was active.
|
|
150
|
+
|
|
151
|
+
Privacy filter coverage is complete at every code path I traced. See L5-02 for an at-rest-only concern around `setup` / `index` CLI commands.
|
|
152
|
+
|
|
153
|
+
### 8. Cache permissions
|
|
154
|
+
|
|
155
|
+
Cache files (verified):
|
|
156
|
+
|
|
157
|
+
| File | chmod 0600 | Parent dir chmod 0700 | Source |
|
|
158
|
+
|---|---|---|---|
|
|
159
|
+
| `.embed.db` + `-wal` + `-shm` | Yes | Yes | `src/embed-db.ts:210, 211, 217` |
|
|
160
|
+
| `.fts5.db` + `-wal` + `-shm` | Yes | Yes | `src/fts5.ts:125, 126, 135` |
|
|
161
|
+
| Persistent note cache (JSON) | Yes (0600 on write + chmod) | Yes (0700 on mkdir + chmod) | `src/vault.ts:277, 282, 285, 288` |
|
|
162
|
+
| `.hnsw.bin` (binary index) | **No (defaults to 0644 via umask)** | Implicit 0700 (shares dir with `.embed.db` if EmbedDb opened first) | `src/hnsw.ts:300` (no chmod) |
|
|
163
|
+
| `.hnsw.meta.json` (path + text-preview rows) | **No (defaults to 0644 via umask)** | Same as above | `src/hnsw.ts:309` (no chmod) |
|
|
164
|
+
|
|
165
|
+
See L5-01 for the HNSW chmod gap.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Findings detail
|
|
170
|
+
|
|
171
|
+
### Finding L5-01 (Medium)
|
|
172
|
+
|
|
173
|
+
**File**: `src/hnsw.ts:289-312` (`saveTo` method, the inner of `wrapNativeIndex`).
|
|
174
|
+
**Class**: Cache-file permission drift — sidecar files written via `fs.writeFile` or native libs without an explicit `mode` argument or post-write `chmod`. The strict 0600/0700 invariant enforced in `embed-db.ts`, `fts5.ts`, and `vault.ts` is broken at the HNSW persistence path.
|
|
175
|
+
**Severity**: Medium (defense-in-depth — files live in a 0700 parent dir created by `EmbedDb.open()`, so a sibling user can't traverse in unless they were granted access at the parent level. But the SECURITY.md "0600 cache" guarantee doesn't apply to HNSW sidecar files, and a fresh HNSW save creates files under the user's umask, which on shared / NFS / corporate-image systems can be 0664 or 0644).
|
|
176
|
+
|
|
177
|
+
**Description**: When the HNSW index is persisted, the workflow is:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
// src/hnsw.ts:289-310
|
|
181
|
+
async saveTo(file, rowsByLabel, signature): Promise<boolean> {
|
|
182
|
+
const fs = await import("node:fs/promises");
|
|
183
|
+
const path = await import("node:path");
|
|
184
|
+
await fs.mkdir(path.dirname(file), { recursive: true }); // ← no mode: 0o700
|
|
185
|
+
const binFile = `${file}.bin`;
|
|
186
|
+
const metaFile = `${file}.meta.json`;
|
|
187
|
+
await ctor.writeIndex(binFile); // ← native lib write, no chmod after
|
|
188
|
+
const meta: HnswPersistedMeta = { ... };
|
|
189
|
+
await fs.writeFile(metaFile, JSON.stringify(meta, null, 2), "utf8"); // ← no mode option, no chmod after
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The `.hnsw.meta.json` payload includes (`src/hnsw.ts:82-92`):
|
|
195
|
+
- `rel_path` (vault-relative path to every chunk)
|
|
196
|
+
- `text_preview` (note-content snippet, up to 480 chars per chunk)
|
|
197
|
+
- `chunk_index`, `line_start`, `line_end`, `kind`
|
|
198
|
+
|
|
199
|
+
This is the same class of sensitive metadata that `.embed.db` and `.fts5.db` already protect with 0600. The HNSW sidecar leaks it under a more permissive default mode.
|
|
200
|
+
|
|
201
|
+
Mitigating factor: HNSW files are written to `<embedDir>/<vaultname>.hnsw.{bin,meta.json}`, and `embedDir` is set to 0700 by `EmbedDb.connect()` at `src/embed-db.ts:210-211`. So in practice, file mode 0644 is overridden by the parent dir's 0700 — a sibling user can't `cd` in to read them. BUT: (a) defense-in-depth wants both layers, (b) `saveTo` is also called when the user has not run `EmbedDb.open()` for this exact dir before (parent might not exist), (c) some filesystems (NFS, FAT-on-USB) ignore Unix mode bits — the parent-dir guarantee evaporates.
|
|
202
|
+
|
|
203
|
+
**Class**: Same-class instances of "writes cache file without explicit chmod":
|
|
204
|
+
- `src/hnsw.ts:295` `fs.mkdir` — missing `mode: 0o700` (other call-sites set it: `src/embed-db.ts:210`, `src/fts5.ts:125`, `src/vault.ts:277`).
|
|
205
|
+
- `src/hnsw.ts:300` `ctor.writeIndex(binFile)` — native lib write, no post-write `chmod`.
|
|
206
|
+
- `src/hnsw.ts:309` `fs.writeFile(metaFile, ...)` — no `mode` option in the third arg, no post-write `chmod`.
|
|
207
|
+
|
|
208
|
+
**Class fix**:
|
|
209
|
+
1. In `src/hnsw.ts:saveTo`, mirror the pattern from `src/embed-db.ts:207-219`:
|
|
210
|
+
```ts
|
|
211
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
212
|
+
await fs.chmod(path.dirname(file), 0o700).catch(() => {});
|
|
213
|
+
await ctor.writeIndex(binFile);
|
|
214
|
+
await fs.writeFile(metaFile, JSON.stringify(meta, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
215
|
+
await Promise.all([binFile, metaFile].map((p) => fs.chmod(p, 0o600).catch(() => {})));
|
|
216
|
+
```
|
|
217
|
+
2. Add a test in `tests/hnsw.test.ts` (if it exists) or a new test that asserts mode bits after `saveTo`. See `tests/embed-db.test.ts` for the pattern (assertions on `stat.mode & 0o777`).
|
|
218
|
+
3. Document the chmod-on-cache invariant in `CLAUDE.md` or a new `docs/internals/cache-permissions.md` so future cache-file additions get audited.
|
|
219
|
+
|
|
220
|
+
**Backfill**: Single instance; the fix above resolves L5-01.
|
|
221
|
+
|
|
222
|
+
**Recommendation**: Ship in v3.6.1. Low complexity, no behavior change for the common case.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
### Finding L5-02 (Medium)
|
|
227
|
+
|
|
228
|
+
**File**: `src/cli.ts:298` (`enquire-mcp index` command), `src/cli.ts:487` (`enquire-mcp setup` command).
|
|
229
|
+
**Class**: CLI-flag drift — privacy flags (`--exclude-glob` / `--read-paths`) accepted by `serve`, `serve-http`, and `build-embeddings`, but NOT by `setup` and `index`. A user who runs `setup --vault foo` (the documented "zero-touch onboarding" path) gets a `.fts5.db` containing chunks of every file in their vault, including any path they later want to mark private.
|
|
230
|
+
|
|
231
|
+
**Severity**: Medium (the runtime filter at `tools/search.ts:1151` strips excluded paths from search results, so an LLM never receives them — but at-rest content of supposedly-private notes lives in `.fts5.db` and `.embed.db` with 0600 perms, contrary to the SECURITY.md "privacy filter at indexing time" guarantee).
|
|
232
|
+
|
|
233
|
+
**Description**:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
// src/cli.ts:298 — `index` subcommand
|
|
237
|
+
const vault = new Vault(opts.vault); // no excludeGlobs, no readPaths
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
// src/cli.ts:487 — `setup` subcommand
|
|
242
|
+
const v = new Vault(opts.vault); // no excludeGlobs, no readPaths
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Compare to:
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
// src/cli.ts:384 — `build-embeddings` (has flags)
|
|
249
|
+
const vault = new Vault(opts.vault, { excludeGlobs: opts.excludeGlob, readPaths: opts.readPaths });
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
// src/cli.ts:57-62 — `serve` accepts these flags
|
|
254
|
+
program.option("--exclude-glob <pattern...>", "...");
|
|
255
|
+
program.option("--read-paths <pattern...>", "...");
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
So the CLI surface is inconsistent: `serve` + `build-embeddings` honor privacy, `setup` + `index` don't.
|
|
259
|
+
|
|
260
|
+
Practical attack scenario: User runs `enquire-mcp setup --vault ~/Notes` (recommended in README QUICKSTART). Cold-built `.fts5.db` and `.embed.db` now contain every file. User then writes a script that runs `enquire-mcp serve --vault ~/Notes --exclude-glob '02_Private/**'`. At runtime, search results are filtered. But:
|
|
261
|
+
1. Any other process that opens the `.fts5.db` directly (a SQLite client, a `sqlite3` shell, the `enquire-mcp dump-index` command if one exists, leaked backup) sees all the private chunks.
|
|
262
|
+
2. If the user later removes the `--exclude-glob` flag, the index already has the private chunks — no rebuild needed for them to surface.
|
|
263
|
+
3. SECURITY.md section `--read-paths: strict-allowlist posture` (line 50-59) implies the filter is enforced at every layer, not just runtime.
|
|
264
|
+
|
|
265
|
+
**Class**: Same-class instances of "Vault constructor not threading user privacy flags":
|
|
266
|
+
- `src/cli.ts:266` (`clear-index` — Vault used only for `defaultIndexFile()` path derivation; no file content access). **Not a finding** — just path resolution.
|
|
267
|
+
- `src/cli.ts:298` (`index`). **Finding**.
|
|
268
|
+
- `src/cli.ts:425` (`clear-embeddings` — same, path-only). **Not a finding**.
|
|
269
|
+
- `src/cli.ts:487` (`setup`). **Finding**.
|
|
270
|
+
- `src/cli.ts:607` (`eval` — diagnostic / benchmark; query set is explicit). **Not a finding** — eval is intended to exercise the full corpus for retrieval quality measurement.
|
|
271
|
+
|
|
272
|
+
**Class fix**:
|
|
273
|
+
1. Add `--exclude-glob` and `--read-paths` options to both `index` (`src/cli.ts:283-295`) and `setup` (`src/cli.ts:468-485`) commands.
|
|
274
|
+
2. Thread them through the `new Vault(...)` constructor at lines 298 and 487 (matching the pattern at `src/cli.ts:384`).
|
|
275
|
+
3. Add a guard in `setup` that warns when a user runs `setup` without privacy flags but their `serve` invocations elsewhere DO use them. Alternative: add a `--re-setup-needed` notice on `serve` start when the index mtime predates the privacy flags being introduced. (Low priority — main fix is just threading the flags.)
|
|
276
|
+
4. Add a CHANGELOG-tracked invariant: "every CLI command that opens a Vault for indexing must accept `--exclude-glob` and `--read-paths`."
|
|
277
|
+
5. Update `docs/QUICKSTART.md` to show `enquire-mcp setup --vault ~/Notes --exclude-glob '02_Private/**'` as the privacy-aware default.
|
|
278
|
+
|
|
279
|
+
**Backfill**: Two instances (`src/cli.ts:298`, `src/cli.ts:487`). Plus an integration test that runs `enquire-mcp setup --vault tmp --exclude-glob 'Secret/**'` and asserts the `.fts5.db` doesn't contain any rows where `rel_path` matches the exclude glob.
|
|
280
|
+
|
|
281
|
+
**Recommendation**: Ship in v3.6.1 alongside L5-01. Could be batched as a single "v3.6.1: hardening" release.
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
### Finding L5-03 (Info)
|
|
286
|
+
|
|
287
|
+
**File**: 5 dismissed CodeQL alerts on `oomkapwn/enquire-mcp/security/code-scanning`.
|
|
288
|
+
**Class**: CodeQL `js/polynomial-redos` false positives on anchored `$` regexes over single character classes. Same class as the v3.5.8 `chunker.ts` / `bases.ts` dismissals (referenced in the dismissed_comment template).
|
|
289
|
+
**Severity**: Info — no action needed. Captured here so the v3.7+ auditor can confirm at re-audit time that the dismissed_comment template still applies.
|
|
290
|
+
|
|
291
|
+
**Description**: All 5 alerts dismissed 2026-05-13 by Alex with the same inline-reasoning comment template ("Anchored-$ regex on single char class — strictly linear..."). I verified each alert's cited source line and confirmed:
|
|
292
|
+
|
|
293
|
+
1. Code at the cited lines is **unchanged** since dismissal — `git log --since="2026-05-13" -- src/embed-db.ts src/fts5.ts` is empty.
|
|
294
|
+
2. Each regex has an inline `// CodeQL js/polynomial-redos flags ... false positive...` comment in source pointing readers at the reasoning. Examples: `src/embed-db.ts:403-406`, `src/fts5.ts:373-376`, `src/fts5.ts:591-595`.
|
|
295
|
+
3. The regex patterns are all `/\/+$/`, `/\s+$/`, `/#+$/`, `/^(#{1,6})\s+(.+)$/` — all anchored, all character-class greedy with no nested quantifier. Linear-time by construction.
|
|
296
|
+
|
|
297
|
+
**Class fix**: None — these are working as intended. The class invariant ("any new `$`-anchored regex on a single char class with a `// CodeQL js/polynomial-redos: anchored-$ ...` comment is acceptable; otherwise grep for backtrack-able combinators") should be added to CLAUDE.md for future auditors.
|
|
298
|
+
|
|
299
|
+
**Backfill**: None.
|
|
300
|
+
|
|
301
|
+
**Recommendation**: No action. Re-audit at v3.7+ to confirm code lines haven't shifted under the dismissed alerts.
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## Verification commands (rerunnable)
|
|
306
|
+
|
|
307
|
+
```bash
|
|
308
|
+
# CodeQL state
|
|
309
|
+
gh api repos/oomkapwn/enquire-mcp/code-scanning/alerts --jq '[.[] | select(.state == "open")] | length'
|
|
310
|
+
gh api repos/oomkapwn/enquire-mcp/code-scanning/alerts --jq '[.[] | select(.state == "dismissed")] | .[] | {number, rule: .rule.id, dismissed_comment, most_recent_instance: .most_recent_instance.location}'
|
|
311
|
+
|
|
312
|
+
# Dependabot
|
|
313
|
+
gh api repos/oomkapwn/enquire-mcp/dependabot/alerts --jq '[.[] | select(.state == "open")] | length'
|
|
314
|
+
|
|
315
|
+
# npm audit (run from project root)
|
|
316
|
+
npm audit --omit=dev --audit-level=moderate
|
|
317
|
+
npm audit --include=dev --audit-level=high
|
|
318
|
+
npm audit --include=dev # full
|
|
319
|
+
|
|
320
|
+
# SLSA provenance
|
|
321
|
+
npm view @oomkapwn/enquire-mcp@latest --json | jq '.dist.attestations'
|
|
322
|
+
|
|
323
|
+
# Bearer auth
|
|
324
|
+
grep -n 'timingSafeEqual\|=== bearerToken\|===.*token' src/http-transport.ts
|
|
325
|
+
|
|
326
|
+
# Path traversal — direct fs calls outside vault.ts
|
|
327
|
+
grep -rn 'fs\.readFile\|fs\.writeFile\|fsp\.readFile\|fsp\.writeFile' src/ | grep -v "vault.ts"
|
|
328
|
+
|
|
329
|
+
# Privacy filter sites
|
|
330
|
+
grep -rn 'isExcluded' src/
|
|
331
|
+
|
|
332
|
+
# Cache file modes
|
|
333
|
+
grep -n '0o600\|0o700\|chmod\|mode: 0' src/embed-db.ts src/fts5.ts src/vault.ts src/hnsw.ts
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## Sign-off
|
|
339
|
+
|
|
340
|
+
- CodeQL: 0 open, 5 dismissed with current reasoning.
|
|
341
|
+
- Dependabot: 0 open.
|
|
342
|
+
- npm audit: 0 findings at every level.
|
|
343
|
+
- SLSA-3: v3.6.0 attestation present, `slsa.dev/provenance/v1` predicate confirmed.
|
|
344
|
+
- Bearer auth: constant-time via SHA-256 + `timingSafeEqual`.
|
|
345
|
+
- Path traversal: every read/write through `vault.resolveSafePath()` or `vault.resolveInside()`; 5 direct `fs.*` calls outside `vault.ts` reviewed, all benign.
|
|
346
|
+
- Privacy filters: 11+ enforcement points traced; 4 distinct code paths cited (FTS5 build, embeddings build, hybrid search, TF-IDF build).
|
|
347
|
+
- Cache permissions: 0600 / 0700 enforced for `.embed.db`, `.fts5.db`, persistent-cache; **gap at HNSW sidecar files** (L5-01).
|
|
348
|
+
- Privacy flag CLI surface: **inconsistent** — `setup` and `index` don't accept them (L5-02).
|
|
349
|
+
|
|
350
|
+
No Critical / High. Two Mediums (L5-01, L5-02) shipable in a single v3.6.1 hardening release.
|