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.
- package/README.md +24 -0
- package/package.json +65 -0
- package/src/brain/compaction.test.ts +156 -0
- package/src/brain/compaction.ts +131 -0
- package/src/brain/demo-endpoint.ts +13 -0
- package/src/brain/frozen-prefix.test.ts +235 -0
- package/src/brain/frozen-prefix.ts +320 -0
- package/src/brain/history-persist.test.ts +129 -0
- package/src/brain/history-persist.ts +154 -0
- package/src/brain/index.ts +44 -0
- package/src/brain/openai-brain.test.ts +61 -0
- package/src/brain/openai-brain.ts +544 -0
- package/src/brain/sanitize.test.ts +27 -0
- package/src/brain/sanitize.ts +23 -0
- package/src/brain/stub.ts +20 -0
- package/src/brain/types.ts +129 -0
- package/src/chain.ts +35 -0
- package/src/claude-plugins/discovery.test.ts +71 -0
- package/src/claude-plugins/discovery.ts +152 -0
- package/src/claude-plugins/index.ts +6 -0
- package/src/claude-plugins/types.ts +38 -0
- package/src/commands/index.ts +16 -0
- package/src/commands/registry.test.ts +186 -0
- package/src/commands/registry.ts +255 -0
- package/src/config.ts +201 -0
- package/src/economy/index.ts +6 -0
- package/src/events/index.ts +4 -0
- package/src/events/listeners.ts +37 -0
- package/src/events/queue.test.ts +43 -0
- package/src/events/queue.ts +63 -0
- package/src/events/router.ts +42 -0
- package/src/events/types.ts +28 -0
- package/src/format.ts +13 -0
- package/src/index.test.ts +6 -0
- package/src/index.ts +314 -0
- package/src/locks.test.ts +259 -0
- package/src/locks.ts +233 -0
- package/src/mcp/discovery.test.ts +97 -0
- package/src/mcp/discovery.ts +150 -0
- package/src/mcp/index.ts +10 -0
- package/src/mcp/manager.test.ts +97 -0
- package/src/mcp/manager.ts +110 -0
- package/src/mcp/stdio-client.ts +154 -0
- package/src/mcp/types.ts +44 -0
- package/src/memory/edit.test.ts +41 -0
- package/src/memory/edit.ts +53 -0
- package/src/memory/encryption.test.ts +68 -0
- package/src/memory/encryption.ts +94 -0
- package/src/memory/fs-util.ts +15 -0
- package/src/memory/index-file.ts +74 -0
- package/src/memory/index-sync.test.ts +81 -0
- package/src/memory/index-sync.ts +99 -0
- package/src/memory/index.ts +58 -0
- package/src/memory/list-tool.ts +105 -0
- package/src/memory/pack-blob.test.ts +90 -0
- package/src/memory/pack-blob.ts +120 -0
- package/src/memory/pack-gather.test.ts +110 -0
- package/src/memory/pack-gather.ts +112 -0
- package/src/memory/parser.test.ts +29 -0
- package/src/memory/parser.ts +20 -0
- package/src/memory/path-drift.test.ts +71 -0
- package/src/memory/read-tool-fallback.test.ts +54 -0
- package/src/memory/read-tool.ts +198 -0
- package/src/memory/save-tool.test.ts +193 -0
- package/src/memory/save-tool.ts +189 -0
- package/src/memory/scan.test.ts +24 -0
- package/src/memory/scan.ts +63 -0
- package/src/memory/topic.ts +32 -0
- package/src/memory/types.ts +49 -0
- package/src/migration/index.ts +6 -0
- package/src/migration/option3-crypto.test.ts +106 -0
- package/src/migration/option3-crypto.ts +143 -0
- package/src/pairing.test.ts +212 -0
- package/src/pairing.ts +285 -0
- package/src/paths.ts +70 -0
- package/src/permission/dangerous.ts +105 -0
- package/src/permission/env-redact.ts +54 -0
- package/src/permission/index.ts +16 -0
- package/src/permission/path-guard.ts +114 -0
- package/src/permission/permission.test.ts +299 -0
- package/src/permission/service.ts +191 -0
- package/src/plugins/context.ts +226 -0
- package/src/plugins/hooks.ts +81 -0
- package/src/plugins/index.ts +24 -0
- package/src/plugins/plugins.test.ts +196 -0
- package/src/plugins/tool-search.ts +49 -0
- package/src/public/card.test.ts +70 -0
- package/src/public/card.ts +67 -0
- package/src/runtime/activity.ts +29 -0
- package/src/runtime/index.ts +7 -0
- package/src/runtime/runtime.test.ts +55 -0
- package/src/runtime/runtime.ts +126 -0
- package/src/sandbox/credentials.ts +25 -0
- package/src/sandbox/docker.ts +389 -0
- package/src/sandbox/factory.ts +99 -0
- package/src/sandbox/index.ts +15 -0
- package/src/sandbox/linux.ts +141 -0
- package/src/sandbox/local.ts +19 -0
- package/src/sandbox/macos.ts +71 -0
- package/src/sandbox/sandbox.test.ts +386 -0
- package/src/sandbox/seatbelt-profile.ts +139 -0
- package/src/sandbox/types.ts +129 -0
- package/src/skills/index.ts +8 -0
- package/src/skills/scanner.test.ts +137 -0
- package/src/skills/scanner.ts +257 -0
- package/src/skills/triggers.test.ts +77 -0
- package/src/skills/triggers.ts +78 -0
- package/src/skills/types.ts +37 -0
- package/src/storage/encryption.test.ts +30 -0
- package/src/storage/encryption.ts +87 -0
- package/src/storage/factory.ts +31 -0
- package/src/storage/index.ts +11 -0
- package/src/storage/local-stub.ts +70 -0
- package/src/storage/sqlite.test.ts +29 -0
- package/src/storage/sqlite.ts +95 -0
- package/src/storage/types.ts +21 -0
- package/src/tools/escalation.test.ts +348 -0
- package/src/tools/escalation.ts +200 -0
- package/src/tools/index.ts +11 -0
- package/src/tools/registry.test.ts +70 -0
- package/src/tools/registry.ts +152 -0
- package/src/tools/types.ts +65 -0
- package/src/tools/zod-helpers.test.ts +63 -0
- package/src/tools/zod-helpers.ts +36 -0
- package/src/tools/zod-schema.ts +99 -0
- package/src/wallet/drain.test.ts +41 -0
- package/src/wallet/drain.ts +76 -0
- package/src/wallet/eoa.ts +61 -0
- package/src/wallet/index.ts +14 -0
- package/src/wallet/keystore.test.ts +17 -0
- 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
|
+
}
|