pantheon-opencode 1.5.2 → 1.6.0-beta.10
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/.pantheon/code-mode/README.md +4 -2
- package/.pantheon/code-mode/checkpoint_session.py +138 -102
- package/.pantheon/code-mode/compress-inline.py +102 -57
- package/.pantheon/code-mode/manifest.json +2 -8
- package/AGENTS.global.md +182 -0
- package/AGENTS.md +10 -891
- package/CHANGELOG.md +343 -0
- package/README.md +577 -79
- package/README.pt-BR.md +345 -60
- package/bin/pantheon-init.mjs +26 -3
- package/commands/pantheon-audit.md +7 -1
- package/commands/pantheon-consolidate.md +1 -1
- package/commands/pantheon-deepwork.md +14 -3
- package/commands/pantheon-focus.md +17 -0
- package/commands/pantheon-optimize.md +2 -2
- package/commands/pantheon-vision.md +61 -12
- package/package.json +30 -11
- package/plugin.json +2 -2
- package/pyproject.toml +11 -16
- package/scripts/build-agents-md.mjs +376 -58
- package/scripts/changelog-merge.mjs +161 -0
- package/scripts/check-plugin-v2-coverage.mjs +213 -0
- package/scripts/ci-validate-yaml.py +7 -5
- package/scripts/{code_mode_server.py → code_mode.py} +119 -27
- package/scripts/cost.mjs +244 -41
- package/scripts/doctor.mjs +236 -29
- package/scripts/install/agents-md.mjs +4 -4
- package/scripts/install/cli-ui.mjs +1 -1
- package/scripts/install/config-migration.mjs +319 -6
- package/scripts/install/health-check.mjs +26 -37
- package/scripts/install/interactive.mjs +7 -7
- package/scripts/install/migrate.mjs +1 -1
- package/scripts/install/opencode-version.mjs +246 -4
- package/scripts/install/opencode.mjs +627 -179
- package/scripts/install/plugin.mjs +43 -9
- package/scripts/install/shared.mjs +120 -12
- package/scripts/install/vision-prompt.mjs +161 -0
- package/scripts/install-mcp.mjs +28 -9
- package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
- package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
- package/scripts/{memory_mcp_server.py → memory_mcp.py} +372 -198
- package/scripts/postinstall.mjs +0 -7
- package/scripts/probe-context-rehydrate.mjs +36 -13
- package/scripts/probe-pantheon-cost.mjs +71 -15
- package/scripts/prune.mjs +2 -2
- package/scripts/run-node-tests.mjs +189 -0
- package/scripts/sync-artifacts.mjs +26 -13
- package/scripts/sync-tui.mjs +24 -3
- package/scripts/test-opencode-v2-sandbox.sh +1913 -0
- package/scripts/validate-routing.mjs +43 -3
- package/scripts/validate-sandbox-install.mjs +138 -0
- package/scripts/versioning.mjs +13 -1
- package/scripts/vision-status.mjs +682 -0
- package/skills-lock.json +15 -16
- package/src/agents/aphrodite.md +20 -37
- package/src/agents/apollo.md +7 -3
- package/src/agents/athena.md +63 -35
- package/src/agents/demeter.md +8 -28
- package/src/agents/gaia.md +1 -1
- package/src/agents/hephaestus.md +10 -21
- package/src/agents/hermes.md +15 -38
- package/src/agents/iris.md +1 -1
- package/src/agents/mnemosyne.md +23 -60
- package/src/agents/nyx.md +1 -1
- package/src/agents/prometheus.md +16 -20
- package/src/agents/talos.md +10 -2
- package/src/agents/themis.md +5 -5
- package/src/agents/zeus.md +99 -130
- package/src/instructions/agent-return-format.instructions.md +18 -3
- package/src/instructions/backend-standards.instructions.md +2 -0
- package/src/instructions/frontend-standards.instructions.md +3 -1
- package/src/instructions/memory-protocol.instructions.md +11 -110
- package/src/instructions/zeus-anti-stall.instructions.md +14 -61
- package/src/instructions/zeus-communication-rules.instructions.md +0 -1
- package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
- package/src/instructions/zeus-memory-operations.instructions.md +26 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
- package/src/mcp/{code_mode_server.py → code_mode.py} +119 -27
- package/src/mcp/eval_store.py +2 -2
- package/src/mcp/mcp_codemap_module.py +559 -129
- package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
- package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
- package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +373 -250
- package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +396 -40
- package/src/mcp/requirements-mcp.txt +10 -6
- package/src/mcp/requirements-vision.txt +4 -2
- package/src/pantheon/cost-command.ts +399 -101
- package/src/pantheon/delegation-enforce.ts +10 -1
- package/src/pantheon/model-command.ts +18 -7
- package/src/pantheon/presets.d.mts +0 -8
- package/src/pantheon/presets.mjs +5 -32
- package/src/pantheon/todo-enforcer.ts +1 -1
- package/src/pantheon/v2-bounded-read.ts +640 -0
- package/src/pantheon/v2-delegation-enforce.ts +214 -0
- package/src/pantheon/v2-execute-after.ts +186 -0
- package/src/pantheon/v2-tools.ts +429 -0
- package/src/pantheon/v2-unsupported.d.mts +11 -0
- package/src/pantheon/v2-unsupported.mjs +90 -0
- package/src/pantheon/v2-vision.ts +622 -0
- package/src/pantheon/vision-local.ts +531 -0
- package/src/pantheon/vision.ts +1161 -116
- package/src/plugin-v2.ts +1244 -288
- package/src/plugin.ts +12 -9
- package/src/plugins/tui/dist/server.d.ts +1 -2
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
- package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
- package/src/plugins/tui/dist/tui-v2.js +659 -0
- package/src/plugins/tui/dist/tui-v2.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +85 -82
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +72 -59
- package/src/plugins/tui/index-v2.ts +6 -0
- package/src/plugins/tui/package-lock.json +5504 -1341
- package/src/plugins/tui/package.json +25 -15
- package/src/plugins/tui/src/index-v2.tsx +734 -0
- package/src/plugins/tui/src/index.tsx +106 -85
- package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
- package/src/plugins/tui/tsconfig.build.json +5 -0
- package/src/plugins/tui/tsconfig.typecheck.json +5 -0
- package/src/plugins/tui/tsdown.config.ts +22 -2
- package/src/plugins/tui/tui-v2.ts +2 -0
- package/src/plugins/tui/tui.ts +3 -0
- package/src/routing.yml +11 -22
- package/src/skills/README.md +34 -39
- package/src/skills/agent-coordination/SKILL.md +7 -2
- package/src/skills/artifact-management/SKILL.md +12 -5
- package/src/skills/auto-continue/SKILL.md +54 -95
- package/src/skills/code-review-checklist/SKILL.md +5 -5
- package/src/skills/context-compression/SKILL.md +26 -879
- package/src/skills/session-goal/SKILL.md +14 -5
- package/src/skills/spec-driven-development/SKILL.md +3 -1
- package/src/skills/tdd-with-agents/SKILL.md +18 -10
- package/.pantheon/code-mode/eval-llm-judge.py +0 -255
- package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
- package/.pantheon/code-mode/eval-run.py +0 -197
- package/.pantheon/code-mode/eval-static.py +0 -321
- package/.pantheon/code-mode/session-end-save.py +0 -186
- package/.pantheon/code-mode/session-end-save.sh +0 -18
- package/opencode.json +0 -58
- package/scripts/recover-zenodo-deposition.mjs +0 -146
- package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
- package/scripts/validate-zenodo-release.mjs +0 -154
- package/scripts/zenodo-artifact.mjs +0 -94
- package/scripts/zenodo-release-state.mjs +0 -53
- package/src/skills/orchestration-workflow/SKILL.md +0 -311
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
/** Minimal, session-scoped bounded file reader for the OpenCode V2 surface. */
|
|
2
|
+
|
|
3
|
+
import { constants } from 'node:fs'
|
|
4
|
+
import { lstat, open, realpath } from 'node:fs/promises'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import type { V2ToolDef } from './v2-tools.ts'
|
|
7
|
+
|
|
8
|
+
export const BOUNDED_READ_TOOL = 'pantheon_bounded_read'
|
|
9
|
+
export const BOUNDED_READ_MAX_CALLS = 2
|
|
10
|
+
export const BOUNDED_READ_MAX_FILES = 2
|
|
11
|
+
export const BOUNDED_READ_MAX_LINES = 400
|
|
12
|
+
export const BOUNDED_READ_MAX_BYTES = 32 * 1024
|
|
13
|
+
const BYTES_PER_CALL = BOUNDED_READ_MAX_BYTES / BOUNDED_READ_MAX_CALLS
|
|
14
|
+
const ORIGIN_TTL_MS = 60_000
|
|
15
|
+
const MAX_SESSIONS = 256
|
|
16
|
+
const READ_CHUNK_BYTES = 1
|
|
17
|
+
const DENIED_COMPONENTS = new Set([
|
|
18
|
+
'.aws',
|
|
19
|
+
'.config',
|
|
20
|
+
'.docker',
|
|
21
|
+
'.gnupg',
|
|
22
|
+
'.kube',
|
|
23
|
+
'.netrc',
|
|
24
|
+
'.npmrc',
|
|
25
|
+
'.pypirc',
|
|
26
|
+
'.ssh',
|
|
27
|
+
'.git',
|
|
28
|
+
'auth',
|
|
29
|
+
'authentication',
|
|
30
|
+
'database',
|
|
31
|
+
'db',
|
|
32
|
+
'global',
|
|
33
|
+
'migration',
|
|
34
|
+
'migrations',
|
|
35
|
+
'schema',
|
|
36
|
+
'schemas',
|
|
37
|
+
'data',
|
|
38
|
+
'dataset',
|
|
39
|
+
'datasets',
|
|
40
|
+
'authorized_keys',
|
|
41
|
+
'credentials',
|
|
42
|
+
'id_ed25519',
|
|
43
|
+
'id_rsa',
|
|
44
|
+
'known_hosts',
|
|
45
|
+
'secrets',
|
|
46
|
+
])
|
|
47
|
+
const DENIED_EXTENSIONS = new Set([
|
|
48
|
+
'.cer',
|
|
49
|
+
'.crt',
|
|
50
|
+
'.der',
|
|
51
|
+
'.jks',
|
|
52
|
+
'.key',
|
|
53
|
+
'.p12',
|
|
54
|
+
'.p7b',
|
|
55
|
+
'.p7c',
|
|
56
|
+
'.pem',
|
|
57
|
+
'.pfx',
|
|
58
|
+
'.pub',
|
|
59
|
+
'.sqlite',
|
|
60
|
+
'.sqlite3',
|
|
61
|
+
'.db',
|
|
62
|
+
'.sql',
|
|
63
|
+
])
|
|
64
|
+
const DENIED_MEDIA_EXTENSIONS = new Set([
|
|
65
|
+
'.avif',
|
|
66
|
+
'.bmp',
|
|
67
|
+
'.gif',
|
|
68
|
+
'.heic',
|
|
69
|
+
'.heif',
|
|
70
|
+
'.ico',
|
|
71
|
+
'.jpeg',
|
|
72
|
+
'.jpg',
|
|
73
|
+
'.png',
|
|
74
|
+
'.svg',
|
|
75
|
+
'.webp',
|
|
76
|
+
'.pdf',
|
|
77
|
+
'.mp3',
|
|
78
|
+
'.mp4',
|
|
79
|
+
'.wav',
|
|
80
|
+
'.zip',
|
|
81
|
+
'.gz',
|
|
82
|
+
'.tar',
|
|
83
|
+
'.7z',
|
|
84
|
+
'.a',
|
|
85
|
+
'.class',
|
|
86
|
+
'.dll',
|
|
87
|
+
'.docx',
|
|
88
|
+
'.dylib',
|
|
89
|
+
'.exe',
|
|
90
|
+
'.jar',
|
|
91
|
+
'.odt',
|
|
92
|
+
'.ods',
|
|
93
|
+
'.otf',
|
|
94
|
+
'.o',
|
|
95
|
+
'.pptx',
|
|
96
|
+
'.pyc',
|
|
97
|
+
'.rar',
|
|
98
|
+
'.so',
|
|
99
|
+
'.ttf',
|
|
100
|
+
'.wasm',
|
|
101
|
+
'.woff',
|
|
102
|
+
'.woff2',
|
|
103
|
+
'.xlsx',
|
|
104
|
+
'.xz',
|
|
105
|
+
])
|
|
106
|
+
|
|
107
|
+
export interface BoundedReadIdentity {
|
|
108
|
+
sessionID: string
|
|
109
|
+
messageID: string
|
|
110
|
+
callID: string
|
|
111
|
+
agent: string
|
|
112
|
+
relativePath: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface StoredOrigin extends BoundedReadIdentity {
|
|
116
|
+
capturedAt: number
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
120
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function nonEmpty(value: unknown): string | undefined {
|
|
124
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function originKey(sessionID: string, messageID: string, callID: string): string {
|
|
128
|
+
return JSON.stringify([sessionID, messageID, callID])
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function budgetKey(sessionID: string, messageID: string): string {
|
|
132
|
+
return JSON.stringify([sessionID, messageID])
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** One-shot proof that this exact V2 root tool invocation reached execute(). */
|
|
136
|
+
export class V2BoundedReadOriginRegistry {
|
|
137
|
+
private readonly entries = new Map<string, StoredOrigin>()
|
|
138
|
+
|
|
139
|
+
capture(rawEvent: unknown, now = Date.now()): BoundedReadIdentity | undefined {
|
|
140
|
+
this.prune(now)
|
|
141
|
+
if (!isRecord(rawEvent) || rawEvent.tool !== BOUNDED_READ_TOOL || !isRecord(rawEvent.input)) {
|
|
142
|
+
return undefined
|
|
143
|
+
}
|
|
144
|
+
const identity = {
|
|
145
|
+
sessionID: nonEmpty(rawEvent.sessionID),
|
|
146
|
+
messageID: nonEmpty(rawEvent.messageID),
|
|
147
|
+
callID: nonEmpty(rawEvent.id),
|
|
148
|
+
agent: nonEmpty(rawEvent.agent),
|
|
149
|
+
relativePath: validateRelativePath(rawEvent.input.path) ? rawEvent.input.path : undefined,
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
identity.sessionID === undefined ||
|
|
153
|
+
identity.messageID === undefined ||
|
|
154
|
+
identity.callID === undefined ||
|
|
155
|
+
identity.agent !== 'zeus' ||
|
|
156
|
+
identity.relativePath === undefined ||
|
|
157
|
+
this.entries.size >= MAX_SESSIONS
|
|
158
|
+
) {
|
|
159
|
+
return undefined
|
|
160
|
+
}
|
|
161
|
+
const origin: BoundedReadIdentity = {
|
|
162
|
+
sessionID: identity.sessionID,
|
|
163
|
+
messageID: identity.messageID,
|
|
164
|
+
callID: identity.callID,
|
|
165
|
+
agent: identity.agent,
|
|
166
|
+
relativePath: identity.relativePath,
|
|
167
|
+
}
|
|
168
|
+
this.entries.set(originKey(origin.sessionID, origin.messageID, origin.callID), {
|
|
169
|
+
...origin,
|
|
170
|
+
capturedAt: now,
|
|
171
|
+
})
|
|
172
|
+
return origin
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
consume(
|
|
176
|
+
identity: Omit<BoundedReadIdentity, 'relativePath'> & { relativePath: unknown },
|
|
177
|
+
now = Date.now(),
|
|
178
|
+
): BoundedReadIdentity | undefined {
|
|
179
|
+
this.prune(now)
|
|
180
|
+
const key = originKey(identity.sessionID, identity.messageID, identity.callID)
|
|
181
|
+
const origin = this.entries.get(key)
|
|
182
|
+
this.entries.delete(key)
|
|
183
|
+
if (
|
|
184
|
+
origin === undefined ||
|
|
185
|
+
identity.agent !== origin.agent ||
|
|
186
|
+
identity.relativePath !== origin.relativePath
|
|
187
|
+
) {
|
|
188
|
+
return undefined
|
|
189
|
+
}
|
|
190
|
+
const { capturedAt: _capturedAt, ...result } = origin
|
|
191
|
+
return result
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
forget(rawEvent: unknown): void {
|
|
195
|
+
if (!isRecord(rawEvent)) return
|
|
196
|
+
const sessionID = nonEmpty(rawEvent.sessionID)
|
|
197
|
+
const messageID = nonEmpty(rawEvent.messageID)
|
|
198
|
+
const callID = nonEmpty(rawEvent.id)
|
|
199
|
+
if (sessionID && messageID && callID) {
|
|
200
|
+
this.entries.delete(originKey(sessionID, messageID, callID))
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
clearSession(sessionID: string): void {
|
|
205
|
+
for (const [key, entry] of this.entries) {
|
|
206
|
+
if (entry.sessionID === sessionID) this.entries.delete(key)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private prune(now: number): void {
|
|
211
|
+
for (const [key, entry] of this.entries) {
|
|
212
|
+
if (now - entry.capturedAt > ORIGIN_TTL_MS) this.entries.delete(key)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
interface Budget {
|
|
218
|
+
sessionID: string
|
|
219
|
+
messageID: string
|
|
220
|
+
root: string
|
|
221
|
+
files: Set<string>
|
|
222
|
+
calls: number
|
|
223
|
+
lines: number
|
|
224
|
+
bytes: number
|
|
225
|
+
sealed: boolean
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface BoundedReadHostSession {
|
|
229
|
+
id?: unknown
|
|
230
|
+
parentID?: unknown
|
|
231
|
+
projectID?: unknown
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface BoundedReadDependencies {
|
|
235
|
+
/** Project identity is taken from ctx.location once during plugin setup. */
|
|
236
|
+
rootDirectory: string
|
|
237
|
+
projectID: string
|
|
238
|
+
getSession: (sessionID: string) => Promise<unknown>
|
|
239
|
+
assertPermission: (request: Record<string, unknown>) => Promise<unknown>
|
|
240
|
+
scanContent: (input: {
|
|
241
|
+
content: string
|
|
242
|
+
agent: string
|
|
243
|
+
sessionID: string
|
|
244
|
+
messageID: string
|
|
245
|
+
callID: string
|
|
246
|
+
}) => Promise<'allow' | 'deny' | 'unavailable'>
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
interface ReadArgs {
|
|
250
|
+
path: string
|
|
251
|
+
offset: number
|
|
252
|
+
limit: number
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function validateRelativePath(value: unknown): value is string {
|
|
256
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 240) return false
|
|
257
|
+
if (
|
|
258
|
+
value.startsWith('/') ||
|
|
259
|
+
value.startsWith('\\') ||
|
|
260
|
+
value.includes('\\') ||
|
|
261
|
+
value.includes('\0') ||
|
|
262
|
+
value.includes(':') ||
|
|
263
|
+
value.includes('*') ||
|
|
264
|
+
value.includes('?') ||
|
|
265
|
+
value.includes('[') ||
|
|
266
|
+
value.includes(']') ||
|
|
267
|
+
value.includes('{') ||
|
|
268
|
+
value.includes('}') ||
|
|
269
|
+
value.includes('%') ||
|
|
270
|
+
value.startsWith('~')
|
|
271
|
+
) {
|
|
272
|
+
return false
|
|
273
|
+
}
|
|
274
|
+
const components = value.split('/')
|
|
275
|
+
if (components.some((component) => component === '' || component === '.' || component === '..')) {
|
|
276
|
+
return false
|
|
277
|
+
}
|
|
278
|
+
return components.every((component) =>
|
|
279
|
+
[...component].every((character) => {
|
|
280
|
+
const code = character.charCodeAt(0)
|
|
281
|
+
return code > 0x1f && code !== 0x7f
|
|
282
|
+
}),
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function parseArgs(input: Record<string, unknown>): ReadArgs | undefined {
|
|
287
|
+
if (Object.keys(input).some((key) => !['path', 'offset', 'limit'].includes(key))) return undefined
|
|
288
|
+
const filePath = input.path
|
|
289
|
+
const offset = input.offset
|
|
290
|
+
const limit = input.limit
|
|
291
|
+
if (
|
|
292
|
+
!validateRelativePath(filePath) ||
|
|
293
|
+
!Number.isSafeInteger(offset) ||
|
|
294
|
+
(offset as number) < 1 ||
|
|
295
|
+
!Number.isSafeInteger(limit) ||
|
|
296
|
+
(limit as number) < 1 ||
|
|
297
|
+
(limit as number) > BOUNDED_READ_MAX_LINES
|
|
298
|
+
) {
|
|
299
|
+
return undefined
|
|
300
|
+
}
|
|
301
|
+
return { path: filePath, offset: offset as number, limit: limit as number }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function isWithin(root: string, target: string): boolean {
|
|
305
|
+
const relative = path.relative(root, target)
|
|
306
|
+
return (
|
|
307
|
+
relative === '' ||
|
|
308
|
+
(!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function sensitivePath(relativePath: string): boolean {
|
|
313
|
+
const components = relativePath.split('/').map((component) => component.toLowerCase())
|
|
314
|
+
const leaf = components.at(-1) ?? ''
|
|
315
|
+
const extension = path.extname(leaf)
|
|
316
|
+
return (
|
|
317
|
+
components.some(
|
|
318
|
+
(component) =>
|
|
319
|
+
DENIED_COMPONENTS.has(component) ||
|
|
320
|
+
component.startsWith('.env') ||
|
|
321
|
+
/(^|[._-])(auth|authentication|data|dataset|datasets|database|db|schema|schemas|global|migration|migrations)([._-]|$)/.test(
|
|
322
|
+
component,
|
|
323
|
+
),
|
|
324
|
+
) ||
|
|
325
|
+
/(^|[._-])(secret|token|credential|credentials|password|private)([._-]|$)/.test(leaf) ||
|
|
326
|
+
DENIED_EXTENSIONS.has(extension) ||
|
|
327
|
+
DENIED_MEDIA_EXTENSIONS.has(extension)
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function errorResult(code: string): { output: string } {
|
|
332
|
+
return { output: code }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function hasParent(session: BoundedReadHostSession): boolean {
|
|
336
|
+
return Object.hasOwn(session, 'parentID')
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function readLineWindow(
|
|
340
|
+
filePath: string,
|
|
341
|
+
offset: number,
|
|
342
|
+
limit: number,
|
|
343
|
+
byteCap: number,
|
|
344
|
+
): Promise<{ content: string; linesRead: number; bytes: number }> {
|
|
345
|
+
const before = await lstat(filePath)
|
|
346
|
+
if (before.isSymbolicLink()) throw new Error('PATH_DENIED')
|
|
347
|
+
if (!before.isFile() || before.nlink > 1) throw new Error('PATH_DENIED')
|
|
348
|
+
|
|
349
|
+
let file: Awaited<ReturnType<typeof open>> | undefined
|
|
350
|
+
try {
|
|
351
|
+
file = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
|
|
352
|
+
const opened = await file.stat()
|
|
353
|
+
if (
|
|
354
|
+
!opened.isFile() ||
|
|
355
|
+
opened.dev !== before.dev ||
|
|
356
|
+
opened.ino !== before.ino ||
|
|
357
|
+
opened.nlink > 1
|
|
358
|
+
) {
|
|
359
|
+
throw new Error('CHANGED')
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const chunks: Buffer[] = []
|
|
363
|
+
const inspectedChunks: Buffer[] = []
|
|
364
|
+
const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES)
|
|
365
|
+
let position = 0
|
|
366
|
+
let currentLine = 1
|
|
367
|
+
let linesRead = 0
|
|
368
|
+
let selectedBytes = 0
|
|
369
|
+
let inspectedBytes = 0
|
|
370
|
+
let currentLineHasBytes = false
|
|
371
|
+
let intervalComplete = false
|
|
372
|
+
while (inspectedBytes < byteCap) {
|
|
373
|
+
const size = Math.min(buffer.length, byteCap - inspectedBytes)
|
|
374
|
+
const read = await file.read(buffer, 0, size, position)
|
|
375
|
+
if (read.bytesRead === 0) break
|
|
376
|
+
position += read.bytesRead
|
|
377
|
+
inspectedBytes += read.bytesRead
|
|
378
|
+
inspectedChunks.push(Buffer.from(buffer.subarray(0, read.bytesRead)))
|
|
379
|
+
let consumed = 0
|
|
380
|
+
let selectedStart = -1
|
|
381
|
+
for (; consumed < read.bytesRead; consumed++) {
|
|
382
|
+
const byte = buffer.readUInt8(consumed)
|
|
383
|
+
if (
|
|
384
|
+
byte === 0 ||
|
|
385
|
+
(byte < 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d) ||
|
|
386
|
+
byte === 0x7f
|
|
387
|
+
) {
|
|
388
|
+
throw new Error('PATH_DENIED')
|
|
389
|
+
}
|
|
390
|
+
const selected = currentLine >= offset && currentLine < offset + limit
|
|
391
|
+
if (selected) {
|
|
392
|
+
if (selectedStart === -1) selectedStart = consumed
|
|
393
|
+
currentLineHasBytes = true
|
|
394
|
+
}
|
|
395
|
+
if (byte === 0x0a) {
|
|
396
|
+
if (selected) linesRead += 1
|
|
397
|
+
currentLine += 1
|
|
398
|
+
currentLineHasBytes = false
|
|
399
|
+
if (currentLine >= offset + limit) {
|
|
400
|
+
intervalComplete = true
|
|
401
|
+
consumed += 1
|
|
402
|
+
break
|
|
403
|
+
}
|
|
404
|
+
} else if (!selected && currentLine >= offset && currentLine < offset + limit) {
|
|
405
|
+
currentLineHasBytes = true
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (selectedStart !== -1) {
|
|
409
|
+
const selectedEnd = consumed
|
|
410
|
+
const selectedChunk = Buffer.from(buffer.subarray(selectedStart, selectedEnd))
|
|
411
|
+
selectedBytes += selectedChunk.length
|
|
412
|
+
if (selectedBytes > byteCap) throw new Error('BUDGET_EXCEEDED')
|
|
413
|
+
chunks.push(selectedChunk)
|
|
414
|
+
}
|
|
415
|
+
if (intervalComplete) break
|
|
416
|
+
if (read.bytesRead < size) break
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (
|
|
420
|
+
!intervalComplete &&
|
|
421
|
+
currentLineHasBytes &&
|
|
422
|
+
currentLine >= offset &&
|
|
423
|
+
currentLine < offset + limit
|
|
424
|
+
) {
|
|
425
|
+
linesRead += 1
|
|
426
|
+
}
|
|
427
|
+
if (!intervalComplete && inspectedBytes === byteCap && currentLine < offset + limit) {
|
|
428
|
+
throw new Error('BUDGET_EXCEEDED')
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
new TextDecoder('utf-8', { fatal: true }).decode(
|
|
432
|
+
Buffer.concat(inspectedChunks, inspectedBytes),
|
|
433
|
+
)
|
|
434
|
+
} catch {
|
|
435
|
+
throw new Error('PATH_DENIED')
|
|
436
|
+
}
|
|
437
|
+
const bytes = Buffer.concat(chunks, selectedBytes)
|
|
438
|
+
let content: string
|
|
439
|
+
try {
|
|
440
|
+
content = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
|
441
|
+
} catch {
|
|
442
|
+
throw new Error('PATH_DENIED')
|
|
443
|
+
}
|
|
444
|
+
return { content, linesRead, bytes: selectedBytes }
|
|
445
|
+
} finally {
|
|
446
|
+
await file?.close().catch(() => undefined)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Build one registered tool and its in-memory, session-lifecycle state. */
|
|
451
|
+
export function createV2BoundedReadTool(dependencies: BoundedReadDependencies): {
|
|
452
|
+
definition: V2ToolDef
|
|
453
|
+
origins: V2BoundedReadOriginRegistry
|
|
454
|
+
clearSession: (sessionID: string) => void
|
|
455
|
+
} {
|
|
456
|
+
const origins = new V2BoundedReadOriginRegistry()
|
|
457
|
+
const budgets = new Map<string, Budget>()
|
|
458
|
+
|
|
459
|
+
const reserve = (
|
|
460
|
+
sessionID: string,
|
|
461
|
+
messageID: string,
|
|
462
|
+
root: string,
|
|
463
|
+
args: ReadArgs,
|
|
464
|
+
): string | undefined => {
|
|
465
|
+
const key = budgetKey(sessionID, messageID)
|
|
466
|
+
let budget = budgets.get(key)
|
|
467
|
+
if (budget === undefined) {
|
|
468
|
+
if (budgets.size >= MAX_SESSIONS) return 'UNAVAILABLE'
|
|
469
|
+
budget = {
|
|
470
|
+
sessionID,
|
|
471
|
+
messageID,
|
|
472
|
+
root,
|
|
473
|
+
files: new Set(),
|
|
474
|
+
calls: 0,
|
|
475
|
+
lines: 0,
|
|
476
|
+
bytes: 0,
|
|
477
|
+
sealed: false,
|
|
478
|
+
}
|
|
479
|
+
budgets.set(key, budget)
|
|
480
|
+
}
|
|
481
|
+
if (budget.root !== root || budget.sealed) return 'SCOPE_DENIED'
|
|
482
|
+
if (budget.files.has(args.path)) return 'SCOPE_DENIED'
|
|
483
|
+
if (
|
|
484
|
+
budget.calls + 1 > BOUNDED_READ_MAX_CALLS ||
|
|
485
|
+
budget.files.size + 1 > BOUNDED_READ_MAX_FILES ||
|
|
486
|
+
budget.lines + args.limit > BOUNDED_READ_MAX_LINES ||
|
|
487
|
+
budget.bytes + BYTES_PER_CALL > BOUNDED_READ_MAX_BYTES
|
|
488
|
+
) {
|
|
489
|
+
budget.sealed = true
|
|
490
|
+
return 'BUDGET_EXCEEDED'
|
|
491
|
+
}
|
|
492
|
+
budget.calls += 1
|
|
493
|
+
budget.files.add(args.path)
|
|
494
|
+
budget.lines += args.limit
|
|
495
|
+
budget.bytes += BYTES_PER_CALL
|
|
496
|
+
return undefined
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const definition: V2ToolDef = {
|
|
500
|
+
name: BOUNDED_READ_TOOL,
|
|
501
|
+
description:
|
|
502
|
+
'Read a small line range from one exact text file inside the current project. Root Zeus only; two files and 400 lines total per assistant message. Denies recognized sensitive scopes and known binary/media formats; returns only bounded valid UTF-8 text after secret scanning. No directories or fallback reading.',
|
|
503
|
+
input: {
|
|
504
|
+
type: 'object',
|
|
505
|
+
additionalProperties: false,
|
|
506
|
+
required: ['path', 'offset', 'limit'],
|
|
507
|
+
properties: {
|
|
508
|
+
path: {
|
|
509
|
+
type: 'string',
|
|
510
|
+
minLength: 1,
|
|
511
|
+
description:
|
|
512
|
+
'Exact relative file path inside the current project; no globs or traversal.',
|
|
513
|
+
},
|
|
514
|
+
offset: {
|
|
515
|
+
type: 'integer',
|
|
516
|
+
minimum: 1,
|
|
517
|
+
description: 'First line to return, starting at 1.',
|
|
518
|
+
},
|
|
519
|
+
limit: {
|
|
520
|
+
type: 'integer',
|
|
521
|
+
minimum: 1,
|
|
522
|
+
maximum: BOUNDED_READ_MAX_LINES,
|
|
523
|
+
description: 'Number of lines requested; at most 400 lines total across this turn.',
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
output: {},
|
|
528
|
+
async execute(rawInput: Record<string, unknown>, rawContext: unknown) {
|
|
529
|
+
const args = parseArgs(rawInput)
|
|
530
|
+
if (args === undefined) return errorResult('ARGUMENT_INVALID')
|
|
531
|
+
if (!isRecord(rawContext)) return errorResult('BOUND_READ_UNAUTHORIZED')
|
|
532
|
+
const sessionID = nonEmpty(rawContext.sessionID)
|
|
533
|
+
const messageID = nonEmpty(rawContext.messageID)
|
|
534
|
+
const callID = nonEmpty(rawContext.id)
|
|
535
|
+
const agent = nonEmpty(rawContext.agent)
|
|
536
|
+
if (!sessionID || !messageID || !callID || agent !== 'zeus') {
|
|
537
|
+
return errorResult('BOUND_READ_UNAUTHORIZED')
|
|
538
|
+
}
|
|
539
|
+
const origin = origins.consume({
|
|
540
|
+
sessionID,
|
|
541
|
+
messageID,
|
|
542
|
+
callID,
|
|
543
|
+
agent,
|
|
544
|
+
relativePath: args.path,
|
|
545
|
+
})
|
|
546
|
+
if (origin === undefined) return errorResult('BOUND_READ_UNAUTHORIZED')
|
|
547
|
+
|
|
548
|
+
let sessionValue: unknown
|
|
549
|
+
try {
|
|
550
|
+
sessionValue = await dependencies.getSession(sessionID)
|
|
551
|
+
} catch {
|
|
552
|
+
return errorResult('BOUND_READ_UNAUTHORIZED')
|
|
553
|
+
}
|
|
554
|
+
if (!isRecord(sessionValue)) return errorResult('BOUND_READ_UNAUTHORIZED')
|
|
555
|
+
const session = sessionValue as BoundedReadHostSession
|
|
556
|
+
if (
|
|
557
|
+
session.id !== sessionID ||
|
|
558
|
+
hasParent(session) ||
|
|
559
|
+
session.projectID !== dependencies.projectID
|
|
560
|
+
) {
|
|
561
|
+
return errorResult('BOUND_READ_UNAUTHORIZED')
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// The caller supplies the realpath captured before registration. This is
|
|
565
|
+
// pure lexical work, so quota reservation still precedes filesystem I/O.
|
|
566
|
+
const root = path.resolve(dependencies.rootDirectory)
|
|
567
|
+
const target = path.resolve(root, ...args.path.split('/'))
|
|
568
|
+
if (!isWithin(root, target) || sensitivePath(args.path)) return errorResult('PATH_DENIED')
|
|
569
|
+
const reservationError = reserve(sessionID, messageID, root, args)
|
|
570
|
+
if (reservationError !== undefined) return errorResult(reservationError)
|
|
571
|
+
|
|
572
|
+
try {
|
|
573
|
+
await dependencies.assertPermission({
|
|
574
|
+
sessionID,
|
|
575
|
+
agent,
|
|
576
|
+
action: 'read',
|
|
577
|
+
resources: [target],
|
|
578
|
+
save: [],
|
|
579
|
+
source: { type: 'tool', messageID, id: callID },
|
|
580
|
+
})
|
|
581
|
+
} catch {
|
|
582
|
+
return errorResult('TOOL_DENIED')
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const rootInfo = await lstat(root).catch(() => undefined)
|
|
586
|
+
if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink()) return errorResult('PATH_DENIED')
|
|
587
|
+
const components = args.path.split('/')
|
|
588
|
+
let cursor = root
|
|
589
|
+
for (const [index, component] of components.entries()) {
|
|
590
|
+
cursor = path.join(cursor, component)
|
|
591
|
+
const info = await lstat(cursor).catch(() => undefined)
|
|
592
|
+
if (info === undefined || info.isSymbolicLink()) return errorResult('PATH_DENIED')
|
|
593
|
+
if (index < components.length - 1 && !info.isDirectory()) return errorResult('PATH_DENIED')
|
|
594
|
+
if (index === components.length - 1 && !info.isFile()) return errorResult('PATH_DENIED')
|
|
595
|
+
}
|
|
596
|
+
const targetReal = await realpath(target).catch(() => undefined)
|
|
597
|
+
if (targetReal === undefined || !isWithin(root, targetReal)) return errorResult('PATH_DENIED')
|
|
598
|
+
|
|
599
|
+
try {
|
|
600
|
+
const result = await readLineWindow(targetReal, args.offset, args.limit, BYTES_PER_CALL)
|
|
601
|
+
const scanDecision = await dependencies.scanContent({
|
|
602
|
+
content: result.content,
|
|
603
|
+
agent,
|
|
604
|
+
sessionID,
|
|
605
|
+
messageID,
|
|
606
|
+
callID,
|
|
607
|
+
})
|
|
608
|
+
if (scanDecision !== 'allow') {
|
|
609
|
+
return errorResult(scanDecision === 'deny' ? 'SCOPE_DENIED' : 'UNAVAILABLE')
|
|
610
|
+
}
|
|
611
|
+
return {
|
|
612
|
+
output: JSON.stringify({
|
|
613
|
+
path: args.path,
|
|
614
|
+
offset: args.offset,
|
|
615
|
+
linesRead: result.linesRead,
|
|
616
|
+
bytes: result.bytes,
|
|
617
|
+
status: result.linesRead === 0 ? 'empty' : 'ok',
|
|
618
|
+
content: result.content,
|
|
619
|
+
}),
|
|
620
|
+
}
|
|
621
|
+
} catch (error) {
|
|
622
|
+
const code = error instanceof Error ? error.message : ''
|
|
623
|
+
return errorResult(
|
|
624
|
+
['CHANGED', 'BUDGET_EXCEEDED', 'PATH_DENIED'].includes(code) ? code : 'UNAVAILABLE',
|
|
625
|
+
)
|
|
626
|
+
}
|
|
627
|
+
},
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return {
|
|
631
|
+
definition,
|
|
632
|
+
origins,
|
|
633
|
+
clearSession(sessionID) {
|
|
634
|
+
for (const [key, budget] of budgets) {
|
|
635
|
+
if (budget.sessionID === sessionID) budgets.delete(key)
|
|
636
|
+
}
|
|
637
|
+
origins.clearSession(sessionID)
|
|
638
|
+
},
|
|
639
|
+
}
|
|
640
|
+
}
|