lyra-core 0.1.0

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 (131) hide show
  1. package/README.md +24 -0
  2. package/package.json +65 -0
  3. package/src/brain/compaction.test.ts +156 -0
  4. package/src/brain/compaction.ts +131 -0
  5. package/src/brain/demo-endpoint.ts +13 -0
  6. package/src/brain/frozen-prefix.test.ts +235 -0
  7. package/src/brain/frozen-prefix.ts +320 -0
  8. package/src/brain/history-persist.test.ts +129 -0
  9. package/src/brain/history-persist.ts +154 -0
  10. package/src/brain/index.ts +44 -0
  11. package/src/brain/openai-brain.test.ts +61 -0
  12. package/src/brain/openai-brain.ts +544 -0
  13. package/src/brain/sanitize.test.ts +27 -0
  14. package/src/brain/sanitize.ts +23 -0
  15. package/src/brain/stub.ts +20 -0
  16. package/src/brain/types.ts +129 -0
  17. package/src/chain.ts +35 -0
  18. package/src/claude-plugins/discovery.test.ts +71 -0
  19. package/src/claude-plugins/discovery.ts +152 -0
  20. package/src/claude-plugins/index.ts +6 -0
  21. package/src/claude-plugins/types.ts +38 -0
  22. package/src/commands/index.ts +16 -0
  23. package/src/commands/registry.test.ts +186 -0
  24. package/src/commands/registry.ts +255 -0
  25. package/src/config.ts +201 -0
  26. package/src/economy/index.ts +6 -0
  27. package/src/events/index.ts +4 -0
  28. package/src/events/listeners.ts +37 -0
  29. package/src/events/queue.test.ts +43 -0
  30. package/src/events/queue.ts +63 -0
  31. package/src/events/router.ts +42 -0
  32. package/src/events/types.ts +28 -0
  33. package/src/format.ts +13 -0
  34. package/src/index.test.ts +6 -0
  35. package/src/index.ts +314 -0
  36. package/src/locks.test.ts +259 -0
  37. package/src/locks.ts +233 -0
  38. package/src/mcp/discovery.test.ts +97 -0
  39. package/src/mcp/discovery.ts +150 -0
  40. package/src/mcp/index.ts +10 -0
  41. package/src/mcp/manager.test.ts +97 -0
  42. package/src/mcp/manager.ts +110 -0
  43. package/src/mcp/stdio-client.ts +154 -0
  44. package/src/mcp/types.ts +44 -0
  45. package/src/memory/edit.test.ts +41 -0
  46. package/src/memory/edit.ts +53 -0
  47. package/src/memory/encryption.test.ts +68 -0
  48. package/src/memory/encryption.ts +94 -0
  49. package/src/memory/fs-util.ts +15 -0
  50. package/src/memory/index-file.ts +74 -0
  51. package/src/memory/index-sync.test.ts +81 -0
  52. package/src/memory/index-sync.ts +99 -0
  53. package/src/memory/index.ts +58 -0
  54. package/src/memory/list-tool.ts +105 -0
  55. package/src/memory/pack-blob.test.ts +90 -0
  56. package/src/memory/pack-blob.ts +120 -0
  57. package/src/memory/pack-gather.test.ts +110 -0
  58. package/src/memory/pack-gather.ts +112 -0
  59. package/src/memory/parser.test.ts +29 -0
  60. package/src/memory/parser.ts +20 -0
  61. package/src/memory/path-drift.test.ts +71 -0
  62. package/src/memory/read-tool-fallback.test.ts +54 -0
  63. package/src/memory/read-tool.ts +198 -0
  64. package/src/memory/save-tool.test.ts +193 -0
  65. package/src/memory/save-tool.ts +189 -0
  66. package/src/memory/scan.test.ts +24 -0
  67. package/src/memory/scan.ts +63 -0
  68. package/src/memory/topic.ts +32 -0
  69. package/src/memory/types.ts +49 -0
  70. package/src/migration/index.ts +6 -0
  71. package/src/migration/option3-crypto.test.ts +106 -0
  72. package/src/migration/option3-crypto.ts +143 -0
  73. package/src/pairing.test.ts +212 -0
  74. package/src/pairing.ts +285 -0
  75. package/src/paths.ts +70 -0
  76. package/src/permission/dangerous.ts +105 -0
  77. package/src/permission/env-redact.ts +54 -0
  78. package/src/permission/index.ts +16 -0
  79. package/src/permission/path-guard.ts +114 -0
  80. package/src/permission/permission.test.ts +299 -0
  81. package/src/permission/service.ts +191 -0
  82. package/src/plugins/context.ts +226 -0
  83. package/src/plugins/hooks.ts +81 -0
  84. package/src/plugins/index.ts +24 -0
  85. package/src/plugins/plugins.test.ts +196 -0
  86. package/src/plugins/tool-search.ts +49 -0
  87. package/src/public/card.test.ts +70 -0
  88. package/src/public/card.ts +67 -0
  89. package/src/runtime/activity.ts +29 -0
  90. package/src/runtime/index.ts +7 -0
  91. package/src/runtime/runtime.test.ts +55 -0
  92. package/src/runtime/runtime.ts +126 -0
  93. package/src/sandbox/credentials.ts +25 -0
  94. package/src/sandbox/docker.ts +389 -0
  95. package/src/sandbox/factory.ts +99 -0
  96. package/src/sandbox/index.ts +15 -0
  97. package/src/sandbox/linux.ts +141 -0
  98. package/src/sandbox/local.ts +19 -0
  99. package/src/sandbox/macos.ts +71 -0
  100. package/src/sandbox/sandbox.test.ts +386 -0
  101. package/src/sandbox/seatbelt-profile.ts +139 -0
  102. package/src/sandbox/types.ts +129 -0
  103. package/src/skills/index.ts +8 -0
  104. package/src/skills/scanner.test.ts +137 -0
  105. package/src/skills/scanner.ts +257 -0
  106. package/src/skills/triggers.test.ts +77 -0
  107. package/src/skills/triggers.ts +78 -0
  108. package/src/skills/types.ts +37 -0
  109. package/src/storage/encryption.test.ts +30 -0
  110. package/src/storage/encryption.ts +87 -0
  111. package/src/storage/factory.ts +31 -0
  112. package/src/storage/index.ts +11 -0
  113. package/src/storage/local-stub.ts +70 -0
  114. package/src/storage/sqlite.test.ts +29 -0
  115. package/src/storage/sqlite.ts +95 -0
  116. package/src/storage/types.ts +21 -0
  117. package/src/tools/escalation.test.ts +348 -0
  118. package/src/tools/escalation.ts +200 -0
  119. package/src/tools/index.ts +11 -0
  120. package/src/tools/registry.test.ts +70 -0
  121. package/src/tools/registry.ts +152 -0
  122. package/src/tools/types.ts +65 -0
  123. package/src/tools/zod-helpers.test.ts +63 -0
  124. package/src/tools/zod-helpers.ts +36 -0
  125. package/src/tools/zod-schema.ts +99 -0
  126. package/src/wallet/drain.test.ts +41 -0
  127. package/src/wallet/drain.ts +76 -0
  128. package/src/wallet/eoa.ts +61 -0
  129. package/src/wallet/index.ts +14 -0
  130. package/src/wallet/keystore.test.ts +17 -0
  131. package/src/wallet/keystore.ts +50 -0
@@ -0,0 +1,212 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
2
+ import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import {
6
+ PAIRING_ALPHABET,
7
+ PAIRING_CODE_LENGTH,
8
+ PAIRING_CODE_TTL_SECONDS,
9
+ PAIRING_LOCKOUT_SECONDS,
10
+ PAIRING_MAX_FAILED_ATTEMPTS,
11
+ PAIRING_MAX_PENDING_PER_PLATFORM,
12
+ PairingStore,
13
+ } from './pairing'
14
+
15
+ let testDir: string
16
+
17
+ beforeEach(() => {
18
+ testDir = mkdtempSync(join(tmpdir(), 'lyra-pairing-test-'))
19
+ })
20
+
21
+ afterEach(() => {
22
+ rmSync(testDir, { recursive: true, force: true })
23
+ })
24
+
25
+ describe('PairingStore.generateCode', () => {
26
+ it('returns an 8-char code from the alphabet', () => {
27
+ const store = new PairingStore({ dir: testDir })
28
+ const code = store.generateCode('telegram', '111')
29
+ expect(code).not.toBeNull()
30
+ expect(code!.length).toBe(PAIRING_CODE_LENGTH)
31
+ for (const ch of code!) expect(PAIRING_ALPHABET).toContain(ch)
32
+ })
33
+
34
+ it('returns null when MAX_PENDING_PER_PLATFORM reached', () => {
35
+ const store = new PairingStore({ dir: testDir })
36
+ for (let i = 0; i < PAIRING_MAX_PENDING_PER_PLATFORM; i++) {
37
+ const c = store.generateCode('telegram', `user-${i}`)
38
+ expect(c).not.toBeNull()
39
+ }
40
+ const overflow = store.generateCode('telegram', 'user-overflow')
41
+ expect(overflow).toBeNull()
42
+ })
43
+
44
+ it('returns null when same user requests within rate limit window', () => {
45
+ const store = new PairingStore({ dir: testDir })
46
+ const a = store.generateCode('telegram', '111')
47
+ expect(a).not.toBeNull()
48
+ const b = store.generateCode('telegram', '111')
49
+ expect(b).toBeNull()
50
+ })
51
+
52
+ it('different users on same platform do not rate-limit each other', () => {
53
+ const store = new PairingStore({ dir: testDir })
54
+ expect(store.generateCode('telegram', '111')).not.toBeNull()
55
+ expect(store.generateCode('telegram', '222')).not.toBeNull()
56
+ })
57
+
58
+ it('cleans up expired codes before generating', () => {
59
+ let now = 1000
60
+ const store = new PairingStore({ dir: testDir, now: () => now })
61
+ const c1 = store.generateCode('telegram', 'user-1')
62
+ expect(c1).not.toBeNull()
63
+ now += PAIRING_CODE_TTL_SECONDS + 1
64
+ const after = store.listPending('telegram')
65
+ expect(after.length).toBe(0)
66
+ })
67
+ })
68
+
69
+ describe('PairingStore.approveCode', () => {
70
+ it('approves a valid code and adds the user to approved list', () => {
71
+ const store = new PairingStore({ dir: testDir })
72
+ const code = store.generateCode('telegram', '111', 'phantom')!
73
+ const result = store.approveCode('telegram', code)
74
+ expect(result).toEqual({ userId: '111', userName: 'phantom' })
75
+ expect(store.isApproved('telegram', '111')).toBe(true)
76
+ })
77
+
78
+ it('removes the code from pending after approval', () => {
79
+ const store = new PairingStore({ dir: testDir })
80
+ const code = store.generateCode('telegram', '111')!
81
+ store.approveCode('telegram', code)
82
+ expect(store.listPending('telegram').length).toBe(0)
83
+ })
84
+
85
+ it('returns null for unknown codes', () => {
86
+ const store = new PairingStore({ dir: testDir })
87
+ const result = store.approveCode('telegram', 'WRONGCOD')
88
+ expect(result).toBeNull()
89
+ })
90
+
91
+ it('is case-insensitive on the code input', () => {
92
+ const store = new PairingStore({ dir: testDir })
93
+ const code = store.generateCode('telegram', '111')!
94
+ const result = store.approveCode('telegram', code.toLowerCase())
95
+ expect(result?.userId).toBe('111')
96
+ })
97
+
98
+ it('trims whitespace from the code input', () => {
99
+ const store = new PairingStore({ dir: testDir })
100
+ const code = store.generateCode('telegram', '111')!
101
+ const result = store.approveCode('telegram', ` ${code} `)
102
+ expect(result?.userId).toBe('111')
103
+ })
104
+
105
+ it('locks out platform after MAX_FAILED_ATTEMPTS bad approvals', () => {
106
+ const now = 1000
107
+ const store = new PairingStore({ dir: testDir, now: () => now })
108
+ for (let i = 0; i < PAIRING_MAX_FAILED_ATTEMPTS; i++) {
109
+ expect(store.approveCode('telegram', 'NOTREAL1')).toBeNull()
110
+ }
111
+ expect(store.isLockedOut('telegram')).toBe(true)
112
+ expect(store.generateCode('telegram', '111')).toBeNull()
113
+ })
114
+
115
+ it('lockout clears after LOCKOUT_SECONDS', () => {
116
+ let now = 1000
117
+ const store = new PairingStore({ dir: testDir, now: () => now })
118
+ for (let i = 0; i < PAIRING_MAX_FAILED_ATTEMPTS; i++) {
119
+ store.approveCode('telegram', 'NOTREAL1')
120
+ }
121
+ expect(store.isLockedOut('telegram')).toBe(true)
122
+ now += PAIRING_LOCKOUT_SECONDS + 1
123
+ expect(store.isLockedOut('telegram')).toBe(false)
124
+ })
125
+
126
+ it('successful approval resets the failure counter', () => {
127
+ const store = new PairingStore({ dir: testDir })
128
+ store.approveCode('telegram', 'NOTREAL1')
129
+ store.approveCode('telegram', 'NOTREAL2')
130
+ const code = store.generateCode('telegram', '111')!
131
+ store.approveCode('telegram', code)
132
+ // Should not reach lockout from prior 2 fails
133
+ for (let i = 0; i < PAIRING_MAX_FAILED_ATTEMPTS - 1; i++) {
134
+ store.approveCode('telegram', 'NOTREALX')
135
+ }
136
+ expect(store.isLockedOut('telegram')).toBe(false)
137
+ })
138
+ })
139
+
140
+ describe('PairingStore.listPending / listApproved / clearPending', () => {
141
+ it('listPending returns codes for one platform', () => {
142
+ const store = new PairingStore({ dir: testDir })
143
+ store.generateCode('telegram', '111', 'a')
144
+ store.generateCode('telegram', '222', 'b')
145
+ const pending = store.listPending('telegram')
146
+ expect(pending.length).toBe(2)
147
+ expect(pending.map(p => p.userId).sort()).toEqual(['111', '222'])
148
+ })
149
+
150
+ it('listPending(undefined) aggregates across platforms', () => {
151
+ const store = new PairingStore({ dir: testDir })
152
+ store.generateCode('telegram', '111')
153
+ store.generateCode('discord', '222')
154
+ const pending = store.listPending()
155
+ expect(pending.length).toBe(2)
156
+ const platforms = pending.map(p => p.platform).sort()
157
+ expect(platforms).toEqual(['discord', 'telegram'])
158
+ })
159
+
160
+ it('listApproved aggregates across platforms', () => {
161
+ const store = new PairingStore({ dir: testDir })
162
+ const c1 = store.generateCode('telegram', '111', 'a')!
163
+ store.approveCode('telegram', c1)
164
+ const c2 = store.generateCode('discord', '222', 'b')!
165
+ store.approveCode('discord', c2)
166
+ const approved = store.listApproved()
167
+ expect(approved.length).toBe(2)
168
+ })
169
+
170
+ it('clearPending removes all pending and returns count', () => {
171
+ const store = new PairingStore({ dir: testDir })
172
+ store.generateCode('telegram', '111')
173
+ store.generateCode('telegram', '222')
174
+ expect(store.clearPending('telegram')).toBe(2)
175
+ expect(store.listPending('telegram').length).toBe(0)
176
+ })
177
+
178
+ it('revoke removes an approved user', () => {
179
+ const store = new PairingStore({ dir: testDir })
180
+ const code = store.generateCode('telegram', '111')!
181
+ store.approveCode('telegram', code)
182
+ expect(store.revoke('telegram', '111')).toBe(true)
183
+ expect(store.isApproved('telegram', '111')).toBe(false)
184
+ })
185
+
186
+ it('revoke returns false when user is not approved', () => {
187
+ const store = new PairingStore({ dir: testDir })
188
+ expect(store.revoke('telegram', '999')).toBe(false)
189
+ })
190
+ })
191
+
192
+ describe('PairingStore file permissions and atomicity', () => {
193
+ it('writes pending file with 0600 mode on POSIX', () => {
194
+ const store = new PairingStore({ dir: testDir })
195
+ store.generateCode('telegram', '111')
196
+ const path = join(testDir, 'telegram-pending.json')
197
+ if (process.platform !== 'win32') {
198
+ const mode = statSync(path).mode & 0o777
199
+ expect(mode).toBe(0o600)
200
+ }
201
+ const raw = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
202
+ expect(Object.keys(raw).length).toBe(1)
203
+ })
204
+
205
+ it('atomic writes leave no .tmp residue on success', () => {
206
+ const store = new PairingStore({ dir: testDir })
207
+ store.generateCode('telegram', '111')
208
+ const { readdirSync } = require('node:fs') as typeof import('node:fs')
209
+ const files = readdirSync(testDir)
210
+ expect(files.some(f => f.includes('.tmp-'))).toBe(false)
211
+ })
212
+ })
package/src/pairing.ts ADDED
@@ -0,0 +1,285 @@
1
+ // DM pairing system — one-time codes for authorizing new platform users.
2
+ //
3
+ // Ports hermes gateway/pairing.py 1:1 to TypeScript. Operators run
4
+ // `lyra pairing approve telegram <code>` after the bot DMs an unrecognized
5
+ // user a code.
6
+ //
7
+ // Security:
8
+ // - 8-char codes from 32-char unambiguous alphabet (no 0/O, 1/I)
9
+ // - crypto-secure randomness via randomInt
10
+ // - 1-hour code TTL, max 3 pending per platform
11
+ // - 1 request / user / 10 min rate limit
12
+ // - 1-hour lockout after 5 failed approvals
13
+ // - chmod 0600 on all data files (best-effort on non-POSIX)
14
+ //
15
+ // Storage layout under `dir`:
16
+ // <platform>-pending.json pending codes
17
+ // <platform>-approved.json approved users
18
+ // _rate_limits.json rate-limit + lockout tracking
19
+
20
+ import { randomInt } from 'node:crypto'
21
+ import {
22
+ chmodSync,
23
+ existsSync,
24
+ mkdirSync,
25
+ readFileSync,
26
+ readdirSync,
27
+ renameSync,
28
+ writeFileSync,
29
+ } from 'node:fs'
30
+ import { join } from 'node:path'
31
+
32
+ export const PAIRING_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
33
+ export const PAIRING_CODE_LENGTH = 8
34
+ export const PAIRING_CODE_TTL_SECONDS = 3600
35
+ export const PAIRING_RATE_LIMIT_SECONDS = 600
36
+ export const PAIRING_LOCKOUT_SECONDS = 3600
37
+ export const PAIRING_MAX_PENDING_PER_PLATFORM = 3
38
+ export const PAIRING_MAX_FAILED_ATTEMPTS = 5
39
+
40
+ export interface PairingStoreOpts {
41
+ dir: string
42
+ now?: () => number
43
+ }
44
+
45
+ export interface PendingEntry {
46
+ userId: string
47
+ userName: string
48
+ createdAt: number
49
+ }
50
+
51
+ export interface ApprovedEntry {
52
+ userName: string
53
+ approvedAt: number
54
+ }
55
+
56
+ export interface PendingListing {
57
+ platform: string
58
+ code: string
59
+ userId: string
60
+ userName: string
61
+ ageMinutes: number
62
+ createdAt: number
63
+ }
64
+
65
+ export interface ApprovedListing {
66
+ platform: string
67
+ userId: string
68
+ userName: string
69
+ approvedAt: number
70
+ }
71
+
72
+ export interface ApproveResult {
73
+ userId: string
74
+ userName: string
75
+ }
76
+
77
+ export class PairingStore {
78
+ readonly #dir: string
79
+ readonly #now: () => number
80
+
81
+ constructor(opts: PairingStoreOpts) {
82
+ this.#dir = opts.dir
83
+ this.#now = opts.now ?? (() => Date.now() / 1000)
84
+ mkdirSync(this.#dir, { recursive: true })
85
+ }
86
+
87
+ isApproved(platform: string, userId: string): boolean {
88
+ const approved = this.#loadJson<Record<string, ApprovedEntry>>(this.#approvedPath(platform))
89
+ return userId in approved
90
+ }
91
+
92
+ listApproved(platform?: string): ApprovedListing[] {
93
+ const platforms = platform ? [platform] : this.#allPlatforms('approved')
94
+ const out: ApprovedListing[] = []
95
+ for (const p of platforms) {
96
+ const approved = this.#loadJson<Record<string, ApprovedEntry>>(this.#approvedPath(p))
97
+ for (const [uid, info] of Object.entries(approved)) {
98
+ out.push({ platform: p, userId: uid, userName: info.userName, approvedAt: info.approvedAt })
99
+ }
100
+ }
101
+ return out
102
+ }
103
+
104
+ generateCode(platform: string, userId: string, userName = ''): string | null {
105
+ this.#cleanupExpired(platform)
106
+ if (this.#isLockedOut(platform)) return null
107
+ if (this.#isRateLimited(platform, userId)) return null
108
+ const pending = this.#loadJson<Record<string, PendingEntry>>(this.#pendingPath(platform))
109
+ if (Object.keys(pending).length >= PAIRING_MAX_PENDING_PER_PLATFORM) return null
110
+
111
+ let code = ''
112
+ for (let i = 0; i < PAIRING_CODE_LENGTH; i++) {
113
+ code += PAIRING_ALPHABET[randomInt(0, PAIRING_ALPHABET.length)]
114
+ }
115
+
116
+ pending[code] = { userId, userName, createdAt: this.#now() }
117
+ this.#saveJson(this.#pendingPath(platform), pending)
118
+ this.#recordRateLimit(platform, userId)
119
+ return code
120
+ }
121
+
122
+ approveCode(platform: string, code: string): ApproveResult | null {
123
+ this.#cleanupExpired(platform)
124
+ const normalized = code.toUpperCase().trim()
125
+ const pending = this.#loadJson<Record<string, PendingEntry>>(this.#pendingPath(platform))
126
+ const entry = pending[normalized]
127
+ if (!entry) {
128
+ this.#recordFailedAttempt(platform)
129
+ return null
130
+ }
131
+ delete pending[normalized]
132
+ this.#saveJson(this.#pendingPath(platform), pending)
133
+
134
+ const approved = this.#loadJson<Record<string, ApprovedEntry>>(this.#approvedPath(platform))
135
+ approved[entry.userId] = { userName: entry.userName, approvedAt: this.#now() }
136
+ this.#saveJson(this.#approvedPath(platform), approved)
137
+
138
+ this.#clearFailedAttempts(platform)
139
+ return { userId: entry.userId, userName: entry.userName }
140
+ }
141
+
142
+ listPending(platform?: string): PendingListing[] {
143
+ const platforms = platform ? [platform] : this.#allPlatforms('pending')
144
+ const out: PendingListing[] = []
145
+ for (const p of platforms) {
146
+ this.#cleanupExpired(p)
147
+ const pending = this.#loadJson<Record<string, PendingEntry>>(this.#pendingPath(p))
148
+ for (const [code, info] of Object.entries(pending)) {
149
+ const ageMinutes = Math.floor((this.#now() - info.createdAt) / 60)
150
+ out.push({
151
+ platform: p,
152
+ code,
153
+ userId: info.userId,
154
+ userName: info.userName,
155
+ ageMinutes,
156
+ createdAt: info.createdAt,
157
+ })
158
+ }
159
+ }
160
+ return out
161
+ }
162
+
163
+ clearPending(platform?: string): number {
164
+ const platforms = platform ? [platform] : this.#allPlatforms('pending')
165
+ let count = 0
166
+ for (const p of platforms) {
167
+ const pending = this.#loadJson<Record<string, PendingEntry>>(this.#pendingPath(p))
168
+ count += Object.keys(pending).length
169
+ this.#saveJson(this.#pendingPath(p), {})
170
+ }
171
+ return count
172
+ }
173
+
174
+ revoke(platform: string, userId: string): boolean {
175
+ const path = this.#approvedPath(platform)
176
+ const approved = this.#loadJson<Record<string, ApprovedEntry>>(path)
177
+ if (!(userId in approved)) return false
178
+ delete approved[userId]
179
+ this.#saveJson(path, approved)
180
+ return true
181
+ }
182
+
183
+ isLockedOut(platform: string): boolean {
184
+ return this.#isLockedOut(platform)
185
+ }
186
+
187
+ // ----- private helpers -----
188
+
189
+ #pendingPath(platform: string): string {
190
+ return join(this.#dir, `${platform}-pending.json`)
191
+ }
192
+ #approvedPath(platform: string): string {
193
+ return join(this.#dir, `${platform}-approved.json`)
194
+ }
195
+ #rateLimitPath(): string {
196
+ return join(this.#dir, '_rate_limits.json')
197
+ }
198
+
199
+ #loadJson<T>(path: string): T {
200
+ if (!existsSync(path)) return {} as T
201
+ try {
202
+ return JSON.parse(readFileSync(path, 'utf8')) as T
203
+ } catch {
204
+ return {} as T
205
+ }
206
+ }
207
+
208
+ #saveJson(path: string, data: unknown): void {
209
+ const tmp = `${path}.tmp-${process.pid}-${Date.now().toString(36)}`
210
+ writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8')
211
+ renameSync(tmp, path)
212
+ try {
213
+ chmodSync(path, 0o600)
214
+ } catch {
215
+ // non-POSIX; permissions are advisory only
216
+ }
217
+ }
218
+
219
+ #cleanupExpired(platform: string): void {
220
+ const path = this.#pendingPath(platform)
221
+ if (!existsSync(path)) return
222
+ const pending = this.#loadJson<Record<string, PendingEntry>>(path)
223
+ const now = this.#now()
224
+ let changed = false
225
+ for (const [code, info] of Object.entries(pending)) {
226
+ if (now - info.createdAt > PAIRING_CODE_TTL_SECONDS) {
227
+ delete pending[code]
228
+ changed = true
229
+ }
230
+ }
231
+ if (changed) this.#saveJson(path, pending)
232
+ }
233
+
234
+ #isRateLimited(platform: string, userId: string): boolean {
235
+ const limits = this.#loadJson<Record<string, number>>(this.#rateLimitPath())
236
+ const last = limits[`${platform}:${userId}`] ?? 0
237
+ return this.#now() - last < PAIRING_RATE_LIMIT_SECONDS
238
+ }
239
+
240
+ #recordRateLimit(platform: string, userId: string): void {
241
+ const limits = this.#loadJson<Record<string, number>>(this.#rateLimitPath())
242
+ limits[`${platform}:${userId}`] = this.#now()
243
+ this.#saveJson(this.#rateLimitPath(), limits)
244
+ }
245
+
246
+ #isLockedOut(platform: string): boolean {
247
+ const limits = this.#loadJson<Record<string, number>>(this.#rateLimitPath())
248
+ const lockoutUntil = limits[`_lockout:${platform}`] ?? 0
249
+ return this.#now() < lockoutUntil
250
+ }
251
+
252
+ #recordFailedAttempt(platform: string): void {
253
+ const limits = this.#loadJson<Record<string, number>>(this.#rateLimitPath())
254
+ const failKey = `_failures:${platform}`
255
+ const fails = (limits[failKey] ?? 0) + 1
256
+ limits[failKey] = fails
257
+ if (fails >= PAIRING_MAX_FAILED_ATTEMPTS) {
258
+ limits[`_lockout:${platform}`] = this.#now() + PAIRING_LOCKOUT_SECONDS
259
+ limits[failKey] = 0
260
+ }
261
+ this.#saveJson(this.#rateLimitPath(), limits)
262
+ }
263
+
264
+ #clearFailedAttempts(platform: string): void {
265
+ const limits = this.#loadJson<Record<string, number>>(this.#rateLimitPath())
266
+ if (`_failures:${platform}` in limits) {
267
+ delete limits[`_failures:${platform}`]
268
+ this.#saveJson(this.#rateLimitPath(), limits)
269
+ }
270
+ }
271
+
272
+ #allPlatforms(suffix: 'pending' | 'approved'): string[] {
273
+ if (!existsSync(this.#dir)) return []
274
+ const entries = readdirSync(this.#dir)
275
+ const tail = `-${suffix}.json`
276
+ const platforms = new Set<string>()
277
+ for (const f of entries) {
278
+ if (f.endsWith(tail)) {
279
+ const p = f.slice(0, -tail.length)
280
+ if (!p.startsWith('_')) platforms.add(p)
281
+ }
282
+ }
283
+ return Array.from(platforms)
284
+ }
285
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { homedir } from 'node:os'
2
+ import { join } from 'node:path'
3
+
4
+ /** Resolve `~/.lyra` at call time so tests can override via LYRA_ROOT or HOME. */
5
+ function lyraRoot(): string {
6
+ return process.env.LYRA_ROOT ?? join(homedir(), '.lyra')
7
+ }
8
+
9
+ export interface AgentPaths {
10
+ readonly root: string
11
+ readonly config: string
12
+ readonly skills: string
13
+ readonly plugins: string
14
+ readonly agentsDir: string
15
+ agent(id: string): {
16
+ dir: string
17
+ keystore: string
18
+ cache: string
19
+ memoryDir: string
20
+ memoryIndex: string
21
+ agentMemoryDir: string
22
+ userMemoryDir: string
23
+ publicDir: string
24
+ activityLog: string
25
+ runtimeState: string
26
+ inboxDir: string
27
+ pairingDir: string
28
+ }
29
+ }
30
+
31
+ export const agentPaths: AgentPaths = {
32
+ get root() {
33
+ return lyraRoot()
34
+ },
35
+ get config() {
36
+ return join(lyraRoot(), 'config.ts')
37
+ },
38
+ get skills() {
39
+ return join(lyraRoot(), 'skills')
40
+ },
41
+ get plugins() {
42
+ return join(lyraRoot(), 'plugins')
43
+ },
44
+ get agentsDir() {
45
+ return join(lyraRoot(), 'agents')
46
+ },
47
+ agent(id: string) {
48
+ const dir = join(lyraRoot(), 'agents', id)
49
+ return {
50
+ dir,
51
+ keystore: join(dir, 'keystore.json'),
52
+ cache: join(dir, 'cache'),
53
+ memoryDir: join(dir, 'memory'),
54
+ memoryIndex: join(dir, 'memory', 'MEMORY.md'),
55
+ agentMemoryDir: join(dir, 'memory', 'agent'),
56
+ userMemoryDir: join(dir, 'memory', 'user'),
57
+ publicDir: join(dir, 'memory', 'public'),
58
+ activityLog: join(dir, 'activity.jsonl'),
59
+ runtimeState: join(dir, 'runtime', 'state.json'),
60
+ inboxDir: join(dir, 'inbox'),
61
+ pairingDir: join(dir, 'pairing'),
62
+ }
63
+ },
64
+ }
65
+
66
+ /** Compute the deterministic agent id from a wallet address. Stable pre-iNFT. */
67
+ export function placeholderAgentId(walletAddress: string): string {
68
+ const clean = walletAddress.toLowerCase().replace(/^0x/, '')
69
+ return clean.slice(0, 16)
70
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Dangerous command pattern set ported from hermes-agent/tools/approval.py.
3
+ * Pattern matching is the cheap pre-LLM safety floor for `shell.run` and
4
+ * destructive shell-equivalent tool args. Brain still needs explicit approval
5
+ * for matches in `prompt` mode, but YOLO mode (`approvals.mode = "off"`) skips.
6
+ *
7
+ * Patterns adapted to JS regex flavor (no \b on hex/utf, no DOTALL by default).
8
+ * Each entry returns the human description used in approval prompts.
9
+ */
10
+
11
+ const SENSITIVE_WRITE_TARGETS =
12
+ '/etc/[a-z]|/etc/passwd|/etc/shadow|/etc/sudoers|/boot/|/usr/local/etc/'
13
+
14
+ export const DANGEROUS_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = [
15
+ [/\brm\s+(-[^\s]*\s+)*\//, 'delete in root path'],
16
+ [/\brm\s+-[^\s]*r/, 'recursive delete'],
17
+ [/\brm\s+--recursive\b/, 'recursive delete (long flag)'],
18
+ [/\bchmod\s+(-[^\s]*\s+)*(777|666|o\+[rwx]*w|a\+[rwx]*w)\b/, 'world/other-writable permissions'],
19
+ [/\bchown\s+(-[^\s]*)?R\s+root/, 'recursive chown to root'],
20
+ [/\bmkfs\b/, 'format filesystem'],
21
+ [/\bdd\s+.*if=/, 'disk copy'],
22
+ [/>\s*\/dev\/sd/, 'write to block device'],
23
+ [/\bDROP\s+(TABLE|DATABASE)\b/i, 'SQL DROP'],
24
+ [/\bDELETE\s+FROM\b(?!.*\bWHERE\b)/i, 'SQL DELETE without WHERE'],
25
+ [/\bTRUNCATE\s+(TABLE)?\s*\w/i, 'SQL TRUNCATE'],
26
+ [/>\s*\/etc\//, 'overwrite system config'],
27
+ [/\bsystemctl\s+(stop|disable|mask)\b/, 'stop/disable system service'],
28
+ [/\bkill\s+-9\s+-1\b/, 'kill all processes'],
29
+ [/\bpkill\s+-9\b/, 'force kill processes'],
30
+ [/:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/, 'fork bomb'],
31
+ [/\b(bash|sh|zsh|ksh)\s+-[^\s]*c(\s+|$)/, 'shell command via -c/-lc flag'],
32
+ [/\b(python[23]?|perl|ruby|node)\s+-[ec]\s+/, 'script execution via -e/-c flag'],
33
+ [/\b(curl|wget)\b.*\|\s*(ba)?sh\b/, 'pipe remote content to shell'],
34
+ [
35
+ /\b(bash|sh|zsh|ksh)\s+<\s*<?\s*\(\s*(curl|wget)\b/,
36
+ 'execute remote script via process substitution',
37
+ ],
38
+ [new RegExp(`\\btee\\b.*["']?(${SENSITIVE_WRITE_TARGETS})`), 'overwrite system file via tee'],
39
+ [new RegExp(`>>?\\s*["']?(${SENSITIVE_WRITE_TARGETS})`), 'overwrite system file via redirection'],
40
+ [/\bxargs\s+.*\brm\b/, 'xargs with rm'],
41
+ [/\bfind\b.*-exec\s+(\/\S*\/)?rm\b/, 'find -exec rm'],
42
+ [/\bfind\b.*-delete\b/, 'find -delete'],
43
+ // Self-termination protection
44
+ [/\b(pkill|killall)\b.*\b(lyra|cli\.ts|lyra\/bin)\b/, 'kill lyra process (self-termination)'],
45
+ [/\bkill\b.*\$\(\s*pgrep\b/, 'kill process via pgrep expansion (self-termination)'],
46
+ [/\bkill\b.*`\s*pgrep\b/, 'kill process via backtick pgrep expansion (self-termination)'],
47
+ [/\b(cp|mv|install)\b.*\s\/etc\//, 'copy/move file into /etc/'],
48
+ [/\bsed\s+-[^\s]*i.*\s\/etc\//, 'in-place edit of system config'],
49
+ [/\bsed\s+--in-place\b.*\s\/etc\//, 'in-place edit of system config (long flag)'],
50
+ [/\b(python[23]?|perl|ruby|node)\s+<</, 'script execution via heredoc'],
51
+ [/\bgit\s+reset\s+--hard\b/, 'git reset --hard (destroys uncommitted changes)'],
52
+ [/\bgit\s+push\b.*--force\b/, 'git force push (rewrites remote history)'],
53
+ [/\bgit\s+push\b.*\s-f\b/, 'git force push short flag (rewrites remote history)'],
54
+ [/\bgit\s+clean\s+-[^\s]*f/, 'git clean with force (deletes untracked files)'],
55
+ [/\bgit\s+branch\s+-D\b/, 'git branch force delete'],
56
+ [/\bchmod\s+\+x\b.*[;&|]+\s*\.\//, 'chmod +x followed by immediate execution'],
57
+ ] as const
58
+
59
+ export interface DangerousMatch {
60
+ match: true
61
+ key: string
62
+ description: string
63
+ }
64
+
65
+ export interface NoMatch {
66
+ match: false
67
+ }
68
+
69
+ /**
70
+ * Pre-compiled case-insensitive twin of every pattern. `detectDangerousCommand`
71
+ * runs on the hot path of every shell.run; building 35 RegExp objects per call
72
+ * was visible in profiles. Compile once at module load, reuse forever.
73
+ */
74
+ const COMPILED_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = DANGEROUS_PATTERNS.map(
75
+ ([pattern, description]) =>
76
+ [
77
+ pattern.flags.includes('i') ? pattern : new RegExp(pattern.source, `${pattern.flags}i`),
78
+ description,
79
+ ] as const,
80
+ )
81
+
82
+ /**
83
+ * Normalize a command before pattern matching: strip ANSI sequences, NULs,
84
+ * and Unicode lookalikes (NFKC). Mirrors hermes' defense-in-depth so
85
+ * obfuscation tricks don't bypass detection. The patterns below intentionally
86
+ * include the control characters they detect, hence the noControlCharactersInRegex
87
+ * suppressions.
88
+ */
89
+ function normalize(command: string): string {
90
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI matcher
91
+ const ansi = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|P[^\x1B]*\x1B\\)/g
92
+ let s = command.replace(ansi, '')
93
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional NUL stripping
94
+ s = s.replace(/\x00/g, '')
95
+ s = s.normalize('NFKC')
96
+ return s
97
+ }
98
+
99
+ export function detectDangerousCommand(command: string): DangerousMatch | NoMatch {
100
+ const norm = normalize(command).toLowerCase()
101
+ for (const [re, description] of COMPILED_PATTERNS) {
102
+ if (re.test(norm)) return { match: true, key: description, description }
103
+ }
104
+ return { match: false }
105
+ }