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.
@@ -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
+ }
@@ -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
- // Also register directly on the client instance
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
- await client.initialize();
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
- // Tree-sitter init failed — code panel will work without highlighting
62
+ console.warn(`[syntax] Tree-sitter init failed:`, e);
45
63
  return null;
46
64
  }
47
65
  }