wyrm-mcp 7.2.1 → 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 (150) hide show
  1. package/LICENSE +26 -667
  2. package/NOTICE +14 -33
  3. package/dist/activation.js +1 -60
  4. package/dist/agent-daemon.js +4 -281
  5. package/dist/agent-loop.js +7 -332
  6. package/dist/analytics.js +13 -236
  7. package/dist/attribution.js +1 -49
  8. package/dist/audit.js +2 -457
  9. package/dist/auto-capture.js +3 -138
  10. package/dist/auto-orchestrator.js +1 -325
  11. package/dist/autoconfig.js +39 -840
  12. package/dist/buddy-runner.js +1 -109
  13. package/dist/buddy.js +14 -564
  14. package/dist/build-flags.js +1 -17
  15. package/dist/capabilities.js +3 -183
  16. package/dist/capture.js +1 -56
  17. package/dist/causality.js +6 -107
  18. package/dist/cli.js +20 -281
  19. package/dist/cloud/cli.js +5 -541
  20. package/dist/cloud/client.js +1 -221
  21. package/dist/cloud/crypto.js +1 -85
  22. package/dist/cloud/machine-id.js +2 -113
  23. package/dist/cloud/recovery.js +1 -60
  24. package/dist/cloud/sync-engine.js +7 -543
  25. package/dist/cloud-backup.js +5 -579
  26. package/dist/cloud-profile.js +1 -138
  27. package/dist/cloud-sync-entrypoint.js +1 -47
  28. package/dist/cloud-sync.js +2 -309
  29. package/dist/constellation.js +12 -168
  30. package/dist/context-build-budgeted.js +4 -144
  31. package/dist/context-ranking.js +1 -69
  32. package/dist/crypto.js +1 -179
  33. package/dist/daemon-write-endpoint.js +1 -290
  34. package/dist/daemon-writer.js +2 -406
  35. package/dist/database.js +43 -1110
  36. package/dist/deprecations.js +2 -162
  37. package/dist/design.js +13 -141
  38. package/dist/event-replication.js +1 -112
  39. package/dist/events-sse.js +7 -43
  40. package/dist/events.js +6 -238
  41. package/dist/failure-patterns.js +42 -659
  42. package/dist/federation.js +12 -236
  43. package/dist/goals.js +13 -101
  44. package/dist/golden.js +3 -355
  45. package/dist/handlers/agent.js +4 -165
  46. package/dist/handlers/alias-adapters.js +1 -129
  47. package/dist/handlers/aliases.js +1 -171
  48. package/dist/handlers/audit.js +1 -87
  49. package/dist/handlers/boundary.js +1 -221
  50. package/dist/handlers/capture.js +73 -1109
  51. package/dist/handlers/causality.js +7 -114
  52. package/dist/handlers/cloud.js +85 -382
  53. package/dist/handlers/companion.js +28 -459
  54. package/dist/handlers/datalake.js +7 -187
  55. package/dist/handlers/dispatch-context.js +0 -22
  56. package/dist/handlers/entity.js +25 -256
  57. package/dist/handlers/events.js +16 -335
  58. package/dist/handlers/failure.js +13 -340
  59. package/dist/handlers/goals.js +4 -296
  60. package/dist/handlers/intelligence.js +126 -674
  61. package/dist/handlers/invoicing.js +1 -70
  62. package/dist/handlers/mcpclient.js +6 -137
  63. package/dist/handlers/orchestration.js +40 -125
  64. package/dist/handlers/output-schemas.js +1 -24
  65. package/dist/handlers/presence.js +3 -99
  66. package/dist/handlers/project.js +28 -182
  67. package/dist/handlers/prompts.js +6 -157
  68. package/dist/handlers/quest.js +4 -224
  69. package/dist/handlers/recall.js +11 -218
  70. package/dist/handlers/registry.js +1 -167
  71. package/dist/handlers/resources.js +1 -288
  72. package/dist/handlers/review.js +11 -74
  73. package/dist/handlers/run.js +17 -487
  74. package/dist/handlers/search.js +15 -326
  75. package/dist/handlers/session.js +28 -615
  76. package/dist/handlers/share.js +8 -184
  77. package/dist/handlers/shims.js +1 -464
  78. package/dist/handlers/skill.js +67 -449
  79. package/dist/handlers/survivors.js +1 -120
  80. package/dist/handlers/symbols.js +8 -109
  81. package/dist/handlers/syncops.js +4 -302
  82. package/dist/handlers/types.js +1 -27
  83. package/dist/harvest.js +5 -191
  84. package/dist/hours.js +7 -156
  85. package/dist/http-auth.js +3 -321
  86. package/dist/http-fast.js +21 -1137
  87. package/dist/icons.js +1 -47
  88. package/dist/index.js +2 -924
  89. package/dist/indexer.js +4 -145
  90. package/dist/intelligence.js +31 -261
  91. package/dist/internal-dispatch.js +3 -212
  92. package/dist/keyset.js +1 -110
  93. package/dist/knowledge-graph.js +12 -176
  94. package/dist/license.js +2 -441
  95. package/dist/logger.js +2 -199
  96. package/dist/maintenance.js +2 -148
  97. package/dist/mcp-client.js +6 -262
  98. package/dist/memory-artifacts.js +30 -449
  99. package/dist/migrate-prompt.js +2 -124
  100. package/dist/migrations.js +40 -655
  101. package/dist/performance.js +1 -228
  102. package/dist/presence.js +11 -140
  103. package/dist/priority-embed.js +5 -164
  104. package/dist/providers/embedding-provider.js +1 -196
  105. package/dist/readonly-gate.js +1 -29
  106. package/dist/rehydration.js +9 -157
  107. package/dist/reindex.js +1 -88
  108. package/dist/render-target.js +21 -514
  109. package/dist/render.js +4 -280
  110. package/dist/repl-guard.js +1 -173
  111. package/dist/replication-daemon-entrypoint.js +1 -31
  112. package/dist/replication-daemon.js +2 -262
  113. package/dist/resilience.js +1 -591
  114. package/dist/reverse-bridge.js +5 -360
  115. package/dist/security.js +1 -244
  116. package/dist/session-seen.js +3 -51
  117. package/dist/setup.js +1 -260
  118. package/dist/skill-author.js +5 -168
  119. package/dist/spec-kit.js +1 -191
  120. package/dist/sqlite-busy.js +1 -154
  121. package/dist/statusline.js +11 -315
  122. package/dist/sub-agent.js +13 -262
  123. package/dist/summarizer.js +13 -139
  124. package/dist/symbols.js +7 -283
  125. package/dist/sync.js +5 -359
  126. package/dist/tasks-dispatch.js +1 -84
  127. package/dist/tasks.js +1 -282
  128. package/dist/token-budget.js +1 -143
  129. package/dist/tool-analytics.js +7 -129
  130. package/dist/tool-annotations.js +1 -365
  131. package/dist/tool-manifest-v2.json +1 -1
  132. package/dist/tool-manifest.json +1 -1
  133. package/dist/tool-profiles.js +1 -75
  134. package/dist/trace-harvest.js +6 -244
  135. package/dist/types.js +1 -30
  136. package/dist/ui-dashboard.js +41 -50
  137. package/dist/ulid.js +1 -81
  138. package/dist/validate.js +1 -129
  139. package/dist/vault.js +1 -534
  140. package/dist/vectors.js +3 -184
  141. package/dist/version-check.js +4 -136
  142. package/dist/visibility.js +19 -155
  143. package/dist/wyrm-cli.js +98 -2464
  144. package/dist/wyrm-guard.js +14 -424
  145. package/dist/wyrm-loop.js +3 -150
  146. package/dist/wyrm-manifest.json +1 -1
  147. package/dist/wyrm-statusline-daemon.js +1 -11
  148. package/dist/wyrm-statusline.js +4 -56
  149. package/dist/wyrm-ui.js +9 -77
  150. package/package.json +4 -2
package/dist/render.js CHANGED
@@ -1,280 +1,4 @@
1
- /**
2
- * The single dual-emit renderer (v7 F3, T019 — spec FR-3).
3
- *
4
- * MCP responses ride two channels: `content[0].text` (the text wire every
5
- * 6.x client reads) and `structuredContent` (the machine wire, MCP
6
- * 2025-06-18). Before T019 each surface hand-built both and kept them in
7
- * sync by discipline — the WYRM_BUSY body (sqlite-busy.ts, T011) and the
8
- * failure_check verdict (T014) were the precedents. This module makes drift
9
- * IMPOSSIBLE BY CONSTRUCTION: the structured body is the ONLY input, and the
10
- * text is DERIVED from it by a pure template `(body, glyphs) => string` that
11
- * sees nothing else — there is no second source of truth to drift.
12
- *
13
- * Glyph policy (spec FR-3): plain ASCII by default — fleet subagents, CI
14
- * logs, and pipe consumers must never need a Nerd Font — with the Ghost
15
- * Protocol brand glyphs opt-in behind WYRM_FANCY=1. Toggling WYRM_FANCY
16
- * changes ONLY the text channel; `structuredContent` is byte-identical in
17
- * both modes (locked by tests/render-contract.test.ts).
18
- *
19
- * Rollout (spec FR-3, version-pinned): ToolSpec-resident domains render
20
- * through here from T019 on (goals first); switch-resident tools keep their
21
- * 6.x prose verbatim until their domain extracts (T026 hot paths, T036 full
22
- * drain) — the renderer is the path forward, not a big-bang rewrite.
23
- *
24
- * Article III: zero LLM, zero network, zero clock — rendering is a pure
25
- * function of the body and the WYRM_FANCY env read.
26
- *
27
- * @copyright 2026 Ghost Protocol (Pvt) Ltd.
28
- * @license AGPL-3.0-or-later — dual-licensed; commercial terms: ghosts.lk@proton.me. See LICENSE.
29
- */
30
- import { ICON } from './icons.js';
31
- // ── Glyph sets ──────────────────────────────────────────────────────────────
32
- /** True when the operator opted into brand glyphs (WYRM_FANCY=1|true). Read
33
- * per render call (not cached at module load) so one long-lived server obeys
34
- * a harness-provided env and tests can toggle it deterministically. */
35
- export function isFancy() {
36
- const v = process.env.WYRM_FANCY;
37
- return v === '1' || v === 'true';
38
- }
39
- /** Plain ASCII (the default): decoration collapses to nothing, the prose
40
- * carries the semantics, lists keep a '-' bullet. */
41
- const ASCII_GLYPHS = {
42
- brand: '',
43
- ok: '',
44
- fail: '',
45
- warn: '',
46
- paused: '',
47
- resumed: '',
48
- bullet: '-',
49
- point: '>',
50
- };
51
- /** WYRM_FANCY=1 — the curated icons.ts vocabulary (ICON.brand honours the
52
- * WYRM_BRAND override; the pause/resume marks predate icons.ts and are kept
53
- * for 6.x visual continuity). */
54
- const FANCY_GLYPHS = {
55
- brand: ICON.brand,
56
- ok: ICON.ok,
57
- fail: ICON.blocked,
58
- warn: ICON.failure,
59
- paused: '⏸', // ⏸
60
- resumed: '▶', // ▶
61
- bullet: ICON.bullet,
62
- point: ICON.point,
63
- };
64
- /** The active glyph set for this render call. */
65
- export function glyphs() {
66
- return isFancy() ? FANCY_GLYPHS : ASCII_GLYPHS;
67
- }
68
- /** Prefix `text` with a glyph — or return it untouched when the glyph is
69
- * empty (ASCII mode), so default output never carries stray separators. */
70
- export function withGlyph(glyph, text) {
71
- return glyph ? `${glyph} ${text}` : text;
72
- }
73
- // ── Text-wire control-char floor (F3 security pass #1) ─────────────────────
74
- /** C0 control chars (minus \n 0x0a and \t 0x09) + DEL. ESC (0x1b) is the ANSI
75
- * introducer; CR/backspace enable line-spoofing in terminal consumers. */
76
- const TEXT_WIRE_CONTROLS = /[\x00-\x08\x0b-\x1f\x7f]/g;
77
- /**
78
- * Strip terminal-control characters from a derived text channel (security
79
- * pass #1, confirmed finding): boundary strings validated only for LENGTH
80
- * (asString — e.g. wyrm_run's `role`/`orchestrator`, stored prime sections)
81
- * may legally carry ESC/CR/backspace, and templates interpolate them raw.
82
- * `content[0].text` feeds terminals (wyrm watch, CLI, CI logs), so injected
83
- * ANSI could spoof those consumers. This is the ONE chokepoint every template
84
- * render passes through — the by-construction guarantee stays a renderer
85
- * property, not per-handler discipline. \n and \t survive (templates are
86
- * multi-line); `structuredContent` is untouched (the machine wire JSON-escapes
87
- * C0 on serialization, so it was never exposed).
88
- */
89
- export function stripWireControls(text) {
90
- return text.replace(TEXT_WIRE_CONTROLS, '');
91
- }
92
- export function channelPolicy() {
93
- const v = process.env.WYRM_CHANNEL;
94
- return v === 'text' || v === 'structured' ? v : 'both';
95
- }
96
- /**
97
- * The READ_ONLY_TOOLS read-cache key for a resolved (name, args) call.
98
- *
99
- * The cached value is a FULLY-RENDERED response whose channel shape
100
- * (content[0].text vs structuredContent presence) depends on channelPolicy()
101
- * — read per render call by design (see the channelPolicy header). The cache
102
- * must therefore be partitioned by that policy: otherwise a policy change
103
- * mid-process serves a stale-channel cache-hit until the entry expires (up to
104
- * its TTL), silently overriding the renderer's per-call freshness for every
105
- * READ_ONLY tool (wyrm_search, wyrm_session_prime, wyrm_context_build, ...).
106
- * In normal production WYRM_CHANNEL is process-static so the prefix is constant
107
- * (zero behavior change); the partition only matters when a harness/test
108
- * toggles the policy at runtime — exactly what the renderer was built to honour.
109
- *
110
- * Single source of truth for the key convention documented in handlers/types.ts
111
- * (ResponseCache): the dispatcher read+write path and every handler-side
112
- * cache.set MUST build keys through this so a publish and its later hit collide.
113
- */
114
- export function cacheKeyFor(name, argsStr) {
115
- return `${channelPolicy()}:${name}:${argsStr}`;
116
- }
117
- /** The pointer appended to a collapsed text channel in 'structured' mode, so a
118
- * text-reading consumer that DID end up on a structured-only payload is told
119
- * where the body went rather than seeing a bare summary line. */
120
- const STRUCTURED_POINTER = '\n\n_(full body in structuredContent)_';
121
- /** First non-empty line of a rendered text block — the implicit summary used
122
- * when a template declares no explicit `summary`. */
123
- function firstLine(text) {
124
- const nl = text.indexOf('\n');
125
- return nl === -1 ? text : text.slice(0, nl);
126
- }
127
- /**
128
- * THE dual-emit constructor for success responses: `structuredContent` is the
129
- * canonical body; `content[0].text` is derived from it — by the template when
130
- * the tool has prose, else the deterministic 2-space JSON serialization (the
131
- * sqlite-busy.ts precedent). Nothing else may write both channels.
132
- *
133
- * The WYRM_CHANNEL policy (see ChannelPolicy) decides which channel(s) ride
134
- * the wire. 'both' (default) reproduces the base @7.0.0 bytes exactly.
135
- */
136
- export function renderResult(body, template, opts) {
137
- // Template path: sanitize at THE chokepoint (stripWireControls above) so no
138
- // boundary string can ride ANSI/C0 controls onto the terminal-facing text
139
- // wire. The JSON path needs none: JSON.stringify escapes C0 to \uXXXX.
140
- const fullText = template ? stripWireControls(template(body, glyphs())) : JSON.stringify(body, null, 2);
141
- const policy = channelPolicy();
142
- if (policy === 'structured' && !opts?.exemptStructured) {
143
- // Collapse the text channel to a summary line + pointer; the body (the
144
- // canonical, information-complete payload) rides structuredContent
145
- // unchanged. The summary is the marked `summary` derivation, else the
146
- // template's first line, else the body JSON (no template ⇒ nothing to
147
- // summarize, keep the deterministic serialization).
148
- const summaryText = opts?.summary
149
- ? stripWireControls(opts.summary(body, glyphs()))
150
- : template
151
- ? firstLine(fullText)
152
- : fullText;
153
- const collapsed = summaryText + STRUCTURED_POINTER;
154
- // Never make the text channel LARGER: a one-line response (a join ack, a
155
- // clean failure verdict) is already minimal, and summary+pointer would
156
- // ADD bytes. Collapse only when it actually shrinks — so 'structured' is a
157
- // monotone-no-worse text wire for every template.
158
- const text = collapsed.length < fullText.length ? collapsed : fullText;
159
- return {
160
- content: [{ type: 'text', text }],
161
- structuredContent: body,
162
- };
163
- }
164
- if (policy === 'text') {
165
- // Single text channel: the human-complete derivation. structuredContent is
166
- // dropped — the consumer reads prose only. (No body channel means nothing
167
- // to shrink, and no information is lost: the text already carried it all.)
168
- return { content: [{ type: 'text', text: fullText }] };
169
- }
170
- // 'both' (default) — byte-identical to the base @7.0.0 wire.
171
- return {
172
- content: [{ type: 'text', text: fullText }],
173
- structuredContent: body,
174
- };
175
- }
176
- /**
177
- * Dual-emit constructor for `isError:true` responses: `content[0].text` is
178
- * the JSON serialization of the SAME body riding `structuredContent` —
179
- * text-parsing and structured clients see one truth. (Error bodies are JSON
180
- * on the text channel by design: their consumers are programs, and the 6.x
181
- * busy path already shipped these bytes.)
182
- */
183
- export function renderErrorResponse(body) {
184
- // Error bodies are JSON on the text channel BY DESIGN (their consumers are
185
- // programs; the 6.x busy path shipped these bytes), so 'structured' shrink
186
- // does not apply — the text already IS the body and collapsing it would drop
187
- // the machine-actionable {error,expected} contract. The only policy branch
188
- // that touches errors is 'text', which drops the redundant structuredContent
189
- // copy (the text channel carries the identical SEP-1303 envelope). 'both'
190
- // (default) and 'structured' keep the base @7.0.0 dual-emit bytes.
191
- if (channelPolicy() === 'text') {
192
- return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], isError: true };
193
- }
194
- return {
195
- content: [{ type: 'text', text: JSON.stringify(body, null, 2) }],
196
- isError: true,
197
- structuredContent: body,
198
- };
199
- }
200
- // ── SEP-1303 boundary-validation errors (v7 F3, T020) ──────────────────────
201
- /** Machine-readable code for boundary-validation rejections. Deliberately
202
- * distinct from WYRM_BUSY: a busy write is retryable AS-IS; a validation
203
- * failure is only "retryable" after the caller CORRECTS the named argument. */
204
- export const WYRM_VALIDATION_CODE = 'WYRM_VALIDATION';
205
- /**
206
- * Build the validation body. `detail` is ValidationError.message — already
207
- * `'<field>' <reason>` (validate.ts), so the message reads
208
- * `wyrm_x: 'field' must be one of: …`. Deterministic (Article III/VIII: no
209
- * clock, no model — same inputs, same bytes).
210
- */
211
- export function validationErrorBody(tool, field, detail) {
212
- return {
213
- error: {
214
- code: WYRM_VALIDATION_CODE,
215
- message: `${tool}: ${detail}`,
216
- retryable: false,
217
- field,
218
- },
219
- expected: `Correct the '${field}' argument — it ${detail.replace(`'${field}' `, '')} — and call ${tool} again. ` +
220
- `This rejection is deterministic: the SAME arguments will fail the SAME way, so do not retry unchanged ` +
221
- `(unlike WYRM_BUSY, which is retryable as-is). Validators run at the argument-binding seam ` +
222
- `(validate.ts) before the handler's write path, so re-calling with corrected arguments is safe. ` +
223
- `The tool's inputSchema documents the accepted values.`,
224
- };
225
- }
226
- /** The full dual-emit MCP response for a boundary-validation rejection —
227
- * `isError:true`, text = JSON of the SAME body riding structuredContent. */
228
- export function validationErrorResponse(tool, field, detail) {
229
- return renderErrorResponse(validationErrorBody(tool, field, detail));
230
- }
231
- // ── outputSchema contract checking ──────────────────────────────────────────
232
- /**
233
- * Minimal hand-rolled JSON-Schema subset validator (the repo deliberately
234
- * carries no ajv/zod for core ops — Article III keeps the core dependency-
235
- * light). Covers exactly the constructs our outputSchemas use: `type` (incl.
236
- * `["x","null"]` unions), `properties`, `required`, `items`, `enum`,
237
- * `minimum`/`maximum`. Returns a list of human-readable violations (empty =
238
- * conforms). Used by the T019 contract tests to validate every ToolSpec-
239
- * routed response against its declared outputSchema; lifted here from
240
- * tests/toolspec-registry.test.ts so there is ONE validator to keep honest.
241
- */
242
- export function validateAgainstSchema(value, schema, path = '$') {
243
- const errors = [];
244
- const types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : [];
245
- const jsType = (v) => v === null ? 'null'
246
- : Array.isArray(v) ? 'array'
247
- : typeof v === 'number' ? (Number.isInteger(v) ? 'integer' : 'number')
248
- : typeof v;
249
- if (types.length > 0) {
250
- const actual = jsType(value);
251
- const ok = types.some((t) => t === actual || (t === 'number' && actual === 'integer'));
252
- if (!ok)
253
- errors.push(`${path}: expected ${types.join('|')}, got ${actual}`);
254
- }
255
- if (schema.enum && !schema.enum.includes(value)) {
256
- errors.push(`${path}: ${JSON.stringify(value)} not in enum`);
257
- }
258
- if (typeof value === 'number') {
259
- if (typeof schema.minimum === 'number' && value < schema.minimum)
260
- errors.push(`${path}: < minimum`);
261
- if (typeof schema.maximum === 'number' && value > schema.maximum)
262
- errors.push(`${path}: > maximum`);
263
- }
264
- if (value && typeof value === 'object' && !Array.isArray(value) && schema.properties) {
265
- const props = schema.properties;
266
- for (const req of schema.required ?? []) {
267
- if (!(req in value))
268
- errors.push(`${path}: missing required '${req}'`);
269
- }
270
- for (const [k, v] of Object.entries(value)) {
271
- if (props[k])
272
- errors.push(...validateAgainstSchema(v, props[k], `${path}.${k}`));
273
- }
274
- }
275
- if (Array.isArray(value) && schema.items) {
276
- value.forEach((v, i) => errors.push(...validateAgainstSchema(v, schema.items, `${path}[${i}]`)));
277
- }
278
- return errors;
279
- }
280
- //# sourceMappingURL=render.js.map
1
+ import{ICON as u}from"./icons.js";function a(){const t=process.env.WYRM_FANCY;return t==="1"||t==="true"}const x={brand:"",ok:"",fail:"",warn:"",paused:"",resumed:"",bullet:"-",point:">"},d={brand:u.brand,ok:u.ok,fail:u.blocked,warn:u.failure,paused:"\u23F8",resumed:"\u25B6",bullet:u.bullet,point:u.point};function l(){return a()?d:x}function N(t,r){return t?`${t} ${r}`:r}const $=/[\x00-\x08\x0b-\x1f\x7f]/g;function m(t){return t.replace($,"")}function p(){const t=process.env.WYRM_CHANNEL;return t==="text"||t==="structured"?t:"both"}function T(t,r){return`${p()}:${t}:${r}`}const g=`
2
+
3
+ _(full body in structuredContent)_`;function A(t){const r=t.indexOf(`
4
+ `);return r===-1?t:t.slice(0,r)}function O(t,r,n){const o=r?m(r(t,l())):JSON.stringify(t,null,2),s=p();if(s==="structured"&&!n?.exemptStructured){const e=(n?.summary?m(n.summary(t,l())):r?A(o):o)+g;return{content:[{type:"text",text:e.length<o.length?e:o}],structuredContent:t}}return s==="text"?{content:[{type:"text",text:o}]}:{content:[{type:"text",text:o}],structuredContent:t}}function b(t){return p()==="text"?{content:[{type:"text",text:JSON.stringify(t,null,2)}],isError:!0}:{content:[{type:"text",text:JSON.stringify(t,null,2)}],isError:!0,structuredContent:t}}const S="WYRM_VALIDATION";function C(t,r,n){return{error:{code:S,message:`${t}: ${n}`,retryable:!1,field:r},expected:`Correct the '${r}' argument \u2014 it ${n.replace(`'${r}' `,"")} \u2014 and call ${t} again. This rejection is deterministic: the SAME arguments will fail the SAME way, so do not retry unchanged (unlike WYRM_BUSY, which is retryable as-is). Validators run at the argument-binding seam (validate.ts) before the handler's write path, so re-calling with corrected arguments is safe. The tool's inputSchema documents the accepted values.`}}function R(t,r,n){return b(C(t,r,n))}function y(t,r,n="$"){const o=[],s=Array.isArray(r.type)?r.type:r.type?[r.type]:[],f=e=>e===null?"null":Array.isArray(e)?"array":typeof e=="number"?Number.isInteger(e)?"integer":"number":typeof e;if(s.length>0){const e=f(t);s.some(c=>c===e||c==="number"&&e==="integer")||o.push(`${n}: expected ${s.join("|")}, got ${e}`)}if(r.enum&&!r.enum.includes(t)&&o.push(`${n}: ${JSON.stringify(t)} not in enum`),typeof t=="number"&&(typeof r.minimum=="number"&&t<r.minimum&&o.push(`${n}: < minimum`),typeof r.maximum=="number"&&t>r.maximum&&o.push(`${n}: > maximum`)),t&&typeof t=="object"&&!Array.isArray(t)&&r.properties){const e=r.properties;for(const i of r.required??[])i in t||o.push(`${n}: missing required '${i}'`);for(const[i,c]of Object.entries(t))e[i]&&o.push(...y(c,e[i],`${n}.${i}`))}return Array.isArray(t)&&r.items&&t.forEach((e,i)=>o.push(...y(e,r.items,`${n}[${i}]`))),o}export{S as WYRM_VALIDATION_CODE,T as cacheKeyFor,p as channelPolicy,l as glyphs,a as isFancy,b as renderErrorResponse,O as renderResult,m as stripWireControls,y as validateAgainstSchema,C as validationErrorBody,R as validationErrorResponse,N as withGlyph};
@@ -1,173 +1 @@
1
- /**
2
- * Live Memory v6.4 — replication egress guard.
3
- *
4
- * The replication client makes OUTBOUND HTTP requests to a caller/registry-supplied
5
- * peer URL, with a bearer token attached. Left unguarded that's an SSRF + credential
6
- * exfiltration primitive (point it at cloud metadata / localhost / an attacker host).
7
- * This module is the chokepoint every outbound replication request goes through:
8
- *
9
- * - scheme allowlist (http/https only),
10
- * - block loopback / link-local / private / metadata hosts (unless
11
- * WYRM_REPL_ALLOW_PRIVATE=1, which is needed for same-host two-node testing),
12
- * - `redirect: 'manual'` so a peer can't 3xx the token to another origin,
13
- * - a hard request timeout (a hung peer must not wedge the daemon),
14
- * - a byte-bounded body read (a hostile peer can't OOM us with a huge response).
15
- *
16
- * Residual: DNS-rebinding (host resolves to a public IP at check time, a private one
17
- * at connect time) is NOT fully closed — fetch doesn't expose connect-time IP. The
18
- * literal-host checks below cover the realistic SSRF targets for an operator-driven
19
- * tool; treat cross-trust peers accordingly.
20
- *
21
- * @copyright 2026 Ghost Protocol (Pvt) Ltd.
22
- * @license AGPL-3.0-or-later — dual-licensed; commercial terms: ghosts.lk@proton.me. See LICENSE.
23
- */
24
- import { isIP } from 'node:net';
25
- export const PEER_FETCH_TIMEOUT_MS = 15_000;
26
- export const MAX_PEER_RESPONSE_BYTES = 4 * 1024 * 1024; // 4 MB — matches the 500-event push batch
27
- /** Is this hostname/IP literal a loopback / link-local / private / metadata target? */
28
- export function isBlockedHost(hostname) {
29
- // strip IPv6 brackets + a trailing FQDN-root dot ("localhost." resolves to loopback)
30
- const h = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
31
- if (!h || h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal'))
32
- return true;
33
- const kind = isIP(h);
34
- if (kind === 4) {
35
- const o = h.split('.').map(Number);
36
- if (o.length !== 4 || o.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
37
- return true; // malformed → block
38
- if (o[0] === 127)
39
- return true; // 127.0.0.0/8 loopback
40
- if (o[0] === 10)
41
- return true; // 10.0.0.0/8
42
- if (o[0] === 0)
43
- return true; // 0.0.0.0/8
44
- if (o[0] === 169 && o[1] === 254)
45
- return true; // 169.254.0.0/16 link-local + metadata
46
- if (o[0] === 172 && o[1] >= 16 && o[1] <= 31)
47
- return true; // 172.16.0.0/12
48
- if (o[0] === 192 && o[1] === 168)
49
- return true; // 192.168.0.0/16
50
- if (o[0] === 100 && o[1] >= 64 && o[1] <= 127)
51
- return true; // 100.64.0.0/10 CGNAT
52
- return false;
53
- }
54
- if (kind === 6) {
55
- if (h === '::1' || h === '::')
56
- return true; // loopback / unspecified
57
- if (h.startsWith('fe80') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb'))
58
- return true; // fe80::/10 link-local
59
- if (h.startsWith('fc') || h.startsWith('fd'))
60
- return true; // fc00::/7 ULA
61
- // IPv4-mapped (::ffff:a.b.c.d). WHATWG URL normalizes this to the HEX form
62
- // (::ffff:7f00:1), so handle BOTH the dotted tail and the two-hex-group tail —
63
- // otherwise http://[::ffff:127.0.0.1]/ slips through to loopback.
64
- if (h.startsWith('::ffff:')) {
65
- const tail = h.slice('::ffff:'.length);
66
- if (isIP(tail) === 4)
67
- return isBlockedHost(tail);
68
- const m = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
69
- if (m) {
70
- const hi = parseInt(m[1], 16), lo = parseInt(m[2], 16);
71
- return isBlockedHost(`${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`);
72
- }
73
- return true; // unrecognized ::ffff: form → block to be safe
74
- }
75
- if (h.startsWith('64:ff9b:'))
76
- return true; // NAT64 well-known prefix (can embed private IPv4)
77
- return false;
78
- }
79
- return false; // a non-IP hostname (public DNS name) — allowed
80
- }
81
- /** Validate a peer base URL for outbound replication. Returns the parsed URL or a reason. */
82
- export function checkPeerUrl(baseUrl) {
83
- let url;
84
- try {
85
- url = new URL(baseUrl);
86
- }
87
- catch {
88
- return { ok: false, reason: 'invalid URL' };
89
- }
90
- if (url.protocol !== 'http:' && url.protocol !== 'https:') {
91
- return { ok: false, reason: `scheme ${url.protocol} not allowed (http/https only)` };
92
- }
93
- const allowPrivate = process.env.WYRM_REPL_ALLOW_PRIVATE === '1';
94
- if (!allowPrivate && isBlockedHost(url.hostname)) {
95
- return { ok: false, reason: `host ${url.hostname} is loopback/private/link-local (set WYRM_REPL_ALLOW_PRIVATE=1 for same-host/LAN peers)` };
96
- }
97
- return { ok: true, url };
98
- }
99
- /**
100
- * Is a peer token env-var name allowed? Must be `WYRM_`-prefixed — this stops a
101
- * (possibly attacker-supplied) registry entry from pointing `tokenEnv` at an
102
- * arbitrary process secret like `OPENAI_API_KEY` / `AWS_SECRET_ACCESS_KEY` and
103
- * exfiltrating it to a peer. Set WYRM_REPL_ALLOW_ANY_TOKEN_ENV=1 to override.
104
- */
105
- export function isValidTokenEnvName(name) {
106
- if (process.env.WYRM_REPL_ALLOW_ANY_TOKEN_ENV === '1')
107
- return /^[A-Z][A-Z0-9_]{0,63}$/.test(name);
108
- return /^WYRM_[A-Z0-9_]{1,58}$/.test(name);
109
- }
110
- /**
111
- * Fetch a peer endpoint with the egress guard applied: validated URL, hard timeout,
112
- * no redirect following, and a byte-bounded body. Returns the parsed JSON (object).
113
- * Throws on any violation — callers are failure-isolated and treat throw as "skip".
114
- */
115
- export async function guardedFetchJson(fetchImpl, baseUrl, path, init = {}, maxBytes = MAX_PEER_RESPONSE_BYTES, timeoutMs = PEER_FETCH_TIMEOUT_MS) {
116
- const check = checkPeerUrl(baseUrl);
117
- if (!check.ok)
118
- throw new Error(`peer URL rejected: ${check.reason}`);
119
- const controller = new AbortController();
120
- const timer = setTimeout(() => controller.abort(), timeoutMs);
121
- try {
122
- const res = await fetchImpl(`${baseUrl}${path}`, {
123
- ...init,
124
- redirect: 'manual', // never follow a 3xx — it could leak the token cross-origin
125
- signal: controller.signal,
126
- });
127
- if (res.status >= 300 && res.status < 400)
128
- throw new Error(`peer redirected (${res.status}) — refused`);
129
- if (!res.ok)
130
- throw new Error(`peer HTTP ${res.status}`);
131
- const declared = Number(res.headers?.get?.('content-length') ?? '');
132
- if (Number.isFinite(declared) && declared > maxBytes)
133
- throw new Error('peer response too large (content-length)');
134
- // Real fetch: byte-bounded stream read. Test fakes / bodyless responses: fall back to json()/text().
135
- if (typeof res.body?.getReader === 'function') {
136
- const text = await readBounded(res, maxBytes);
137
- return text ? JSON.parse(text) : {};
138
- }
139
- if (typeof res.json === 'function')
140
- return await res.json();
141
- const t = typeof res.text === 'function' ? await res.text() : '';
142
- return t ? JSON.parse(t) : {};
143
- }
144
- finally {
145
- clearTimeout(timer);
146
- }
147
- }
148
- /** Read a fetch Response body, aborting if it exceeds maxBytes (defeats a no-content-length OOM). */
149
- async function readBounded(res, maxBytes) {
150
- const reader = res.body?.getReader?.();
151
- if (!reader)
152
- return typeof res.text === 'function' ? res.text() : '';
153
- let total = 0;
154
- const chunks = [];
155
- for (;;) {
156
- const { done, value } = await reader.read();
157
- if (done)
158
- break;
159
- if (value) {
160
- total += value.length;
161
- if (total > maxBytes) {
162
- try {
163
- await reader.cancel();
164
- }
165
- catch { /* */ }
166
- throw new Error('peer response too large');
167
- }
168
- chunks.push(value);
169
- }
170
- }
171
- return Buffer.concat(chunks).toString('utf8');
172
- }
173
- //# sourceMappingURL=repl-guard.js.map
1
+ import{isIP as p}from"node:net";const w=15e3,E=4*1024*1024;function f(o){const t=o.toLowerCase().replace(/^\[|\]$/g,"").replace(/\.$/,"");if(!t||t==="localhost"||t.endsWith(".localhost")||t.endsWith(".local")||t.endsWith(".internal"))return!0;const s=p(t);if(s===4){const e=t.split(".").map(Number);return!!(e.length!==4||e.some(r=>!Number.isInteger(r)||r<0||r>255)||e[0]===127||e[0]===10||e[0]===0||e[0]===169&&e[1]===254||e[0]===172&&e[1]>=16&&e[1]<=31||e[0]===192&&e[1]===168||e[0]===100&&e[1]>=64&&e[1]<=127)}if(s===6){if(t==="::1"||t==="::"||t.startsWith("fe80")||t.startsWith("fe9")||t.startsWith("fea")||t.startsWith("feb")||t.startsWith("fc")||t.startsWith("fd"))return!0;if(t.startsWith("::ffff:")){const e=t.slice(7);if(p(e)===4)return f(e);const r=e.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);if(r){const i=parseInt(r[1],16),a=parseInt(r[2],16);return f(`${i>>8&255}.${i&255}.${a>>8&255}.${a&255}`)}return!0}return!!t.startsWith("64:ff9b:")}return!1}function _(o){let t;try{t=new URL(o)}catch{return{ok:!1,reason:"invalid URL"}}return t.protocol!=="http:"&&t.protocol!=="https:"?{ok:!1,reason:`scheme ${t.protocol} not allowed (http/https only)`}:!(process.env.WYRM_REPL_ALLOW_PRIVATE==="1")&&f(t.hostname)?{ok:!1,reason:`host ${t.hostname} is loopback/private/link-local (set WYRM_REPL_ALLOW_PRIVATE=1 for same-host/LAN peers)`}:{ok:!0,url:t}}function R(o){return process.env.WYRM_REPL_ALLOW_ANY_TOKEN_ENV==="1"?/^[A-Z][A-Z0-9_]{0,63}$/.test(o):/^WYRM_[A-Z0-9_]{1,58}$/.test(o)}async function g(o,t,s,e={},r=E,i=w){const a=_(t);if(!a.ok)throw new Error(`peer URL rejected: ${a.reason}`);const u=new AbortController,d=setTimeout(()=>u.abort(),i);try{const n=await o(`${t}${s}`,{...e,redirect:"manual",signal:u.signal});if(n.status>=300&&n.status<400)throw new Error(`peer redirected (${n.status}) \u2014 refused`);if(!n.ok)throw new Error(`peer HTTP ${n.status}`);const c=Number(n.headers?.get?.("content-length")??"");if(Number.isFinite(c)&&c>r)throw new Error("peer response too large (content-length)");if(typeof n.body?.getReader=="function"){const h=await W(n,r);return h?JSON.parse(h):{}}if(typeof n.json=="function")return await n.json();const l=typeof n.text=="function"?await n.text():"";return l?JSON.parse(l):{}}finally{clearTimeout(d)}}async function W(o,t){const s=o.body?.getReader?.();if(!s)return typeof o.text=="function"?o.text():"";let e=0;const r=[];for(;;){const{done:i,value:a}=await s.read();if(i)break;if(a){if(e+=a.length,e>t){try{await s.cancel()}catch{}throw new Error("peer response too large")}r.push(a)}}return Buffer.concat(r).toString("utf8")}export{E as MAX_PEER_RESPONSE_BYTES,w as PEER_FETCH_TIMEOUT_MS,_ as checkPeerUrl,g as guardedFetchJson,f as isBlockedHost,R as isValidTokenEnvName};
@@ -1,31 +1 @@
1
- /**
2
- * Wyrm Live Memory — replication daemon entrypoint.
3
- *
4
- * Spawned detached by `ReplicationManager.start()`. Opens the same DB the MCP
5
- * server uses (`WYRM_DB_PATH` or `~/.wyrm/wyrm.db`) and runs the sync loop until
6
- * SIGTERM/SIGINT. Not meant to be invoked directly — go through the
7
- * `wyrm_replication` MCP tool, which manages the PID file.
8
- *
9
- * argv[2] = interval_ms
10
- *
11
- * @copyright 2026 Ghost Protocol (Pvt) Ltd.
12
- * @license AGPL-3.0-or-later — dual-licensed; commercial terms: ghosts.lk@proton.me. See LICENSE.
13
- */
14
- import { WyrmDB } from './database.js';
15
- import { ReplicationDaemon, DEFAULT_INTERVAL_MS } from './replication-daemon.js';
16
- async function main() {
17
- const intervalMs = Number(process.argv[2]) || DEFAULT_INTERVAL_MS;
18
- const db = new WyrmDB(process.env.WYRM_DB_PATH);
19
- if (!db.liveMemoryEnabled()) {
20
- console.error('[replication-entrypoint] WYRM_LIVE_MEMORY is off — refusing to start');
21
- process.exit(2);
22
- }
23
- const daemon = new ReplicationDaemon(db, intervalMs);
24
- console.log(`[replication] started · interval=${intervalMs}ms · db=${process.env.WYRM_DB_PATH || '~/.wyrm/wyrm.db'}`);
25
- await daemon.run();
26
- }
27
- main().catch((e) => {
28
- console.error('[replication-entrypoint] fatal:', e);
29
- process.exit(1);
30
- });
31
- //# sourceMappingURL=replication-daemon-entrypoint.js.map
1
+ import{WyrmDB as n}from"./database.js";import{ReplicationDaemon as t,DEFAULT_INTERVAL_MS as i}from"./replication-daemon.js";async function s(){const o=Number(process.argv[2])||i,e=new n(process.env.WYRM_DB_PATH);e.liveMemoryEnabled()||(console.error("[replication-entrypoint] WYRM_LIVE_MEMORY is off \u2014 refusing to start"),process.exit(2));const r=new t(e,o);console.log(`[replication] started \xB7 interval=${o}ms \xB7 db=${process.env.WYRM_DB_PATH||"~/.wyrm/wyrm.db"}`),await r.run()}s().catch(o=>{console.error("[replication-entrypoint] fatal:",o),process.exit(1)});