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,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS sandbox-exec backend. Wraps every tool spawn in:
|
|
3
|
+
*
|
|
4
|
+
* sandbox-exec -p '<seatbelt-profile>' <orig-command> <orig-args...>
|
|
5
|
+
*
|
|
6
|
+
* `sandbox-exec` is at /usr/bin/sandbox-exec on every macOS install. The
|
|
7
|
+
* `man` page calls it "deprecated" in favour of the modern App Sandbox API,
|
|
8
|
+
* but it's still ships and is used by Apple internally; verified working on
|
|
9
|
+
* macOS 25.4.0. The deprecation is a recommendation that new GUI apps adopt
|
|
10
|
+
* App Sandbox, not a removal.
|
|
11
|
+
*
|
|
12
|
+
* The profile is built once at backend-init and reused for every spawn. The
|
|
13
|
+
* profile is passed inline via `-p` (no temp file management needed).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync } from 'node:fs'
|
|
17
|
+
import { buildSeatbeltProfile } from './seatbelt-profile'
|
|
18
|
+
import type {
|
|
19
|
+
SandboxBackend,
|
|
20
|
+
SandboxBackendOpts,
|
|
21
|
+
SandboxEnvHint,
|
|
22
|
+
SandboxSpawnRequest,
|
|
23
|
+
WrappedSpawn,
|
|
24
|
+
} from './types'
|
|
25
|
+
|
|
26
|
+
const SANDBOX_EXEC_PATH = '/usr/bin/sandbox-exec'
|
|
27
|
+
|
|
28
|
+
export class MacOSSandboxExecBackend implements SandboxBackend {
|
|
29
|
+
readonly mode = 'os' as const
|
|
30
|
+
readonly label = 'os:darwin'
|
|
31
|
+
private readonly profile: string
|
|
32
|
+
|
|
33
|
+
constructor(opts: SandboxBackendOpts) {
|
|
34
|
+
if (!existsSync(SANDBOX_EXEC_PATH)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`sandbox-exec not found at ${SANDBOX_EXEC_PATH}. macOS sandbox backend requires the system tool.`,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
this.profile = buildSeatbeltProfile({
|
|
40
|
+
agentDir: opts.agentDir,
|
|
41
|
+
workspaceRoot: opts.workspaceRoot,
|
|
42
|
+
homedir: opts.homedir,
|
|
43
|
+
extraWriteAllow: opts.extraWriteAllow,
|
|
44
|
+
extraWriteDeny: opts.extraWriteDeny,
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Test-only accessor for the rendered profile. */
|
|
49
|
+
getProfile(): string {
|
|
50
|
+
return this.profile
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
envHint(): SandboxEnvHint {
|
|
54
|
+
return {
|
|
55
|
+
mode: 'os',
|
|
56
|
+
label: this.label,
|
|
57
|
+
innerOs: 'darwin',
|
|
58
|
+
workspaceMount: null,
|
|
59
|
+
scope:
|
|
60
|
+
'shell.run, code.execute, shell.process_start are wrapped in sandbox-exec; writes outside agentDir + cwd + /tmp/lyra-* are denied',
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async wrapSpawn(req: SandboxSpawnRequest): Promise<WrappedSpawn> {
|
|
65
|
+
return {
|
|
66
|
+
command: SANDBOX_EXEC_PATH,
|
|
67
|
+
args: ['-p', this.profile, req.command, ...req.args],
|
|
68
|
+
options: req.options,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { makeSandboxBackend } from './factory'
|
|
3
|
+
import { buildBwrapArgs } from './linux'
|
|
4
|
+
import { LocalBackend } from './local'
|
|
5
|
+
import { MacOSSandboxExecBackend } from './macos'
|
|
6
|
+
import { buildSeatbeltProfile } from './seatbelt-profile'
|
|
7
|
+
|
|
8
|
+
describe('LocalBackend (passthrough)', () => {
|
|
9
|
+
test('returns spawn request unchanged', async () => {
|
|
10
|
+
const b = new LocalBackend()
|
|
11
|
+
const req = {
|
|
12
|
+
command: '/bin/sh',
|
|
13
|
+
args: ['-c', 'echo ok'],
|
|
14
|
+
options: { cwd: '/tmp' },
|
|
15
|
+
}
|
|
16
|
+
const out = await b.wrapSpawn(req)
|
|
17
|
+
expect(out.command).toBe('/bin/sh')
|
|
18
|
+
expect(out.args).toEqual(['-c', 'echo ok'])
|
|
19
|
+
expect(out.options).toEqual({ cwd: '/tmp' })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('mode + label expose correct identifiers', () => {
|
|
23
|
+
const b = new LocalBackend()
|
|
24
|
+
expect(b.mode).toBe('none')
|
|
25
|
+
expect(b.label).toBe('none')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('buildSeatbeltProfile', () => {
|
|
30
|
+
test('emits a deny-default profile that explicitly allows agentDir + workspaceRoot', () => {
|
|
31
|
+
const profile = buildSeatbeltProfile({
|
|
32
|
+
agentDir: '/Users/test/.lyra/agents/abc',
|
|
33
|
+
workspaceRoot: '/Users/test/Documents/proj',
|
|
34
|
+
homedir: '/Users/test',
|
|
35
|
+
})
|
|
36
|
+
expect(profile).toContain('(deny default)')
|
|
37
|
+
expect(profile).toContain('(allow file-write* (subpath "/Users/test/.lyra/agents/abc"))')
|
|
38
|
+
expect(profile).toContain('(allow file-write* (subpath "/Users/test/Documents/proj"))')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('explicitly denies credential paths for both reads and writes', () => {
|
|
42
|
+
const profile = buildSeatbeltProfile({
|
|
43
|
+
agentDir: '/Users/test/.lyra/agents/abc',
|
|
44
|
+
workspaceRoot: '/Users/test/work',
|
|
45
|
+
homedir: '/Users/test',
|
|
46
|
+
})
|
|
47
|
+
expect(profile).toContain('(deny file-write* (subpath "/Users/test/.ssh"))')
|
|
48
|
+
expect(profile).toContain('(deny file-write* (subpath "/Users/test/.aws"))')
|
|
49
|
+
expect(profile).toContain('(deny file-write* (subpath "/Users/test/Library/Keychains"))')
|
|
50
|
+
expect(profile).toContain('(deny file-write* (subpath "/Users/test/.config/gcloud"))')
|
|
51
|
+
expect(profile).toContain('(deny file-write* (subpath "/Users/test/.lyra"))')
|
|
52
|
+
// Reads of credential dirs blocked too — `cat ~/.ssh/id_rsa` should fail
|
|
53
|
+
expect(profile).toContain('(deny file-read* (subpath "/Users/test/.ssh"))')
|
|
54
|
+
expect(profile).toContain('(deny file-read* (subpath "/Users/test/.aws"))')
|
|
55
|
+
expect(profile).toContain('(deny file-read* (subpath "/Users/test/Library/Keychains"))')
|
|
56
|
+
expect(profile).toContain('(deny file-read* (subpath "/Users/test/.config/gcloud"))')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('allows process-fork + process-exec + network', () => {
|
|
60
|
+
const profile = buildSeatbeltProfile({
|
|
61
|
+
agentDir: '/a',
|
|
62
|
+
workspaceRoot: '/b',
|
|
63
|
+
homedir: '/h',
|
|
64
|
+
})
|
|
65
|
+
expect(profile).toContain('(allow process-fork)')
|
|
66
|
+
expect(profile).toContain('(allow process-exec)')
|
|
67
|
+
expect(profile).toContain('(allow network*)')
|
|
68
|
+
expect(profile).toContain('(allow file-read*)')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('allows /tmp/lyra-* + /var/folders for temp dirs', () => {
|
|
72
|
+
const profile = buildSeatbeltProfile({
|
|
73
|
+
agentDir: '/a',
|
|
74
|
+
workspaceRoot: '/b',
|
|
75
|
+
homedir: '/h',
|
|
76
|
+
})
|
|
77
|
+
expect(profile).toContain('(allow file-write* (regex #"^/tmp/lyra-"))')
|
|
78
|
+
expect(profile).toContain('(allow file-write* (regex #"^/private/tmp/lyra-"))')
|
|
79
|
+
expect(profile).toContain('(allow file-write* (subpath "/var/folders"))')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('respects extraWriteAllow + extraWriteDeny', () => {
|
|
83
|
+
const profile = buildSeatbeltProfile({
|
|
84
|
+
agentDir: '/a',
|
|
85
|
+
workspaceRoot: '/b',
|
|
86
|
+
homedir: '/h',
|
|
87
|
+
extraWriteAllow: ['/tmp/lyra-test-sandbox-XYZ'],
|
|
88
|
+
extraWriteDeny: ['/Users/h/Documents/sensitive'],
|
|
89
|
+
})
|
|
90
|
+
expect(profile).toContain('(allow file-write* (subpath "/tmp/lyra-test-sandbox-XYZ"))')
|
|
91
|
+
expect(profile).toContain('(deny file-write* (subpath "/Users/h/Documents/sensitive"))')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('escapes embedded double quotes + backslashes in paths', () => {
|
|
95
|
+
const profile = buildSeatbeltProfile({
|
|
96
|
+
agentDir: '/path/with"quote',
|
|
97
|
+
workspaceRoot: '/path\\with\\backslash',
|
|
98
|
+
homedir: '/h',
|
|
99
|
+
})
|
|
100
|
+
expect(profile).toContain('/path/with\\"quote')
|
|
101
|
+
expect(profile).toContain('/path\\\\with\\\\backslash')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// Property-style fuzz of the escape function: every input must produce a
|
|
105
|
+
// syntactically valid (subpath "X") string — no unescaped backslashes/quotes/
|
|
106
|
+
// newlines that would prematurely close the literal or break SBPL parsing.
|
|
107
|
+
// The actual exploit risk is near-zero (operators don't trick themselves) but
|
|
108
|
+
// the cost of a clean test is one regex check per scenario.
|
|
109
|
+
test('escapes pathological inputs cleanly: nested quotes, sbpl operators, unicode', () => {
|
|
110
|
+
const cases: Array<[string, string]> = [
|
|
111
|
+
// Bare close-paren that could escape an SBPL form (must remain inside the literal)
|
|
112
|
+
['/tmp/test)', '/tmp/test)'],
|
|
113
|
+
// Embedded "(allow file-write*" attempt to inject a new rule
|
|
114
|
+
[
|
|
115
|
+
'/tmp/" (allow file-write* (subpath "/etc"))',
|
|
116
|
+
'/tmp/\\" (allow file-write* (subpath \\"/etc\\"))',
|
|
117
|
+
],
|
|
118
|
+
// Nested quotes
|
|
119
|
+
['/path"with"many"quotes', '/path\\"with\\"many\\"quotes'],
|
|
120
|
+
// Backslash-quote pair
|
|
121
|
+
['/x\\"y', '/x\\\\\\"y'],
|
|
122
|
+
// Carriage return / form feed
|
|
123
|
+
['/x\ny', '/x y'], // newline becomes space
|
|
124
|
+
// Unicode emoji (valid path component on macOS)
|
|
125
|
+
['/Users/alice/\u{1F600}-projects', '/Users/alice/\u{1F600}-projects'],
|
|
126
|
+
// SBPL pattern delimiters embedded
|
|
127
|
+
['/path/#(literal)', '/path/#(literal)'],
|
|
128
|
+
]
|
|
129
|
+
for (const [input, expectedFragment] of cases) {
|
|
130
|
+
const profile = buildSeatbeltProfile({
|
|
131
|
+
agentDir: input,
|
|
132
|
+
workspaceRoot: '/w',
|
|
133
|
+
homedir: '/h',
|
|
134
|
+
})
|
|
135
|
+
expect(profile).toContain(`(subpath "${expectedFragment}")`)
|
|
136
|
+
// After escape + concat, no unbalanced double quote inside (subpath "..."):
|
|
137
|
+
// every non-escaped " inside the literal would prematurely close it.
|
|
138
|
+
const subpathPattern = /\(subpath\s+"((?:\\.|[^"\\])*)"\)/g
|
|
139
|
+
// Strip well-formed (subpath "...") instances; remaining text shouldn't
|
|
140
|
+
// contain a stray (subpath ".. " missing its terminator.
|
|
141
|
+
const stripped = profile.replace(subpathPattern, '<<subpath>>')
|
|
142
|
+
expect(stripped).not.toContain('(subpath "')
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('re-allows agentDir AFTER the broad ~/.lyra deny so lyra state stays writable', () => {
|
|
147
|
+
const profile = buildSeatbeltProfile({
|
|
148
|
+
agentDir: '/Users/test/.lyra/agents/abc',
|
|
149
|
+
workspaceRoot: '/w',
|
|
150
|
+
homedir: '/Users/test',
|
|
151
|
+
})
|
|
152
|
+
const agentAllowIdx = profile.lastIndexOf(
|
|
153
|
+
'(allow file-write* (subpath "/Users/test/.lyra/agents/abc"))',
|
|
154
|
+
)
|
|
155
|
+
const lyraDenyIdx = profile.indexOf('(deny file-write* (subpath "/Users/test/.lyra"))')
|
|
156
|
+
expect(agentAllowIdx).toBeGreaterThan(-1)
|
|
157
|
+
expect(lyraDenyIdx).toBeGreaterThan(-1)
|
|
158
|
+
expect(agentAllowIdx).toBeGreaterThan(lyraDenyIdx)
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe('MacOSSandboxExecBackend', () => {
|
|
163
|
+
// sandbox-exec is macOS-only; skip these on Linux CI but assert constructor
|
|
164
|
+
// there too.
|
|
165
|
+
if (process.platform !== 'darwin') {
|
|
166
|
+
test('skipped on non-darwin', () => {
|
|
167
|
+
expect(true).toBe(true)
|
|
168
|
+
})
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
test('constructs with valid opts on darwin', () => {
|
|
173
|
+
const b = new MacOSSandboxExecBackend({
|
|
174
|
+
agentDir: '/tmp/lyra-test-agent',
|
|
175
|
+
workspaceRoot: '/tmp',
|
|
176
|
+
homedir: process.env.HOME ?? '/tmp',
|
|
177
|
+
})
|
|
178
|
+
expect(b.mode).toBe('os')
|
|
179
|
+
expect(b.label).toBe('os:darwin')
|
|
180
|
+
const profile = b.getProfile()
|
|
181
|
+
expect(profile).toContain('(deny default)')
|
|
182
|
+
expect(profile).toContain('(allow file-write* (subpath "/tmp/lyra-test-agent"))')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('wrapSpawn prepends sandbox-exec + profile to argv', async () => {
|
|
186
|
+
const b = new MacOSSandboxExecBackend({
|
|
187
|
+
agentDir: '/tmp/lyra-test-agent',
|
|
188
|
+
workspaceRoot: '/tmp',
|
|
189
|
+
homedir: process.env.HOME ?? '/tmp',
|
|
190
|
+
})
|
|
191
|
+
const out = await b.wrapSpawn({
|
|
192
|
+
command: '/bin/sh',
|
|
193
|
+
args: ['-c', 'echo hi'],
|
|
194
|
+
options: { cwd: '/tmp' },
|
|
195
|
+
})
|
|
196
|
+
expect(out.command).toBe('/usr/bin/sandbox-exec')
|
|
197
|
+
expect(out.args[0]).toBe('-p')
|
|
198
|
+
expect(typeof out.args[1]).toBe('string')
|
|
199
|
+
expect(out.args[1]?.includes('(deny default)')).toBe(true)
|
|
200
|
+
expect(out.args.slice(2)).toEqual(['/bin/sh', '-c', 'echo hi'])
|
|
201
|
+
expect(out.options).toEqual({ cwd: '/tmp' })
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('makeSandboxBackend factory', () => {
|
|
206
|
+
test('mode=none returns LocalBackend on any platform', () => {
|
|
207
|
+
const b = makeSandboxBackend({
|
|
208
|
+
mode: 'none',
|
|
209
|
+
agentDir: '/a',
|
|
210
|
+
workspaceRoot: '/w',
|
|
211
|
+
homedir: '/h',
|
|
212
|
+
platform: 'darwin',
|
|
213
|
+
})
|
|
214
|
+
expect(b.mode).toBe('none')
|
|
215
|
+
const b2 = makeSandboxBackend({
|
|
216
|
+
mode: 'none',
|
|
217
|
+
agentDir: '/a',
|
|
218
|
+
workspaceRoot: '/w',
|
|
219
|
+
homedir: '/h',
|
|
220
|
+
platform: 'linux',
|
|
221
|
+
})
|
|
222
|
+
expect(b2.mode).toBe('none')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('mode=docker constructs DockerBackend if a runtime exists, else falls back', () => {
|
|
226
|
+
let warned = ''
|
|
227
|
+
const b = makeSandboxBackend({
|
|
228
|
+
mode: 'docker',
|
|
229
|
+
agentDir: '/a',
|
|
230
|
+
workspaceRoot: '/w',
|
|
231
|
+
homedir: '/h',
|
|
232
|
+
platform: 'darwin',
|
|
233
|
+
warn: m => {
|
|
234
|
+
warned = m
|
|
235
|
+
},
|
|
236
|
+
})
|
|
237
|
+
// On a developer machine with docker/podman installed, mode === 'docker'.
|
|
238
|
+
// On a stripped CI box with neither, factory falls back to LocalBackend
|
|
239
|
+
// and emits a warning. Either is acceptable for this test.
|
|
240
|
+
if (b.mode === 'docker') {
|
|
241
|
+
expect(b.label.startsWith('docker:') || b.label.startsWith('podman:')).toBe(true)
|
|
242
|
+
} else {
|
|
243
|
+
expect(b.mode).toBe('none')
|
|
244
|
+
expect(warned).toContain('docker')
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('mode=docker accepts resource caps + network/runtime overrides without crashing', () => {
|
|
249
|
+
// Wide-input smoke test: factory should accept all DockerBackend opts and
|
|
250
|
+
// construct (or fall back) without throwing, regardless of runtime presence.
|
|
251
|
+
let warned = ''
|
|
252
|
+
const b = makeSandboxBackend({
|
|
253
|
+
mode: 'docker',
|
|
254
|
+
agentDir: '/a',
|
|
255
|
+
workspaceRoot: '/w',
|
|
256
|
+
homedir: '/h',
|
|
257
|
+
platform: 'darwin',
|
|
258
|
+
dockerCpu: 1.5,
|
|
259
|
+
dockerMemoryMb: 4096,
|
|
260
|
+
dockerDiskMb: 51200,
|
|
261
|
+
dockerNoNetwork: true,
|
|
262
|
+
dockerMountWorkspace: false,
|
|
263
|
+
warn: m => {
|
|
264
|
+
warned = m
|
|
265
|
+
},
|
|
266
|
+
})
|
|
267
|
+
if (b.mode === 'docker') {
|
|
268
|
+
expect(b.label.startsWith('docker:') || b.label.startsWith('podman:')).toBe(true)
|
|
269
|
+
} else {
|
|
270
|
+
expect(b.mode).toBe('none')
|
|
271
|
+
expect(warned).toContain('docker')
|
|
272
|
+
}
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('mode=os on linux constructs bubblewrap backend if bwrap exists, else falls back', () => {
|
|
276
|
+
let warned = ''
|
|
277
|
+
const b = makeSandboxBackend({
|
|
278
|
+
mode: 'os',
|
|
279
|
+
agentDir: '/tmp/lyra-agent',
|
|
280
|
+
workspaceRoot: '/tmp',
|
|
281
|
+
homedir: '/root',
|
|
282
|
+
platform: 'linux',
|
|
283
|
+
warn: m => {
|
|
284
|
+
warned = m
|
|
285
|
+
},
|
|
286
|
+
})
|
|
287
|
+
// On a Linux machine with bubblewrap installed (apt install bubblewrap),
|
|
288
|
+
// mode === 'os' + label 'os:linux'. On macOS or stripped Linux without
|
|
289
|
+
// bwrap, factory falls back to LocalBackend with a warning. Either is
|
|
290
|
+
// acceptable; this test pins the contract, not the platform.
|
|
291
|
+
if (b.mode === 'os') {
|
|
292
|
+
expect(b.label).toBe('os:linux')
|
|
293
|
+
} else {
|
|
294
|
+
expect(b.mode).toBe('none')
|
|
295
|
+
expect(warned).toContain('bubblewrap')
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
test('mode=os on unknown platform falls back to LocalBackend with warning', () => {
|
|
300
|
+
let warned = ''
|
|
301
|
+
const b = makeSandboxBackend({
|
|
302
|
+
mode: 'os',
|
|
303
|
+
agentDir: '/a',
|
|
304
|
+
workspaceRoot: '/w',
|
|
305
|
+
homedir: '/h',
|
|
306
|
+
platform: 'aix',
|
|
307
|
+
warn: m => {
|
|
308
|
+
warned = m
|
|
309
|
+
},
|
|
310
|
+
})
|
|
311
|
+
expect(b.mode).toBe('none')
|
|
312
|
+
expect(warned).toContain('aix')
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
if (process.platform === 'darwin') {
|
|
316
|
+
test('mode=os on darwin returns MacOSSandboxExecBackend', () => {
|
|
317
|
+
const b = makeSandboxBackend({
|
|
318
|
+
mode: 'os',
|
|
319
|
+
agentDir: '/tmp/lyra-test-agent',
|
|
320
|
+
workspaceRoot: '/tmp',
|
|
321
|
+
homedir: process.env.HOME ?? '/tmp',
|
|
322
|
+
})
|
|
323
|
+
expect(b.mode).toBe('os')
|
|
324
|
+
expect(b.label).toBe('os:darwin')
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
describe('buildBwrapArgs (Linux profile)', () => {
|
|
330
|
+
test('binds agentDir + workspaceRoot writable', () => {
|
|
331
|
+
const args = buildBwrapArgs({
|
|
332
|
+
agentDir: '/home/u/.lyra/agents/abc',
|
|
333
|
+
workspaceRoot: '/home/u/proj',
|
|
334
|
+
homedir: '/home/u',
|
|
335
|
+
})
|
|
336
|
+
expect(args).toContain('--bind')
|
|
337
|
+
expect(args.join(' ')).toContain('/home/u/.lyra/agents/abc /home/u/.lyra/agents/abc')
|
|
338
|
+
expect(args.join(' ')).toContain('/home/u/proj /home/u/proj')
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
test('blocks credential dirs via tmpfs overlay', () => {
|
|
342
|
+
const args = buildBwrapArgs({
|
|
343
|
+
agentDir: '/a',
|
|
344
|
+
workspaceRoot: '/w',
|
|
345
|
+
homedir: '/home/u',
|
|
346
|
+
})
|
|
347
|
+
const argString = args.join(' ')
|
|
348
|
+
expect(argString).toContain('--tmpfs /home/u/.ssh')
|
|
349
|
+
expect(argString).toContain('--tmpfs /home/u/.aws')
|
|
350
|
+
expect(argString).toContain('--tmpfs /home/u/.config/gcloud')
|
|
351
|
+
expect(argString).toContain('--tmpfs /home/u/.gnupg')
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
test('reads of root are allowed via --ro-bind', () => {
|
|
355
|
+
const args = buildBwrapArgs({ agentDir: '/a', workspaceRoot: '/w', homedir: '/h' })
|
|
356
|
+
expect(args.slice(0, 3)).toEqual(['--ro-bind', '/', '/'])
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
test('shares network but unshares everything else', () => {
|
|
360
|
+
const args = buildBwrapArgs({ agentDir: '/a', workspaceRoot: '/w', homedir: '/h' })
|
|
361
|
+
expect(args).toContain('--unshare-all')
|
|
362
|
+
expect(args).toContain('--share-net')
|
|
363
|
+
expect(args).toContain('--die-with-parent')
|
|
364
|
+
expect(args).toContain('--new-session')
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('respects extraWriteAllow for sandbox dirs', () => {
|
|
368
|
+
const args = buildBwrapArgs({
|
|
369
|
+
agentDir: '/a',
|
|
370
|
+
workspaceRoot: '/w',
|
|
371
|
+
homedir: '/h',
|
|
372
|
+
extraWriteAllow: ['/tmp/lyra-test-sandbox-XYZ'],
|
|
373
|
+
})
|
|
374
|
+
expect(args.join(' ')).toContain('--bind /tmp/lyra-test-sandbox-XYZ /tmp/lyra-test-sandbox-XYZ')
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
test('respects extraWriteDeny via tmpfs', () => {
|
|
378
|
+
const args = buildBwrapArgs({
|
|
379
|
+
agentDir: '/a',
|
|
380
|
+
workspaceRoot: '/w',
|
|
381
|
+
homedir: '/h',
|
|
382
|
+
extraWriteDeny: ['/home/u/secrets'],
|
|
383
|
+
})
|
|
384
|
+
expect(args.join(' ')).toContain('--tmpfs /home/u/secrets')
|
|
385
|
+
})
|
|
386
|
+
})
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS seatbelt (SBPL) profile generator. Used by MacOSSandboxExecBackend to
|
|
3
|
+
* build the `-p` argument for `sandbox-exec`.
|
|
4
|
+
*
|
|
5
|
+
* Profile policy (deny-default + targeted allows):
|
|
6
|
+
*
|
|
7
|
+
* READS: broad (allow file-read*). Reading is fine; it's writes + network
|
|
8
|
+
* exfil + process-fork-into-system that need gating. The brain's job is to
|
|
9
|
+
* help with files; we don't want it crippled on read.
|
|
10
|
+
*
|
|
11
|
+
* WRITES: deny default, allow ONLY:
|
|
12
|
+
* - agentDir (lyra state)
|
|
13
|
+
* - workspaceRoot (the cwd lyra was launched from; fs.write authorized
|
|
14
|
+
* through the modal lands here)
|
|
15
|
+
* - /tmp/lyra-* (lyra's own temp scratch — code.execute snippets, etc.)
|
|
16
|
+
* - /private/tmp/lyra-* (macOS canonical /tmp)
|
|
17
|
+
* - /var/folders (macOS user temp dir, where mkdtemp() defaults land)
|
|
18
|
+
* - any extra subpaths in `extraWriteAllow`
|
|
19
|
+
*
|
|
20
|
+
* EXPLICIT WRITE DENY (overrides allows on overlap):
|
|
21
|
+
* - $HOME/.ssh
|
|
22
|
+
* - $HOME/.aws
|
|
23
|
+
* - $HOME/Library/Keychains
|
|
24
|
+
* - $HOME/.config/gcloud
|
|
25
|
+
* - $HOME/.lyra (the broader lyra state tree — only the agent's own
|
|
26
|
+
* agentDir is allowed; brain shouldn't rewrite ~/.lyra/config.ts)
|
|
27
|
+
*
|
|
28
|
+
* NETWORK: allow* (lyra legitimately needs Sui RPC, indexer, compute,
|
|
29
|
+
* WC relay, plus user-asked-for HTTP). Future hardening: allowlist by host.
|
|
30
|
+
*
|
|
31
|
+
* PROCESS: allow process-fork + process-exec (tools spawn child binaries).
|
|
32
|
+
* IPC: allow mach-lookup, ipc-posix-shm, signal — needed by most CLI
|
|
33
|
+
* tooling, otherwise the simplest commands fail.
|
|
34
|
+
*
|
|
35
|
+
* The seatbelt syntax is Apple's internal SBPL (Scheme-like). It's
|
|
36
|
+
* undocumented officially but stable across macOS versions; deprecated in
|
|
37
|
+
* `man sandbox-exec` but still functional and used by Apple itself for many
|
|
38
|
+
* system services.
|
|
39
|
+
*
|
|
40
|
+
* NOTE on order: in seatbelt SBPL, deny rules placed AFTER allows take
|
|
41
|
+
* precedence on overlap. So we put the denylist after the allowlist for
|
|
42
|
+
* credentials.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { credentialDirs } from './credentials'
|
|
46
|
+
|
|
47
|
+
export interface SeatbeltProfileOpts {
|
|
48
|
+
agentDir: string
|
|
49
|
+
workspaceRoot: string
|
|
50
|
+
homedir: string
|
|
51
|
+
extraWriteAllow?: string[]
|
|
52
|
+
extraWriteDeny?: string[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Escape a path for safe inclusion in an SBPL string literal. Seatbelt strings
|
|
57
|
+
* are double-quoted; embedded backslashes and double quotes need escaping.
|
|
58
|
+
* Newlines also break the parser. Lyra paths come from process.cwd() and
|
|
59
|
+
* homedir() so they're well-formed Unix paths in practice, but we escape
|
|
60
|
+
* defensively.
|
|
61
|
+
*/
|
|
62
|
+
function sbplEscape(p: string): string {
|
|
63
|
+
return p.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, ' ')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function buildSeatbeltProfile(opts: SeatbeltProfileOpts): string {
|
|
67
|
+
const home = sbplEscape(opts.homedir)
|
|
68
|
+
const agent = sbplEscape(opts.agentDir)
|
|
69
|
+
const workspace = sbplEscape(opts.workspaceRoot)
|
|
70
|
+
|
|
71
|
+
const allowSubpaths = [
|
|
72
|
+
`(allow file-write* (subpath "${agent}"))`,
|
|
73
|
+
`(allow file-write* (subpath "${workspace}"))`,
|
|
74
|
+
`(allow file-write* (regex #"^/tmp/lyra-"))`,
|
|
75
|
+
`(allow file-write* (regex #"^/private/tmp/lyra-"))`,
|
|
76
|
+
`(allow file-write* (subpath "/var/folders"))`,
|
|
77
|
+
`(allow file-write* (subpath "/private/var/folders"))`,
|
|
78
|
+
...(opts.extraWriteAllow ?? []).map(p => `(allow file-write* (subpath "${sbplEscape(p)}"))`),
|
|
79
|
+
].join('\n ')
|
|
80
|
+
|
|
81
|
+
const credDirs = credentialDirs(opts.homedir).map(sbplEscape)
|
|
82
|
+
const denySubpaths = [
|
|
83
|
+
...credDirs.map(p => `(deny file-write* (subpath "${p}"))`),
|
|
84
|
+
`(deny file-write* (subpath "${home}/.lyra"))`,
|
|
85
|
+
...(opts.extraWriteDeny ?? []).map(p => `(deny file-write* (subpath "${sbplEscape(p)}"))`),
|
|
86
|
+
].join('\n ')
|
|
87
|
+
|
|
88
|
+
// Read-side: allow broadly (the agent legitimately needs to read system
|
|
89
|
+
// binaries, libraries, project files, etc.) but EXPLICITLY deny credential
|
|
90
|
+
// dirs. This blocks `cat ~/.ssh/id_rsa` -- shell.run that bypasses
|
|
91
|
+
// PathGuard's tool-level checks. Network is broad; if exfil is a concern
|
|
92
|
+
// (read public file + POST somewhere), use Docker mode.
|
|
93
|
+
const denyReadSubpaths = credDirs.map(p => `(deny file-read* (subpath "${p}"))`).join('\n ')
|
|
94
|
+
|
|
95
|
+
// The agentDir is under ~/.lyra/agents/<id>/, and we deny ~/.lyra broadly,
|
|
96
|
+
// so we MUST re-allow agentDir AFTER the deny to keep lyra's own state
|
|
97
|
+
// writable. SBPL is order-sensitive: later rules win on overlap.
|
|
98
|
+
return `(version 1)
|
|
99
|
+
(deny default)
|
|
100
|
+
|
|
101
|
+
;; Process management — tools spawn binaries.
|
|
102
|
+
(allow process-fork)
|
|
103
|
+
(allow process-exec)
|
|
104
|
+
|
|
105
|
+
;; IPC + system bookkeeping that any non-trivial CLI needs.
|
|
106
|
+
(allow mach-lookup)
|
|
107
|
+
(allow mach-priv-host-port)
|
|
108
|
+
(allow mach-task-name)
|
|
109
|
+
(allow ipc-posix-shm)
|
|
110
|
+
(allow signal)
|
|
111
|
+
(allow sysctl-read)
|
|
112
|
+
(allow sysctl-write)
|
|
113
|
+
(allow system-fsctl)
|
|
114
|
+
(allow system-info)
|
|
115
|
+
(allow system-socket)
|
|
116
|
+
(allow iokit-open)
|
|
117
|
+
|
|
118
|
+
;; Network — broad. Lyra needs Sui RPC, indexer, compute, WC relay, plus
|
|
119
|
+
;; arbitrary HTTP for browse + brain-driven fetches. Tighten via allowlist
|
|
120
|
+
;; when we have explicit host policy.
|
|
121
|
+
(allow network*)
|
|
122
|
+
|
|
123
|
+
;; Reads — broad by default so binaries/libraries/project files work.
|
|
124
|
+
(allow file-read*)
|
|
125
|
+
|
|
126
|
+
;; Explicit credential read denies (override allow file-read*).
|
|
127
|
+
${denyReadSubpaths}
|
|
128
|
+
|
|
129
|
+
;; Writes — deny default, allowlist:
|
|
130
|
+
${allowSubpaths}
|
|
131
|
+
|
|
132
|
+
;; Explicit credential + state-tree denies (override allowlist on overlap).
|
|
133
|
+
${denySubpaths}
|
|
134
|
+
|
|
135
|
+
;; Re-allow agentDir AFTER the ~/.lyra broad deny so lyra's own state is
|
|
136
|
+
;; writable. SBPL applies later rules first, so this comes last.
|
|
137
|
+
(allow file-write* (subpath "${agent}"))
|
|
138
|
+
`
|
|
139
|
+
}
|