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.
Files changed (109) hide show
  1. package/README.md +24 -0
  2. package/package.json +69 -0
  3. package/src/brain/compaction.ts +131 -0
  4. package/src/brain/frozen-prefix.ts +320 -0
  5. package/src/brain/history-persist.ts +154 -0
  6. package/src/brain/index.ts +43 -0
  7. package/src/brain/openai-brain.ts +533 -0
  8. package/src/brain/sanitize.ts +23 -0
  9. package/src/brain/stub.ts +20 -0
  10. package/src/brain/types.ts +129 -0
  11. package/src/chain.ts +75 -0
  12. package/src/claude-plugins/discovery.ts +152 -0
  13. package/src/claude-plugins/index.ts +6 -0
  14. package/src/claude-plugins/types.ts +38 -0
  15. package/src/commands/index.ts +16 -0
  16. package/src/commands/registry.ts +255 -0
  17. package/src/config.ts +213 -0
  18. package/src/economy/index.ts +6 -0
  19. package/src/events/index.ts +4 -0
  20. package/src/events/listeners.ts +37 -0
  21. package/src/events/queue.ts +63 -0
  22. package/src/events/router.ts +42 -0
  23. package/src/events/types.ts +28 -0
  24. package/src/format.ts +12 -0
  25. package/src/identity/agent-card.ts +110 -0
  26. package/src/identity/deployments.ts +20 -0
  27. package/src/identity/erc8004.ts +161 -0
  28. package/src/identity/index.ts +29 -0
  29. package/src/identity/keystore-blob.ts +60 -0
  30. package/src/identity/receipt.ts +27 -0
  31. package/src/identity/stub.ts +29 -0
  32. package/src/identity/types.ts +20 -0
  33. package/src/index.ts +372 -0
  34. package/src/locks.ts +233 -0
  35. package/src/mcp/discovery.ts +150 -0
  36. package/src/mcp/index.ts +10 -0
  37. package/src/mcp/manager.ts +110 -0
  38. package/src/mcp/stdio-client.ts +154 -0
  39. package/src/mcp/types.ts +44 -0
  40. package/src/memory/edit.ts +53 -0
  41. package/src/memory/encryption.ts +88 -0
  42. package/src/memory/fs-util.ts +15 -0
  43. package/src/memory/index-file.ts +74 -0
  44. package/src/memory/index-sync.ts +99 -0
  45. package/src/memory/index.ts +58 -0
  46. package/src/memory/list-tool.ts +105 -0
  47. package/src/memory/pack-blob.ts +120 -0
  48. package/src/memory/pack-gather.ts +112 -0
  49. package/src/memory/parser.ts +20 -0
  50. package/src/memory/read-tool.ts +198 -0
  51. package/src/memory/save-tool.ts +189 -0
  52. package/src/memory/scan.ts +63 -0
  53. package/src/memory/topic.ts +32 -0
  54. package/src/memory/types.ts +49 -0
  55. package/src/migration/index.ts +6 -0
  56. package/src/migration/option3-crypto.ts +127 -0
  57. package/src/operator/index.ts +9 -0
  58. package/src/operator/keychain.ts +53 -0
  59. package/src/operator/keystore-file.ts +33 -0
  60. package/src/operator/privkey-base.ts +60 -0
  61. package/src/operator/raw-privkey.ts +39 -0
  62. package/src/operator/signer.ts +46 -0
  63. package/src/operator/walletconnect.ts +454 -0
  64. package/src/pairing.ts +285 -0
  65. package/src/paths.ts +70 -0
  66. package/src/permission/dangerous.ts +108 -0
  67. package/src/permission/env-redact.ts +54 -0
  68. package/src/permission/index.ts +16 -0
  69. package/src/permission/path-guard.ts +114 -0
  70. package/src/permission/service.ts +191 -0
  71. package/src/plugins/context.ts +225 -0
  72. package/src/plugins/hooks.ts +81 -0
  73. package/src/plugins/index.ts +24 -0
  74. package/src/plugins/tool-search.ts +49 -0
  75. package/src/public/card.ts +67 -0
  76. package/src/runtime/activity.ts +29 -0
  77. package/src/runtime/index.ts +2 -0
  78. package/src/runtime/runtime.ts +113 -0
  79. package/src/sandbox/credentials.ts +25 -0
  80. package/src/sandbox/docker.ts +396 -0
  81. package/src/sandbox/factory.ts +99 -0
  82. package/src/sandbox/index.ts +15 -0
  83. package/src/sandbox/linux.ts +141 -0
  84. package/src/sandbox/local.ts +19 -0
  85. package/src/sandbox/macos.ts +71 -0
  86. package/src/sandbox/seatbelt-profile.ts +139 -0
  87. package/src/sandbox/types.ts +129 -0
  88. package/src/skills/index.ts +8 -0
  89. package/src/skills/scanner.ts +257 -0
  90. package/src/skills/triggers.ts +78 -0
  91. package/src/skills/types.ts +37 -0
  92. package/src/storage/encryption.ts +87 -0
  93. package/src/storage/factory.ts +31 -0
  94. package/src/storage/index.ts +11 -0
  95. package/src/storage/local-stub.ts +70 -0
  96. package/src/storage/sqlite.ts +95 -0
  97. package/src/storage/types.ts +21 -0
  98. package/src/tools/escalation.ts +200 -0
  99. package/src/tools/index.ts +11 -0
  100. package/src/tools/registry.ts +152 -0
  101. package/src/tools/types.ts +65 -0
  102. package/src/tools/zod-helpers.ts +36 -0
  103. package/src/tools/zod-schema.ts +99 -0
  104. package/src/wallet/drain.ts +79 -0
  105. package/src/wallet/eoa.ts +51 -0
  106. package/src/wallet/index.ts +47 -0
  107. package/src/wallet/keystore.ts +50 -0
  108. package/src/wallet/operator-keystore-crypto.ts +530 -0
  109. 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
+ }