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.
- package/LICENSE +26 -667
- package/NOTICE +14 -33
- package/dist/activation.d.ts.map +1 -1
- package/dist/activation.js +1 -44
- package/dist/activation.js.map +1 -1
- package/dist/agent-daemon.js +4 -281
- package/dist/agent-loop.js +7 -332
- package/dist/analytics.js +13 -236
- package/dist/attribution.js +1 -49
- package/dist/audit.js +2 -457
- package/dist/auto-capture.js +3 -138
- package/dist/auto-orchestrator.js +1 -325
- package/dist/autoconfig.js +39 -840
- package/dist/buddy-runner.js +1 -109
- package/dist/buddy.js +14 -564
- package/dist/build-flags.js +1 -17
- package/dist/capabilities.js +3 -183
- package/dist/capture.js +1 -56
- package/dist/causality.js +6 -107
- package/dist/cli.js +20 -281
- package/dist/cloud/cli.js +5 -541
- package/dist/cloud/client.js +1 -221
- package/dist/cloud/crypto.js +1 -85
- package/dist/cloud/machine-id.js +2 -113
- package/dist/cloud/recovery.js +1 -60
- package/dist/cloud/sync-engine.js +7 -543
- package/dist/cloud-backup.js +5 -579
- package/dist/cloud-profile.js +1 -138
- package/dist/cloud-sync-entrypoint.js +1 -47
- package/dist/cloud-sync.js +2 -309
- package/dist/constellation.js +12 -168
- package/dist/context-build-budgeted.js +4 -144
- package/dist/context-ranking.js +1 -69
- package/dist/crypto.js +1 -179
- package/dist/daemon-write-endpoint.js +1 -290
- package/dist/daemon-writer.js +2 -406
- package/dist/database.js +43 -1110
- package/dist/deprecations.js +2 -162
- package/dist/design.js +13 -141
- package/dist/event-replication.js +1 -112
- package/dist/events-sse.js +7 -43
- package/dist/events.js +6 -238
- package/dist/failure-patterns.js +42 -659
- package/dist/federation.js +12 -236
- package/dist/goals.js +13 -101
- package/dist/golden.js +3 -355
- package/dist/handlers/agent.js +4 -165
- package/dist/handlers/alias-adapters.js +1 -129
- package/dist/handlers/aliases.js +1 -171
- package/dist/handlers/audit.js +1 -87
- package/dist/handlers/boundary.js +1 -221
- package/dist/handlers/capture.js +73 -1109
- package/dist/handlers/causality.js +7 -114
- package/dist/handlers/cloud.js +85 -382
- package/dist/handlers/companion.js +28 -459
- package/dist/handlers/datalake.js +7 -187
- package/dist/handlers/dispatch-context.js +0 -22
- package/dist/handlers/entity.js +25 -256
- package/dist/handlers/events.js +16 -335
- package/dist/handlers/failure.js +13 -340
- package/dist/handlers/goals.js +4 -296
- package/dist/handlers/intelligence.js +126 -674
- package/dist/handlers/invoicing.js +1 -70
- package/dist/handlers/mcpclient.js +6 -137
- package/dist/handlers/orchestration.js +40 -125
- package/dist/handlers/output-schemas.js +1 -24
- package/dist/handlers/presence.js +3 -99
- package/dist/handlers/project.js +28 -182
- package/dist/handlers/prompts.js +6 -157
- package/dist/handlers/quest.js +4 -224
- package/dist/handlers/recall.js +11 -218
- package/dist/handlers/registry.js +1 -167
- package/dist/handlers/resources.js +1 -288
- package/dist/handlers/review.js +11 -74
- package/dist/handlers/run.js +17 -487
- package/dist/handlers/search.js +15 -326
- package/dist/handlers/session.js +28 -615
- package/dist/handlers/share.js +8 -184
- package/dist/handlers/shims.js +1 -464
- package/dist/handlers/skill.js +67 -449
- package/dist/handlers/survivors.js +1 -120
- package/dist/handlers/symbols.js +8 -109
- package/dist/handlers/syncops.js +4 -302
- package/dist/handlers/types.js +1 -27
- package/dist/harvest.js +5 -191
- package/dist/hours.js +7 -156
- package/dist/http-auth.js +3 -321
- package/dist/http-fast.js +21 -1137
- package/dist/icons.js +1 -47
- package/dist/index.js +2 -924
- package/dist/indexer.js +4 -145
- package/dist/intelligence.js +31 -261
- package/dist/internal-dispatch.js +3 -212
- package/dist/keyset.js +1 -110
- package/dist/knowledge-graph.js +12 -176
- package/dist/license.d.ts +11 -0
- package/dist/license.d.ts.map +1 -1
- package/dist/license.js +2 -414
- package/dist/license.js.map +1 -1
- package/dist/logger.js +2 -199
- package/dist/maintenance.js +2 -148
- package/dist/mcp-client.js +6 -262
- package/dist/memory-artifacts.js +30 -449
- package/dist/migrate-prompt.js +2 -124
- package/dist/migrations.js +40 -655
- package/dist/performance.js +1 -228
- package/dist/presence.js +11 -140
- package/dist/priority-embed.js +5 -164
- package/dist/providers/embedding-provider.js +1 -196
- package/dist/readonly-gate.js +1 -29
- package/dist/rehydration.js +9 -157
- package/dist/reindex.js +1 -88
- package/dist/render-target.js +21 -514
- package/dist/render.js +4 -280
- package/dist/repl-guard.js +1 -173
- package/dist/replication-daemon-entrypoint.js +1 -31
- package/dist/replication-daemon.js +2 -262
- package/dist/resilience.js +1 -591
- package/dist/reverse-bridge.js +5 -360
- package/dist/security.js +1 -244
- package/dist/session-seen.js +3 -51
- package/dist/setup.js +1 -260
- package/dist/skill-author.js +5 -168
- package/dist/spec-kit.js +1 -191
- package/dist/sqlite-busy.js +1 -154
- package/dist/statusline.js +11 -315
- package/dist/sub-agent.js +13 -262
- package/dist/summarizer.js +13 -139
- package/dist/symbols.js +7 -283
- package/dist/sync.js +5 -359
- package/dist/tasks-dispatch.js +1 -84
- package/dist/tasks.js +1 -282
- package/dist/token-budget.js +1 -143
- package/dist/tool-analytics.js +7 -129
- package/dist/tool-annotations.js +1 -365
- package/dist/tool-manifest-v2.json +1 -1
- package/dist/tool-manifest.json +1 -1
- package/dist/tool-profiles.js +1 -75
- package/dist/trace-harvest.js +6 -244
- package/dist/types.js +1 -30
- package/dist/ui-dashboard.js +41 -50
- package/dist/ulid.js +1 -81
- package/dist/validate.js +1 -129
- package/dist/vault.js +1 -534
- package/dist/vectors.js +3 -184
- package/dist/version-check.js +4 -136
- package/dist/visibility.js +19 -155
- package/dist/wyrm-cli.js +98 -2451
- package/dist/wyrm-cli.js.map +1 -1
- package/dist/wyrm-guard.js +14 -424
- package/dist/wyrm-loop.js +3 -150
- package/dist/wyrm-manifest.json +1 -1
- package/dist/wyrm-statusline-daemon.js +1 -11
- package/dist/wyrm-statusline.js +4 -56
- package/dist/wyrm-ui.js +9 -77
- package/package.json +4 -2
package/dist/reverse-bridge.js
CHANGED
|
@@ -1,363 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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(
|
|
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};
|