horizon-code 0.3.3 → 0.5.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/package.json +1 -1
- package/src/ai/client.ts +2 -2
- package/src/app.ts +23 -9
- package/src/components/code-panel.ts +2 -2
- package/src/strategy/dashboard.ts +459 -217
- package/src/strategy/prompts.ts +212 -13
- package/src/strategy/tools.ts +211 -23
- package/src/strategy/validator.ts +25 -1
- package/src/strategy/workspace.ts +175 -0
- package/src/syntax/setup.ts +22 -4
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Sandboxed file I/O for LLM strategy mode
|
|
2
|
+
// All operations are confined to ~/.horizon/workspace/
|
|
3
|
+
// Prevents directory traversal, dotfiles, symlink escapes
|
|
4
|
+
|
|
5
|
+
import { resolve, join, relative, normalize } from "path";
|
|
6
|
+
import { homedir } from "os";
|
|
7
|
+
import { existsSync, mkdirSync, readdirSync, statSync, lstatSync, readlinkSync } from "fs";
|
|
8
|
+
|
|
9
|
+
const WORKSPACE_ROOT = resolve(homedir(), ".horizon", "workspace");
|
|
10
|
+
const MAX_FILE_SIZE = 1_048_576; // 1MB
|
|
11
|
+
const BLOCKED_EXTENSIONS = new Set([".sh", ".bash", ".zsh"]);
|
|
12
|
+
const DEFAULT_SUBDIRS = ["dashboards", "scripts", "data"];
|
|
13
|
+
|
|
14
|
+
// Ensure workspace and default subdirs exist
|
|
15
|
+
function ensureWorkspace(): void {
|
|
16
|
+
if (!existsSync(WORKSPACE_ROOT)) {
|
|
17
|
+
mkdirSync(WORKSPACE_ROOT, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
for (const sub of DEFAULT_SUBDIRS) {
|
|
20
|
+
const dir = join(WORKSPACE_ROOT, sub);
|
|
21
|
+
if (!existsSync(dir)) {
|
|
22
|
+
mkdirSync(dir, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve a user-provided path to an absolute path within the workspace.
|
|
29
|
+
* Blocks: absolute paths, .., dotfiles, null bytes, symlink escapes.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveSafePath(userPath: string): string {
|
|
32
|
+
// Block null bytes
|
|
33
|
+
if (userPath.includes("\0")) {
|
|
34
|
+
throw new Error("Invalid path: null bytes not allowed");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Block absolute paths
|
|
38
|
+
if (userPath.startsWith("/") || userPath.startsWith("~")) {
|
|
39
|
+
throw new Error("Invalid path: absolute paths not allowed. Use relative paths within workspace.");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Normalize and resolve relative to workspace
|
|
43
|
+
const normalized = normalize(userPath);
|
|
44
|
+
|
|
45
|
+
// Block .. traversal
|
|
46
|
+
if (normalized.startsWith("..") || normalized.includes("/..") || normalized.includes("\\..")) {
|
|
47
|
+
throw new Error("Invalid path: directory traversal (..) not allowed");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Block dotfiles/dotdirs (hidden files)
|
|
51
|
+
const parts = normalized.split(/[/\\]/);
|
|
52
|
+
for (const part of parts) {
|
|
53
|
+
if (part.startsWith(".") && part !== "." && part !== "..") {
|
|
54
|
+
throw new Error(`Invalid path: dotfiles not allowed (${part})`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const absolute = resolve(WORKSPACE_ROOT, normalized);
|
|
59
|
+
|
|
60
|
+
// Verify the resolved path is still within workspace
|
|
61
|
+
const rel = relative(WORKSPACE_ROOT, absolute);
|
|
62
|
+
if (rel.startsWith("..") || resolve(WORKSPACE_ROOT, rel) !== absolute) {
|
|
63
|
+
throw new Error("Invalid path: resolves outside workspace");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Check for symlink escape (if the path exists)
|
|
67
|
+
if (existsSync(absolute)) {
|
|
68
|
+
try {
|
|
69
|
+
const stat = lstatSync(absolute);
|
|
70
|
+
if (stat.isSymbolicLink()) {
|
|
71
|
+
const target = readlinkSync(absolute);
|
|
72
|
+
const resolvedTarget = resolve(join(absolute, ".."), target);
|
|
73
|
+
const targetRel = relative(WORKSPACE_ROOT, resolvedTarget);
|
|
74
|
+
if (targetRel.startsWith("..")) {
|
|
75
|
+
throw new Error("Invalid path: symlink points outside workspace");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch (e: any) {
|
|
79
|
+
if (e.message.includes("symlink")) throw e;
|
|
80
|
+
// stat errors on non-existent paths are fine
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return absolute;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Write a file to the workspace. Creates parent directories as needed.
|
|
89
|
+
* Blocks: files > 1MB, dangerous extensions (.sh, .bash, .zsh).
|
|
90
|
+
*/
|
|
91
|
+
export async function writeWorkspaceFile(path: string, content: string): Promise<{ path: string; size: number }> {
|
|
92
|
+
ensureWorkspace();
|
|
93
|
+
const absolute = resolveSafePath(path);
|
|
94
|
+
|
|
95
|
+
// Check extension
|
|
96
|
+
const ext = path.includes(".") ? "." + path.split(".").pop()!.toLowerCase() : "";
|
|
97
|
+
if (BLOCKED_EXTENSIONS.has(ext)) {
|
|
98
|
+
throw new Error(`Blocked extension: ${ext} files cannot be written for security`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Check size
|
|
102
|
+
const bytes = new TextEncoder().encode(content);
|
|
103
|
+
if (bytes.length > MAX_FILE_SIZE) {
|
|
104
|
+
throw new Error(`File too large: ${bytes.length} bytes (max ${MAX_FILE_SIZE})`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Create parent dirs
|
|
108
|
+
const parentDir = join(absolute, "..");
|
|
109
|
+
if (!existsSync(parentDir)) {
|
|
110
|
+
mkdirSync(parentDir, { recursive: true });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await Bun.write(absolute, content);
|
|
114
|
+
return { path: relative(WORKSPACE_ROOT, absolute), size: bytes.length };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Read a file from the workspace.
|
|
119
|
+
*/
|
|
120
|
+
export async function readWorkspaceFile(path: string): Promise<string> {
|
|
121
|
+
ensureWorkspace();
|
|
122
|
+
const absolute = resolveSafePath(path);
|
|
123
|
+
|
|
124
|
+
if (!existsSync(absolute)) {
|
|
125
|
+
throw new Error(`File not found: ${path}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const stat = statSync(absolute);
|
|
129
|
+
if (stat.isDirectory()) {
|
|
130
|
+
throw new Error(`Path is a directory, not a file: ${path}`);
|
|
131
|
+
}
|
|
132
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
133
|
+
throw new Error(`File too large to read: ${stat.size} bytes (max ${MAX_FILE_SIZE})`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const file = Bun.file(absolute);
|
|
137
|
+
return await file.text();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* List files in a workspace directory.
|
|
142
|
+
*/
|
|
143
|
+
export function listWorkspaceFiles(dir?: string): { name: string; size: number; modified: string }[] {
|
|
144
|
+
ensureWorkspace();
|
|
145
|
+
const absolute = dir ? resolveSafePath(dir) : WORKSPACE_ROOT;
|
|
146
|
+
|
|
147
|
+
if (!existsSync(absolute)) {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const stat = statSync(absolute);
|
|
152
|
+
if (!stat.isDirectory()) {
|
|
153
|
+
throw new Error(`Not a directory: ${dir}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const entries = readdirSync(absolute, { withFileTypes: true });
|
|
157
|
+
return entries
|
|
158
|
+
.filter(e => !e.name.startsWith("."))
|
|
159
|
+
.map(e => {
|
|
160
|
+
const fullPath = join(absolute, e.name);
|
|
161
|
+
const s = statSync(fullPath);
|
|
162
|
+
return {
|
|
163
|
+
name: e.isDirectory() ? e.name + "/" : e.name,
|
|
164
|
+
size: s.size,
|
|
165
|
+
modified: s.mtime.toISOString(),
|
|
166
|
+
};
|
|
167
|
+
})
|
|
168
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Get the absolute workspace root path */
|
|
172
|
+
export function getWorkspaceRoot(): string {
|
|
173
|
+
ensureWorkspace();
|
|
174
|
+
return WORKSPACE_ROOT;
|
|
175
|
+
}
|
package/src/syntax/setup.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Loads tree-sitter-python WASM and registers it for markdown code blocks
|
|
3
3
|
|
|
4
4
|
import { resolve } from "path";
|
|
5
|
+
import { existsSync } from "fs";
|
|
5
6
|
|
|
6
7
|
const assetsDir = resolve(import.meta.dir, "../../assets/python");
|
|
7
8
|
const wasmPath = resolve(assetsDir, "tree-sitter-python.wasm");
|
|
@@ -17,9 +18,19 @@ export async function getTreeSitterClient(): Promise<any> {
|
|
|
17
18
|
if (client) return client;
|
|
18
19
|
|
|
19
20
|
try {
|
|
21
|
+
// Verify WASM file exists before attempting to load
|
|
22
|
+
if (!existsSync(wasmPath)) {
|
|
23
|
+
console.warn(`[syntax] Python WASM not found at ${wasmPath}`);
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
if (!existsSync(highlightsPath)) {
|
|
27
|
+
console.warn(`[syntax] Python highlights.scm not found at ${highlightsPath}`);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
const { TreeSitterClient, addDefaultParsers } = await import("@opentui/core");
|
|
21
32
|
|
|
22
|
-
// Register Python parser globally
|
|
33
|
+
// Register Python parser globally BEFORE creating the client
|
|
23
34
|
addDefaultParsers([{
|
|
24
35
|
filetype: "python",
|
|
25
36
|
wasm: wasmPath,
|
|
@@ -29,7 +40,10 @@ export async function getTreeSitterClient(): Promise<any> {
|
|
|
29
40
|
const dataPath = resolve(import.meta.dir, "../../node_modules/@opentui/core/assets");
|
|
30
41
|
client = new TreeSitterClient({ dataPath });
|
|
31
42
|
|
|
32
|
-
//
|
|
43
|
+
// Initialize FIRST, then register filetype parser
|
|
44
|
+
await client.initialize();
|
|
45
|
+
|
|
46
|
+
// Register directly on the client instance AFTER initialize
|
|
33
47
|
if (client.addFiletypeParser) {
|
|
34
48
|
client.addFiletypeParser({
|
|
35
49
|
filetype: "python",
|
|
@@ -38,10 +52,14 @@ export async function getTreeSitterClient(): Promise<any> {
|
|
|
38
52
|
});
|
|
39
53
|
}
|
|
40
54
|
|
|
41
|
-
|
|
55
|
+
// Eagerly preload the Python parser to verify it works
|
|
56
|
+
if (client.preloadParser) {
|
|
57
|
+
await client.preloadParser("python");
|
|
58
|
+
}
|
|
59
|
+
|
|
42
60
|
return client;
|
|
43
61
|
} catch (e) {
|
|
44
|
-
|
|
62
|
+
console.warn(`[syntax] Tree-sitter init failed:`, e);
|
|
45
63
|
return null;
|
|
46
64
|
}
|
|
47
65
|
}
|