wyrm-mcp 7.2.0 → 7.2.2

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.
Files changed (156) hide show
  1. package/LICENSE +26 -667
  2. package/NOTICE +14 -33
  3. package/dist/activation.d.ts.map +1 -1
  4. package/dist/activation.js +1 -44
  5. package/dist/activation.js.map +1 -1
  6. package/dist/agent-daemon.js +4 -281
  7. package/dist/agent-loop.js +7 -332
  8. package/dist/analytics.js +13 -236
  9. package/dist/attribution.js +1 -49
  10. package/dist/audit.js +2 -457
  11. package/dist/auto-capture.js +3 -138
  12. package/dist/auto-orchestrator.js +1 -325
  13. package/dist/autoconfig.js +39 -840
  14. package/dist/buddy-runner.js +1 -109
  15. package/dist/buddy.js +14 -564
  16. package/dist/build-flags.js +1 -17
  17. package/dist/capabilities.js +3 -183
  18. package/dist/capture.js +1 -56
  19. package/dist/causality.js +6 -107
  20. package/dist/cli.js +20 -281
  21. package/dist/cloud/cli.js +5 -541
  22. package/dist/cloud/client.js +1 -221
  23. package/dist/cloud/crypto.js +1 -85
  24. package/dist/cloud/machine-id.js +2 -113
  25. package/dist/cloud/recovery.js +1 -60
  26. package/dist/cloud/sync-engine.js +7 -543
  27. package/dist/cloud-backup.js +5 -579
  28. package/dist/cloud-profile.js +1 -138
  29. package/dist/cloud-sync-entrypoint.js +1 -47
  30. package/dist/cloud-sync.js +2 -309
  31. package/dist/constellation.js +12 -168
  32. package/dist/context-build-budgeted.js +4 -144
  33. package/dist/context-ranking.js +1 -69
  34. package/dist/crypto.js +1 -179
  35. package/dist/daemon-write-endpoint.js +1 -290
  36. package/dist/daemon-writer.js +2 -406
  37. package/dist/database.js +43 -1110
  38. package/dist/deprecations.js +2 -162
  39. package/dist/design.js +13 -141
  40. package/dist/event-replication.js +1 -112
  41. package/dist/events-sse.js +7 -43
  42. package/dist/events.js +6 -238
  43. package/dist/failure-patterns.js +42 -659
  44. package/dist/federation.js +12 -236
  45. package/dist/goals.js +13 -101
  46. package/dist/golden.js +3 -355
  47. package/dist/handlers/agent.js +4 -165
  48. package/dist/handlers/alias-adapters.js +1 -129
  49. package/dist/handlers/aliases.js +1 -171
  50. package/dist/handlers/audit.js +1 -87
  51. package/dist/handlers/boundary.js +1 -221
  52. package/dist/handlers/capture.js +73 -1109
  53. package/dist/handlers/causality.js +7 -114
  54. package/dist/handlers/cloud.js +85 -382
  55. package/dist/handlers/companion.js +28 -459
  56. package/dist/handlers/datalake.js +7 -187
  57. package/dist/handlers/dispatch-context.js +0 -22
  58. package/dist/handlers/entity.js +25 -256
  59. package/dist/handlers/events.js +16 -335
  60. package/dist/handlers/failure.js +13 -340
  61. package/dist/handlers/goals.js +4 -296
  62. package/dist/handlers/intelligence.js +126 -674
  63. package/dist/handlers/invoicing.js +1 -70
  64. package/dist/handlers/mcpclient.js +6 -137
  65. package/dist/handlers/orchestration.js +40 -125
  66. package/dist/handlers/output-schemas.js +1 -24
  67. package/dist/handlers/presence.js +3 -99
  68. package/dist/handlers/project.js +28 -182
  69. package/dist/handlers/prompts.js +6 -157
  70. package/dist/handlers/quest.js +4 -224
  71. package/dist/handlers/recall.js +11 -218
  72. package/dist/handlers/registry.js +1 -167
  73. package/dist/handlers/resources.js +1 -288
  74. package/dist/handlers/review.js +11 -74
  75. package/dist/handlers/run.js +17 -487
  76. package/dist/handlers/search.js +15 -326
  77. package/dist/handlers/session.js +28 -615
  78. package/dist/handlers/share.js +8 -184
  79. package/dist/handlers/shims.js +1 -464
  80. package/dist/handlers/skill.js +67 -449
  81. package/dist/handlers/survivors.js +1 -120
  82. package/dist/handlers/symbols.js +8 -109
  83. package/dist/handlers/syncops.js +4 -302
  84. package/dist/handlers/types.js +1 -27
  85. package/dist/harvest.js +5 -191
  86. package/dist/hours.js +7 -156
  87. package/dist/http-auth.js +3 -321
  88. package/dist/http-fast.js +21 -1137
  89. package/dist/icons.js +1 -47
  90. package/dist/index.js +2 -924
  91. package/dist/indexer.js +4 -145
  92. package/dist/intelligence.js +31 -261
  93. package/dist/internal-dispatch.js +3 -212
  94. package/dist/keyset.js +1 -110
  95. package/dist/knowledge-graph.js +12 -176
  96. package/dist/license.d.ts +11 -0
  97. package/dist/license.d.ts.map +1 -1
  98. package/dist/license.js +2 -414
  99. package/dist/license.js.map +1 -1
  100. package/dist/logger.js +2 -199
  101. package/dist/maintenance.js +2 -148
  102. package/dist/mcp-client.js +6 -262
  103. package/dist/memory-artifacts.js +30 -449
  104. package/dist/migrate-prompt.js +2 -124
  105. package/dist/migrations.js +40 -655
  106. package/dist/performance.js +1 -228
  107. package/dist/presence.js +11 -140
  108. package/dist/priority-embed.js +5 -164
  109. package/dist/providers/embedding-provider.js +1 -196
  110. package/dist/readonly-gate.js +1 -29
  111. package/dist/rehydration.js +9 -157
  112. package/dist/reindex.js +1 -88
  113. package/dist/render-target.js +21 -514
  114. package/dist/render.js +4 -280
  115. package/dist/repl-guard.js +1 -173
  116. package/dist/replication-daemon-entrypoint.js +1 -31
  117. package/dist/replication-daemon.js +2 -262
  118. package/dist/resilience.js +1 -591
  119. package/dist/reverse-bridge.js +5 -360
  120. package/dist/security.js +1 -244
  121. package/dist/session-seen.js +3 -51
  122. package/dist/setup.js +1 -260
  123. package/dist/skill-author.js +5 -168
  124. package/dist/spec-kit.js +1 -191
  125. package/dist/sqlite-busy.js +1 -154
  126. package/dist/statusline.js +11 -315
  127. package/dist/sub-agent.js +13 -262
  128. package/dist/summarizer.js +13 -139
  129. package/dist/symbols.js +7 -283
  130. package/dist/sync.js +5 -359
  131. package/dist/tasks-dispatch.js +1 -84
  132. package/dist/tasks.js +1 -282
  133. package/dist/token-budget.js +1 -143
  134. package/dist/tool-analytics.js +7 -129
  135. package/dist/tool-annotations.js +1 -365
  136. package/dist/tool-manifest-v2.json +1 -1
  137. package/dist/tool-manifest.json +1 -1
  138. package/dist/tool-profiles.js +1 -75
  139. package/dist/trace-harvest.js +6 -244
  140. package/dist/types.js +1 -30
  141. package/dist/ui-dashboard.js +41 -50
  142. package/dist/ulid.js +1 -81
  143. package/dist/validate.js +1 -129
  144. package/dist/vault.js +1 -534
  145. package/dist/vectors.js +3 -184
  146. package/dist/version-check.js +4 -136
  147. package/dist/visibility.js +19 -155
  148. package/dist/wyrm-cli.js +98 -2451
  149. package/dist/wyrm-cli.js.map +1 -1
  150. package/dist/wyrm-guard.js +14 -424
  151. package/dist/wyrm-loop.js +3 -150
  152. package/dist/wyrm-manifest.json +1 -1
  153. package/dist/wyrm-statusline-daemon.js +1 -11
  154. package/dist/wyrm-statusline.js +4 -56
  155. package/dist/wyrm-ui.js +9 -77
  156. package/package.json +4 -2
@@ -1,363 +1,8 @@
1
- /**
2
- * Wyrm Reverse Bridge (v7 F4 — T039) — watch native memory files, harvest edits.
3
- *
4
- * `wyrm render` (T038) compiles the DB into harness-native memory files
5
- * (`MEMORY.md`, `CLAUDE.md`, `AGENTS.md`, `.cursor/rules/…`, the Copilot
6
- * instructions). Humans and agents then EDIT those files in their editor. The
7
- * reverse bridge closes the loop: it watches those native files, diffs them
8
- * against what Wyrm last rendered, and feeds the human/agent edits through
9
- * auto_capture's candidate pipeline into the REVIEW QUEUE.
10
- *
11
- * Two hard invariants (spec FR-6 / Article VII), enforced by this module:
12
- *
13
- * 1. NEVER SILENT INGEST — every edit becomes a `needs_review=1` candidate the
14
- * operator vets with `wyrm_review`. Nothing is auto-trusted. (Same discipline
15
- * as harvest.ts / auto-capture.ts.)
16
- *
17
- * 2. NEVER SILENT OVERWRITE — if a human edited the Wyrm-managed region (the
18
- * bytes BETWEEN the markers, which the next `wyrm render` would clobber), the
19
- * overwrite is GUARDED: {@link regionEditedSinceRender} detects it from the
20
- * on-disk bytes alone, and {@link guardRender} tells the writer to harvest
21
- * the edit FIRST (queue it) rather than destroy it. An unharvested edit is
22
- * never lost.
23
- *
24
- * The DIFF + EXTRACT core is PURE and deps-injected (the harvest.ts pattern), so
25
- * it is unit-testable against in-memory fixtures with no FS and no DB. The watch
26
- * loop is opt-in (off by default, Article VII) and fail-safe — a read error on
27
- * one file never aborts the sweep.
28
- *
29
- * @copyright 2026 Ghost Protocol (Pvt) Ltd.
30
- * @license AGPL-3.0-or-later — dual-licensed; commercial terms: ghosts.lk@proton.me. See LICENSE.
31
- */
32
- import { existsSync, readFileSync } from 'fs';
33
- import { join } from 'path';
34
- import { RENDER_MARKER_START, RENDER_MARKER_END, hasWyrmRegion, resolveInsideRoot, } from './render-target.js';
35
- import { extractCandidates, candidateToArtifact, escapeLikePattern } from './auto-capture.js';
36
- /**
37
- * Split file content into {before, region, after} around the wyrm:render markers.
38
- * The `region` is the body BETWEEN the markers, with the single leading/trailing
39
- * newline the writer adds ({@link spliceWyrmRegion} wraps the block as
40
- * `START\n{block}\n END`) stripped, so it round-trips against the rendered block.
41
- *
42
- * Pure, total: malformed/absent markers ⇒ {hasRegion:false, before:content}.
43
- */
44
- export function splitWyrmRegion(content) {
45
- const startIdx = content.indexOf(RENDER_MARKER_START);
46
- const endIdx = content.indexOf(RENDER_MARKER_END);
47
- if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
48
- return { hasRegion: false, before: content, region: '', after: '' };
49
- }
50
- const before = content.slice(0, startIdx);
51
- let region = content.slice(startIdx + RENDER_MARKER_START.length, endIdx);
52
- const after = content.slice(endIdx + RENDER_MARKER_END.length);
53
- // spliceWyrmRegion writes `START\n{block}\n END` — peel exactly one wrapping
54
- // newline each side so `region` equals the rendered `block` for a clean file.
55
- if (region.startsWith('\n'))
56
- region = region.slice(1);
57
- if (region.endsWith('\n'))
58
- region = region.slice(0, -1);
59
- return { hasRegion: true, before, region, after };
60
- }
61
- /**
62
- * Has the Wyrm-managed region been edited since Wyrm last rendered `lastBlock`?
63
- * Compares the on-disk region body against the exact block the renderer wrote.
64
- * Whitespace-insensitive at the edges (the writer's wrapping newline), but any
65
- * real content change ⇒ true. If the file has no region, there's nothing Wyrm
66
- * owns to be edited ⇒ false.
67
- *
68
- * This is the SILENT-OVERWRITE guard's eyes: it sees a human edit purely from
69
- * the bytes, with no separate state to drift.
70
- */
71
- export function regionEditedSinceRender(content, lastBlock) {
72
- const split = splitWyrmRegion(content);
73
- if (!split.hasRegion)
74
- return false;
75
- return split.region.trim() !== lastBlock.trim();
76
- }
77
- /**
78
- * Detect harvestable edits in a file's CURRENT content, given the BLOCK Wyrm last
79
- * rendered into it (or null if Wyrm never rendered this file — then the whole
80
- * file is operator-authored prose worth harvesting once).
81
- *
82
- * - If the Wyrm region was edited (≠ lastBlock) → a 'region' edit carrying the
83
- * edited region body. This is the case the silent-overwrite guard cares about.
84
- * - Operator prose OUTSIDE the markers that is NOT part of what Wyrm rendered →
85
- * an 'outside' edit carrying that prose. (Wyrm never wrote it; it's durable
86
- * operator knowledge worth offering to memory.)
87
- *
88
- * Returns [] when nothing changed (idempotent: a freshly-rendered file yields no
89
- * edits). Pure — no FS, no DB.
90
- */
91
- export function detectEdits(path, content, lastBlock) {
92
- const edits = [];
93
- const split = splitWyrmRegion(content);
94
- if (!split.hasRegion) {
95
- // No Wyrm region. Either a non-Wyrm file the operator pointed us at, or a
96
- // file Wyrm has not rendered yet. Harvest the whole thing as operator prose.
97
- const text = content.trim();
98
- if (text.length > 0)
99
- edits.push({ path, zone: 'outside', text });
100
- return edits;
101
- }
102
- // Region present. Was the managed body edited away from what we rendered?
103
- if (lastBlock != null && split.region.trim() !== lastBlock.trim()) {
104
- edits.push({ path, zone: 'region', text: split.region.trim() });
105
- }
106
- // Operator prose around the region is always durable and worth offering — but
107
- // only the prose, not the markers. Trim and only surface non-trivial text.
108
- const outside = `${split.before}\n${split.after}`.trim();
109
- if (outside.length > 0)
110
- edits.push({ path, zone: 'outside', text: outside });
111
- return edits;
112
- }
113
- /**
114
- * Turn one detected edit into review-queue candidates via the SAME local
115
- * extractor auto_capture uses (Ollama if configured, deterministic fallback).
116
- * Never throws, never auto-trusts. Each candidate is provenance-stamped
117
- * `reverse-bridge` + its zone, and carries a `rb:`-prefixed content-derived
118
- * dedup signature so re-watching the same edit does not re-queue it.
119
- */
120
- export async function editToCandidates(edit, opts = {}) {
121
- const { candidates } = await extractCandidates(edit.text, opts);
122
- const out = [];
123
- for (const c of candidates) {
124
- const a = candidateToArtifact(c);
125
- // Replace auto-capture's `ax:` provenance with reverse-bridge provenance so
126
- // the operator can see WHERE a candidate came from, and so the dedup sig
127
- // namespaces separately from harvest/auto_capture.
128
- // The sig is stored as one TAG inside a comma-joined tag string and read
129
- // back by splitting on commas (reverseBridgeSigFromTags). So the sig itself
130
- // must be COMMA-FREE and have no leading/trailing space, or the round-trip
131
- // truncates/mismatches it and the rejection tombstone (security pass #2,
132
- // finding #1) is defeated. We therefore (1) fold commas INTO the whitespace
133
- // normalization (prose commas would otherwise survive into the sig and split
134
- // it at store/read time), and (2) trim AFTER the 64-char slice (a slice that
135
- // lands on a space would leave a TRAILING space the tag round-trip drops).
136
- // Second-round review fix: the earlier pass fixed only the trailing-space
137
- // case; a comma inside the first 64 prose chars broke the same round-trip.
138
- const sig = 'rb:' + c.text.toLowerCase().replace(/[\s,]+/g, ' ').trim().slice(0, 64).trim();
139
- const tags = a.tags.filter((t) => !t.startsWith('ax:'));
140
- tags.push('reverse-bridge', `zone:${edit.zone}`, sig);
141
- out.push({ kind: a.kind, problem: a.problem, tags, confidence: a.confidence, sig, zone: edit.zone });
142
- }
143
- return out;
144
- }
145
- /**
146
- * Harvest a set of detected edits into the review queue. Idempotent: dedup is
147
- * BOTH in-run (two files yielding the same candidate) AND against the DB
148
- * (`existsBySig`). NEVER auto-applies — every candidate lands `needs_review=1`.
149
- * Never throws on extraction; a per-edit failure is isolated.
150
- */
151
- export async function harvestEdits(deps, projectId, edits, opts = {}) {
152
- let added = 0;
153
- let skipped = 0;
154
- const sample = [];
155
- const seenThisRun = new Set();
156
- for (const edit of edits) {
157
- let candidates = [];
158
- try {
159
- candidates = await editToCandidates(edit, opts);
160
- }
161
- catch {
162
- continue; // fail-safe: a bad edit never aborts the sweep
163
- }
164
- for (const c of candidates) {
165
- if (seenThisRun.has(c.sig) || deps.existsBySig(projectId, c.sig)) {
166
- skipped++;
167
- continue;
168
- }
169
- seenThisRun.add(c.sig);
170
- if (!opts.dryRun)
171
- deps.addCandidate(projectId, c);
172
- added++;
173
- if (sample.length < 5)
174
- sample.push(`[${c.zone}] ${c.problem.slice(0, 80)}`);
175
- }
176
- }
177
- return { added, skipped, sample };
178
- }
179
- /**
180
- * The default {@link BridgeDeps} backed by SQLite. The `existsBySig` probe uses
181
- * the SAME ESCAPE-guarded LIKE the auto_capture / harvest paths use (F3 security
182
- * pass #1): the sig is content-derived, so `%`/`_`/`\` MUST be literal or a
183
- * wildcard-bearing edit broad-matches other tags and silently suppresses queueing.
184
- */
185
- export function makeBridgeDeps(db) {
186
- return {
187
- existsBySig(projectId, sig) {
188
- // A live review-queue row carrying this sig → already queued.
189
- const queued = !!db.prepare("SELECT 1 FROM memory_artifacts WHERE project_id = ? AND tags LIKE ? ESCAPE '\\' LIMIT 1").get(projectId, '%' + escapeLikePattern(sig) + '%');
190
- if (queued)
191
- return true;
192
- // A rejection tombstone for this sig → the operator already deleted this
193
- // candidate; treating it as "exists" stops the re-queue loop (finding #1).
194
- // The table is brand-new (migration 26); on an un-migrated DB the probe
195
- // throws and we fall back to "not present" — no loop-suppression, but no
196
- // crash either (the daemon swallows it as it does any sweep error).
197
- try {
198
- return !!db.prepare('SELECT 1 FROM reverse_bridge_tombstones WHERE project_id = ? AND sig = ? LIMIT 1').get(projectId, sig);
199
- }
200
- catch {
201
- return false;
202
- }
203
- },
204
- addCandidate(projectId, c) {
205
- db.prepare(`
1
+ import{existsSync as R,readFileSync as x}from"fs";import{join as g}from"path";import{RENDER_MARKER_START as h,RENDER_MARKER_END as m,hasWyrmRegion as y,resolveInsideRoot as b}from"./render-target.js";import{extractCandidates as _,candidateToArtifact as S,escapeLikePattern as T}from"./auto-capture.js";function f(r){const t=r.indexOf(h),e=r.indexOf(m);if(t===-1||e===-1||e<=t)return{hasRegion:!1,before:r,region:"",after:""};const i=r.slice(0,t);let n=r.slice(t+h.length,e);const o=r.slice(e+m.length);return n.startsWith(`
2
+ `)&&(n=n.slice(1)),n.endsWith(`
3
+ `)&&(n=n.slice(0,-1)),{hasRegion:!0,before:i,region:n,after:o}}function v(r,t){const e=f(r);return e.hasRegion?e.region.trim()!==t.trim():!1}function W(r,t,e){const i=[],n=f(t);if(!n.hasRegion){const s=t.trim();return s.length>0&&i.push({path:r,zone:"outside",text:s}),i}e!=null&&n.region.trim()!==e.trim()&&i.push({path:r,zone:"region",text:n.region.trim()});const o=`${n.before}
4
+ ${n.after}`.trim();return o.length>0&&i.push({path:r,zone:"outside",text:o}),i}async function w(r,t={}){const{candidates:e}=await _(r.text,t),i=[];for(const n of e){const o=S(n),s="rb:"+n.text.toLowerCase().replace(/[\s,]+/g," ").trim().slice(0,64).trim(),c=o.tags.filter(l=>!l.startsWith("ax:"));c.push("reverse-bridge",`zone:${r.zone}`,s),i.push({kind:o.kind,problem:o.problem,tags:c,confidence:o.confidence,sig:s,zone:r.zone})}return i}async function P(r,t,e,i={}){let n=0,o=0;const s=[],c=new Set;for(const l of e){let u=[];try{u=await w(l,i)}catch{continue}for(const a of u){if(c.has(a.sig)||r.existsBySig(t,a.sig)){o++;continue}c.add(a.sig),i.dryRun||r.addCandidate(t,a),n++,s.length<5&&s.push(`[${a.zone}] ${a.problem.slice(0,80)}`)}}return{added:n,skipped:o,sample:s}}function z(r){return{existsBySig(t,e){if(!!r.prepare("SELECT 1 FROM memory_artifacts WHERE project_id = ? AND tags LIKE ? ESCAPE '\\' LIMIT 1").get(t,"%"+T(e)+"%"))return!0;try{return!!r.prepare("SELECT 1 FROM reverse_bridge_tombstones WHERE project_id = ? AND sig = ? LIMIT 1").get(t,e)}catch{return!1}},addCandidate(t,e){r.prepare(`
206
5
  INSERT INTO memory_artifacts
207
6
  (project_id, kind, problem, tags, confidence, needs_review, created_by)
208
7
  VALUES (?, ?, ?, ?, ?, 1, 'reverse-bridge')
209
- `).run(projectId, c.kind, c.problem, c.tags.join(','), c.confidence);
210
- },
211
- };
212
- }
213
- /**
214
- * Extract the reverse-bridge dedup sig (`rb:`-prefixed, the last tag
215
- * {@link editToCandidates} appends) from a candidate's comma-joined tag string.
216
- * Returns null when the artifact did not originate from the reverse bridge.
217
- * Pure — used by the reject path to decide whether to tombstone.
218
- */
219
- export function reverseBridgeSigFromTags(tags) {
220
- if (!tags)
221
- return null;
222
- for (const t of tags.split(',')) {
223
- const tag = t.trim();
224
- if (tag.startsWith('rb:'))
225
- return tag;
226
- }
227
- return null;
228
- }
229
- /**
230
- * Record a rejection tombstone so a once-rejected reverse-bridge candidate is
231
- * never re-queued by a later sweep (security pass #2, finding #1). Idempotent
232
- * (INSERT OR IGNORE on the (project_id, sig) PK). Best-effort: on an un-migrated
233
- * DB the table is absent and the write is silently skipped — the reject itself
234
- * has already happened; the worst case is the pre-fix re-queue behaviour, never
235
- * a crash of wyrm_review.
236
- */
237
- export function recordReverseBridgeRejection(db, projectId, sig) {
238
- try {
239
- db.prepare('INSERT OR IGNORE INTO reverse_bridge_tombstones (project_id, sig) VALUES (?, ?)').run(projectId, sig);
240
- }
241
- catch {
242
- /* table absent on a pre-migration-26 DB — skip silently */
243
- }
244
- }
245
- /**
246
- * Decide whether `wyrm render` may overwrite a file's Wyrm region. This is the
247
- * NEVER-SILENT-OVERWRITE guard:
248
- *
249
- * - No on-disk file, or no Wyrm region yet → proceed (nothing to lose).
250
- * - Region byte-identical to what Wyrm last rendered → proceed (no human edit).
251
- * - Region EDITED since the last render → DO NOT proceed: return the pending
252
- * edit so the caller queues it (harvestEdits) before re-rendering. The
253
- * operator's edit is preserved as a review candidate, never destroyed.
254
- *
255
- * Pure given (currentContent, lastBlock). `currentContent=null` means the file
256
- * does not exist on disk.
257
- */
258
- export function guardRender(path, currentContent, lastBlock) {
259
- if (currentContent == null)
260
- return { proceed: true, reason: 'no existing file' };
261
- if (!hasWyrmRegion(currentContent))
262
- return { proceed: true, reason: 'no Wyrm region to overwrite' };
263
- if (lastBlock == null) {
264
- // We have a region but no record of what we rendered — treat as edited to be
265
- // safe (harvest before overwrite), never assume it is ours to clobber.
266
- const split = splitWyrmRegion(currentContent);
267
- return {
268
- proceed: false,
269
- pendingEdit: { path, zone: 'region', text: split.region.trim() },
270
- reason: 'region present but no last-render record — harvest before overwrite',
271
- };
272
- }
273
- if (!regionEditedSinceRender(currentContent, lastBlock)) {
274
- return { proceed: true, reason: 'region unchanged since last render' };
275
- }
276
- const split = splitWyrmRegion(currentContent);
277
- return {
278
- proceed: false,
279
- pendingEdit: { path, zone: 'region', text: split.region.trim() },
280
- reason: 'region edited since last render — harvest the edit before overwriting',
281
- };
282
- }
283
- // ──────────────────────────────────────────────────────────────────────────
284
- // Watch surface — which native memory files the bridge watches.
285
- // ──────────────────────────────────────────────────────────────────────────
286
- /** The native memory files the reverse bridge watches in a project root. */
287
- export const WATCHED_MEMORY_FILES = [
288
- { relPath: 'MEMORY.md' },
289
- { relPath: 'CLAUDE.md', client: 'claude' },
290
- { relPath: 'AGENTS.md', client: 'agents' },
291
- { relPath: join('.cursor', 'rules', 'wyrm-memory.md'), client: 'cursor' },
292
- { relPath: join('.github', 'copilot-instructions.md'), client: 'copilot' },
293
- ];
294
- const NODE_FS = { existsSync, readFileSync };
295
- /**
296
- * Resolve + read the watched memory files under `rootDir`. Every path is
297
- * validated to stay INSIDE rootDir (Article VII — a watcher must never escape
298
- * its target dir), and a read error on one file degrades to {exists:false} so
299
- * the sweep is fail-safe.
300
- */
301
- export function scanWatchedFiles(rootDir, fs = NODE_FS) {
302
- const out = [];
303
- for (const { relPath } of WATCHED_MEMORY_FILES) {
304
- let absPath;
305
- try {
306
- absPath = resolveInsideRoot(rootDir, relPath); // throws on escape
307
- }
308
- catch {
309
- continue; // never follow an escaping path
310
- }
311
- let exists = false;
312
- let content = null;
313
- try {
314
- exists = fs.existsSync(absPath);
315
- content = exists ? fs.readFileSync(absPath, 'utf-8') : null;
316
- }
317
- catch {
318
- exists = false;
319
- content = null; // fail-safe: an unreadable file is simply skipped
320
- }
321
- out.push({ relPath, absPath, exists, content });
322
- }
323
- return out;
324
- }
325
- /**
326
- * Sweep a project's watched memory files once: detect edits against the
327
- * last-rendered blocks, and queue them as review candidates. Returns a report.
328
- * Fail-safe end-to-end; never auto-applies; never overwrites a file (read-only).
329
- */
330
- export async function sweepProject(deps, project, lastBlocks, opts = {}) {
331
- const root = opts.rootDir ?? project.path;
332
- const files = scanWatchedFiles(root, opts.fs);
333
- const allEdits = [];
334
- let filesWithEdits = 0;
335
- for (const f of files) {
336
- if (!f.exists || f.content == null)
337
- continue;
338
- const last = Object.prototype.hasOwnProperty.call(lastBlocks, f.relPath) ? lastBlocks[f.relPath] : null;
339
- const edits = detectEdits(f.absPath, f.content, last);
340
- if (edits.length > 0)
341
- filesWithEdits++;
342
- allEdits.push(...edits);
343
- }
344
- const { added, skipped, sample } = await harvestEdits(deps, project.id, allEdits, opts);
345
- return { filesScanned: files.length, filesWithEdits, added, skipped, sample };
346
- }
347
- // ──────────────────────────────────────────────────────────────────────────
348
- // Watch gate — opt-in, off by default (Article VII).
349
- // ──────────────────────────────────────────────────────────────────────────
350
- /**
351
- * Whether the reverse-bridge watcher is enabled. Off by default; the operator
352
- * opts in with WYRM_REVERSE_BRIDGE=1 (or the shared WYRM_RENDER_WATCH=1, since
353
- * the render daemon and reverse bridge are the two halves of one watch loop).
354
- * Read fresh (never cached) so tests/daemon see live config.
355
- */
356
- export function reverseBridgeEnabled(env = process.env) {
357
- const direct = (env.WYRM_REVERSE_BRIDGE ?? '').toLowerCase();
358
- if (direct === '1' || direct === 'true' || direct === 'yes')
359
- return true;
360
- const shared = (env.WYRM_RENDER_WATCH ?? '').toLowerCase();
361
- return shared === '1' || shared === 'true' || shared === 'yes';
362
- }
363
- //# sourceMappingURL=reverse-bridge.js.map
8
+ `).run(t,e.kind,e.problem,e.tags.join(","),e.confidence)}}}function C(r){if(!r)return null;for(const t of r.split(",")){const e=t.trim();if(e.startsWith("rb:"))return e}return null}function F(r,t,e){try{r.prepare("INSERT OR IGNORE INTO reverse_bridge_tombstones (project_id, sig) VALUES (?, ?)").run(t,e)}catch{}}function j(r,t,e){if(t==null)return{proceed:!0,reason:"no existing file"};if(!y(t))return{proceed:!0,reason:"no Wyrm region to overwrite"};if(e==null){const n=f(t);return{proceed:!1,pendingEdit:{path:r,zone:"region",text:n.region.trim()},reason:"region present but no last-render record \u2014 harvest before overwrite"}}if(!v(t,e))return{proceed:!0,reason:"region unchanged since last render"};const i=f(t);return{proceed:!1,pendingEdit:{path:r,zone:"region",text:i.region.trim()},reason:"region edited since last render \u2014 harvest the edit before overwriting"}}const A=[{relPath:"MEMORY.md"},{relPath:"CLAUDE.md",client:"claude"},{relPath:"AGENTS.md",client:"agents"},{relPath:g(".cursor","rules","wyrm-memory.md"),client:"cursor"},{relPath:g(".github","copilot-instructions.md"),client:"copilot"}],I={existsSync:R,readFileSync:x};function L(r,t=I){const e=[];for(const{relPath:i}of A){let n;try{n=b(r,i)}catch{continue}let o=!1,s=null;try{o=t.existsSync(n),s=o?t.readFileSync(n,"utf-8"):null}catch{o=!1,s=null}e.push({relPath:i,absPath:n,exists:o,content:s})}return e}async function k(r,t,e,i={}){const n=i.rootDir??t.path,o=L(n,i.fs),s=[];let c=0;for(const d of o){if(!d.exists||d.content==null)continue;const E=Object.prototype.hasOwnProperty.call(e,d.relPath)?e[d.relPath]:null,p=W(d.absPath,d.content,E);p.length>0&&c++,s.push(...p)}const{added:l,skipped:u,sample:a}=await P(r,t.id,s,i);return{filesScanned:o.length,filesWithEdits:c,added:l,skipped:u,sample:a}}function $(r=process.env){const t=(r.WYRM_REVERSE_BRIDGE??"").toLowerCase();if(t==="1"||t==="true"||t==="yes")return!0;const e=(r.WYRM_RENDER_WATCH??"").toLowerCase();return e==="1"||e==="true"||e==="yes"}export{A as WATCHED_MEMORY_FILES,W as detectEdits,w as editToCandidates,j as guardRender,P as harvestEdits,z as makeBridgeDeps,F as recordReverseBridgeRejection,v as regionEditedSinceRender,$ as reverseBridgeEnabled,C as reverseBridgeSigFromTags,L as scanWatchedFiles,f as splitWyrmRegion,k as sweepProject};
package/dist/security.js CHANGED
@@ -1,244 +1 @@
1
- /**
2
- * Wyrm Security Module - Input validation and path security
3
- *
4
- * @copyright 2026 Ghost Protocol (Pvt) Ltd.
5
- * @license AGPL-3.0-or-later — dual-licensed; commercial terms: ghosts.lk@proton.me. See LICENSE.
6
- * @module security
7
- * @version 3.0.0
8
- */
9
- import { resolve, relative, normalize, sep } from 'path';
10
- import { existsSync, statSync } from 'fs';
11
- import { homedir } from 'os';
12
- import { createHash } from 'crypto';
13
- // ==================== PATH SECURITY ====================
14
- /**
15
- * Validate and sanitize a path to prevent traversal attacks
16
- */
17
- export function validatePath(basePath, targetPath) {
18
- const normalizedBase = normalize(resolve(basePath));
19
- const normalizedTarget = normalize(resolve(basePath, targetPath));
20
- // Check if target is within base
21
- const rel = relative(normalizedBase, normalizedTarget);
22
- if (rel.startsWith('..') || rel.startsWith(sep)) {
23
- throw new SecurityError('Path traversal detected', 'PATH_TRAVERSAL');
24
- }
25
- // Double-check resolved path
26
- if (!normalizedTarget.startsWith(normalizedBase)) {
27
- throw new SecurityError('Path traversal detected', 'PATH_TRAVERSAL');
28
- }
29
- return normalizedTarget;
30
- }
31
- /**
32
- * Check if a path is a valid directory
33
- */
34
- export function validateDirectory(path) {
35
- try {
36
- return existsSync(path) && statSync(path).isDirectory();
37
- }
38
- catch {
39
- return false;
40
- }
41
- }
42
- /**
43
- * Validate project path is within allowed directories
44
- */
45
- export function validateProjectPath(projectPath) {
46
- const normalizedPath = normalize(resolve(projectPath));
47
- // List of allowed root directories
48
- const allowedRoots = [
49
- normalize(resolve(homedir(), 'Git Projects')),
50
- normalize(resolve(homedir(), 'Projects')),
51
- normalize(resolve(homedir(), 'dev')),
52
- normalize(resolve(homedir(), 'code')),
53
- normalize(resolve(homedir(), 'repos')),
54
- ];
55
- // Add from environment
56
- if (process.env.WYRM_ALLOWED_PATHS) {
57
- const envPaths = process.env.WYRM_ALLOWED_PATHS.split(':');
58
- for (const p of envPaths) {
59
- allowedRoots.push(normalize(resolve(p)));
60
- }
61
- }
62
- // Check if path is within an allowed root
63
- const isAllowed = allowedRoots.some(root => normalizedPath === root || normalizedPath.startsWith(root + sep));
64
- if (!isAllowed) {
65
- throw new SecurityError('Project path not in allowed directories', 'INVALID_PROJECT_PATH');
66
- }
67
- if (!validateDirectory(normalizedPath)) {
68
- throw new SecurityError('Path is not a valid directory', 'INVALID_DIRECTORY');
69
- }
70
- return normalizedPath;
71
- }
72
- // ==================== INPUT VALIDATION ====================
73
- /**
74
- * Sanitize FTS5 search query to prevent injection
75
- */
76
- export function sanitizeFtsQuery(query) {
77
- if (!query || typeof query !== 'string') {
78
- throw new SecurityError('Invalid search query', 'INVALID_INPUT');
79
- }
80
- // Remove FTS5 special syntax characters
81
- // Allowed: alphanumeric, spaces, common punctuation
82
- const sanitized = query
83
- // eslint-disable-next-line no-control-regex
84
- .replace(/[\x00-\x1f\x7f]/g, ' ') // Strip control chars FIRST: a NUL (\x00) survives
85
- // otherwise and makes FTS5 MATCH throw "unterminated string".
86
- .replace(/[*"():^{}[\]\\.@#$%&!?<>~`|;,]/g, ' ') // Remove FTS operators and special chars
87
- .replace(/\s+/g, ' ') // Normalize whitespace
88
- .trim()
89
- .slice(0, 500); // Limit length
90
- if (!sanitized) {
91
- throw new SecurityError('Search query is empty after sanitization', 'INVALID_INPUT');
92
- }
93
- return sanitized;
94
- }
95
- /**
96
- * Build an FTS5 MATCH expression from a sanitized query string.
97
- *
98
- * The naive `MATCH 'a b c'` is an implicit AND — every term must be present, so
99
- * one unfamiliar word kills the whole query (recall falls off a cliff on
100
- * paraphrase). Instead we OR the terms: any shared word surfaces the row, and
101
- * the caller orders by `bm25()` so the BEST match comes first. Combined with the
102
- * porter tokenizer (migration v16), `override`/`overrides`/`overriding` all match.
103
- *
104
- * Each term is quoted so it's a single FTS token (the input is already sanitized,
105
- * so no operator injection). Returns '' for an empty query (caller returns []).
106
- */
107
- export function buildFtsMatchQuery(sanitized) {
108
- // Defensive: strip control chars (esp. NUL) here too, so a caller that passes
109
- // a RAW query (not run through sanitizeFtsQuery first) can never produce an
110
- // FTS5 token that throws "unterminated string". Harmless on already-sanitized
111
- // input. eslint-disable-next-line no-control-regex
112
- const terms = sanitized
113
- // eslint-disable-next-line no-control-regex
114
- .replace(/[\x00-\x1f\x7f]/g, ' ')
115
- .split(/\s+/)
116
- .map((t) => t.replace(/"/g, '').trim())
117
- .filter(Boolean);
118
- if (terms.length === 0)
119
- return '';
120
- return terms.map((t) => `"${t}"`).join(' OR ');
121
- }
122
- /**
123
- * Validate and sanitize string input
124
- */
125
- export function sanitizeString(input, maxLength = 10000) {
126
- if (input === null || input === undefined) {
127
- return '';
128
- }
129
- if (typeof input !== 'string') {
130
- throw new SecurityError('Expected string input', 'INVALID_TYPE');
131
- }
132
- // Truncate if too long
133
- return input.slice(0, maxLength);
134
- }
135
- /**
136
- * Validate integer input
137
- */
138
- export function validateInt(input, min = 0, max = Number.MAX_SAFE_INTEGER) {
139
- const num = Number(input);
140
- if (!Number.isInteger(num)) {
141
- throw new SecurityError('Expected integer input', 'INVALID_TYPE');
142
- }
143
- if (num < min || num > max) {
144
- throw new SecurityError(`Integer out of range [${min}, ${max}]`, 'OUT_OF_RANGE');
145
- }
146
- return num;
147
- }
148
- /**
149
- * Validate enum value
150
- */
151
- export function validateEnum(input, allowed, defaultValue) {
152
- if (input === undefined && defaultValue !== undefined) {
153
- return defaultValue;
154
- }
155
- if (typeof input !== 'string' || !allowed.includes(input)) {
156
- throw new SecurityError(`Invalid value. Allowed: ${allowed.join(', ')}`, 'INVALID_ENUM');
157
- }
158
- return input;
159
- }
160
- // ==================== HTTP SECURITY ====================
161
- /**
162
- * Validate API key authentication
163
- */
164
- export function validateApiKey(authHeader, expectedHash) {
165
- if (!authHeader || !authHeader.startsWith('Bearer ')) {
166
- return false;
167
- }
168
- const token = authHeader.slice(7);
169
- if (!token || token.length < 32) {
170
- return false;
171
- }
172
- // Constant-time comparison
173
- return constantTimeCompare(hashToken(token), expectedHash);
174
- }
175
- /**
176
- * Hash a token for storage/comparison
177
- */
178
- export function hashToken(token) {
179
- return createHash('sha256').update(token).digest('hex');
180
- }
181
- /**
182
- * Constant-time string comparison to prevent timing attacks
183
- */
184
- export function constantTimeCompare(a, b) {
185
- if (a.length !== b.length) {
186
- return false;
187
- }
188
- let result = 0;
189
- for (let i = 0; i < a.length; i++) {
190
- result |= a.charCodeAt(i) ^ b.charCodeAt(i);
191
- }
192
- return result === 0;
193
- }
194
- const rateLimitStore = new Map();
195
- /**
196
- * Check rate limit for a given key (IP, API key, etc.)
197
- */
198
- export function checkRateLimit(key, maxRequests = 100, windowMs = 60000) {
199
- const now = Date.now();
200
- let entry = rateLimitStore.get(key);
201
- // Clean up expired entries periodically
202
- if (rateLimitStore.size > 10000) {
203
- for (const [k, v] of rateLimitStore) {
204
- if (v.resetAt < now) {
205
- rateLimitStore.delete(k);
206
- }
207
- }
208
- }
209
- if (!entry || entry.resetAt < now) {
210
- entry = { count: 0, resetAt: now + windowMs };
211
- rateLimitStore.set(key, entry);
212
- }
213
- entry.count++;
214
- return {
215
- allowed: entry.count <= maxRequests,
216
- remaining: Math.max(0, maxRequests - entry.count),
217
- resetAt: entry.resetAt
218
- };
219
- }
220
- // ==================== SECURITY ERROR ====================
221
- export class SecurityError extends Error {
222
- code;
223
- constructor(message, code) {
224
- super(message);
225
- this.name = 'SecurityError';
226
- this.code = code;
227
- }
228
- }
229
- // ==================== REQUEST VALIDATION ====================
230
- export const MAX_REQUEST_SIZE = 1024 * 1024; // 1MB
231
- export const MAX_BATCH_SIZE = 1000;
232
- export const MAX_QUERY_RESULTS = 1000;
233
- /**
234
- * Validate batch operation size
235
- */
236
- export function validateBatchSize(items) {
237
- if (!Array.isArray(items)) {
238
- throw new SecurityError('Expected array for batch operation', 'INVALID_TYPE');
239
- }
240
- if (items.length > MAX_BATCH_SIZE) {
241
- throw new SecurityError(`Batch size ${items.length} exceeds maximum of ${MAX_BATCH_SIZE}`, 'BATCH_TOO_LARGE');
242
- }
243
- }
244
- //# sourceMappingURL=security.js.map
1
+ import{resolve as s,relative as p,normalize as a,sep as u}from"path";import{existsSync as A,statSync as w}from"fs";import{homedir as c}from"os";import{createHash as m}from"crypto";function v(t,e){const r=a(s(t)),n=a(s(t,e)),o=p(r,n);if(o.startsWith("..")||o.startsWith(u))throw new i("Path traversal detected","PATH_TRAVERSAL");if(!n.startsWith(r))throw new i("Path traversal detected","PATH_TRAVERSAL");return n}function x(t){try{return A(t)&&w(t).isDirectory()}catch{return!1}}function y(t){const e=a(s(t)),r=[a(s(c(),"Git Projects")),a(s(c(),"Projects")),a(s(c(),"dev")),a(s(c(),"code")),a(s(c(),"repos"))];if(process.env.WYRM_ALLOWED_PATHS){const o=process.env.WYRM_ALLOWED_PATHS.split(":");for(const l of o)r.push(a(s(l)))}if(!r.some(o=>e===o||e.startsWith(o+u)))throw new i("Project path not in allowed directories","INVALID_PROJECT_PATH");if(!x(e))throw new i("Path is not a valid directory","INVALID_DIRECTORY");return e}function L(t){if(!t||typeof t!="string")throw new i("Invalid search query","INVALID_INPUT");const e=t.replace(/[\x00-\x1f\x7f]/g," ").replace(/[*"():^{}[\]\\.@#$%&!?<>~`|;,]/g," ").replace(/\s+/g," ").trim().slice(0,500);if(!e)throw new i("Search query is empty after sanitization","INVALID_INPUT");return e}function S(t){const e=t.replace(/[\x00-\x1f\x7f]/g," ").split(/\s+/).map(r=>r.replace(/"/g,"").trim()).filter(Boolean);return e.length===0?"":e.map(r=>`"${r}"`).join(" OR ")}function R(t,e=1e4){if(t==null)return"";if(typeof t!="string")throw new i("Expected string input","INVALID_TYPE");return t.slice(0,e)}function N(t,e=0,r=Number.MAX_SAFE_INTEGER){const n=Number(t);if(!Number.isInteger(n))throw new i("Expected integer input","INVALID_TYPE");if(n<e||n>r)throw new i(`Integer out of range [${e}, ${r}]`,"OUT_OF_RANGE");return n}function D(t,e,r){if(t===void 0&&r!==void 0)return r;if(typeof t!="string"||!e.includes(t))throw new i(`Invalid value. Allowed: ${e.join(", ")}`,"INVALID_ENUM");return t}function z(t,e){if(!t||!t.startsWith("Bearer "))return!1;const r=t.slice(7);return!r||r.length<32?!1:I(E(r),e)}function E(t){return m("sha256").update(t).digest("hex")}function I(t,e){if(t.length!==e.length)return!1;let r=0;for(let n=0;n<t.length;n++)r|=t.charCodeAt(n)^e.charCodeAt(n);return r===0}const f=new Map;function M(t,e=100,r=6e4){const n=Date.now();let o=f.get(t);if(f.size>1e4)for(const[l,d]of f)d.resetAt<n&&f.delete(l);return(!o||o.resetAt<n)&&(o={count:0,resetAt:n+r},f.set(t,o)),o.count++,{allowed:o.count<=e,remaining:Math.max(0,e-o.count),resetAt:o.resetAt}}class i extends Error{code;constructor(e,r){super(e),this.name="SecurityError",this.code=r}}const O=1024*1024,h=1e3,V=1e3;function W(t){if(!Array.isArray(t))throw new i("Expected array for batch operation","INVALID_TYPE");if(t.length>h)throw new i(`Batch size ${t.length} exceeds maximum of ${h}`,"BATCH_TOO_LARGE")}export{h as MAX_BATCH_SIZE,V as MAX_QUERY_RESULTS,O as MAX_REQUEST_SIZE,i as SecurityError,S as buildFtsMatchQuery,M as checkRateLimit,I as constantTimeCompare,E as hashToken,L as sanitizeFtsQuery,R as sanitizeString,z as validateApiKey,W as validateBatchSize,x as validateDirectory,D as validateEnum,N as validateInt,v as validatePath,y as validateProjectPath};