nebula-ai-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 +69 -0
- package/src/brain/compaction.ts +131 -0
- package/src/brain/frozen-prefix.ts +320 -0
- package/src/brain/history-persist.ts +154 -0
- package/src/brain/index.ts +43 -0
- package/src/brain/openai-brain.ts +533 -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 +75 -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.ts +255 -0
- package/src/config.ts +213 -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.ts +63 -0
- package/src/events/router.ts +42 -0
- package/src/events/types.ts +28 -0
- package/src/format.ts +12 -0
- package/src/identity/agent-card.ts +110 -0
- package/src/identity/deployments.ts +20 -0
- package/src/identity/erc8004.ts +161 -0
- package/src/identity/index.ts +29 -0
- package/src/identity/keystore-blob.ts +60 -0
- package/src/identity/receipt.ts +27 -0
- package/src/identity/stub.ts +29 -0
- package/src/identity/types.ts +20 -0
- package/src/index.ts +372 -0
- package/src/locks.ts +233 -0
- package/src/mcp/discovery.ts +150 -0
- package/src/mcp/index.ts +10 -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.ts +53 -0
- package/src/memory/encryption.ts +88 -0
- package/src/memory/fs-util.ts +15 -0
- package/src/memory/index-file.ts +74 -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.ts +120 -0
- package/src/memory/pack-gather.ts +112 -0
- package/src/memory/parser.ts +20 -0
- package/src/memory/read-tool.ts +198 -0
- package/src/memory/save-tool.ts +189 -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.ts +127 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/keychain.ts +53 -0
- package/src/operator/keystore-file.ts +33 -0
- package/src/operator/privkey-base.ts +60 -0
- package/src/operator/raw-privkey.ts +39 -0
- package/src/operator/signer.ts +46 -0
- package/src/operator/walletconnect.ts +454 -0
- package/src/pairing.ts +285 -0
- package/src/paths.ts +70 -0
- package/src/permission/dangerous.ts +108 -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/service.ts +191 -0
- package/src/plugins/context.ts +225 -0
- package/src/plugins/hooks.ts +81 -0
- package/src/plugins/index.ts +24 -0
- package/src/plugins/tool-search.ts +49 -0
- package/src/public/card.ts +67 -0
- package/src/runtime/activity.ts +29 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/runtime.ts +113 -0
- package/src/sandbox/credentials.ts +25 -0
- package/src/sandbox/docker.ts +396 -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/seatbelt-profile.ts +139 -0
- package/src/sandbox/types.ts +129 -0
- package/src/skills/index.ts +8 -0
- package/src/skills/scanner.ts +257 -0
- package/src/skills/triggers.ts +78 -0
- package/src/skills/types.ts +37 -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.ts +95 -0
- package/src/storage/types.ts +21 -0
- package/src/tools/escalation.ts +200 -0
- package/src/tools/index.ts +11 -0
- package/src/tools/registry.ts +152 -0
- package/src/tools/types.ts +65 -0
- package/src/tools/zod-helpers.ts +36 -0
- package/src/tools/zod-schema.ts +99 -0
- package/src/wallet/drain.ts +79 -0
- package/src/wallet/eoa.ts +51 -0
- package/src/wallet/index.ts +47 -0
- package/src/wallet/keystore.ts +50 -0
- package/src/wallet/operator-keystore-crypto.ts +530 -0
- package/src/wallet/operator-session.ts +344 -0
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
|
+
// `nebula 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 `~/.nebula` at call time so tests can override via NEBULA_ROOT or HOME. */
|
|
5
|
+
function nebulaRoot(): string {
|
|
6
|
+
return process.env.NEBULA_ROOT ?? join(homedir(), '.nebula')
|
|
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 nebulaRoot()
|
|
34
|
+
},
|
|
35
|
+
get config() {
|
|
36
|
+
return join(nebulaRoot(), 'config.ts')
|
|
37
|
+
},
|
|
38
|
+
get skills() {
|
|
39
|
+
return join(nebulaRoot(), 'skills')
|
|
40
|
+
},
|
|
41
|
+
get plugins() {
|
|
42
|
+
return join(nebulaRoot(), 'plugins')
|
|
43
|
+
},
|
|
44
|
+
get agentsDir() {
|
|
45
|
+
return join(nebulaRoot(), 'agents')
|
|
46
|
+
},
|
|
47
|
+
agent(id: string) {
|
|
48
|
+
const dir = join(nebulaRoot(), '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,108 @@
|
|
|
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
|
+
[
|
|
45
|
+
/\b(pkill|killall)\b.*\b(nebula|cli\.ts|nebula\/bin)\b/,
|
|
46
|
+
'kill nebula process (self-termination)',
|
|
47
|
+
],
|
|
48
|
+
[/\bkill\b.*\$\(\s*pgrep\b/, 'kill process via pgrep expansion (self-termination)'],
|
|
49
|
+
[/\bkill\b.*`\s*pgrep\b/, 'kill process via backtick pgrep expansion (self-termination)'],
|
|
50
|
+
[/\b(cp|mv|install)\b.*\s\/etc\//, 'copy/move file into /etc/'],
|
|
51
|
+
[/\bsed\s+-[^\s]*i.*\s\/etc\//, 'in-place edit of system config'],
|
|
52
|
+
[/\bsed\s+--in-place\b.*\s\/etc\//, 'in-place edit of system config (long flag)'],
|
|
53
|
+
[/\b(python[23]?|perl|ruby|node)\s+<</, 'script execution via heredoc'],
|
|
54
|
+
[/\bgit\s+reset\s+--hard\b/, 'git reset --hard (destroys uncommitted changes)'],
|
|
55
|
+
[/\bgit\s+push\b.*--force\b/, 'git force push (rewrites remote history)'],
|
|
56
|
+
[/\bgit\s+push\b.*\s-f\b/, 'git force push short flag (rewrites remote history)'],
|
|
57
|
+
[/\bgit\s+clean\s+-[^\s]*f/, 'git clean with force (deletes untracked files)'],
|
|
58
|
+
[/\bgit\s+branch\s+-D\b/, 'git branch force delete'],
|
|
59
|
+
[/\bchmod\s+\+x\b.*[;&|]+\s*\.\//, 'chmod +x followed by immediate execution'],
|
|
60
|
+
] as const
|
|
61
|
+
|
|
62
|
+
export interface DangerousMatch {
|
|
63
|
+
match: true
|
|
64
|
+
key: string
|
|
65
|
+
description: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface NoMatch {
|
|
69
|
+
match: false
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Pre-compiled case-insensitive twin of every pattern. `detectDangerousCommand`
|
|
74
|
+
* runs on the hot path of every shell.run; building 35 RegExp objects per call
|
|
75
|
+
* was visible in profiles. Compile once at module load, reuse forever.
|
|
76
|
+
*/
|
|
77
|
+
const COMPILED_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = DANGEROUS_PATTERNS.map(
|
|
78
|
+
([pattern, description]) =>
|
|
79
|
+
[
|
|
80
|
+
pattern.flags.includes('i') ? pattern : new RegExp(pattern.source, `${pattern.flags}i`),
|
|
81
|
+
description,
|
|
82
|
+
] as const,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Normalize a command before pattern matching: strip ANSI sequences, NULs,
|
|
87
|
+
* and Unicode lookalikes (NFKC). Mirrors hermes' defense-in-depth so
|
|
88
|
+
* obfuscation tricks don't bypass detection. The patterns below intentionally
|
|
89
|
+
* include the control characters they detect, hence the noControlCharactersInRegex
|
|
90
|
+
* suppressions.
|
|
91
|
+
*/
|
|
92
|
+
function normalize(command: string): string {
|
|
93
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI matcher
|
|
94
|
+
const ansi = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|P[^\x1B]*\x1B\\)/g
|
|
95
|
+
let s = command.replace(ansi, '')
|
|
96
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional NUL stripping
|
|
97
|
+
s = s.replace(/\x00/g, '')
|
|
98
|
+
s = s.normalize('NFKC')
|
|
99
|
+
return s
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function detectDangerousCommand(command: string): DangerousMatch | NoMatch {
|
|
103
|
+
const norm = normalize(command).toLowerCase()
|
|
104
|
+
for (const [re, description] of COMPILED_PATTERNS) {
|
|
105
|
+
if (re.test(norm)) return { match: true, key: description, description }
|
|
106
|
+
}
|
|
107
|
+
return { match: false }
|
|
108
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redact secrets-bearing env vars before passing process.env to a shell
|
|
3
|
+
* subprocess. Mirrors hermes' env passthrough policy: API keys, wallet
|
|
4
|
+
* material, and provider creds never leak to a child process the brain
|
|
5
|
+
* controls. The brain may still need PATH, HOME, SHELL, LANG, TERM, etc.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const ALWAYS_DENY: RegExp[] = [
|
|
9
|
+
/^NEBULA_(OPERATOR|AGENT)_PRIVKEY/i,
|
|
10
|
+
/^NEBULA_KEYCHAIN/i,
|
|
11
|
+
/^NEBULA_TEST_AGENT_PRIVKEY/i,
|
|
12
|
+
/^OPENAI_API_KEY$/i,
|
|
13
|
+
/^ANTHROPIC_API_KEY$/i,
|
|
14
|
+
/^GOOGLE_API_KEY$/i,
|
|
15
|
+
/^GEMINI_API_KEY$/i,
|
|
16
|
+
/^GROQ_API_KEY$/i,
|
|
17
|
+
/^AZURE_OPENAI_API_KEY$/i,
|
|
18
|
+
/^DEEPSEEK_API_KEY$/i,
|
|
19
|
+
/^MISTRAL_API_KEY$/i,
|
|
20
|
+
/^TOGETHER_API_KEY$/i,
|
|
21
|
+
/^XAI_API_KEY$/i,
|
|
22
|
+
/^GH_TOKEN$/i,
|
|
23
|
+
/^GITHUB_TOKEN$/i,
|
|
24
|
+
/^GITLAB_TOKEN$/i,
|
|
25
|
+
/^NPM_TOKEN$/i,
|
|
26
|
+
/^AWS_(ACCESS_KEY_ID|SECRET_ACCESS_KEY|SESSION_TOKEN)$/i,
|
|
27
|
+
/^GCP_/i,
|
|
28
|
+
/^GOOGLE_APPLICATION_CREDENTIALS$/i,
|
|
29
|
+
/^OG_(PRIVKEY|PRIVATE_KEY|MNEMONIC)/i,
|
|
30
|
+
/_(PRIVKEY|PRIVATE_KEY|SECRET|MNEMONIC|API_KEY|AUTH_TOKEN)$/i,
|
|
31
|
+
/^DATABASE_URL$/i,
|
|
32
|
+
/^TELEGRAM_BOT_TOKEN$/i,
|
|
33
|
+
/^DISCORD_BOT_TOKEN$/i,
|
|
34
|
+
/^STRIPE_SECRET_KEY$/i,
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
export interface EnvRedactResult {
|
|
38
|
+
env: Record<string, string>
|
|
39
|
+
removed: string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function redactEnv(env: NodeJS.ProcessEnv | Record<string, string>): EnvRedactResult {
|
|
43
|
+
const out: Record<string, string> = {}
|
|
44
|
+
const removed: string[] = []
|
|
45
|
+
for (const [k, v] of Object.entries(env)) {
|
|
46
|
+
if (typeof v !== 'string') continue
|
|
47
|
+
if (ALWAYS_DENY.some(re => re.test(k))) {
|
|
48
|
+
removed.push(k)
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
out[k] = v
|
|
52
|
+
}
|
|
53
|
+
return { env: out, removed }
|
|
54
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export {
|
|
2
|
+
detectDangerousCommand,
|
|
3
|
+
DANGEROUS_PATTERNS,
|
|
4
|
+
type DangerousMatch,
|
|
5
|
+
type NoMatch,
|
|
6
|
+
} from './dangerous'
|
|
7
|
+
export { PathGuard, type PathGuardOpts, type PathGuardResult } from './path-guard'
|
|
8
|
+
export { redactEnv, type EnvRedactResult } from './env-redact'
|
|
9
|
+
export {
|
|
10
|
+
PermissionService,
|
|
11
|
+
type PermissionMode,
|
|
12
|
+
type PermissionDecision,
|
|
13
|
+
type PermissionRequest,
|
|
14
|
+
type PermissionPrompter,
|
|
15
|
+
type PermissionServiceOpts,
|
|
16
|
+
} from './service'
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Default denylist for `fs.write` / `fs.patch` writes. Hard-deny paths whose
|
|
7
|
+
* compromise would let the agent leak operator credentials or system state:
|
|
8
|
+
*
|
|
9
|
+
* - SSH/AWS/GCP credential trees (~/.ssh, ~/.aws, ~/.config/gcloud)
|
|
10
|
+
* - Dotenv-style files (.env, .env.local, etc.)
|
|
11
|
+
* - System config (/etc/, /boot/, /usr/local/etc/)
|
|
12
|
+
* - The nebula state tree itself (`agentDir` and parent `~/.nebula/`)
|
|
13
|
+
* so the brain can't rewrite its own config or operator keystore.
|
|
14
|
+
*
|
|
15
|
+
* The constructor takes the agentDir explicitly so each ToolRegistry instance
|
|
16
|
+
* has the right denylist for its agent.
|
|
17
|
+
*/
|
|
18
|
+
export interface PathGuardOpts {
|
|
19
|
+
agentDir: string
|
|
20
|
+
/** Extra absolute paths to deny (test override). */
|
|
21
|
+
extraDeny?: string[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PathGuardResult {
|
|
25
|
+
allowed: boolean
|
|
26
|
+
reason?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_DENY_PATTERNS: RegExp[] = [
|
|
30
|
+
/\.ssh(\/|$)/,
|
|
31
|
+
/\.aws(\/|$)/,
|
|
32
|
+
/\.config\/gcloud(\/|$)/,
|
|
33
|
+
/(^|\/)\.env(\.|$)/,
|
|
34
|
+
/^\/etc\//,
|
|
35
|
+
/^\/boot\//,
|
|
36
|
+
/^\/usr\/local\/etc\//,
|
|
37
|
+
/^\/var\/log\//,
|
|
38
|
+
/^\/sys(\/|$)/,
|
|
39
|
+
/^\/proc(\/|$)/,
|
|
40
|
+
/^\/dev(\/|$)/,
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* macOS `/var/folders/...` resolves to `/private/var/folders/...` via symlink;
|
|
45
|
+
* Linux is usually direct. `path.resolve()` does NOT follow symlinks, so a
|
|
46
|
+
* naive textual compare would let a brain that addresses the canonical form
|
|
47
|
+
* smuggle past the denylist. Canonicalise at construction (and at check time)
|
|
48
|
+
* so both forms are caught.
|
|
49
|
+
*/
|
|
50
|
+
function safeRealpath(p: string): string {
|
|
51
|
+
try {
|
|
52
|
+
return realpathSync(p)
|
|
53
|
+
} catch {
|
|
54
|
+
return p
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Both forms of one denylist entry, so as-given OR canonical can match. */
|
|
59
|
+
function denyEntry(p: string): string[] {
|
|
60
|
+
const raw = resolve(p)
|
|
61
|
+
const canon = safeRealpath(raw)
|
|
62
|
+
return raw === canon ? [raw] : [raw, canon]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class PathGuard {
|
|
66
|
+
private readonly absolutePathsDenied: string[]
|
|
67
|
+
|
|
68
|
+
constructor(private readonly opts: PathGuardOpts) {
|
|
69
|
+
const home = homedir()
|
|
70
|
+
const nebulaRoot = resolve(home, '.nebula')
|
|
71
|
+
// Each protected location contributes BOTH the raw resolve()'d form and
|
|
72
|
+
// the realpath-canonical form. macOS resolves /var/folders to /private/...
|
|
73
|
+
// and a path being checked may not exist yet (e.g. fs.write of a new file
|
|
74
|
+
// inside agentDir), so realpath at check time would return the raw form.
|
|
75
|
+
// Storing both at construction lets either match.
|
|
76
|
+
this.absolutePathsDenied = [
|
|
77
|
+
...denyEntry(opts.agentDir),
|
|
78
|
+
...denyEntry(nebulaRoot),
|
|
79
|
+
...denyEntry(resolve(home, '.ssh')),
|
|
80
|
+
...denyEntry(resolve(home, '.aws')),
|
|
81
|
+
...denyEntry(resolve(home, '.config', 'gcloud')),
|
|
82
|
+
...(opts.extraDeny ?? []).flatMap(p => denyEntry(p)),
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
check(rawPath: string): PathGuardResult {
|
|
87
|
+
let abs: string
|
|
88
|
+
try {
|
|
89
|
+
abs = resolve(rawPath.startsWith('~') ? rawPath.replace('~', homedir()) : rawPath)
|
|
90
|
+
} catch {
|
|
91
|
+
return { allowed: false, reason: 'unresolvable path' }
|
|
92
|
+
}
|
|
93
|
+
// Check both the as-given form (`/var/folders/.../foo`) and the canonical
|
|
94
|
+
// form (`/private/var/folders/.../foo`) — either matching the denylist
|
|
95
|
+
// is a hit. Cheap (one realpath syscall) and closes a real bypass hole.
|
|
96
|
+
const canonical = safeRealpath(abs)
|
|
97
|
+
for (const denied of this.absolutePathsDenied) {
|
|
98
|
+
if (
|
|
99
|
+
abs === denied ||
|
|
100
|
+
abs.startsWith(`${denied}/`) ||
|
|
101
|
+
canonical === denied ||
|
|
102
|
+
canonical.startsWith(`${denied}/`)
|
|
103
|
+
) {
|
|
104
|
+
return { allowed: false, reason: `protected path: ${denied}` }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
for (const re of DEFAULT_DENY_PATTERNS) {
|
|
108
|
+
if (re.test(abs) || re.test(canonical)) {
|
|
109
|
+
return { allowed: false, reason: `protected path pattern: ${re.source}` }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { allowed: true }
|
|
113
|
+
}
|
|
114
|
+
}
|